diff --git a/mattermost-plugin/server/boards/configuration.go b/mattermost-plugin/server/boards/configuration.go index ddcbeba18..fcd44a5c4 100644 --- a/mattermost-plugin/server/boards/configuration.go +++ b/mattermost-plugin/server/boards/configuration.go @@ -107,6 +107,11 @@ func (b *BoardsApp) OnConfigurationChange() error { showFullName = *mmconfig.PrivacySettings.ShowFullName } b.server.Config().ShowFullName = showFullName + maxFileSize := int64(0) + if mmconfig.FileSettings.MaxFileSize != nil { + maxFileSize = *mmconfig.FileSettings.MaxFileSize + } + b.server.Config().MaxFileSize = maxFileSize b.server.UpdateAppConfig() b.wsPluginAdapter.BroadcastConfigChange(*b.server.App().GetClientConfig()) diff --git a/server/api/files.go b/server/api/files.go index 825ff22f1..4c2c10379 100644 --- a/server/api/files.go +++ b/server/api/files.go @@ -5,7 +5,6 @@ import ( "errors" "io" "net/http" - "path/filepath" "strings" "time" @@ -13,7 +12,9 @@ import ( "github.com/gorilla/mux" "github.com/mattermost/focalboard/server/model" + "github.com/mattermost/focalboard/server/services/audit" + mmModel "github.com/mattermost/mattermost-server/v6/model" "github.com/mattermost/mattermost-server/v6/shared/mlog" ) @@ -35,9 +36,19 @@ func FileUploadResponseFromJSON(data io.Reader) (*FileUploadResponse, error) { return &fileUploadResponse, nil } +func FileInfoResponseFromJSON(data io.Reader) (*mmModel.FileInfo, error) { + var fileInfo mmModel.FileInfo + + if err := json.NewDecoder(data).Decode(&fileInfo); err != nil { + return nil, err + } + return &fileInfo, nil +} + func (a *API) registerFilesRoutes(r *mux.Router) { // Files API r.HandleFunc("/files/teams/{teamID}/{boardID}/{filename}", a.attachSession(a.handleServeFile, false)).Methods("GET") + r.HandleFunc("/files/teams/{teamID}/{boardID}/{filename}/info", a.attachSession(a.getFileInfo, false)).Methods("GET") r.HandleFunc("/teams/{teamID}/{boardID}/files", a.sessionRequired(a.handleUploadFile)).Methods("POST") } @@ -108,19 +119,6 @@ func (a *API) handleServeFile(w http.ResponseWriter, r *http.Request) { auditRec.AddMeta("teamID", board.TeamID) auditRec.AddMeta("filename", filename) - contentType := "image/jpg" - - fileExtension := strings.ToLower(filepath.Ext(filename)) - if fileExtension == "png" { - contentType = "image/png" - } - - if fileExtension == "gif" { - contentType = "image/gif" - } - - w.Header().Set("Content-Type", contentType) - fileInfo, err := a.app.GetFileInfo(filename) if err != nil && !model.IsErrNotFound(err) { a.errorResponse(w, r, err) @@ -172,6 +170,80 @@ func (a *API) handleServeFile(w http.ResponseWriter, r *http.Request) { auditRec.Success() } +func (a *API) getFileInfo(w http.ResponseWriter, r *http.Request) { + // swagger:operation GET /files/teams/{teamID}/{boardID}/{filename}/info getFile + // + // Returns the metadata of an uploaded file + // + // --- + // produces: + // - application/json + // parameters: + // - name: teamID + // in: path + // description: Team ID + // required: true + // type: string + // - name: boardID + // in: path + // description: Board ID + // required: true + // type: string + // - name: filename + // in: path + // description: name of the file + // required: true + // type: string + // security: + // - BearerAuth: [] + // responses: + // '200': + // description: success + // '404': + // description: file not found + // default: + // description: internal error + // schema: + // "$ref": "#/definitions/ErrorResponse" + + vars := mux.Vars(r) + boardID := vars["boardID"] + teamID := vars["teamID"] + filename := vars["filename"] + userID := getUserID(r) + + hasValidReadToken := a.hasValidReadTokenForBoard(r, boardID) + if userID == "" && !hasValidReadToken { + a.errorResponse(w, r, model.NewErrUnauthorized("access denied to board")) + return + } + + if !hasValidReadToken && !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionViewBoard) { + a.errorResponse(w, r, model.NewErrPermission("access denied to board")) + return + } + + auditRec := a.makeAuditRecord(r, "getFile", audit.Fail) + defer a.audit.LogRecord(audit.LevelRead, auditRec) + auditRec.AddMeta("boardID", boardID) + auditRec.AddMeta("teamID", teamID) + auditRec.AddMeta("filename", filename) + + fileInfo, err := a.app.GetFileInfo(filename) + if err != nil && !model.IsErrNotFound(err) { + a.errorResponse(w, r, err) + return + } + + data, err := json.Marshal(fileInfo) + if err != nil { + a.errorResponse(w, r, err) + return + } + + jsonBytesResponse(w, http.StatusOK, data) +} + func (a *API) handleUploadFile(w http.ResponseWriter, r *http.Request) { // swagger:operation POST /teams/{teamID}/boards/{boardID}/files uploadFile // diff --git a/server/app/clientConfig.go b/server/app/clientConfig.go index b681512e6..f78a23d59 100644 --- a/server/app/clientConfig.go +++ b/server/app/clientConfig.go @@ -11,5 +11,6 @@ func (a *App) GetClientConfig() *model.ClientConfig { EnablePublicSharedBoards: a.config.EnablePublicSharedBoards, TeammateNameDisplay: a.config.TeammateNameDisplay, FeatureFlags: a.config.FeatureFlags, + MaxFileSize: a.config.MaxFileSize, } } diff --git a/server/client/client.go b/server/client/client.go index 4bc4e08af..3a7ff2487 100644 --- a/server/client/client.go +++ b/server/client/client.go @@ -11,6 +11,7 @@ import ( "github.com/mattermost/focalboard/server/api" "github.com/mattermost/focalboard/server/model" + mmModel "github.com/mattermost/mattermost-server/v6/model" ) const ( @@ -823,6 +824,19 @@ func (c *Client) TeamUploadFile(teamID, boardID string, data io.Reader) (*api.Fi return fileUploadResponse, BuildResponse(r) } +func (c *Client) TeamUploadFileInfo(teamID, boardID string, fileName string) (*mmModel.FileInfo, *Response) { + r, err := c.DoAPIGet(fmt.Sprintf("/files/teams/%s/%s/%s/info", teamID, boardID, fileName), "") + if err != nil { + return nil, BuildErrorResponse(r, err) + } + defer closeBody(r) + fileInfoResponse, error := api.FileInfoResponseFromJSON(r.Body) + if error != nil { + return nil, BuildErrorResponse(r, error) + } + return fileInfoResponse, BuildResponse(r) +} + func (c *Client) GetSubscriptionsRoute() string { return "/subscriptions" } diff --git a/server/integrationtests/file_test.go b/server/integrationtests/file_test.go index 3d3e1610f..9be32b9da 100644 --- a/server/integrationtests/file_test.go +++ b/server/integrationtests/file_test.go @@ -69,3 +69,20 @@ func TestUploadFile(t *testing.T) { require.NotNil(t, file.FileID) }) } + +func TestFileInfo(t *testing.T) { + const ( + testTeamID = "team-id" + ) + + t.Run("Retrieving file info", func(t *testing.T) { + th := SetupTestHelper(t).InitBasic() + defer th.TearDown() + testBoard := th.CreateBoard(testTeamID, model.BoardTypeOpen) + + fileInfo, resp := th.Client.TeamUploadFileInfo(testTeamID, testBoard.ID, "test") + th.CheckOK(resp) + require.NotNil(t, fileInfo) + require.NotNil(t, fileInfo.Id) + }) +} diff --git a/server/model/clientConfig.go b/server/model/clientConfig.go index 545b329d7..38d8e3cba 100644 --- a/server/model/clientConfig.go +++ b/server/model/clientConfig.go @@ -22,4 +22,8 @@ type ClientConfig struct { // The server feature flags // required: true FeatureFlags map[string]string `json:"featureFlags"` + + // Required for file upload to check the size of the file + // required: true + MaxFileSize int64 `json:"maxFileSize"` } diff --git a/server/services/config/config.go b/server/services/config/config.go index 9d7ffc22f..dbabe0e1e 100644 --- a/server/services/config/config.go +++ b/server/services/config/config.go @@ -38,7 +38,7 @@ type Configuration struct { FilesDriver string `json:"filesdriver" mapstructure:"filesdriver"` FilesS3Config AmazonS3Config `json:"filess3config" mapstructure:"filess3config"` FilesPath string `json:"filespath" mapstructure:"filespath"` - MaxFileSize int64 `json:"maxfilesize" mapstructure:"mafilesize"` + MaxFileSize int64 `json:"maxfilesize" mapstructure:"maxfilesize"` Telemetry bool `json:"telemetry" mapstructure:"telemetry"` TelemetryID string `json:"telemetryid" mapstructure:"telemetryid"` PrometheusAddress string `json:"prometheusaddress" mapstructure:"prometheusaddress"` diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json index a716d5176..403efc569 100644 --- a/webapp/i18n/en.json +++ b/webapp/i18n/en.json @@ -1,5 +1,15 @@ { "AppBar.Tooltip": "Toggle Linked Boards", + "Attachment.Attachment-title": "Attachment", + "AttachmentBlock.DeleteAction": "delete", + "AttachmentBlock.addElement": "add {type}", + "AttachmentBlock.delete": "Attachment Deleted Successfully.", + "AttachmentBlock.failed": "Unable to upload the file. Attachment size limit reached.", + "AttachmentBlock.upload": "Attachment uploading.", + "AttachmentBlock.uploadSuccess": "Attachment uploaded successfull.", + "AttachmentElement.delete-confirmation-dialog-button-text": "Delete", + "AttachmentElement.download": "Download", + "AttachmentElement.upload-percentage": "Uploading...({uploadPercent}%)", "BoardComponent.add-a-group": "+ Add a group", "BoardComponent.delete": "Delete", "BoardComponent.hidden-columns": "Hidden columns", @@ -71,6 +81,7 @@ "CardBadges.title-checkboxes": "Checkboxes", "CardBadges.title-comments": "Comments", "CardBadges.title-description": "This card has a description", + "CardDetail.Attach": "Attach", "CardDetail.Follow": "Follow", "CardDetail.Following": "Following", "CardDetail.add-content": "Add content", @@ -92,6 +103,7 @@ "CardDetailProperty.property-deleted": "Deleted {propertyName} successfully!", "CardDetailProperty.property-name-change-subtext": "type from \"{oldPropType}\" to \"{newPropType}\"", "CardDetial.limited-link": "Learn more about our plans.", + "CardDialog.delete-confirmation-dialog-attachment": "Confirm Attachment delete!", "CardDialog.delete-confirmation-dialog-button-text": "Delete", "CardDialog.delete-confirmation-dialog-heading": "Confirm card delete!", "CardDialog.editing-template": "You're editing a template.", @@ -119,6 +131,7 @@ "ContentBlock.editText": "Edit text...", "ContentBlock.image": "image", "ContentBlock.insertAbove": "Insert above", + "ContentBlock.moveBlock": "move card content", "ContentBlock.moveDown": "Move down", "ContentBlock.moveUp": "Move up", "ContentBlock.text": "text", diff --git a/webapp/src/blocks/attachmentBlock.tsx b/webapp/src/blocks/attachmentBlock.tsx new file mode 100644 index 000000000..bf0568505 --- /dev/null +++ b/webapp/src/blocks/attachmentBlock.tsx @@ -0,0 +1,28 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import {Block, createBlock} from './block' + +type AttachmentBlockFields = { + attachmentId: string +} + +type AttachmentBlock = Block & { + type: 'attachment' + fields: AttachmentBlockFields + isUploading: boolean + uploadingPercent: number +} + +function createAttachmentBlock(block?: Block): AttachmentBlock { + return { + ...createBlock(block), + type: 'attachment', + fields: { + attachmentId: block?.fields.attachmentId || '', + }, + isUploading: false, + uploadingPercent: 0, + } +} + +export {AttachmentBlock, createAttachmentBlock} diff --git a/webapp/src/blocks/block.ts b/webapp/src/blocks/block.ts index 0fac16f1a..de1bb908a 100644 --- a/webapp/src/blocks/block.ts +++ b/webapp/src/blocks/block.ts @@ -8,7 +8,7 @@ import {Utils} from '../utils' const contentBlockTypes = ['text', 'image', 'divider', 'checkbox', 'h1', 'h2', 'h3', 'list-item', 'attachment', 'quote', 'video'] as const // ToDo: remove type board -const blockTypes = [...contentBlockTypes, 'board', 'view', 'card', 'comment', 'unknown'] as const +const blockTypes = [...contentBlockTypes, 'board', 'view', 'card', 'comment', 'attachment', 'unknown'] as const type ContentBlockTypes = typeof contentBlockTypes[number] type BlockTypes = typeof blockTypes[number] diff --git a/webapp/src/components/__snapshots__/cardDialog.test.tsx.snap b/webapp/src/components/__snapshots__/cardDialog.test.tsx.snap index 4d2bfa022..8b46e1a21 100644 --- a/webapp/src/components/__snapshots__/cardDialog.test.tsx.snap +++ b/webapp/src/components/__snapshots__/cardDialog.test.tsx.snap @@ -29,6 +29,16 @@ exports[`components/cardDialog already following card 1`] = ` class="toolbar--right" >
+ + + ) + } + const followActionButton = (following: boolean): React.ReactNode => { const followBtn = ( - + <> + + ) const unfollowBtn = ( - + <> + + ) - return following ? unfollowBtn : followBtn + return (<>{attachBtn()}{following ? unfollowBtn : followBtn}) } const followingCards = useAppSelector(getUserBlockSubscriptionList) @@ -183,8 +291,11 @@ const CardDialog = (props: Props): JSX.Element => { card={card} contents={contents} comments={comments} + attachments={attachments} readonly={props.readonly} onClose={props.onClose} + onDelete={deleteBlock} + addAttachment={addElement} />} {!card && diff --git a/webapp/src/components/content/__snapshots__/attachmentElement.test.tsx.snap b/webapp/src/components/content/__snapshots__/attachmentElement.test.tsx.snap new file mode 100644 index 000000000..e25fd984f --- /dev/null +++ b/webapp/src/components/content/__snapshots__/attachmentElement.test.tsx.snap @@ -0,0 +1,135 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`component/content/FileBlock archived file 1`] = ` +
+
+
+ +
+
+
+
+ test.txt +
+
+
+ txt + + 2.2 KiB +
+
+
+ +
+
+ +
+
+
+
+
+`; + +exports[`component/content/FileBlock should match snapshot 1`] = ` +
+
+
+ +
+
+
+
+ test.txt +
+
+
+ txt + + 2.2 KiB +
+
+
+ +
+
+ +
+
+
+
+
+`; diff --git a/webapp/src/components/content/attachmentElement.scss b/webapp/src/components/content/attachmentElement.scss new file mode 100644 index 000000000..d6aa77ebe --- /dev/null +++ b/webapp/src/components/content/attachmentElement.scss @@ -0,0 +1,100 @@ +.FileElement { + background: rgb(var(--center-channel-bg-rgb)); + border: 1px solid rgba(var(--center-channel-color-rgb), 0.16); + min-width: 300px; + width: max-content; + height: 64px; + box-shadow: var(--elevation-1); + display: flex; + position: relative; + + .fileElement-file-name { + font-size: 14px; + font-weight: 600; + } + + .fileElement-file-ext-and-size { + text-transform: uppercase; + font-weight: 400; + font-size: 12px; + line-height: 16px; + color: rgb(var(--center-channel-color-rgb)); + } + + .fileElement-file-uploading { + font-weight: 400; + font-size: 12px; + line-height: 16px; + color: rgb(var(--center-channel-color-rgb)); + } + + .fileElement-icon-division { + margin-top: 8px; + } + + .fileElement-icon { + font-size: 48px; + color: rgba(237, 82, 42, 1); + } + + .fileElement-download-btn { + display: none; + font-size: 20px; + color: rgba(var(--center-channel-color-rgb), 0.56); + padding: 8px; + + &:hover { + background-color: rgba(var(--center-channel-color-rgb), 0.08); + border-radius: 5px; + cursor: pointer; + } + } + + .fileElement-menu-icon { + display: none; + float: right; + } + + .delete-menu { + margin-top: -30px; + } + + .fileElement-delete-download { + position: absolute; + display: flex; + right: 0; + } + + &:hover { + .fileElement-download-btn { + display: block; + } + + .fileElement-menu-icon { + display: block; + } + } + + .progress { + position: absolute; + bottom: 0; + width: 100%; + height: 7px; + margin-bottom: 0; + border-radius: 0; + } + + .progress-bar { + float: left; + width: 0%; + height: 100%; + line-height: 20px; + color: #fff; + text-align: center; + background-color: #285ab9; + } + + .dialog { + max-width: 550px !important; + } +} diff --git a/webapp/src/components/content/attachmentElement.test.tsx b/webapp/src/components/content/attachmentElement.test.tsx new file mode 100644 index 000000000..4a9ab2af2 --- /dev/null +++ b/webapp/src/components/content/attachmentElement.test.tsx @@ -0,0 +1,140 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react' +import {Provider as ReduxProvider} from 'react-redux' +import {render} from '@testing-library/react' +import {act} from 'react-dom/test-utils' +import {mocked} from 'jest-mock' + +import {AttachmentBlock} from '../../blocks/attachmentBlock' +import {mockStateStore, wrapIntl} from '../../testUtils' +import octoClient from '../../octoClient' +import {TestBlockFactory} from '../../test/testBlockFactory' +import {IUser} from '../../user' + +import AttachmentElement from './attachmentElement' + +jest.mock('../../octoClient') +const mockedOcto = mocked(octoClient, true) +mockedOcto.getFileAsDataUrl.mockResolvedValue({url: 'test.txt'}) +mockedOcto.getFileInfo.mockResolvedValue({ + name: 'test.txt', + size: 2300, + extension: '.txt', +}) + +const board = TestBlockFactory.createBoard() +board.id = '1' +board.teamId = 'team-id' +board.channelId = 'channel_1' + +describe('component/content/FileBlock', () => { + const defaultBlock: AttachmentBlock = { + id: 'test-id', + boardId: '1', + parentId: '', + modifiedBy: 'test-user-id', + schema: 0, + type: 'attachment', + title: 'test-title', + fields: { + attachmentId: 'test.txt', + }, + createdBy: 'test-user-id', + createAt: 0, + updateAt: 0, + deleteAt: 0, + limited: false, + isUploading: false, + uploadingPercent: 0, + } + + const me: IUser = { + id: 'user-id-1', + username: 'username_1', + email: '', + nickname: '', + firstname: '', + lastname: '', + props: {}, + create_at: 0, + update_at: 0, + is_bot: false, + is_guest: false, + roles: 'system_user', + } + + const state = { + teams: { + current: {id: 'team-id', title: 'Test Team'}, + }, + users: { + me, + boardUsers: [me], + blockSubscriptions: [], + }, + boards: { + current: board.id, + boards: { + [board.id]: board, + }, + templates: [], + membersInBoards: { + [board.id]: {}, + }, + myBoardMemberships: { + [board.id]: {userId: me.id, schemeAdmin: true}, + }, + }, + + attachments: { + attachments: { + 'test-id': { + uploadPercent: 0, + }, + }, + }, + } + + const store = mockStateStore([], state) + + test('should match snapshot', async () => { + const component = wrapIntl( + + + , + ) + let fileContainer: Element | undefined + await act(async () => { + const {container} = render(component) + fileContainer = container + }) + expect(fileContainer).toMatchSnapshot() + }) + + test('archived file', async () => { + mockedOcto.getFileAsDataUrl.mockResolvedValue({ + archived: true, + name: 'FileName', + extension: '.txt', + size: 165002, + }) + + const component = wrapIntl( + + + , + ) + let fileContainer: Element | undefined + await act(async () => { + const {container} = render(component) + fileContainer = container + }) + expect(fileContainer).toMatchSnapshot() + }) +}) diff --git a/webapp/src/components/content/attachmentElement.tsx b/webapp/src/components/content/attachmentElement.tsx new file mode 100644 index 000000000..612502d4d --- /dev/null +++ b/webapp/src/components/content/attachmentElement.tsx @@ -0,0 +1,205 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import React, {useEffect, useState} from 'react' +import {useIntl} from 'react-intl' + +import octoClient from '../../octoClient' + +import {AttachmentBlock} from '../../blocks/attachmentBlock' +import {Block, FileInfo} from '../../blocks/block' +import Files from '../../file' +import FileIcons from '../../fileIcons' + +import BoardPermissionGate from '../../components/permissions/boardPermissionGate' +import ConfirmationDialogBox, {ConfirmationDialogBoxProps} from '../../components/confirmationDialogBox' +import {Utils} from '../../utils' +import {getUploadPercent} from '../../store/attachments' +import {useAppSelector} from '../../store/hooks' +import {Permission} from '../../constants' + +import ArchivedFile from './archivedFile/archivedFile' + +import './attachmentElement.scss' +import CompassIcon from './../../widgets/icons/compassIcon' +import MenuWrapper from './../../widgets/menuWrapper' +import IconButton from './../../widgets/buttons/iconButton' +import Menu from './../../widgets/menu' +import Tooltip from './../../widgets/tooltip' + +type Props = { + block: AttachmentBlock + onDelete?: (block: Block) => void +} + +const AttachmentElement = (props: Props): JSX.Element|null => { + const {block, onDelete} = props + const [fileInfo, setFileInfo] = useState({}) + const [fileSize, setFileSize] = useState() + const [fileIcon, setFileIcon] = useState('file-text-outline-larg') + const [fileName, setFileName] = useState() + const [showConfirmationDialogBox, setShowConfirmationDialogBox] = useState(false) + const uploadPercent = useAppSelector(getUploadPercent(block.id)) + const intl = useIntl() + + useEffect(() => { + const loadFile = async () => { + if (block.isUploading) { + setFileInfo({ + name: block.title, + extension: block.title.split('.').slice(0, -1).join('.'), + }) + return + } + const attachmentInfo = await octoClient.getFileInfo(block.boardId, block.fields.attachmentId) + setFileInfo(attachmentInfo) + } + loadFile() + }, []) + + useEffect(() => { + if (fileInfo.size && !fileSize) { + setFileSize(Utils.humanFileSize(fileInfo.size)) + } + if (fileInfo.name && !fileName) { + const generateFileName = (fName: string) => { + if (fName.length > 21) { + let result = fName.slice(0, 18) + result += '...' + return result + } + return fName + } + setFileName(generateFileName(fileInfo.name)) + } + }, [fileInfo.size, fileInfo.name]) + + useEffect(() => { + if (fileInfo.extension) { + const getFileIcon = (fileExt: string) => { + const extType = (Object.keys(Files) as string[]).find((key) => Files[key].find((ext) => ext === fileExt)) + if (extType) { + setFileIcon(FileIcons[extType]) + } else { + setFileIcon('file-generic-outline-large') + } + } + getFileIcon(fileInfo.extension.substring(1)) + } + }, [fileInfo.extension]) + + const deleteAttachment = () => { + if (onDelete) { + onDelete(block) + } + } + + const confirmDialogProps: ConfirmationDialogBoxProps = { + heading: intl.formatMessage({id: 'CardDialog.delete-confirmation-dialog-attachment', defaultMessage: 'Confirm Attachment delete!'}), + confirmButtonText: intl.formatMessage({id: 'AttachmentElement.delete-confirmation-dialog-button-text', defaultMessage: 'Delete'}), + onConfirm: deleteAttachment, + onClose: () => { + setShowConfirmationDialogBox(false) + }, + } + + const handleDeleteButtonClick = () => { + setShowConfirmationDialogBox(true) + } + + if (fileInfo.archived) { + return ( + + ) + } + + const attachmentDownloadHandler = async () => { + const attachment = await octoClient.getFileAsDataUrl(block.boardId, block.fields.attachmentId) + const anchor = document.createElement('a') + anchor.href = attachment.url || '' + anchor.download = fileInfo.name || '' + document.body.appendChild(anchor) + anchor.click() + document.body.removeChild(anchor) + } + + return ( +
+ {showConfirmationDialogBox && } +
+ +
+
+ +
+ {fileName} +
+
+ {!block.isUploading &&
+ {fileInfo.extension?.substring(1)} {fileSize} +
} + {block.isUploading &&
+ {intl.formatMessage({ + id: 'AttachmentElement.upload-percentage', + defaultMessage: 'Uploading...({uploadPercent}%)', + }, { + uploadPercent, + })} +
} +
+ {block.isUploading && +
+ + {''} + +
} + {!block.isUploading && +
+ + + } + /> +
+ + } + name='Delete' + onClick={handleDeleteButtonClick} + /> + +
+
+
+ +
+ +
+
+
} +
+ ) +} + +export default React.memo(AttachmentElement) diff --git a/webapp/src/config/clientConfig.ts b/webapp/src/config/clientConfig.ts index 4676383b4..8322078cc 100644 --- a/webapp/src/config/clientConfig.ts +++ b/webapp/src/config/clientConfig.ts @@ -7,4 +7,5 @@ export type ClientConfig = { enablePublicSharedBoards: boolean featureFlags: Record teammateNameDisplay: string + maxFileSize: number } diff --git a/webapp/src/file.ts b/webapp/src/file.ts new file mode 100644 index 000000000..a50aac5fc --- /dev/null +++ b/webapp/src/file.ts @@ -0,0 +1,18 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +const Files: Record = { + AUDIO_TYPES: ['mp3', 'wav', 'wma', 'm4a', 'flac', 'aac', 'ogg'], + CODE_TYPES: ['as', 'applescript', 'osascript', 'scpt', 'bash', 'sh', 'zsh', 'clj', 'boot', 'cl2', 'cljc', 'cljs', 'cljs.hl', 'cljscm', 'cljx', 'hic', 'coffee', '_coffee', 'cake', 'cjsx', 'cson', 'iced', 'cpp', 'c', 'cc', 'h', 'c++', 'h++', 'hpp', 'cs', 'csharp', 'css', 'd', 'di', 'dart', 'delphi', 'dpr', 'dfm', 'pas', 'pascal', 'freepascal', 'lazarus', 'lpr', 'lfm', 'diff', 'django', 'jinja', 'dockerfile', 'docker', 'erl', 'f90', 'f95', 'fsharp', 'fs', 'gcode', 'nc', 'go', 'groovy', 'handlebars', 'hbs', 'html.hbs', 'html.handlebars', 'hs', 'hx', 'java', 'jsp', 'js', 'jsx', 'json', 'jl', 'kt', 'ktm', 'kts', 'less', 'lisp', 'lua', 'mk', 'mak', 'md', 'mkdown', 'mkd', 'matlab', 'm', 'mm', 'objc', 'obj-c', 'ml', 'perl', 'pl', 'php', 'php3', 'php4', 'php5', 'php6', 'ps', 'ps1', 'pp', 'py', 'gyp', 'r', 'ruby', 'rb', 'gemspec', 'podspec', 'thor', 'irb', 'rs', 'scala', 'scm', 'sld', 'scss', 'st', 'sql', 'swift', 'tex', 'vbnet', 'vb', 'bas', 'vbs', 'v', 'veo', 'xml', 'html', 'xhtml', 'rss', 'atom', 'xsl', 'plist', 'yaml'], + IMAGE_TYPES: ['jpg', 'gif', 'bmp', 'png', 'jpeg', 'tiff', 'tif'], + PATCH_TYPES: ['patch'], + PDF_TYPES: ['pdf'], + PRESENTATION_TYPES: ['ppt', 'pptx'], + SPREADSHEET_TYPES: ['xlsx', 'csv'], + TEXT_TYPES: ['txt', 'rtf'], + VIDEO_TYPES: ['mp4', 'avi', 'webm', 'mkv', 'wmv', 'mpg', 'mov', 'flv'], + WORD_TYPES: ['doc', 'docx'], + COMPRESSED_TYPES: ['arc', 'arj', 'b64', 'btoa', 'bz', 'bz2', 'cab', 'cpt', 'gz', 'hqx', 'iso', 'lha', 'lzh', 'mim', 'mme', 'pak', 'pf', 'rar', 'rpm', 'sea', 'sit', 'sitx', 'tar', 'gz', 'tbz', 'tbz2', 'tgz', 'uu', 'uue', 'z', 'zip', 'zipx', 'zoo'], +} + +export default Files diff --git a/webapp/src/fileIcons.ts b/webapp/src/fileIcons.ts new file mode 100644 index 000000000..35c2e1538 --- /dev/null +++ b/webapp/src/fileIcons.ts @@ -0,0 +1,18 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +const FileIcons: Record = { + AUDIO_TYPES: 'file-audio-outline', + CODE_TYPES: 'file-code-outline-large', + IMAGE_TYPES: 'file-image-outline-large', + PDF_TYPES: 'file-pdf-outline-large', + PATCH_TYPES: 'file-patch-outline-large', + PRESENTATION_TYPES: 'file-powerpoint-outline-large', + SPREADSHEET_TYPES: 'file-excel-outline-large', + TEXT_TYPES: 'file-text-outline-large', + VIDEO_TYPES: 'file-video-outline-large', + WORD_TYPES: 'file-word-outline-large', + COMPRESSED_TYPES: 'file-zip-outline-large', +} + +export default FileIcons diff --git a/webapp/src/octoClient.test.ts b/webapp/src/octoClient.test.ts index e4a3239c5..8383d4f4b 100644 --- a/webapp/src/octoClient.test.ts +++ b/webapp/src/octoClient.test.ts @@ -79,3 +79,22 @@ function createBlocks(): Block[] { return blocks } + +test('OctoClient: GetFileInfo', async () => { + FetchMock.fn.mockReturnValueOnce(FetchMock.jsonResponse(JSON.stringify({ + name: 'test.txt', + size: 2300, + extension: '.txt', + }))) + await octoClient.getFileInfo('board-id', 'file-id') + expect(FetchMock.fn).toBeCalledTimes(1) + expect(FetchMock.fn).toHaveBeenCalledWith( + 'http://localhost/api/v2/files/teams/0/board-id/file-id/info', + expect.objectContaining({ + headers: { + Accept: 'application/json', + Authorization: '', + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }})) +}) diff --git a/webapp/src/octoClient.ts b/webapp/src/octoClient.ts index 48d8e7ebe..d0fdfdbd7 100644 --- a/webapp/src/octoClient.ts +++ b/webapp/src/octoClient.ts @@ -590,6 +590,45 @@ class OctoClient { return undefined } + async uploadAttachment(rootID: string, file: File): Promise { + const formData = new FormData() + formData.append('file', file) + + const xhr = new XMLHttpRequest() + + xhr.open('POST', this.getBaseURL() + this.teamPath() + '/' + rootID + '/files', true) + const headers = this.headers() as Record + delete headers['Content-Type'] + + xhr.setRequestHeader('Accept', 'application/json') + xhr.setRequestHeader('Authorization', this.token ? 'Bearer ' + this.token : '') + xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest') + + if (xhr.upload) { + xhr.upload.onprogress = () => {} + } + xhr.send(formData) + return xhr + } + + async getFileInfo(boardId: string, fileId: string): Promise { + let path = '/api/v2/files/teams/' + this.teamId + '/' + boardId + '/' + fileId + '/info' + const readToken = Utils.getReadToken() + if (readToken) { + path += `?read_token=${readToken}` + } + const response = await fetch(this.getBaseURL() + path, {headers: this.headers()}) + let fileInfo: FileInfo = {} + + if (response.status === 200) { + fileInfo = this.getJson(response, {}) as FileInfo + } else if (response.status === 400) { + fileInfo = await this.getJson(response, {}) as FileInfo + } + + return fileInfo + } + async getFileAsDataUrl(boardId: string, fileId: string): Promise { let path = '/api/v2/files/teams/' + this.teamId + '/' + boardId + '/' + fileId const readToken = Utils.getReadToken() diff --git a/webapp/src/octoUtils.tsx b/webapp/src/octoUtils.tsx index 373d0977c..09979f1e7 100644 --- a/webapp/src/octoUtils.tsx +++ b/webapp/src/octoUtils.tsx @@ -15,6 +15,7 @@ import {createH1Block} from './blocks/h1Block' import {createH2Block} from './blocks/h2Block' import {createH3Block} from './blocks/h3Block' import {FilterCondition} from './blocks/filterClause' +import {createAttachmentBlock} from './blocks/attachmentBlock' import {Utils} from './utils' class OctoUtils { @@ -30,6 +31,7 @@ class OctoUtils { case 'divider': { return createDividerBlock(block) } case 'comment': { return createCommentBlock(block) } case 'checkbox': { return createCheckboxBlock(block) } + case 'attachment': { return createAttachmentBlock(block) } default: { Utils.assertFailure(`Can't hydrate unknown block type: ${block.type}`) return createBlock(block) diff --git a/webapp/src/pages/boardPage/boardPage.tsx b/webapp/src/pages/boardPage/boardPage.tsx index 05fcae7e9..394d83651 100644 --- a/webapp/src/pages/boardPage/boardPage.tsx +++ b/webapp/src/pages/boardPage/boardPage.tsx @@ -16,6 +16,7 @@ import {IUser, UserConfigPatch} from '../../user' import {Block} from '../../blocks/block' import {ContentBlock} from '../../blocks/contentBlock' import {CommentBlock} from '../../blocks/commentBlock' +import {AttachmentBlock} from '../../blocks/attachmentBlock' import {Board, BoardMember} from '../../blocks/board' import {BoardView} from '../../blocks/boardView' import {Card} from '../../blocks/card' @@ -33,6 +34,7 @@ import {useAppSelector, useAppDispatch} from '../../store/hooks' import {setTeam} from '../../store/teams' import {updateCards} from '../../store/cards' import {updateComments} from '../../store/comments' +import {updateAttachments} from '../../store/attachments' import {updateContents} from '../../store/contents' import { fetchUserBlockSubscriptions, @@ -114,7 +116,8 @@ const BoardPage = (props: Props): JSX.Element => { dispatch(updateViews(teamBlocks.filter((b: Block) => b.type === 'view' || b.deleteAt !== 0) as BoardView[])) dispatch(updateCards(teamBlocks.filter((b: Block) => b.type === 'card' || b.deleteAt !== 0) as Card[])) dispatch(updateComments(teamBlocks.filter((b: Block) => b.type === 'comment' || b.deleteAt !== 0) as CommentBlock[])) - dispatch(updateContents(teamBlocks.filter((b: Block) => b.type !== 'card' && b.type !== 'view' && b.type !== 'board' && b.type !== 'comment') as ContentBlock[])) + dispatch(updateAttachments(teamBlocks.filter((b: Block) => b.type === 'attachment' || b.deleteAt !== 0) as AttachmentBlock[])) + dispatch(updateContents(teamBlocks.filter((b: Block) => b.type !== 'card' && b.type !== 'view' && b.type !== 'board' && b.type !== 'comment' && b.type !== 'attachment') as ContentBlock[])) }) } diff --git a/webapp/src/store/attachments.ts b/webapp/src/store/attachments.ts new file mode 100644 index 000000000..918867371 --- /dev/null +++ b/webapp/src/store/attachments.ts @@ -0,0 +1,90 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {createSlice, PayloadAction} from '@reduxjs/toolkit' + +import {AttachmentBlock} from '../blocks/attachmentBlock' + +import {loadBoardData, initialReadOnlyLoad} from './initialLoad' + +import {RootState} from './index' + +type AttachmentsState = { + attachments: {[key: string]: AttachmentBlock} + attachmentsByCard: {[key: string]: AttachmentBlock[]} +} + +const attachmentSlice = createSlice({ + name: 'attachments', + initialState: {attachments: {}, attachmentsByCard: {}} as AttachmentsState, + reducers: { + updateAttachments: (state, action: PayloadAction) => { + for (const attachment of action.payload) { + if (attachment.deleteAt === 0) { + state.attachments[attachment.id] = attachment + if (!state.attachmentsByCard[attachment.parentId]) { + state.attachmentsByCard[attachment.parentId] = [attachment] + return + } + state.attachmentsByCard[attachment.parentId].push(attachment) + } else { + const parentId = state.attachments[attachment.id]?.parentId + if (!state.attachmentsByCard[parentId]) { + delete state.attachments[attachment.id] + return + } + for (let i = 0; i < state.attachmentsByCard[parentId].length; i++) { + if (state.attachmentsByCard[parentId][i].id === attachment.id) { + state.attachmentsByCard[parentId].splice(i, 1) + } + } + delete state.attachments[attachment.id] + } + } + }, + updateUploadPrecent: (state, action: PayloadAction<{blockId: string, uploadPercent: number}>) => { + state.attachments[action.payload.blockId].uploadingPercent = action.payload.uploadPercent + }, + }, + extraReducers: (builder) => { + builder.addCase(initialReadOnlyLoad.fulfilled, (state, action) => { + state.attachments = {} + state.attachmentsByCard = {} + for (const block of action.payload.blocks) { + if (block.type === 'attachment') { + state.attachments[block.id] = block as AttachmentBlock + state.attachmentsByCard[block.parentId] = state.attachmentsByCard[block.parentId] || [] + state.attachmentsByCard[block.parentId].push(block as AttachmentBlock) + } + } + Object.values(state.attachmentsByCard).forEach((arr) => arr.sort((a, b) => a.createAt - b.createAt)) + }) + builder.addCase(loadBoardData.fulfilled, (state, action) => { + state.attachments = {} + state.attachmentsByCard = {} + for (const block of action.payload.blocks) { + if (block.type === 'attachment') { + state.attachments[block.id] = block as AttachmentBlock + state.attachmentsByCard[block.parentId] = state.attachmentsByCard[block.parentId] || [] + state.attachmentsByCard[block.parentId].push(block as AttachmentBlock) + } + } + Object.values(state.attachmentsByCard).forEach((arr) => arr.sort((a, b) => a.createAt - b.createAt)) + }) + }, +}) + +export const {updateAttachments, updateUploadPrecent} = attachmentSlice.actions +export const {reducer} = attachmentSlice + +export function getCardAttachments(cardId: string): (state: RootState) => AttachmentBlock[] { + return (state: RootState): AttachmentBlock[] => { + return (state.attachments?.attachmentsByCard && state.attachments.attachmentsByCard[cardId]) || [] + } +} + +export function getUploadPercent(blockId: string): (state: RootState) => number { + return (state: RootState): number => { + return (state.attachments.attachments[blockId].uploadingPercent) + } +} diff --git a/webapp/src/store/clientConfig.ts b/webapp/src/store/clientConfig.ts index 4130ff9b9..cdd280fd1 100644 --- a/webapp/src/store/clientConfig.ts +++ b/webapp/src/store/clientConfig.ts @@ -18,7 +18,7 @@ export const fetchClientConfig = createAsyncThunk( const clientConfigSlice = createSlice({ name: 'config', - initialState: {value: {telemetry: false, telemetryid: '', enablePublicSharedBoards: false, teammateNameDisplay: ShowUsername, featureFlags: {}}} as {value: ClientConfig}, + initialState: {value: {telemetry: false, telemetryid: '', enablePublicSharedBoards: false, teammateNameDisplay: ShowUsername, featureFlags: {}, maxFileSize: 0}} as {value: ClientConfig}, reducers: { setClientConfig: (state, action: PayloadAction) => { state.value = action.payload @@ -26,7 +26,7 @@ const clientConfigSlice = createSlice({ }, extraReducers: (builder) => { builder.addCase(fetchClientConfig.fulfilled, (state, action) => { - state.value = action.payload || {telemetry: false, telemetryid: '', enablePublicSharedBoards: false, teammateNameDisplay: ShowUsername, featureFlags: {}} + state.value = action.payload || {telemetry: false, telemetryid: '', enablePublicSharedBoards: false, teammateNameDisplay: ShowUsername, featureFlags: {}, maxFileSize: 0} }) }, }) diff --git a/webapp/src/store/index.ts b/webapp/src/store/index.ts index 453238326..3c02380b6 100644 --- a/webapp/src/store/index.ts +++ b/webapp/src/store/index.ts @@ -18,6 +18,7 @@ import {reducer as globalErrorReducer} from './globalError' import {reducer as clientConfigReducer} from './clientConfig' import {reducer as sidebarReducer} from './sidebar' import {reducer as limitsReducer} from './limits' +import {reducer as attachmentsReducer} from './attachments' const store = configureStore({ reducer: { @@ -36,6 +37,7 @@ const store = configureStore({ clientConfig: clientConfigReducer, sidebar: sidebarReducer, limits: limitsReducer, + attachments: attachmentsReducer, }, })