From f12eb75d256084527bf135640501142fd41ca876 Mon Sep 17 00:00:00 2001 From: Caleb Roseland Date: Fri, 28 Jun 2024 16:58:14 -0400 Subject: [PATCH] MM-54416: Channel Bookmarks Web UI (#25889) Co-authored-by: Mattermost Build Co-authored-by: Elias Nahum Co-authored-by: Miguel de la Cruz Co-authored-by: Scott Bishel --- .../channel/channel_bookmarks_spec.ts | 277 +++++++++ .../channels/src/actions/channel_actions.ts | 1 + .../channels/src/actions/channel_bookmarks.ts | 39 ++ webapp/channels/src/actions/file_actions.ts | 9 +- .../src/actions/websocket_actions.jsx | 64 ++ .../license_settings.test.tsx.snap | 144 ++--- .../openid_convert.test.tsx.snap | 4 +- .../bookmark_create_modal.scss | 20 + .../bookmark_delete_modal.tsx | 62 ++ .../channel_bookmarks/bookmark_dot_menu.tsx | 176 ++++++ .../channel_bookmarks/bookmark_icon.tsx | 92 +++ .../channel_bookmarks/bookmark_item.tsx | 245 ++++++++ .../channel_bookmarks/channel_bookmarks.scss | 69 +++ .../channel_bookmarks/channel_bookmarks.tsx | 63 ++ .../channel_bookmarks_create_modal.tsx | 581 ++++++++++++++++++ .../channel_bookmarks_plus_menu.tsx | 156 +++++ .../create_modal_name_input.tsx | 203 ++++++ .../src/components/channel_bookmarks/index.ts | 4 + .../src/components/channel_bookmarks/utils.ts | 135 ++++ .../__snapshots__/channel_view.test.tsx.snap | 9 + .../components/channel_view/channel_view.tsx | 6 +- .../emoji_picker_custom_emoji_button.tsx | 6 +- .../components/emoji_picker/emoji_picker.tsx | 13 +- .../emoji_picker_overlay.tsx | 7 +- .../emoji_picker/emoji_picker_tabs.tsx | 2 + .../__snapshots__/error_link.test.tsx.snap | 4 +- .../__snapshots__/external_link.test.tsx.snap | 4 +- .../src/components/external_link/index.tsx | 9 +- .../filename_overlay.test.tsx.snap | 8 +- .../file_attachment/file_attachment.tsx | 7 +- .../file_thumbnail/file_thumbnail.tsx | 81 ++- .../file_search_result_item.test.tsx.snap | 204 ------ .../file_search_result_item.tsx | 72 +-- .../abstract_command.test.tsx.snap | 12 +- .../abstract_outgoing_webhook.test.tsx.snap | 4 +- .../installed_oauth_apps.test.tsx.snap | 8 +- .../installed_outgoing_webhooks.test.tsx.snap | 8 +- ...d_outgoing_oauth_connections.test.tsx.snap | 12 +- webapp/channels/src/components/menu/menu.tsx | 16 + .../marketplace_item_plugin.test.tsx.snap | 4 +- .../message_attachment.test.tsx.snap | 24 +- .../product_notices.test.tsx.snap | 8 +- .../signup/__snapshots__/signup.test.tsx.snap | 8 +- .../components/widgets/inputs/input/input.tsx | 19 +- .../menu_item_external_link.test.tsx | 4 +- .../__snapshots__/youtube_video.test.tsx.snap | 8 +- webapp/channels/src/i18n/en.json | 26 + .../src/action_types/channel_bookmarks.ts | 11 + .../src/action_types/index.ts | 2 + .../src/actions/channel_bookmarks.ts | 104 ++++ .../reducers/entities/channel_bookmarks.ts | 74 +++ .../src/reducers/entities/files.ts | 40 +- .../src/reducers/entities/index.ts | 2 + .../selectors/entities/channel_bookmarks.ts | 21 + .../src/store/initial_state.ts | 3 + webapp/channels/src/utils/constants.tsx | 6 + webapp/channels/src/utils/emoji_utils.tsx | 4 + webapp/channels/src/utils/file_utils.tsx | 12 +- .../channels/src/utils/markdown/renderer.tsx | 2 +- webapp/channels/src/utils/url.tsx | 24 + webapp/platform/client/src/client4.ts | 60 +- .../platform/types/src/channel_bookmarks.ts | 57 ++ webapp/platform/types/src/client4.ts | 1 + webapp/platform/types/src/store.ts | 2 + 64 files changed, 2924 insertions(+), 438 deletions(-) create mode 100644 e2e-tests/cypress/tests/integration/channels/channel/channel_bookmarks_spec.ts create mode 100644 webapp/channels/src/actions/channel_bookmarks.ts create mode 100644 webapp/channels/src/components/channel_bookmarks/bookmark_create_modal.scss create mode 100644 webapp/channels/src/components/channel_bookmarks/bookmark_delete_modal.tsx create mode 100644 webapp/channels/src/components/channel_bookmarks/bookmark_dot_menu.tsx create mode 100644 webapp/channels/src/components/channel_bookmarks/bookmark_icon.tsx create mode 100644 webapp/channels/src/components/channel_bookmarks/bookmark_item.tsx create mode 100644 webapp/channels/src/components/channel_bookmarks/channel_bookmarks.scss create mode 100644 webapp/channels/src/components/channel_bookmarks/channel_bookmarks.tsx create mode 100644 webapp/channels/src/components/channel_bookmarks/channel_bookmarks_create_modal.tsx create mode 100644 webapp/channels/src/components/channel_bookmarks/channel_bookmarks_plus_menu.tsx create mode 100644 webapp/channels/src/components/channel_bookmarks/create_modal_name_input.tsx create mode 100644 webapp/channels/src/components/channel_bookmarks/index.ts create mode 100644 webapp/channels/src/components/channel_bookmarks/utils.ts create mode 100644 webapp/channels/src/packages/mattermost-redux/src/action_types/channel_bookmarks.ts create mode 100644 webapp/channels/src/packages/mattermost-redux/src/actions/channel_bookmarks.ts create mode 100644 webapp/channels/src/packages/mattermost-redux/src/reducers/entities/channel_bookmarks.ts create mode 100644 webapp/channels/src/packages/mattermost-redux/src/selectors/entities/channel_bookmarks.ts create mode 100644 webapp/platform/types/src/channel_bookmarks.ts diff --git a/e2e-tests/cypress/tests/integration/channels/channel/channel_bookmarks_spec.ts b/e2e-tests/cypress/tests/integration/channels/channel/channel_bookmarks_spec.ts new file mode 100644 index 0000000000..ee08e6366b --- /dev/null +++ b/e2e-tests/cypress/tests/integration/channels/channel/channel_bookmarks_spec.ts @@ -0,0 +1,277 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +// *************************************************************** +// - [#] indicates a test step (e.g. # Go to a page) +// - [*] indicates an assertion (e.g. * Check the title) +// - Use element ID when selecting an element. Create one if none. +// *************************************************************** + +// Group: @channels @channel @channel_bookmarks +// node run_tests.js --group='@channel' + +import {getRandomId} from '../../../utils'; +import * as TIMEOUTS from '../../../fixtures/timeouts'; + +describe('Channel Bookmarks', () => { + let testTeam: Cypress.Team; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + let user1: Cypress.UserProfile; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + let admin: Cypress.UserProfile; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + let channel: Cypress.Channel; + + before(() => { + cy.apiGetMe().then(({user: adminUser}) => { + admin = adminUser; + + cy.apiInitSetup().then(({team, user}) => { + testTeam = team; + user1 = user; + + cy.visit(`/${testTeam.name}/channels/town-square`); + cy.getCurrentChannelId().then((channelId) => { + cy.makeClient().then(async (client) => { + channel = await client.getChannel(channelId); + }); + }); + }); + }); + }); + + it('create link bookmark', () => { + // # Create link + const {link, realLink} = createLinkBookmark(); + + cy.findByTestId('channel-bookmarks-container').within(() => { + // * Verify href + cy.findByRole('link', {name: link}).should('have.attr', 'href', realLink); + }); + }); + + it('create link bookmark, with emoji and custom title', () => { + const {realLink, displayName, emojiName} = createLinkBookmark({displayName: 'custom display name', emojiName: 'smile'}); + + cy.findByTestId('channel-bookmarks-container').within(() => { + // * Verify emoji, displayname, and href + cy.findAllByRole('link', {name: `:${emojiName}: ${displayName}`}).should('have.attr', 'href', realLink); + }); + }); + + it('create file bookmark, and open preview', () => { + // # Create bookmark + const {file} = createFileBookmark({file: 'small-image.png'}); + + // * Verify preview icon + cy.findAllByRole('link', {name: file}).as('link').find('.file-icon.image'); + + // # Open preview + cy.get('@link').click(); + + // * Verify preview opened + cy.get('.file-preview-modal').findByRole('heading', {name: file}); + cy.get('.file-preview-modal__file-details-user-name').should('have.text', admin.username); + cy.get('.file-preview-modal__channel').should('have.text', `Shared in ~${channel.display_name}`); + cy.get('.icon-close').click(); + }); + + it('create file bookmark, progress and cancel upload', () => { + const file = 'powerpointx-file.pptx'; + + cy.intercept( + {method: 'POST', pathname: 'files', middleware: true, times: 1}, + () => { + return new Promise((resolve) => + setTimeout(() => resolve(), 2000), + ); + }).as('uploadRequest'); + + // # Create bookmark + createFileBookmark({file, save: false}); + + // # Cancel upload + cy.get('a.file-preview__remove').click(); + + // * Verify empty preview container + cy.get('.file-preview__container.empty'); + + // * Verify upload cancelled + cy.wait('@uploadRequest').its('state').should('eq', 'Errored'); + + // * Verify cannot save + cy.findByRole('button', {name: 'Add bookmark'}).should('be.disabled'); + + // # Try upload file again + cy.get('#bookmark-create-file-input-in-modal').attachFile(file); + + // * Verify uploaded + cy.findByTestId('titleInput').should('have.value', file); + cy.findByRole('link', {name: `file thumbnail ${file}`}); + + // # Save + editModalCreate(); + + // * Verify bookmark created + cy.findAllByRole('link', {name: file}); + }); + + it('create file bookmark, with emoji and custom title', () => { + // # Create bookmark + const {file, displayName, emojiName} = createFileBookmark({file: 'm4a-audio-file.m4a', displayName: 'custom displayname small-image', emojiName: 'smile'}); + + // * Verify emoji and custom display name + cy.findAllByRole('link', {name: `:${emojiName}: ${displayName}`}).click(); + + // * Verify preview opened + cy.get('.file-preview-modal').findByRole('heading', {name: file}); + cy.get('.icon-close').click(); + }); + + it('edit link bookmark', () => { + // # Create link + const {displayName} = createLinkBookmark(); + + const nextLink = 'google.com/q=test001'; + const realNextLink = `http://${nextLink}`; + const nextDisplayName = 'Next custom display name'; + const nextEmojiName = 'handshake'; + + // # Open edit + openEditModal(displayName); + + // # Change link, displayname, emoji + editTextInput('linkInput', nextLink); + editTextInput('titleInput', nextDisplayName); + selectEmoji(nextEmojiName); + + // # Save + editModalSave(); + + // * Verify changes + cy.findAllByRole('link', {name: `:${nextEmojiName}: ${nextDisplayName}`}).should('have.attr', 'href', realNextLink); + }); + + it('delete bookmark', () => { + const {displayName} = createLinkBookmark(); + + // * Verify bookmark exists + cy.findByRole('link', {name: displayName}); + + // # Start delete bookmark flow + openDotMenu(displayName); + cy.findByRole('menuitem', {name: 'Delete'}).click(); + cy.findByRole('dialog', {name: 'Delete bookmark'}).within(() => { + // * Verify delete dialog contents + cy.findByRole('heading', {name: 'Delete bookmark'}); + cy.contains(`Are you sure you want to delete the bookmark ${displayName}?`); + + // # Delete bookmark + cy.findByRole('button', {name: 'Yes, delete'}).click(); + }); + + // * Verify bookmark deleted + cy.findByRole('link', {name: displayName}).should('not.exist'); + }); +}); + +function promptAddLink() { + cy.get('#channelBookmarksPlusMenuButton').click(); + cy.get('#channelBookmarksAddLink').click(); +} + +function openEditModal(name: string) { + openDotMenu(name); + cy.findByRole('menuitem', {name: 'Edit'}).click(); +} + +function openDotMenu(name: string) { + cy.findByTestId('channel-bookmarks-container').within(() => { + // # open menu + cy.findByRole('link', {name}).scrollIntoView().focus(). + parent('div').find('button').click(); + }); +} + +function editModalSave() { + cy.findByRole('button', {name: 'Save bookmark'}).click(); +} + +function editModalCreate() { + cy.findByRole('button', {name: 'Add bookmark'}).click(); +} + +function createLinkBookmark({ + link = `google.com/?q=test${getRandomId(7)}`, + displayName = '', + emojiName = '', // e.g. smile + save = true, +} = {}) { + const realLink = `http://${link}`; + + // # Add link + promptAddLink(); + + // # Enter link + editTextInput('linkInput', link); + + if (displayName) { + // # Enter displayname + editTextInput('titleInput', displayName); + } + + if (emojiName) { + // # Select emoji + selectEmoji(emojiName); + } + + if (save) { + // # Save + editModalCreate(); + } + + return {link, realLink, displayName: displayName || link, emojiName}; +} + +function createFileBookmark({ + file = 'small-image.png', + displayName = '', + emojiName = '', // e.g. smile + save = true, +} = {}) { + cy.get('#bookmark-create-file-input').attachFile(file); + + if (displayName) { + // # Enter displayname + cy.findByTestId('titleInput').should('have.value', file); + editTextInput('titleInput', displayName); + } + + if (emojiName) { + // # Select emoji + selectEmoji(emojiName); + } + + if (save) { + // # Save + cy.findByRole('button', {name: 'Add bookmark'}).click(); + } + + return {file, displayName, emojiName}; +} + +function editTextInput(testid: string, nextValue: string) { + cy.findByTestId(testid). + focus(). + clear(). + wait(TIMEOUTS.HALF_SEC). + type(nextValue). + wait(TIMEOUTS.HALF_SEC). + should('have.value', nextValue); +} + +function selectEmoji(emojiName: string) { + cy.findByRole('button', {name: 'select an emoji'}).click(); + cy.focused().type(`${emojiName}{downArrow}{enter}`); +} diff --git a/webapp/channels/src/actions/channel_actions.ts b/webapp/channels/src/actions/channel_actions.ts index 1dca18ef12..2b5bc9bb5b 100644 --- a/webapp/channels/src/actions/channel_actions.ts +++ b/webapp/channels/src/actions/channel_actions.ts @@ -174,3 +174,4 @@ export function muteChannel(userId: UserProfile['id'], channelId: Channel['id']) mark_unread: NotificationLevels.MENTION, }); } + diff --git a/webapp/channels/src/actions/channel_bookmarks.ts b/webapp/channels/src/actions/channel_bookmarks.ts new file mode 100644 index 0000000000..4ed49f7026 --- /dev/null +++ b/webapp/channels/src/actions/channel_bookmarks.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {ChannelBookmarkCreate, ChannelBookmarkPatch} from '@mattermost/types/channel_bookmarks'; + +import * as ChannelBookmarkActions from 'mattermost-redux/actions/channel_bookmarks'; +import type {DispatchFunc, GetStateFunc} from 'mattermost-redux/types/actions'; + +import {getConnectionId} from 'selectors/general'; + +import type {GlobalState} from 'types/store'; + +export function deleteBookmark(channelId: string, id: string) { + return async (dispatch: DispatchFunc, getState: GetStateFunc) => { + const state = getState() as GlobalState; + const connectionId = getConnectionId(state); + return dispatch(ChannelBookmarkActions.deleteBookmark(channelId, id, connectionId)); + }; +} + +export function createBookmark(channelId: string, bookmark: ChannelBookmarkCreate) { + return async (dispatch: DispatchFunc, getState: GetStateFunc) => { + const state = getState() as GlobalState; + const connectionId = getConnectionId(state); + return dispatch(ChannelBookmarkActions.createBookmark(channelId, bookmark, connectionId)); + }; +} + +export function editBookmark(channelId: string, id: string, patch: ChannelBookmarkPatch) { + return async (dispatch: DispatchFunc, getState: GetStateFunc) => { + const state = getState() as GlobalState; + const connectionId = getConnectionId(state); + return dispatch(ChannelBookmarkActions.editBookmark(channelId, id, patch, connectionId)); + }; +} + +export function fetchChannelBookmarks(channelId: string) { + return ChannelBookmarkActions.fetchChannelBookmarks(channelId); +} diff --git a/webapp/channels/src/actions/file_actions.ts b/webapp/channels/src/actions/file_actions.ts index ec9359ce71..30b16ff79b 100644 --- a/webapp/channels/src/actions/file_actions.ts +++ b/webapp/channels/src/actions/file_actions.ts @@ -28,13 +28,18 @@ export interface UploadFile { onError: (err: string | ServerError, clientId: string, channelId: string, rootId: string) => void; } -export function uploadFile({file, name, type, rootId, channelId, clientId, onProgress, onSuccess, onError}: UploadFile): ThunkActionFunc { +export function uploadFile({file, name, type, rootId, channelId, clientId, onProgress, onSuccess, onError}: UploadFile, isBookmark?: boolean): ThunkActionFunc { return (dispatch, getState) => { dispatch({type: FileTypes.UPLOAD_FILES_REQUEST}); + let url = Client4.getFilesRoute(); + if (isBookmark) { + url += '?bookmark=true'; + } + const xhr = new XMLHttpRequest(); - xhr.open('POST', Client4.getFilesRoute(), true); + xhr.open('POST', url, true); const client4Headers = Client4.getOptions({method: 'POST'}).headers; Object.keys(client4Headers).forEach((client4Header) => { diff --git a/webapp/channels/src/actions/websocket_actions.jsx b/webapp/channels/src/actions/websocket_actions.jsx index faaa160fce..84732fdf68 100644 --- a/webapp/channels/src/actions/websocket_actions.jsx +++ b/webapp/channels/src/actions/websocket_actions.jsx @@ -20,6 +20,7 @@ import { AppsTypes, CloudTypes, HostedCustomerTypes, + ChannelBookmarkTypes, } from 'mattermost-redux/action_types'; import {getStandardAnalytics} from 'mattermost-redux/actions/admin'; import {fetchAppBindings, fetchRHSAppsBindings} from 'mattermost-redux/actions/apps'; @@ -432,6 +433,22 @@ export function handleEvent(msg) { handleChannelMemberUpdatedEvent(msg); break; + case SocketEvents.CHANNEL_BOOKMARK_CREATED: + dispatch(handleChannelBookmarkCreated(msg)); + break; + + case SocketEvents.CHANNEL_BOOKMARK_UPDATED: + dispatch(handleChannelBookmarkUpdated(msg)); + break; + + case SocketEvents.CHANNEL_BOOKMARK_DELETED: + dispatch(handleChannelBookmarkDeleted(msg)); + break; + + case SocketEvents.CHANNEL_BOOKMARK_SORTED: + dispatch(handleChannelBookmarkSorted(msg)); + break; + case SocketEvents.DIRECT_ADDED: dispatch(handleDirectAddedEvent(msg)); break; @@ -1755,3 +1772,50 @@ function handleHostedCustomerSignupProgressUpdated(msg) { data: msg.data.progress, }; } + +function handleChannelBookmarkCreated(msg) { + const bookmark = JSON.parse(msg.data.bookmark); + + return { + type: ChannelBookmarkTypes.RECEIVED_BOOKMARK, + data: bookmark, + }; +} + +function handleChannelBookmarkUpdated(msg) { + return async (doDispatch) => { + const {updated, deleted} = JSON.parse(msg.data.bookmarks); + + if (updated) { + doDispatch({ + type: ChannelBookmarkTypes.RECEIVED_BOOKMARK, + data: updated, + }); + } + + if (deleted) { + doDispatch({ + type: ChannelBookmarkTypes.BOOKMARK_DELETED, + data: deleted, + }); + } + }; +} + +function handleChannelBookmarkDeleted(msg) { + const bookmark = JSON.parse(msg.data.bookmark); + + return { + type: ChannelBookmarkTypes.BOOKMARK_DELETED, + data: bookmark, + }; +} + +function handleChannelBookmarkSorted(msg) { + const bookmarks = JSON.parse(msg.data.bookmarks); + + return { + type: ChannelBookmarkTypes.RECEIVED_BOOKMARKS, + data: {channelId: msg.broadcast.channel_id, bookmarks}, + }; +} diff --git a/webapp/channels/src/components/admin_console/license_settings/__snapshots__/license_settings.test.tsx.snap b/webapp/channels/src/components/admin_console/license_settings/__snapshots__/license_settings.test.tsx.snap index efb60cdcf3..ec0e06b16c 100644 --- a/webapp/channels/src/components/admin_console/license_settings/__snapshots__/license_settings.test.tsx.snap +++ b/webapp/channels/src/components/admin_console/license_settings/__snapshots__/license_settings.test.tsx.snap @@ -63,21 +63,21 @@ exports[`components/admin_console/license_settings/LicenseSettings load screen a className="terms-and-policy" > See also - Enterprise Edition Terms of Use - + and - Privacy Policy - +
See also - Enterprise Edition Terms of Use - + and - Privacy Policy - +
See also - Enterprise Edition Terms of Use - + and - Privacy Policy - +
Curious about upgrading? - Compare Plans - +
@@ -420,21 +420,21 @@ exports[`components/admin_console/license_settings/LicenseSettings should match className="terms-and-policy" > See also - Enterprise Edition Terms of Use - + and - Privacy Policy - +
See also - Enterprise Edition Terms of Use - + and - Privacy Policy - +
See also - Enterprise Edition Terms of Use - + and - Privacy Policy - +
See also - Enterprise Edition Terms of Use - + and - Privacy Policy - +
See also - Enterprise Edition Terms of Use - + and - Privacy Policy - +
Curious about upgrading? - Compare Plans - +
@@ -1127,21 +1127,21 @@ exports[`components/admin_console/license_settings/LicenseSettings should match className="terms-and-policy" > See also - Enterprise Edition Terms of Use - + and - Privacy Policy - +
Curious about upgrading? - Compare Plans - +
@@ -1239,21 +1239,21 @@ exports[`components/admin_console/license_settings/LicenseSettings should match className="terms-and-policy" > See also - Enterprise Edition Terms of Use - + and - Privacy Policy - +
Curious about upgrading? - Compare Plans - +
@@ -1346,21 +1346,21 @@ exports[`components/admin_console/license_settings/LicenseSettings should match className="terms-and-policy" > See also - Enterprise Edition Terms of Use - + and - Privacy Policy - +
See also - Enterprise Edition Terms of Use - + and - Privacy Policy - +
Curious about upgrading? - Compare Plans - +
@@ -1554,21 +1554,21 @@ exports[`components/admin_console/license_settings/LicenseSettings should match className="terms-and-policy" > See also - Enterprise Edition Terms of Use - + and - Privacy Policy - +
Curious about upgrading? - Compare Plans - +
@@ -1659,21 +1659,21 @@ exports[`components/admin_console/license_settings/LicenseSettings should match className="terms-and-policy" > See also - Enterprise Edition Terms of Use - + and - Privacy Policy - +
See also - Enterprise Edition Terms of Use - + and - Privacy Policy - +
- - +
void; + onCancel?: () => void; + onExited: () => void; +} + +const noop = () => {}; + +function BookmarkDeleteModal({ + displayName, + onExited, + onCancel, + onConfirm, +}: Props) { + const {formatMessage} = useIntl(); + + const title = formatMessage({ + id: 'channel_bookmarks.confirm.delete.title', + defaultMessage: 'Delete bookmark', + }); + + const confirmButtonText = formatMessage({ + id: 'channel_bookmarks.confirm.delete.button', + defaultMessage: 'Yes, delete', + }); + + const message = ( + {displayName}?'} + values={{ + strong: (chunk: string) => {chunk}, + displayName, + }} + /> + ); + + return ( + + {message} + + ); +} + +export default BookmarkDeleteModal; diff --git a/webapp/channels/src/components/channel_bookmarks/bookmark_dot_menu.tsx b/webapp/channels/src/components/channel_bookmarks/bookmark_dot_menu.tsx new file mode 100644 index 0000000000..382bd991bd --- /dev/null +++ b/webapp/channels/src/components/channel_bookmarks/bookmark_dot_menu.tsx @@ -0,0 +1,176 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; +import {useIntl} from 'react-intl'; +import {useDispatch} from 'react-redux'; + +import { + DotsHorizontalIcon, + PencilOutlineIcon, + LinkVariantIcon, + TrashCanOutlineIcon, + ArrowExpandIcon, + OpenInNewIcon, + BookOutlineIcon, +} from '@mattermost/compass-icons/components'; +import type {ChannelBookmark, ChannelBookmarkPatch} from '@mattermost/types/channel_bookmarks'; + +import type {ActionResult} from 'mattermost-redux/types/actions'; +import {getFileDownloadUrl} from 'mattermost-redux/utils/file_utils'; + +import {editBookmark, deleteBookmark} from 'actions/channel_bookmarks'; +import {openModal} from 'actions/views/modals'; + +import GetPublicModal from 'components/get_public_link_modal'; +import * as Menu from 'components/menu'; + +import {ModalIdentifiers} from 'utils/constants'; +import {getSiteURL, shouldOpenInNewTab} from 'utils/url'; +import {copyToClipboard} from 'utils/utils'; + +import BookmarkDeleteModal from './bookmark_delete_modal'; +import ChannelBookmarksCreateModal from './channel_bookmarks_create_modal'; +import {useCanGetPublicLink, useChannelBookmarkPermission} from './utils'; + +type Props = {bookmark: ChannelBookmark; open: () => void}; +const BookmarkItemDotMenu = ({ + bookmark, + open, +}: Props) => { + const {formatMessage} = useIntl(); + const dispatch = useDispatch(); + + const siteURL = getSiteURL(); + const openInNewTab = bookmark.type === 'link' && bookmark.link_url && shouldOpenInNewTab(bookmark.link_url, siteURL); + + let openIcon; + if (bookmark.type === 'file') { + openIcon = ; + } else if (bookmark.link_url) { + openIcon = openInNewTab ? : ; + } + + const canEdit = useChannelBookmarkPermission(bookmark.channel_id, 'edit'); + const canDelete = useChannelBookmarkPermission(bookmark.channel_id, 'delete'); + const canGetPublicLink = useCanGetPublicLink(); + + const editLabel = formatMessage({id: 'channel_bookmarks.edit', defaultMessage: 'Edit'}); + 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 deleteLabel = formatMessage({id: 'channel_bookmarks.delete', defaultMessage: 'Delete'}); + + const handleEdit = useCallback(() => { + dispatch(openModal({ + modalId: ModalIdentifiers.CHANNEL_BOOKMARK_CREATE, + dialogType: ChannelBookmarksCreateModal, + dialogProps: { + bookmark, + channelId: bookmark.channel_id, + onConfirm: async (data: ChannelBookmarkPatch) => dispatch(editBookmark(bookmark.channel_id, bookmark.id, data)) as ActionResult, + }, + })); + }, [editBookmark, dispatch, bookmark]); + + const copyLink = useCallback(() => { + if (bookmark.type === 'link' && bookmark.link_url) { + copyToClipboard(bookmark.link_url); + } else if (bookmark.type === 'file' && bookmark.file_id) { + copyToClipboard(getFileDownloadUrl(bookmark.file_id)); + } + }, [bookmark.type, bookmark.link_url, bookmark.file_id]); + + const handleDelete = useCallback(() => { + dispatch(openModal({ + modalId: ModalIdentifiers.CHANNEL_BOOKMARK_DELETE, + dialogType: BookmarkDeleteModal, + dialogProps: { + displayName: bookmark.display_name, + onConfirm: () => dispatch(deleteBookmark(bookmark.channel_id, bookmark.id)), + }, + })); + }, [deleteBookmark, dispatch, bookmark]); + + const handleGetPublicLink = useCallback(() => { + if (!bookmark.file_id) { + return; + } + + dispatch(openModal({ + modalId: ModalIdentifiers.GET_PUBLIC_LINK_MODAL, + dialogType: GetPublicModal, + dialogProps: { + fileId: bookmark.file_id, + }, + })); + }, [bookmark.file_id, dispatch]); + + return ( + , + 'aria-label': formatMessage({id: 'channel_bookmarks.editBookmarkLabel', defaultMessage: 'Bookmark menu'}), + }} + menu={{ + id: 'channelBookmarksDotMenuDropdown', + }} + > + {openLabel}} + aria-label={openLabel} + /> + {canEdit && ( + } + labels={{editLabel}} + aria-label={editLabel} + /> + )} + {bookmark.type === 'link' && ( + } + labels={{copyLinkLabel}} + aria-label={copyLinkLabel} + /> + )} + {bookmark.type === 'file' && canGetPublicLink && ( + } + labels={{copyFileLabel}} + aria-label={copyFileLabel} + /> + )} + {canDelete && ( + } + labels={{deleteLabel}} + aria-label={deleteLabel} + isDestructive={true} + /> + )} + + ); +}; + +export default BookmarkItemDotMenu; diff --git a/webapp/channels/src/components/channel_bookmarks/bookmark_icon.tsx b/webapp/channels/src/components/channel_bookmarks/bookmark_icon.tsx new file mode 100644 index 0000000000..fd4b74c187 --- /dev/null +++ b/webapp/channels/src/components/channel_bookmarks/bookmark_icon.tsx @@ -0,0 +1,92 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {useSelector} from 'react-redux'; +import styled from 'styled-components'; + +import {FileGenericOutlineIcon, BookOutlineIcon} from '@mattermost/compass-icons/components'; +import type {ChannelBookmark} from '@mattermost/types/channel_bookmarks'; +import type {FileInfo} from '@mattermost/types/files'; + +import {getConfig} from 'mattermost-redux/selectors/entities/general'; + +import RenderEmoji from 'components/emoji/render_emoji'; +import FileThumbnail from 'components/file_attachment/file_thumbnail'; +import type {FilePreviewInfo} from 'components/file_preview/file_preview'; + +import {trimmedEmojiName} from 'utils/emoji_utils'; +import {getImageSrc} from 'utils/post_utils'; + +import type {GlobalState} from 'types/store'; + +type Props = { + type: ChannelBookmark['type']; + emoji?: string; + imageUrl?: string; + fileInfo?: FileInfo | FilePreviewInfo; + size?: 16 | 24; +} + +const BookmarkIcon = ({ + type, + emoji, + imageUrl, + fileInfo, + size = 16, +}: Props) => { + let icon = type === 'link' ? : ; + const emojiName = emoji && trimmedEmojiName(emoji); + const hasImageProxy = useSelector((state: GlobalState) => getConfig(state).HasImageProxy === 'true'); + + if (emojiName) { + icon = ( + + ); + } else if (imageUrl) { + icon = ( + + ); + } else if (fileInfo) { + icon = ( + + ); + } + + return ( + + {icon} + + ); +}; + +export default BookmarkIcon; + +const Icon = styled.div<{$size: number}>` + padding: 3px 1px 3px 2px; + flex-shrink: 0; + display: flex; + align-items: center; + + .file-icon { + width: ${({$size: size}) => size}px; + height: ${({$size: size}) => size}px; + background-size: ${({$size: size}) => size * 0.8}px ${({$size: size}) => size}px; + margin-top: 1px; + } + +`; + +const BookmarkIconImg = styled.img<{size: number}>` + width: ${({size}) => size}px; + height: ${({size}) => size}px; +`; diff --git a/webapp/channels/src/components/channel_bookmarks/bookmark_item.tsx b/webapp/channels/src/components/channel_bookmarks/bookmark_item.tsx new file mode 100644 index 0000000000..c69adf66f1 --- /dev/null +++ b/webapp/channels/src/components/channel_bookmarks/bookmark_item.tsx @@ -0,0 +1,245 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {HTMLAttributes} from 'react'; +import React, {forwardRef, useRef} from 'react'; +import {useDispatch, useSelector} from 'react-redux'; +import {Link} from 'react-router-dom'; +import styled, {css} from 'styled-components'; + +import type {ChannelBookmark} from '@mattermost/types/channel_bookmarks'; +import type {FileInfo} from '@mattermost/types/files'; +import type {Post} from '@mattermost/types/posts'; + +import {getFile} from 'mattermost-redux/selectors/entities/files'; +import {getFileDownloadUrl} from 'mattermost-redux/utils/file_utils'; + +import {openModal} from 'actions/views/modals'; + +import ExternalLink from 'components/external_link'; +import FilePreviewModal from 'components/file_preview_modal'; + +import {ModalIdentifiers} from 'utils/constants'; +import {getSiteURL, shouldOpenInNewTab} from 'utils/url'; + +import type {GlobalState} from 'types/store'; + +import BookmarkItemDotMenu from './bookmark_dot_menu'; +import BookmarkIcon from './bookmark_icon'; + +type Props = {bookmark: ChannelBookmark}; +const BookmarkItem = ({bookmark}: Props) => { + const linkRef = useRef(null); + const dispatch = useDispatch(); + const fileInfo: FileInfo | undefined = useSelector((state: GlobalState) => (bookmark?.file_id && getFile(state, bookmark.file_id)) || undefined); + + const open = () => { + linkRef.current?.click(); + }; + + const handleOpenFile = (e: React.MouseEvent) => { + e.preventDefault(); + + if (fileInfo) { + dispatch(openModal({ + modalId: ModalIdentifiers.FILE_PREVIEW_MODAL, + dialogType: FilePreviewModal, + dialogProps: { + post: {user_id: bookmark.owner_id, channel_id: bookmark.channel_id} as Post, + fileInfos: [fileInfo], + startIndex: 0, + }, + })); + } + }; + + const icon = ( + + ); + let link; + + if (bookmark.type === 'link' && bookmark.link_url) { + link = ( + + {icon} + + + ); + } else if (bookmark.type === 'file' && bookmark.file_id) { + link = ( + + {icon} + + + ); + } + + return ( + + {link} + + + ); +}; + +const Chip = styled.div` + position: relative; + border-radius: 12px; + overflow: hidden; + margin: 1px 0; + flex-shrink: 0; + min-width: 5rem; + max-width: 25rem; + + button { + position: absolute; + visibility: hidden; + right: 6px; + top: 3px; + } + + &:hover, + &:focus-within, + &:has([aria-expanded="true"]) { + button { + visibility: visible; + } + } + + &:hover, + &:focus-within { + a { + text-decoration: none; + } + } + + &:hover, + &:focus-within, + &:has([aria-expanded="true"]) { + a { + background: rgba(var(--center-channel-color-rgb), 0.08); + color: rgba(var(--center-channel-color-rgb), 1); + } + } + + &:active:not(:has(button:active)), + &--active, + &--active:hover { + a { + background: rgba(var(--button-bg-rgb), 0.08); + color: rgb(var(--button-bg-rgb)) !important; + + .icon__text { + color: rgb(var(--button-bg)); + } + + .icon { + color: rgb(var(--button-bg)); + } + } + + } +`; + +const Label = styled.span` + white-space: nowrap; + padding: 4px 0; + text-overflow: ellipsis; + overflow: hidden; +`; + +const TARGET_BLANK_URL_PREFIX = '!'; + +type DynamicLinkProps = {href: string; children: React.ReactNode; isFile: boolean; onClick?: HTMLAttributes['onClick']}; +const DynamicLink = forwardRef(({href, children, isFile, onClick}, ref) => { + const siteURL = getSiteURL(); + const openInNewTab = shouldOpenInNewTab(href, siteURL); + + const prefixed = href[0] === TARGET_BLANK_URL_PREFIX; + + if (prefixed || openInNewTab) { + return ( + + {children} + + ); + } + + if (href.startsWith(siteURL) && !isFile) { + return ( + + {children} + + ); + } + + return ( + + {children} + + ); +}); + +const linkStyles = css` + display: flex; + padding: 0 12px 0 6px; + gap: 5px; + + color: rgba(var(--center-channel-color-rgb), 1); + font-family: Open Sans; + font-size: 12px; + font-style: normal; + font-weight: 600; + line-height: 16px; +`; + +const StyledAnchor = styled.a` + &&&& { + ${linkStyles} + } +`; + +const StyledLink = styled(Link)` + &&&& { + ${linkStyles} + } +`; + +const StyledExternalLink = styled(ExternalLink)` + &&&& { + ${linkStyles} + } +`; + +export default BookmarkItem; diff --git a/webapp/channels/src/components/channel_bookmarks/channel_bookmarks.scss b/webapp/channels/src/components/channel_bookmarks/channel_bookmarks.scss new file mode 100644 index 0000000000..96908b1d53 --- /dev/null +++ b/webapp/channels/src/components/channel_bookmarks/channel_bookmarks.scss @@ -0,0 +1,69 @@ + +.channelBookmarksMenuButton { + position: relative; + display: flex; + padding: 4px; + border: none; + border-radius: 4px; + background: transparent; + color: rgba(var(--center-channel-color-rgb), 0.56); + font-family: Open Sans; + font-size: 12px; + font-weight: 600; + gap: 5px; + + &.withLabel { + padding: 4px 12px 4px 6px; + border-radius: 12px; + } + + &[disabled] { + background: rgba(var(--center-channel-color-rgb), 0.04); + color: rgba(var(--center-channel-color-rgb), 0.56); + } + + &:hover:enabled { + background: rgba(var(--center-channel-color-rgb), 0.08); + color: rgba(var(--center-channel-color-rgb), 0.72); + } + + &:active:enabled, + &[aria-expanded="true"] { + background: rgba(var(--button-bg-rgb), 0.08); + color: rgb(var(--button-bg-rgb)); + } +} + +.channelBookmarksDotMenuButton { + display: flex; + width: 18px; + height: 18px; + align-items: center; + padding: 0; + border: none; + border-radius: 4px; + background: rgba(var(--center-channel-color-rgb), 8%); + background: color-mix(in sRGB, var(--center-channel-bg), var(--center-channel-color) 8%); + color: rgba(var(--center-channel-color-rgb), 0.56); + font-family: Open Sans; + font-size: 12px; + font-weight: 600; + + &:hover { + background: rgba(var(--button-bg-rgb), 16%); + background: color-mix(in sRGB, var(--center-channel-bg), var(--center-channel-color) 16%); + color: rgba(var(--center-channel-color-rgb), 0.72); + } + + &:active, + &[aria-expanded="true"] { + background: rgba(var(--button-bg-rgb), 16%); + background: color-mix(in sRGB, var(--center-channel-bg), var(--button-bg) 16%); + color: rgb(var(--button-bg-rgb)); + } +} + +#bookmark-create-file-input, +#bookmark-create-file-input-in-modal { + display: none; +} diff --git a/webapp/channels/src/components/channel_bookmarks/channel_bookmarks.tsx b/webapp/channels/src/components/channel_bookmarks/channel_bookmarks.tsx new file mode 100644 index 0000000000..c6888fa115 --- /dev/null +++ b/webapp/channels/src/components/channel_bookmarks/channel_bookmarks.tsx @@ -0,0 +1,63 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import styled from 'styled-components'; + +import BookmarkItem from './bookmark_item'; +import PlusMenu from './channel_bookmarks_plus_menu'; +import {useChannelBookmarkPermission, useChannelBookmarks, useIsChannelBookmarksEnabled, MAX_BOOKMARKS_PER_CHANNEL, useCanUploadFiles} from './utils'; + +import './channel_bookmarks.scss'; + +type Props = { + channelId: string; +}; + +const ChannelBookmarks = ({ + channelId, +}: Props) => { + const show = useIsChannelBookmarksEnabled(); + const {order, bookmarks} = useChannelBookmarks(channelId); + const canUploadFiles = useCanUploadFiles(); + const canAdd = useChannelBookmarkPermission(channelId, 'add'); + const hasBookmarks = Boolean(order?.length); + + if (!show || (!hasBookmarks && !canAdd)) { + return null; + } + + return ( + + {order.map((id) => { + return ( + + ); + })} + {canAdd && ( + = MAX_BOOKMARKS_PER_CHANNEL} + canUploadFiles={canUploadFiles} + /> + )} + + ); +}; + +export default ChannelBookmarks; + +const Container = styled.div` + display: flex; + padding: 8px 6px; + padding-right: 0; + align-items: center; + border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.12); + overflow-x: auto; + overflow-y: hidden; + overflow-y: clip; +`; diff --git a/webapp/channels/src/components/channel_bookmarks/channel_bookmarks_create_modal.tsx b/webapp/channels/src/components/channel_bookmarks/channel_bookmarks_create_modal.tsx new file mode 100644 index 0000000000..b17ff25522 --- /dev/null +++ b/webapp/channels/src/components/channel_bookmarks/channel_bookmarks_create_modal.tsx @@ -0,0 +1,581 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {ChangeEvent, ClipboardEventHandler, FocusEventHandler, MouseEvent} from 'react'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; +import {FormattedMessage, defineMessages, useIntl} from 'react-intl'; +import {useDispatch, useSelector} from 'react-redux'; +import styled from 'styled-components'; + +import {PencilOutlineIcon} from '@mattermost/compass-icons/components'; +import {GenericModal} from '@mattermost/components'; +import type {ChannelBookmark, ChannelBookmarkCreate, ChannelBookmarkPatch} from '@mattermost/types/channel_bookmarks'; +import type {FileInfo} from '@mattermost/types/files'; + +import {debounce} from 'mattermost-redux/actions/helpers'; +import {getFile} from 'mattermost-redux/selectors/entities/files'; +import {getConfig} from 'mattermost-redux/selectors/entities/general'; +import type {ActionResult} from 'mattermost-redux/types/actions'; + +import type {UploadFile} from 'actions/file_actions'; +import {uploadFile} from 'actions/file_actions'; + +import FileAttachment from 'components/file_attachment'; +import type {FilePreviewInfo} from 'components/file_preview/file_preview'; +import FileProgressPreview from 'components/file_preview/file_progress_preview'; +import Input from 'components/widgets/inputs/input/input'; + +import Constants from 'utils/constants'; +import {isKeyPressed} from 'utils/keyboard'; +import {isValidUrl, parseLink} from 'utils/url'; +import {generateId} from 'utils/utils'; + +import type {GlobalState} from 'types/store'; + +import './bookmark_create_modal.scss'; + +import CreateModalNameInput from './create_modal_name_input'; +import {useCanUploadFiles} from './utils'; + +type Props = { + channelId: string; + bookmarkType?: ChannelBookmark['type']; + file?: File; + onExited: () => void; + onHide: () => void; +} & ({ + bookmark: ChannelBookmark; + onConfirm: (data: ChannelBookmarkPatch) => Promise> | ActionResult; +} | { + bookmark?: never; + onConfirm: (data: ChannelBookmarkCreate) => Promise> | ActionResult; +}); + +function validHttpUrl(input: string) { + const val = parseLink(input); + + if (!val || !isValidUrl(val)) { + return null; + } + + let url; + try { + url = new URL(val); + } catch { + return null; + } + + return url; +} + +function ChannelBookmarkCreateModal({ + bookmark, + bookmarkType, + file: promptedFile, + channelId, + onExited, + onConfirm, + onHide, +}: Props) { + const {formatMessage} = useIntl(); + const dispatch = useDispatch(); + + // common + const type = bookmark?.type ?? bookmarkType ?? 'link'; + const [showEmojiPicker, setShowEmojiPicker] = useState(false); + const [emoji, setEmoji] = useState(bookmark?.emoji ?? ''); + const [displayName, setDisplayName] = useState(bookmark?.display_name); + const [parsedDisplayName, setParsedDisplayName] = useState(); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(''); + + const handleKeyDown = useCallback((event: KeyboardEvent) => { + if (isKeyPressed(event, Constants.KeyCodes.ESCAPE) && !showEmojiPicker) { + onHide(); + } + }, [showEmojiPicker, onHide]); + + useEffect(() => { + document.addEventListener('keydown', handleKeyDown); + + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [handleKeyDown]); + + // type === 'link' + const [linkInputValue, setLinkInputValue] = useState(bookmark?.link_url ?? ''); + const [link, setLinkImmediately] = useState(linkInputValue); + const [linkError, setLinkError] = useState(''); + const [icon, setIcon] = useState(bookmark?.image_url); + + const handleLinkChange = useCallback(({target: {value}}: ChangeEvent) => { + setLinkInputValue(value); + setLink(value); + }, []); + + const setLink = debounce((val: string) => { + setLinkImmediately(val); + }, 250); + + const handleLinkBlur: FocusEventHandler = useCallback(({target: {value}}) => { + setLinkImmediately(value); + }, []); + + const handleLinkPasted: ClipboardEventHandler = useCallback(({clipboardData}) => { + setLinkImmediately(clipboardData.getData('text/plain')); + }, []); + + const resetParsed = () => { + setParsedDisplayName(link || ''); + setIcon(''); + }; + + useEffect(() => { + if (link === bookmark?.link_url || !link) { + return; + } + + const url = validHttpUrl(link); + + (async () => { + resetParsed(); + + if (!url) { + setLinkError('Please enter a valid link. Could not parse: ' + link); + return; + } + setLinkError(''); + setParsedDisplayName(link); + })(); + }, [link, bookmark?.link_url, channelId]); + + // type === 'file' + const canUploadFiles = useCanUploadFiles(); + const [pendingFile, setPendingFile] = useState(); + const [fileError, setFileError] = useState(''); + const [fileId, setFileId] = useState(bookmark?.file_id); + const uploadRequestRef = useRef(); + const fileInfo: FileInfo | undefined = useSelector((state: GlobalState) => (fileId && getFile(state, fileId)) || undefined); + + const maxFileSize = useSelector((state: GlobalState) => { + const config = getConfig(state); + return parseInt(config.MaxFileSize || '', 10); + }); + const maxFileSizeMB = maxFileSize / 1048576; + + const handleEditFileClick = (e: MouseEvent) => { + const innerClick = document.querySelector(` + .channel-bookmarks-create-modal .post-image__download a, + .channel-bookmarks-create-modal a.file-preview__remove + `); + if ( + innerClick === e.target || + innerClick?.contains(e.target as HTMLElement) + ) { + return; + } + + fileInputRef.current?.click(); + }; + + const handleFileChanged = useCallback((e: ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) { + return; + } + + doUploadFile(file); + }, []); + + const handleFileRemove = () => { + setPendingFile(null); + setFileId(bookmark?.file_id); + setParsedDisplayName(undefined); + uploadRequestRef.current?.abort(); + }; + + const fileInputRef = useRef(null); + const fileInput = ( + + ); + + const onProgress: UploadFile['onProgress'] = (preview) => { + setPendingFile(preview); + }; + const onSuccess: UploadFile['onSuccess'] = ({file_infos: fileInfos}) => { + setPendingFile(null); + const newFile: FileInfo = fileInfos?.[0]; + if (newFile) { + setFileId(newFile.id); + } + setFileError(''); + }; + const onError: UploadFile['onError'] = () => { + setPendingFile(null); + setFileError(formatMessage({id: 'file_upload.generic_error_file', defaultMessage: 'There was a problem uploading your file.'})); + }; + + const displayNameValue = displayName || parsedDisplayName || (type === 'file' ? fileInfo?.name : bookmark?.link_url) || ''; + + const doUploadFile = (file: File) => { + setPendingFile(null); + setFileId(''); + + if (file.size > maxFileSize) { + setFileError(formatMessage({ + id: 'file_upload.fileAbove', + defaultMessage: 'File above {max}MB could not be uploaded: {filename}', + }, {max: maxFileSizeMB, filename: file.name})); + + return; + } + + if (file.size === 0) { + setFileError(formatMessage({ + id: 'file_upload.zeroBytesFile', + defaultMessage: 'You are uploading an empty file: {filename}', + }, {filename: file.name})); + + return; + } + + setFileError(''); + if (displayNameValue === fileInfo?.name) { + setDisplayName(file.name); + } + setParsedDisplayName(file.name); + + const clientId = generateId(); + + uploadRequestRef.current = dispatch(uploadFile({ + file, + name: file.name, + type: file.type, + rootId: '', + channelId, + clientId, + onProgress, + onSuccess, + onError, + }, true)) as unknown as XMLHttpRequest; + }; + + useEffect(() => { + if (promptedFile) { + doUploadFile(promptedFile); + } + }, [promptedFile]); + + const handleOnExited = useCallback(() => { + uploadRequestRef.current?.abort(); + onExited?.(); + }, [onExited]); + + // controls logic + const hasChanges = (() => { + if (displayNameValue !== bookmark?.display_name) { + return true; + } + + if ((emoji || bookmark?.emoji) && emoji !== bookmark?.emoji) { + return true; + } + + if (type === 'file') { + if (fileId && fileId !== bookmark?.file_id) { + return true; + } + } + + if (type === 'link') { + return Boolean(link && link !== bookmark?.link_url); + } + + return false; + })(); + const isValid = (() => { + if (type === 'link') { + if (!link || linkError) { + return false; + } + } + + if (type === 'file') { + if (!fileInfo || !displayNameValue || fileError) { + return false; + } + } + + return true; + })(); + const showControls = type === 'file' || (isValid || bookmark); + + const cancel = useCallback(() => { + if (type === 'file') { + uploadRequestRef.current?.abort(); + } + }, [type]); + + const confirm = useCallback(async () => { + setSaving(true); + if (type === 'link') { + const url = validHttpUrl(link); + + if (!url) { + setSaveError(formatMessage(msg.linkInvalid)); + return; + } + + let validLink = url.toString(); + + if (validLink.endsWith('/')) { + validLink = validLink.slice(0, -1); + } + + const {data: success} = await onConfirm({ + image_url: icon, + link_url: validLink, + emoji, + display_name: displayNameValue, + type: 'link', + }); + + setSaving(false); + + if (success) { + setSaveError(''); + onHide(); + } else { + setSaveError(formatMessage(msg.saveError)); + } + } else if (fileInfo) { + const {data: success} = await onConfirm({ + file_id: fileInfo.id, + display_name: displayNameValue, + type: 'file', + emoji, + }); + + if (success) { + setSaveError(''); + onHide(); + } else { + setSaveError(formatMessage(msg.saveError)); + } + } + }, [type, link, onConfirm, onHide, fileInfo, displayNameValue, emoji, icon]); + + const confirmDisabled = saving || !isValid || !hasChanges; + + return ( + + <> + {type === 'link' ? ( + + ) : ( + <> + + + + + {!pendingFile && fileInfo && ( + + + + )} + {pendingFile && ( + + )} + {!fileInfo && !pendingFile && ( +
+ )} + + + {formatMessage(msg.fileInputEdit)} + + {fileInput} + + {fileError && ( +
+ + {fileError} +
+ )} + + + )} + + {showControls && ( + + + + + + + )} + + + ); +} + +export default ChannelBookmarkCreateModal; + +const TitleWrapper = styled.div` + margin-top: 20px; +`; + +const FieldLabel = styled.span` + display: inline-block; + margin-bottom: 8px; + font-family: Open Sans; + font-size: 14px; + line-height: 16px; + font-style: normal; + font-weight: 600; + line-height: 20px; +`; + +const VisualButton = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + padding: 10px 24px; + color: rgba(var(--center-channel-color-rgb), 0.56); + font-size: 11px; + font-weight: 600; + font-family: Open Sans; +`; + +const FileInputContainer = styled.div` + display: block; + background: rgba(var(--center-channel-color-rgb), 0.04); + padding: 12px; + border-radius: 8px; + display: flex; + + &:hover:not([disabled]) { + background: rgba(var(--center-channel-color-rgb), 0.08); + color: rgba(var(--center-channel-color-rgb), 0.72); + cursor: pointer; + } + + &:disabled { + cursor: default; + ${VisualButton} { + opacity: 0.4; + } + } + + input[type="file"] { + opacity: 0; + width: 0; + height: 0; + } + + .file-preview__container, + .file-preview { + width: auto; + height: auto; + flex: 1 1 auto; + padding: 0; + + &.empty { + border: 2px dashed rgba(var(--center-channel-color-rgb), 0.16); + border-radius : 4px; + } + + .post-image__column { + width: 100%; + margin: 0; + } + } +`; + +const FileItemContainer = styled.div` + display: flex; + flex: 1 1 auto; + + > div { + width: 100%; + margin: 0; + } +`; + +const msg = defineMessages({ + heading: {id: 'channel_bookmarks.create.title', defaultMessage: 'Add a bookmark'}, + editHeading: {id: 'channel_bookmarks.create.edit.title', defaultMessage: 'Edit bookmark'}, + linkPlaceholder: {id: 'channel_bookmarks.create.link_placeholder', defaultMessage: 'Link'}, + linkInfoMessage: {id: 'channel_bookmarks.create.link_info', defaultMessage: 'Add a link to any post, file, or any external link'}, + addBookmarkText: {id: 'channel_bookmarks.create.confirm_add.button', defaultMessage: 'Add bookmark'}, + saveText: {id: 'channel_bookmarks.create.confirm_save.button', defaultMessage: 'Save bookmark'}, + fileInputEdit: {id: 'channel_bookmarks.create.file_input.edit', defaultMessage: 'Edit'}, + linkInvalid: {id: 'channel_bookmarks.create.error.invalid_url', defaultMessage: 'Please enter a valid link'}, + saveError: {id: 'channel_bookmarks.create.error.generic_save', defaultMessage: 'There was an error trying to save the bookmark.'}, +}); diff --git a/webapp/channels/src/components/channel_bookmarks/channel_bookmarks_plus_menu.tsx b/webapp/channels/src/components/channel_bookmarks/channel_bookmarks_plus_menu.tsx new file mode 100644 index 0000000000..e8dbebe501 --- /dev/null +++ b/webapp/channels/src/components/channel_bookmarks/channel_bookmarks_plus_menu.tsx @@ -0,0 +1,156 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import classNames from 'classnames'; +import type {ChangeEvent} from 'react'; +import React, {useCallback, useRef} from 'react'; +import {useIntl} from 'react-intl'; +import {useDispatch} from 'react-redux'; +import styled, {css} from 'styled-components'; + +import { + LinkVariantIcon, + PaperclipIcon, + PlusIcon, +} from '@mattermost/compass-icons/components'; +import type {ChannelBookmarkCreate} from '@mattermost/types/channel_bookmarks'; + +import type {ActionResult} from 'mattermost-redux/types/actions'; + +import {createBookmark} from 'actions/channel_bookmarks'; +import {openModal} from 'actions/views/modals'; + +import * as Menu from 'components/menu'; + +import {ModalIdentifiers} from 'utils/constants'; +import {clearFileInput} from 'utils/utils'; + +import ChannelBookmarkCreateModal from './channel_bookmarks_create_modal'; +import {MAX_BOOKMARKS_PER_CHANNEL} from './utils'; + +type PlusMenuProps = { + channelId: string; + hasBookmarks: boolean; + limitReached: boolean; + canUploadFiles: boolean; +}; +const PlusMenu = ({ + channelId, + hasBookmarks, + limitReached, + canUploadFiles, +}: PlusMenuProps) => { + const {formatMessage} = useIntl(); + const dispatch = useDispatch(); + const showLabel = !hasBookmarks; + + const handleCreate = useCallback((file?: File) => { + dispatch(openModal({ + modalId: ModalIdentifiers.CHANNEL_BOOKMARK_CREATE, + dialogType: ChannelBookmarkCreateModal, + dialogProps: { + channelId, + bookmarkType: file ? 'file' : 'link', + file, + onConfirm: async (data: ChannelBookmarkCreate) => dispatch(createBookmark(channelId, data)) as ActionResult, + }, + })); + }, [channelId, dispatch]); + + const handleFileChanged = useCallback((e: ChangeEvent) => { + if (e.target.files?.length) { + const [file] = e.target.files; + handleCreate(file); + clearFileInput(e.target); + } + }, [handleCreate]); + + const fileInputRef = useRef(null); + const fileInput = ( + + ); + + const handleCreateLink = useCallback(() => { + handleCreate(); + }, [handleCreate]); + + const handleCreateFile = useCallback(() => { + fileInputRef.current?.click(); + }, [fileInputRef.current]); + + const addBookmarkLabel = formatMessage({id: 'channel_bookmarks.addBookmark', defaultMessage: 'Add a bookmark'}); + + const addBookmarkLimitReached = formatMessage({id: 'channel_bookmarks.addBookmarkLimitReached', defaultMessage: 'Cannot add more than {limit} bookmarks'}, {limit: MAX_BOOKMARKS_PER_CHANNEL}); + let addBookmarkTooltipText; + + if (limitReached) { + addBookmarkTooltipText = addBookmarkLimitReached; + } else if (hasBookmarks) { + addBookmarkTooltipText = addBookmarkLabel; + } + const addLinkLabel = formatMessage({id: 'channel_bookmarks.addLink', defaultMessage: 'Add a link'}); + const attachFileLabel = formatMessage({id: 'channel_bookmarks.attachFile', defaultMessage: 'Attach a file'}); + + return ( + + + + {showLabel && {addBookmarkLabel}} + + ), + 'aria-label': addBookmarkLabel, + disabled: limitReached, + }} + menu={{ + id: 'channelBookmarksPlusMenuDropdown', + }} + menuButtonTooltip={addBookmarkTooltipText ? { + id: 'channelBookmarksPlusMenuButtonTooltip', + text: addBookmarkTooltipText, + } : undefined} + > + } + labels={{addLinkLabel}} + aria-label={addLinkLabel} + /> + {canUploadFiles && ( + } + labels={{attachFileLabel}} + aria-label={attachFileLabel} + /> + )} + + {fileInput} + + ); +}; + +export default PlusMenu; + +const PlusButtonContainer = styled.div<{withLabel: boolean}>` + position: sticky; + right: 0; + ${({withLabel}) => !withLabel && css`padding: 0 1rem;`} + background: linear-gradient(to right, rgba(var(--center-channel-bg-rgb), .16), rgba(var(--center-channel-bg-rgb), 1) 25%); +`; diff --git a/webapp/channels/src/components/channel_bookmarks/create_modal_name_input.tsx b/webapp/channels/src/components/channel_bookmarks/create_modal_name_input.tsx new file mode 100644 index 0000000000..fcc8fe577d --- /dev/null +++ b/webapp/channels/src/components/channel_bookmarks/create_modal_name_input.tsx @@ -0,0 +1,203 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {ComponentProps} from 'react'; +import React, {useCallback, useRef} from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; +import styled from 'styled-components'; + +import {ChevronDownIcon} from '@mattermost/compass-icons/components'; +import type {ChannelBookmark} from '@mattermost/types/channel_bookmarks'; +import type {Emoji} from '@mattermost/types/emojis'; +import type {FileInfo} from '@mattermost/types/files'; + +import EmojiPickerOverlay from 'components/emoji_picker/emoji_picker_overlay'; +import Input from 'components/widgets/inputs/input/input'; + +import Constants, {A11yCustomEventTypes, type A11yFocusEventDetail} from 'utils/constants'; +import {isKeyPressed} from 'utils/keyboard'; + +import BookmarkIcon from './bookmark_icon'; + +type Props = { + type: ChannelBookmark['type']; + fileInfo: FileInfo | undefined; + imageUrl: string | undefined; + emoji: string | undefined; + setEmoji: React.Dispatch>; + placeholder: string | undefined; + displayName: string | undefined; + setDisplayName: React.Dispatch>; + showEmojiPicker: boolean; + setShowEmojiPicker: React.Dispatch>; + onAddCustomEmojiClick?: () => void; +} +const CreateModalNameInput = ({ + type, + imageUrl, + fileInfo, + emoji, + setEmoji, + placeholder, + displayName, + setDisplayName, + showEmojiPicker, + setShowEmojiPicker, + onAddCustomEmojiClick, +}: Props) => { + const {formatMessage} = useIntl(); + + const targetRef = useRef(null); + const getTargetRef = () => targetRef.current; + + const icon = ( + + ); + + const refocusEmojiButton = () => { + if (!targetRef.current) { + return; + } + + document.dispatchEvent(new CustomEvent( + A11yCustomEventTypes.FOCUS, { + detail: { + target: targetRef.current, + keyboardOnly: true, + }, + }, + )); + }; + + const toggleEmojiPicker = () => setShowEmojiPicker((prev) => !prev); + + const handleEmojiClick = (selectedEmoji: Emoji) => { + setShowEmojiPicker(false); + const emojiName = ('short_name' in selectedEmoji) ? selectedEmoji.short_name : selectedEmoji.name; + setEmoji(`:${emojiName}:`); + refocusEmojiButton(); + }; + + const handleEmojiClear = () => { + setEmoji(''); + }; + + const handleEmojiClose = () => { + setShowEmojiPicker(false); + refocusEmojiButton(); + }; + + const handleInputChange: ComponentProps['onChange'] = useCallback((e) => { + setDisplayName(e.currentTarget.value); + }, []); + + const handleEmojiKeyDown = (e: React.KeyboardEvent) => { + if (isKeyPressed(e, Constants.KeyCodes.ENTER)) { + e.stopPropagation(); + } + }; + + const handleEmojiResetKeyDown = (e: React.KeyboardEvent) => { + if (isKeyPressed(e, Constants.KeyCodes.ENTER) || isKeyPressed(e, Constants.KeyCodes.SPACE)) { + e.stopPropagation(); + handleEmojiClear(); + } + }; + + return ( + <> + + {showEmojiPicker && ( + + + )} + + + + + + + + ); +}; + +const Clear = styled.a<{visible: boolean}>` + font-size: 12px; + visibility: ${({visible}) => (visible ? 'visible' : 'hidden')}; +`; + +const NameWrapper = styled.div` + position: relative; + + > button { + position: absolute; + left: 1px; + top: 1px; + z-index: 5; + width: 57px; + height: 44px; + border-radius: 4px 0 0 4px; + border-right: 1px solid rgba(var(--center-channel-color-rgb), 0.16); + + align-items: center; + justify-content: center; + gap: 0; + padding-left: 6px; + padding-right: 2px; + + svg { + flex-shrink: 0; + } + } + + .Input_container { + + } + + .Input_wrapper { + padding-left: 7rem; + } +`; + +export default CreateModalNameInput; diff --git a/webapp/channels/src/components/channel_bookmarks/index.ts b/webapp/channels/src/components/channel_bookmarks/index.ts new file mode 100644 index 0000000000..c9c782abbc --- /dev/null +++ b/webapp/channels/src/components/channel_bookmarks/index.ts @@ -0,0 +1,4 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export {default} from './channel_bookmarks'; diff --git a/webapp/channels/src/components/channel_bookmarks/utils.ts b/webapp/channels/src/components/channel_bookmarks/utils.ts new file mode 100644 index 0000000000..5509ac5cc3 --- /dev/null +++ b/webapp/channels/src/components/channel_bookmarks/utils.ts @@ -0,0 +1,135 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {useEffect, useMemo} from 'react'; +import {useDispatch, useSelector} from 'react-redux'; + +import type {Channel} from '@mattermost/types/channels'; +import type {GlobalState} from '@mattermost/types/store'; + +import {Permissions} from 'mattermost-redux/constants'; +import {getChannelBookmarks} from 'mattermost-redux/selectors/entities/channel_bookmarks'; +import {getChannel, getMyChannelMember} from 'mattermost-redux/selectors/entities/channels'; +import {getConfig, getFeatureFlagValue, getLicense} from 'mattermost-redux/selectors/entities/general'; +import {haveIChannelPermission} from 'mattermost-redux/selectors/entities/roles'; + +import {fetchChannelBookmarks} from 'actions/channel_bookmarks'; +import {loadCustomEmojisIfNeeded} from 'actions/emoji_actions'; + +import Constants from 'utils/constants'; +import {trimmedEmojiName} from 'utils/emoji_utils'; +import {canUploadFiles, isPublicLinksEnabled} from 'utils/file_utils'; + +export const MAX_BOOKMARKS_PER_CHANNEL = 50; + +export const useIsChannelBookmarksEnabled = () => { + return useSelector(getIsChannelBookmarksEnabled); +}; + +const {OPEN_CHANNEL, PRIVATE_CHANNEL, GM_CHANNEL, DM_CHANNEL} = Constants as {OPEN_CHANNEL: 'O'; PRIVATE_CHANNEL: 'P'; GM_CHANNEL: 'G'; DM_CHANNEL: 'D'}; + +type TAction = 'add' | 'edit' | 'delete' | 'order'; +type TActionKey = `${TAction}${typeof OPEN_CHANNEL | typeof PRIVATE_CHANNEL}`; + +const key = (a: TAction, c: typeof OPEN_CHANNEL | typeof PRIVATE_CHANNEL): TActionKey => { + return `${a}${c}`; +}; + +const BOOKMARK_PERMISSION = { + + // open channel + [key('add', OPEN_CHANNEL)]: Permissions.ADD_BOOKMARK_PUBLIC_CHANNEL, + [key('edit', OPEN_CHANNEL)]: Permissions.EDIT_BOOKMARK_PUBLIC_CHANNEL, + [key('delete', OPEN_CHANNEL)]: Permissions.DELETE_BOOKMARK_PUBLIC_CHANNEL, + [key('order', OPEN_CHANNEL)]: Permissions.ORDER_BOOKMARK_PUBLIC_CHANNEL, + + // private channel + [key('add', PRIVATE_CHANNEL)]: Permissions.ADD_BOOKMARK_PRIVATE_CHANNEL, + [key('edit', PRIVATE_CHANNEL)]: Permissions.EDIT_BOOKMARK_PRIVATE_CHANNEL, + [key('delete', PRIVATE_CHANNEL)]: Permissions.DELETE_BOOKMARK_PRIVATE_CHANNEL, + [key('order', PRIVATE_CHANNEL)]: Permissions.ORDER_BOOKMARK_PRIVATE_CHANNEL, +} as const; + +export const useChannelBookmarkPermission = (channelId: string, action: TAction) => { + return useSelector((state: GlobalState) => getHaveIChannelBookmarkPermission(state, channelId, action)); +}; + +export const getHaveIChannelBookmarkPermission = (state: GlobalState, channelId: string, action: TAction) => { + const channel: Channel | undefined = getChannel(state, channelId); + + if (!channel) { + return false; + } + const {type} = channel; + + if (type === 'threads') { + return false; + } + + if (type === GM_CHANNEL || type === DM_CHANNEL) { + const myMembership = getMyChannelMember(state, channelId); + return myMembership?.channel_id === channelId; + } + + const permission = BOOKMARK_PERMISSION[key(action, type)]; + + return channel && permission && haveIChannelPermission(state, channel.team_id, channelId, permission); +}; + +export const useCanUploadFiles = () => { + return useSelector((state: GlobalState) => canUploadFiles(getConfig(state))); +}; + +export const useCanGetPublicLink = () => { + return useSelector((state: GlobalState) => isPublicLinksEnabled(getConfig(state))); +}; + +export const useCanGetLinkPreviews = () => { + return useSelector((state: GlobalState) => getConfig(state).EnableLinkPreviews === 'true'); +}; + +export const getIsChannelBookmarksEnabled = (state: GlobalState) => { + const isEnabled = getFeatureFlagValue(state, 'ChannelBookmarks') === 'true'; + + if (!isEnabled) { + return false; + } + + const license = getLicense(state); + + return license?.IsLicensed === 'true'; +}; + +export const useChannelBookmarks = (channelId: string) => { + const dispatch = useDispatch(); + const bookmarks = useSelector((state: GlobalState) => getChannelBookmarks(state, channelId)); + + const order = useMemo(() => { + return Object.keys(bookmarks).sort((a, b) => bookmarks[a].sort_order - bookmarks[b].sort_order); + }, [bookmarks]); + + useEffect(() => { + if (channelId) { + dispatch(fetchChannelBookmarks(channelId)); + } + }, [channelId]); + + useEffect(() => { + const emojis = Object.values(bookmarks).reduce((result, {emoji}) => { + if (emoji) { + result.push(trimmedEmojiName(emoji)); + } + + return result; + }, []); + + if (emojis.length) { + dispatch(loadCustomEmojisIfNeeded(emojis)); + } + }, [bookmarks]); + + return { + bookmarks, + order, + }; +}; diff --git a/webapp/channels/src/components/channel_view/__snapshots__/channel_view.test.tsx.snap b/webapp/channels/src/components/channel_view/__snapshots__/channel_view.test.tsx.snap index 897c40be2a..6cbd67425e 100644 --- a/webapp/channels/src/components/channel_view/__snapshots__/channel_view.test.tsx.snap +++ b/webapp/channels/src/components/channel_view/__snapshots__/channel_view.test.tsx.snap @@ -28,6 +28,9 @@ exports[`components/channel_view Should match snapshot if channel is archived 1` teamUrl="/team" viewArchivedChannels={false} /> + @@ -85,6 +88,9 @@ exports[`components/channel_view Should match snapshot if channel is deactivated teamUrl="/team" viewArchivedChannels={false} /> + @@ -141,6 +147,9 @@ exports[`components/channel_view Should match snapshot with base props 1`] = ` teamUrl="/team" viewArchivedChannels={false} /> + diff --git a/webapp/channels/src/components/channel_view/channel_view.tsx b/webapp/channels/src/components/channel_view/channel_view.tsx index 11f7c53989..a40c4c45e5 100644 --- a/webapp/channels/src/components/channel_view/channel_view.tsx +++ b/webapp/channels/src/components/channel_view/channel_view.tsx @@ -6,6 +6,7 @@ import {FormattedMessage} from 'react-intl'; import type {RouteComponentProps} from 'react-router-dom'; import AdvancedCreatePost from 'components/advanced_create_post'; +import ChannelBookmarks from 'components/channel_bookmarks'; import ChannelHeader from 'components/channel_header'; import deferComponentRender from 'components/deferComponentRender'; import FileUploadOverlay from 'components/file_upload_overlay'; @@ -173,9 +174,8 @@ export default class ChannelView extends React.PureComponent { className='app__content' > - + + void; + onClick: () => void; } -function EmojiPickerCustomEmojiButton({customEmojisEnabled, currentTeamName, handleEmojiPickerClose}: Props) { +function EmojiPickerCustomEmojiButton({customEmojisEnabled, currentTeamName, onClick}: Props) { if (!customEmojisEnabled) { return null; } @@ -30,7 +30,7 @@ function EmojiPickerCustomEmojiButton({customEmojisEnabled, currentTeamName, han void; handleFilterChange: (filter: string) => void; handleEmojiPickerClose: () => void; + onAddCustomEmojiClick?: () => void; } const EmojiPicker = ({ @@ -46,6 +47,7 @@ const EmojiPicker = ({ onEmojiClick, handleFilterChange, handleEmojiPickerClose, + onAddCustomEmojiClick, customEmojisEnabled = false, customEmojiPage = 0, emojiMap, @@ -204,6 +206,11 @@ const EmojiPicker = ({ }); }, []); + const onAddCustomEmojiClickInner = useCallback(() => { + handleEmojiPickerClose(); + onAddCustomEmojiClick?.(); + }, []); + const [cursorCategory, cursorCategoryIndex, cursorEmojiIndex] = getCursorProperties(cursor.rowIndex, cursor.emojiId, categoryOrEmojisRows as EmojiRow[]); function calculateNewCursorForUpArrow(cursorCategory: string, emojiPositions: EmojiPosition[], currentCursorsPositionIndex: number, categories: Categories, focusOnSearchInput: () => void) { @@ -429,13 +436,11 @@ const EmojiPicker = ({ /> )}
- {areSearchResultsEmpty ? (
) : - () - } + {areSearchResultsEmpty ?
: }
diff --git a/webapp/channels/src/components/emoji_picker/emoji_picker_overlay/emoji_picker_overlay.tsx b/webapp/channels/src/components/emoji_picker/emoji_picker_overlay/emoji_picker_overlay.tsx index 4f08c24f17..a456a50a31 100644 --- a/webapp/channels/src/components/emoji_picker/emoji_picker_overlay/emoji_picker_overlay.tsx +++ b/webapp/channels/src/components/emoji_picker/emoji_picker_overlay/emoji_picker_overlay.tsx @@ -3,7 +3,7 @@ import memoize from 'memoize-one'; import React from 'react'; -import type {ReactNode} from 'react'; +import type {ComponentProps, ReactNode} from 'react'; import {Overlay} from 'react-bootstrap'; import type {Emoji} from '@mattermost/types/emojis'; @@ -20,9 +20,11 @@ export interface Props extends PropsFromRedux { target: () => ReactNode; onEmojiClick: (emoji: Emoji) => void; onGifClick?: (gif: string) => void; + onAddCustomEmojiClick?: () => void; onHide: () => void; onExited?: () => void; show: boolean; + placement?: ComponentProps['placement']; topOffset?: number; rightOffset?: number; leftOffset?: number; @@ -89,7 +91,7 @@ export default class EmojiPickerOverlay extends React.PureComponent { return ( { rightOffset={calculatedRightOffset} topOffset={this.props.topOffset} leftOffset={this.props.leftOffset} + onAddCustomEmojiClick={this.props.onAddCustomEmojiClick} /> ); diff --git a/webapp/channels/src/components/emoji_picker/emoji_picker_tabs.tsx b/webapp/channels/src/components/emoji_picker/emoji_picker_tabs.tsx index 102bae3cb9..fd77167bf4 100644 --- a/webapp/channels/src/components/emoji_picker/emoji_picker_tabs.tsx +++ b/webapp/channels/src/components/emoji_picker/emoji_picker_tabs.tsx @@ -26,6 +26,7 @@ export interface Props { onEmojiClose: () => void; onEmojiClick: (emoji: Emoji) => void; onGifClick?: (gif: string) => void; + onAddCustomEmojiClick?: () => void; enableGifPicker?: boolean; } @@ -176,6 +177,7 @@ export default class EmojiPickerTabs extends PureComponent { onEmojiClick={this.props.onEmojiClick} handleFilterChange={this.handleFilterChange} handleEmojiPickerClose={this.handleEmojiPickerClose} + onAddCustomEmojiClick={this.props.onAddCustomEmojiClick} />
); diff --git a/webapp/channels/src/components/error_page/__snapshots__/error_link.test.tsx.snap b/webapp/channels/src/components/error_page/__snapshots__/error_link.test.tsx.snap index 0b12846dcc..c5ddf467f9 100644 --- a/webapp/channels/src/components/error_page/__snapshots__/error_link.test.tsx.snap +++ b/webapp/channels/src/components/error_page/__snapshots__/error_link.test.tsx.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`components/error_page/ErrorLink should match snapshot 1`] = ` - @@ -9,5 +9,5 @@ exports[`components/error_page/ErrorLink should match snapshot 1`] = ` defaultMessage="GitLab" id="error.oauth_missing_code.gitlab.link" /> - + `; diff --git a/webapp/channels/src/components/external_link/__snapshots__/external_link.test.tsx.snap b/webapp/channels/src/components/external_link/__snapshots__/external_link.test.tsx.snap index a4e1cb09eb..f6c1bff5ca 100644 --- a/webapp/channels/src/components/external_link/__snapshots__/external_link.test.tsx.snap +++ b/webapp/channels/src/components/external_link/__snapshots__/external_link.test.tsx.snap @@ -13,7 +13,7 @@ exports[`components/external_link should match snapshot 1`] = ` } } > - @@ -26,6 +26,6 @@ exports[`components/external_link should match snapshot 1`] = ` > Click Me - + `; diff --git a/webapp/channels/src/components/external_link/index.tsx b/webapp/channels/src/components/external_link/index.tsx index 8a3c939dbb..8f0b8db808 100644 --- a/webapp/channels/src/components/external_link/index.tsx +++ b/webapp/channels/src/components/external_link/index.tsx @@ -3,7 +3,7 @@ /* eslint-disable @mattermost/use-external-link */ -import React from 'react'; +import React, {forwardRef} from 'react'; import {useSelector} from 'react-redux'; import {getCurrentUserId} from 'mattermost-redux/selectors/entities/common'; @@ -29,7 +29,7 @@ type Props = React.AnchorHTMLAttributes & { children: React.ReactNode; }; -export default function ExternalLink(props: Props) { +const ExternalLink = forwardRef((props, ref) => { const userId = useSelector(getCurrentUserId); const config = useSelector(getConfig); const license = useSelector(getLicense); @@ -70,6 +70,7 @@ export default function ExternalLink(props: Props) { return ( ); -} +}); + +export default ExternalLink; diff --git a/webapp/channels/src/components/file_attachment/__snapshots__/filename_overlay.test.tsx.snap b/webapp/channels/src/components/file_attachment/__snapshots__/filename_overlay.test.tsx.snap index e0d9c64920..e8c5bf95fa 100644 --- a/webapp/channels/src/components/file_attachment/__snapshots__/filename_overlay.test.tsx.snap +++ b/webapp/channels/src/components/file_attachment/__snapshots__/filename_overlay.test.tsx.snap @@ -37,14 +37,14 @@ exports[`components/file_attachment/FilenameOverlay should match snapshot, stand placement="top" title="Download" > - test_filename - +
`; @@ -58,14 +58,14 @@ exports[`components/file_attachment/FilenameOverlay should match snapshot, with placement="top" title="Download" > - - +
`; diff --git a/webapp/channels/src/components/file_attachment/file_attachment.tsx b/webapp/channels/src/components/file_attachment/file_attachment.tsx index faf90c1824..2554df8508 100644 --- a/webapp/channels/src/components/file_attachment/file_attachment.tsx +++ b/webapp/channels/src/components/file_attachment/file_attachment.tsx @@ -52,6 +52,7 @@ interface Props extends PropsFromRedux { * Display in compact format */ compactDisplay?: boolean; + disablePreview?: boolean; handleFileDropdownOpened?: (open: boolean) => void; } @@ -190,6 +191,7 @@ export default function FileAttachment(props: Props) { defaultItems.push( {loaded ? ( - + ) : (
)} diff --git a/webapp/channels/src/components/file_attachment/file_thumbnail/file_thumbnail.tsx b/webapp/channels/src/components/file_attachment/file_thumbnail/file_thumbnail.tsx index 98c361a9e9..24320586ad 100644 --- a/webapp/channels/src/components/file_attachment/file_thumbnail/file_thumbnail.tsx +++ b/webapp/channels/src/components/file_attachment/file_thumbnail/file_thumbnail.tsx @@ -7,55 +7,72 @@ import type {FileInfo} from '@mattermost/types/files'; import {getFileThumbnailUrl, getFileUrl} from 'mattermost-redux/utils/file_utils'; +import type {FilePreviewInfo} from 'components/file_preview/file_preview'; + import Constants, {FileTypes} from 'utils/constants'; +import {getFileTypeFromMime} from 'utils/file_utils'; import { getFileType, getIconClassName, isGIFImage, } from 'utils/utils'; +type FilePreviewInfoLimited = Pick; + type Props = { enableSVGs: boolean; - fileInfo: FileInfo; -} + fileInfo: FileInfo | FilePreviewInfo | FilePreviewInfoLimited; + disablePreview?: boolean; +}; const FileThumbnail = ({ fileInfo, enableSVGs, + disablePreview, }: Props) => { - const type = getFileType(fileInfo.extension); + 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; - if (type === FileTypes.IMAGE) { - let className = 'post-image'; + let type = FileTypes.OTHER; + if (extension) { + type = getFileType(extension); + } else if (mimeType) { + type = getFileTypeFromMime(mimeType); + } - if (fileInfo.width < Constants.THUMBNAIL_WIDTH && fileInfo.height < Constants.THUMBNAIL_HEIGHT) { - className += ' small'; - } else { - className += ' normal'; + if (id && !disablePreview) { + if (type === FileTypes.IMAGE) { + let className = 'post-image'; + + if (width < Constants.THUMBNAIL_WIDTH && height < Constants.THUMBNAIL_HEIGHT) { + className += ' small'; + } else { + className += ' normal'; + } + + let thumbnailUrl = getFileThumbnailUrl(id); + if (extension && isGIFImage(extension) && !hasPreviewImage) { + thumbnailUrl = getFileUrl(id); + } + + return ( +
+ ); + } else if (extension === FileTypes.SVG && enableSVGs) { + return ( + {'file + ); } - - let thumbnailUrl = getFileThumbnailUrl(fileInfo.id); - if (isGIFImage(fileInfo.extension) && !fileInfo.has_preview_image) { - thumbnailUrl = getFileUrl(fileInfo.id); - } - - return ( -
- ); - } else if (fileInfo.extension === FileTypes.SVG && enableSVGs) { - return ( - {'file - ); } return
; diff --git a/webapp/channels/src/components/file_search_results/__snapshots__/file_search_result_item.test.tsx.snap b/webapp/channels/src/components/file_search_results/__snapshots__/file_search_result_item.test.tsx.snap index de5ba91462..2360a17e67 100644 --- a/webapp/channels/src/components/file_search_results/__snapshots__/file_search_result_item.test.tsx.snap +++ b/webapp/channels/src/components/file_search_results/__snapshots__/file_search_result_item.test.tsx.snap @@ -75,57 +75,6 @@ exports[`components/file_search_result/FileSearchResultItem should match snapsho />
- - More Actions - - } - placement="top" - trigger={ - Array [ - "hover", - "focus", - ] - } - > - -
- - - - - - - -
- - More Actions - - } - placement="top" - trigger={ - Array [ - "hover", - "focus", - ] - } - > - - - - - - - - - -
- - More Actions - - } - placement="top" - trigger={ - Array [ - "hover", - "focus", - ] - } - > - - - - - - - - - - - - More Actions - - } - placement="top" - trigger={ - Array [ - "hover", - "focus", - ] - } - > - - - - - - - - - - - - {localizeMessage('file_search_result_item.more_actions', 'More Actions')} - - } - > - + {localizeMessage('file_search_result_item.more_actions', 'More Actions')} + + } > - - - - - - - {this.renderPluginItems()} - - - + + + + + + + {this.renderPluginItems()} + + + + )} @@ -147,7 +147,7 @@ exports[`components/integrations/AbstractCommand should match snapshot 1`] = ` defaultMessage="See built-in slash commands" id="add_command.trigger.helpReservedLinkText" /> - , + , } } /> @@ -588,7 +588,7 @@ exports[`components/integrations/AbstractCommand should match snapshot when head id="add_command.trigger.helpReserved" values={ Object { - "link": @@ -596,7 +596,7 @@ exports[`components/integrations/AbstractCommand should match snapshot when head defaultMessage="See built-in slash commands" id="add_command.trigger.helpReservedLinkText" /> - , + , } } /> @@ -1037,7 +1037,7 @@ exports[`components/integrations/AbstractCommand should match snapshot, displays id="add_command.trigger.helpReserved" values={ Object { - "link": @@ -1045,7 +1045,7 @@ exports[`components/integrations/AbstractCommand should match snapshot, displays defaultMessage="See built-in slash commands" id="add_command.trigger.helpReservedLinkText" /> - , + , } } /> diff --git a/webapp/channels/src/components/integrations/__snapshots__/abstract_outgoing_webhook.test.tsx.snap b/webapp/channels/src/components/integrations/__snapshots__/abstract_outgoing_webhook.test.tsx.snap index f39b321245..683939a466 100644 --- a/webapp/channels/src/components/integrations/__snapshots__/abstract_outgoing_webhook.test.tsx.snap +++ b/webapp/channels/src/components/integrations/__snapshots__/abstract_outgoing_webhook.test.tsx.snap @@ -291,7 +291,7 @@ callbackUrl2.com id="add_outgoing_webhook.callbackUrls.help" values={ Object { - "link": @@ -299,7 +299,7 @@ callbackUrl2.com defaultMessage="trusted internal connection" id="add_outgoing_webhook.callbackUrls.helpLinkText" /> - , + , } } /> diff --git a/webapp/channels/src/components/integrations/installed_oauth_apps/__snapshots__/installed_oauth_apps.test.tsx.snap b/webapp/channels/src/components/integrations/installed_oauth_apps/__snapshots__/installed_oauth_apps.test.tsx.snap index d8848b6a31..7f80b93a37 100644 --- a/webapp/channels/src/components/integrations/installed_oauth_apps/__snapshots__/installed_oauth_apps.test.tsx.snap +++ b/webapp/channels/src/components/integrations/installed_oauth_apps/__snapshots__/installed_oauth_apps.test.tsx.snap @@ -29,7 +29,7 @@ exports[`components/integrations/InstalledOAuthApps should match snapshot 1`] = id="installed_oauth_apps.help" values={ Object { - "appDirectory": @@ -37,8 +37,8 @@ exports[`components/integrations/InstalledOAuthApps should match snapshot 1`] = defaultMessage="App Directory" id="installed_oauth_apps.help.appDirectory" /> - , - "oauthApplications": , + "oauthApplications": @@ -46,7 +46,7 @@ exports[`components/integrations/InstalledOAuthApps should match snapshot 1`] = defaultMessage="OAuth 2.0 applications" id="installed_oauth_apps.help.oauthApplications" /> - , + , } } /> diff --git a/webapp/channels/src/components/integrations/installed_outgoing_webhooks/__snapshots__/installed_outgoing_webhooks.test.tsx.snap b/webapp/channels/src/components/integrations/installed_outgoing_webhooks/__snapshots__/installed_outgoing_webhooks.test.tsx.snap index fa6299d7d0..fe6e954b5e 100644 --- a/webapp/channels/src/components/integrations/installed_outgoing_webhooks/__snapshots__/installed_outgoing_webhooks.test.tsx.snap +++ b/webapp/channels/src/components/integrations/installed_outgoing_webhooks/__snapshots__/installed_outgoing_webhooks.test.tsx.snap @@ -257,7 +257,7 @@ exports[`components/integrations/InstalledOutgoingWebhooks should match snapshot id="installed_outgoing_webhooks.help" values={ Object { - "appDirectory": @@ -265,8 +265,8 @@ exports[`components/integrations/InstalledOutgoingWebhooks should match snapshot defaultMessage="App Directory" id="installed_outgoing_webhooks.help.appDirectory" /> - , - "buildYourOwn": , + "buildYourOwn": @@ -274,7 +274,7 @@ exports[`components/integrations/InstalledOutgoingWebhooks should match snapshot defaultMessage="Build your own" id="installed_outgoing_webhooks.help.buildYourOwn" /> - , + , } } /> diff --git a/webapp/channels/src/components/integrations/outgoing_oauth_connections/__snapshots__/installed_outgoing_oauth_connections.test.tsx.snap b/webapp/channels/src/components/integrations/outgoing_oauth_connections/__snapshots__/installed_outgoing_oauth_connections.test.tsx.snap index b42f3e1081..689ab2a5ad 100644 --- a/webapp/channels/src/components/integrations/outgoing_oauth_connections/__snapshots__/installed_outgoing_oauth_connections.test.tsx.snap +++ b/webapp/channels/src/components/integrations/outgoing_oauth_connections/__snapshots__/installed_outgoing_oauth_connections.test.tsx.snap @@ -85,7 +85,7 @@ exports[`components/integrations/InstalledOutgoingOAuthConnections should match id="installed_outgoing_oauth_connections.help" values={ Object { - "outgoingOauthConnections": @@ -93,7 +93,7 @@ exports[`components/integrations/InstalledOutgoingOAuthConnections should match defaultMessage="Outgoing OAuth Connections" id="installed_outgoing_oauth_connections.help.outgoingOauthConnections" /> - , + , } } /> @@ -180,7 +180,7 @@ exports[`components/integrations/InstalledOutgoingOAuthConnections should match id="installed_outgoing_oauth_connections.help" values={ Object { - "outgoingOauthConnections": @@ -188,13 +188,13 @@ exports[`components/integrations/InstalledOutgoingOAuthConnections should match defaultMessage="Outgoing OAuth Connections" id="installed_outgoing_oauth_connections.help.outgoingOauthConnections" /> - , + , } } > Create - - + to securely integrate bots and third-party apps with Mattermost. diff --git a/webapp/channels/src/components/menu/menu.tsx b/webapp/channels/src/components/menu/menu.tsx index 3d2c47ff0c..1139c387d2 100644 --- a/webapp/channels/src/components/menu/menu.tsx +++ b/webapp/channels/src/components/menu/menu.tsx @@ -64,11 +64,25 @@ type MenuProps = { width?: string; } +const defaultAnchorOrigin = {vertical: 'bottom', horizontal: 'left'}; +const defaultTransformOrigin = {vertical: 'top', horizontal: 'left'}; + +type VerticalOrigin = 'top' | 'center' | 'bottom'; +type HorizontalOrigin = 'left' | 'center' | 'right'; + interface Props { menuButton: MenuButtonProps; menuButtonTooltip?: MenuButtonTooltipProps; menu: MenuProps; children: ReactNode[]; + anchorOrigin?: { + vertical: VerticalOrigin; + horizontal: HorizontalOrigin; + }; + transformOrigin?: { + vertical: VerticalOrigin; + horizontal: HorizontalOrigin; + }; } /** @@ -236,6 +250,8 @@ export function Menu(props: Props) { onKeyDown={handleMenuKeyDown} className={A11yClassNames.POPUP} width={props.menu.width} + anchorOrigin={props.anchorOrigin || defaultAnchorOrigin} + transformOrigin={props.transformOrigin || defaultTransformOrigin} disableAutoFocusItem={disableAutoFocusItem} // This is not anti-pattern, see handleMenuButtonMouseDown MenuListProps={{ id: props.menu.id, diff --git a/webapp/channels/src/components/plugin_marketplace/marketplace_item/marketplace_item_plugin/__snapshots__/marketplace_item_plugin.test.tsx.snap b/webapp/channels/src/components/plugin_marketplace/marketplace_item/marketplace_item_plugin/__snapshots__/marketplace_item_plugin.test.tsx.snap index 2a57315698..43a5deedff 100644 --- a/webapp/channels/src/components/plugin_marketplace/marketplace_item/marketplace_item_plugin/__snapshots__/marketplace_item_plugin.test.tsx.snap +++ b/webapp/channels/src/components/plugin_marketplace/marketplace_item/marketplace_item_plugin/__snapshots__/marketplace_item_plugin.test.tsx.snap @@ -1006,7 +1006,7 @@ exports[`components/MarketplaceItemPlugin UpdateDetails should render with relea releaseNotesUrl="http://example.com/release" version="0.0.2" > - @@ -1019,7 +1019,7 @@ exports[`components/MarketplaceItemPlugin UpdateDetails should render with relea > 0.0.2 - + - diff --git a/webapp/channels/src/components/post_view/message_attachments/message_attachment/__snapshots__/message_attachment.test.tsx.snap b/webapp/channels/src/components/post_view/message_attachments/message_attachment/__snapshots__/message_attachment.test.tsx.snap index 0097c46359..911d04a1a4 100644 --- a/webapp/channels/src/components/post_view/message_attachments/message_attachment/__snapshots__/message_attachment.test.tsx.snap +++ b/webapp/channels/src/components/post_view/message_attachments/message_attachment/__snapshots__/message_attachment.test.tsx.snap @@ -24,7 +24,7 @@ exports[`components/post_view/MessageAttachment should call actions.doPostAction } } > - author_name - +

- title - +

- author_name - +

- title - +

- author_name - +

- title - +

- Download - +
@@ -157,7 +157,7 @@ exports[`ProductNoticesModal Should match snapshot for system admin notice 1`] = message="your eyes only! [test](https://test.com)" /> - Download - +
diff --git a/webapp/channels/src/components/signup/__snapshots__/signup.test.tsx.snap b/webapp/channels/src/components/signup/__snapshots__/signup.test.tsx.snap index ee6751f7fe..103a6c19f3 100644 --- a/webapp/channels/src/components/signup/__snapshots__/signup.test.tsx.snap +++ b/webapp/channels/src/components/signup/__snapshots__/signup.test.tsx.snap @@ -105,13 +105,13 @@ exports[`components/signup/Signup should match snapshot for all signup options e className="link" > Sign up at - https://mattermost.com/security-updates/ - + .
@@ -279,13 +279,13 @@ exports[`components/signup/Signup should match snapshot for all signup options e className="link" > Sign up at - https://mattermost.com/security-updates/ - + .
diff --git a/webapp/channels/src/components/widgets/inputs/input/input.tsx b/webapp/channels/src/components/widgets/inputs/input/input.tsx index 4e360a0c48..6c4fa53d7c 100644 --- a/webapp/channels/src/components/widgets/inputs/input/input.tsx +++ b/webapp/channels/src/components/widgets/inputs/input/input.tsx @@ -19,7 +19,7 @@ export enum SIZE { LARGE = 'large', } -export type CustomMessageInputType = {type: 'info' | 'error' | 'warning' | 'success'; value: React.ReactNode} | null; +export type CustomMessageInputType = {type?: 'info' | 'error' | 'warning' | 'success'; value: React.ReactNode} | null; interface InputProps extends React.InputHTMLAttributes { required?: boolean; @@ -225,14 +225,15 @@ const Input = React.forwardRef(( {customInputLabel && (
- + {customInputLabel.type && ( + )} {customInputLabel.value}
)} diff --git a/webapp/channels/src/components/widgets/menu/menu_items/menu_item_external_link.test.tsx b/webapp/channels/src/components/widgets/menu/menu_items/menu_item_external_link.test.tsx index 1423941e37..e0ec497280 100644 --- a/webapp/channels/src/components/widgets/menu/menu_items/menu_item_external_link.test.tsx +++ b/webapp/channels/src/components/widgets/menu/menu_items/menu_item_external_link.test.tsx @@ -16,7 +16,7 @@ describe('components/MenuItemExternalLink', () => { ); expect(wrapper).toMatchInlineSnapshot(` - @@ -25,7 +25,7 @@ describe('components/MenuItemExternalLink', () => { > Whatever - + `); }); }); diff --git a/webapp/channels/src/components/youtube_video/__snapshots__/youtube_video.test.tsx.snap b/webapp/channels/src/components/youtube_video/__snapshots__/youtube_video.test.tsx.snap index 7d836faf7a..3fe7ab57fa 100644 --- a/webapp/channels/src/components/youtube_video/__snapshots__/youtube_video.test.tsx.snap +++ b/webapp/channels/src/components/youtube_video/__snapshots__/youtube_video.test.tsx.snap @@ -44,7 +44,7 @@ exports[`YoutubeVideo should match init snapshot 1`] = ` - @@ -57,7 +57,7 @@ exports[`YoutubeVideo should match init snapshot 1`] = ` > Youtube title - +
- Youtube title - +
{displayName}?", + "channel_bookmarks.confirm.delete.title": "Delete bookmark", + "channel_bookmarks.copy": "Copy link", + "channel_bookmarks.copyFilePublicLink": "Get a public link", + "channel_bookmarks.create.confirm_add.button": "Add bookmark", + "channel_bookmarks.create.confirm_save.button": "Save bookmark", + "channel_bookmarks.create.edit.title": "Edit bookmark", + "channel_bookmarks.create.error.generic_save": "There was an error trying to save the bookmark.", + "channel_bookmarks.create.error.invalid_url": "Please enter a valid link", + "channel_bookmarks.create.file_input.edit": "Edit", + "channel_bookmarks.create.file_input.label": "Attachment", + "channel_bookmarks.create.link_info": "Add a link to any post, file, or any external link", + "channel_bookmarks.create.link_placeholder": "Link", + "channel_bookmarks.create.title": "Add a bookmark", + "channel_bookmarks.create.title_input.clear_emoji": "Remove emoji", + "channel_bookmarks.create.title_input.label": "Title", + "channel_bookmarks.delete": "Delete", + "channel_bookmarks.edit": "Edit", + "channel_bookmarks.editBookmarkLabel": "Bookmark menu", + "channel_bookmarks.open": "Open", "channel_groups": "{channel} Groups", "channel_header.addChannelHeader": "Add a channel header", "channel_header.channelFiles": "Channel files", @@ -3617,6 +3642,7 @@ "file_upload.fileAbove": "File above {max}MB could not be uploaded: {filename}", "file_upload.filesAbove": "Files above {max}MB could not be uploaded: {filenames}", "file_upload.generic_error": "There was a problem uploading your files.", + "file_upload.generic_error_file": "There was a problem uploading your file.", "file_upload.limited": "Uploads limited to {count, number} files maximum. Please use additional posts for more files.", "file_upload.menuAriaLabel": "Upload type selector", "file_upload.pasted": "Image Pasted at ", diff --git a/webapp/channels/src/packages/mattermost-redux/src/action_types/channel_bookmarks.ts b/webapp/channels/src/packages/mattermost-redux/src/action_types/channel_bookmarks.ts new file mode 100644 index 0000000000..623e37f9cd --- /dev/null +++ b/webapp/channels/src/packages/mattermost-redux/src/action_types/channel_bookmarks.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import keyMirror from 'mattermost-redux/utils/key_mirror'; + +export default keyMirror({ + RECEIVED_BOOKMARK: null, + RECEIVED_BOOKMARKS: null, + + BOOKMARK_DELETED: null, +}); diff --git a/webapp/channels/src/packages/mattermost-redux/src/action_types/index.ts b/webapp/channels/src/packages/mattermost-redux/src/action_types/index.ts index 5e5c8ab6c6..b218110476 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/action_types/index.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/action_types/index.ts @@ -4,6 +4,7 @@ import AdminTypes from './admin'; import AppsTypes from './apps'; import BotTypes from './bots'; +import ChannelBookmarkTypes from './channel_bookmarks'; import ChannelCategoryTypes from './channel_categories'; import ChannelTypes from './channels'; import CloudTypes from './cloud'; @@ -55,4 +56,5 @@ export { HostedCustomerTypes, DraftTypes, PlaybookType, + ChannelBookmarkTypes, }; diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/channel_bookmarks.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/channel_bookmarks.ts new file mode 100644 index 0000000000..427cc7d991 --- /dev/null +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/channel_bookmarks.ts @@ -0,0 +1,104 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {ChannelBookmarkCreate, ChannelBookmarkPatch} from '@mattermost/types/channel_bookmarks'; + +import {ChannelBookmarkTypes} from 'mattermost-redux/action_types'; +import {Client4} from 'mattermost-redux/client'; +import {getChannelBookmark} from 'mattermost-redux/selectors/entities/channel_bookmarks'; +import type {DispatchFunc, GetStateFunc} from 'mattermost-redux/types/actions'; + +import {logError} from './errors'; +import {forceLogoutIfNecessary} from './helpers'; + +export function deleteBookmark(channelId: string, id: string, connectionId: string) { + return async (dispatch: DispatchFunc, getState: GetStateFunc) => { + const state = getState(); + const bookmark = getChannelBookmark(state, channelId, id); + + try { + await Client4.deleteChannelBookmark(channelId, id, connectionId); + + dispatch({ + type: ChannelBookmarkTypes.BOOKMARK_DELETED, + data: bookmark, + }); + } catch (error) { + return { + data: false, + error, + }; + } + + return {data: true}; + }; +} + +export function createBookmark(channelId: string, bookmark: ChannelBookmarkCreate, connectionId: string) { + return async (dispatch: DispatchFunc) => { + try { + const createdBookmark = await Client4.createChannelBookmark(channelId, bookmark, connectionId); + + dispatch({ + type: ChannelBookmarkTypes.RECEIVED_BOOKMARK, + data: createdBookmark, + }); + } catch (error) { + return { + data: false, + error, + }; + } + + return {data: true}; + }; +} + +export function editBookmark(channelId: string, id: string, patch: ChannelBookmarkPatch, connectionId: string) { + return async (dispatch: DispatchFunc) => { + try { + const {updated, deleted} = await Client4.updateChannelBookmark(channelId, id, patch, connectionId); + + if (updated) { + dispatch({ + type: ChannelBookmarkTypes.RECEIVED_BOOKMARK, + data: updated, + }); + } + + if (deleted) { + dispatch({ + type: ChannelBookmarkTypes.BOOKMARK_DELETED, + data: deleted, + }); + } + } catch (error) { + return { + data: false, + error, + }; + } + + return {data: true}; + }; +} + +export function fetchChannelBookmarks(channelId: string) { + return async (dispatch: DispatchFunc, getState: GetStateFunc) => { + let bookmarks; + try { + bookmarks = await Client4.getChannelBookmarks(channelId); + + dispatch({ + type: ChannelBookmarkTypes.RECEIVED_BOOKMARKS, + data: {channelId, bookmarks}, + }); + } catch (error) { + forceLogoutIfNecessary(error, dispatch, getState); + dispatch(logError(error)); + return {error}; + } + + return {data: bookmarks}; + }; +} diff --git a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/channel_bookmarks.ts b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/channel_bookmarks.ts new file mode 100644 index 0000000000..38f444e281 --- /dev/null +++ b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/channel_bookmarks.ts @@ -0,0 +1,74 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {combineReducers, type AnyAction} from 'redux'; + +import type {ChannelBookmark, ChannelBookmarksState} from '@mattermost/types/channel_bookmarks'; +import type {Channel} from '@mattermost/types/channels'; +import type {IDMappedObjects} from '@mattermost/types/utilities'; + +import {ChannelBookmarkTypes, UserTypes, ChannelTypes} from 'mattermost-redux/action_types'; + +const toNewObj = (current: IDMappedObjects, arr: T[]) => { + return arr.reduce((acc, x) => { + return {...acc, [x.id]: x}; + }, {...current}); +}; + +export function byChannelId(state: ChannelBookmarksState['byChannelId'] = {}, action: AnyAction) { + switch (action.type) { + case ChannelBookmarkTypes.RECEIVED_BOOKMARKS: { + const channelId: Channel['id'] = action.data.channelId; + const bookmarks: ChannelBookmark[] = action.data.bookmarks; + + return { + ...state, + [channelId]: toNewObj(state[channelId], bookmarks), + }; + } + + case ChannelBookmarkTypes.RECEIVED_BOOKMARK: { + const bookmark: ChannelBookmark = action.data; + const {id, channel_id: channelId} = bookmark; + + return { + ...state, + [channelId]: { + ...state[channelId], + [id]: bookmark, + }, + }; + } + + case ChannelBookmarkTypes.BOOKMARK_DELETED: { + const bookmark: ChannelBookmark = action.data; + + const channelNextState = {...state[bookmark.channel_id]}; + + Reflect.deleteProperty(channelNextState, bookmark.id); + + const nextState = {...state, [bookmark.channel_id]: channelNextState}; + + return nextState; + } + + case ChannelTypes.LEAVE_CHANNEL: { + const channelId: string = action.data.channelId; + + const nextState = {...state}; + + Reflect.deleteProperty(nextState, channelId); + + return nextState; + } + + case UserTypes.LOGOUT_SUCCESS: + return {}; + default: + return state; + } +} + +export default combineReducers({ + byChannelId, +}); diff --git a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/files.ts b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/files.ts index f261f8209c..3e16f42a76 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/files.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/files.ts @@ -4,10 +4,11 @@ import type {AnyAction} from 'redux'; import {combineReducers} from 'redux'; +import type {ChannelBookmark} from '@mattermost/types/channel_bookmarks'; import type {FileInfo, FileSearchResultItem} from '@mattermost/types/files'; import type {Post} from '@mattermost/types/posts'; -import {FileTypes, PostTypes, UserTypes} from 'mattermost-redux/action_types'; +import {FileTypes, PostTypes, UserTypes, ChannelBookmarkTypes} from 'mattermost-redux/action_types'; export function files(state: Record = {}, action: AnyAction) { switch (action.type) { @@ -53,6 +54,43 @@ export function files(state: Record = {}, action: AnyAction) { return state; } + case ChannelBookmarkTypes.RECEIVED_BOOKMARKS: { + const bookmarks: ChannelBookmark[] = action.data.bookmarks; + + const nextState = {...state}; + + bookmarks.forEach(({file}) => { + if (file) { + nextState[file.id] = file; + } + }); + + return nextState; + } + + case ChannelBookmarkTypes.RECEIVED_BOOKMARK: { + const {file}: ChannelBookmark = action.data; + + if (file) { + return {...state, [file.id]: file}; + } + + return state; + } + + case ChannelBookmarkTypes.BOOKMARK_DELETED: { + const {file}: ChannelBookmark = action.data; + + if (!file) { + return state; + } + + const nextState = {...state}; + Reflect.deleteProperty(nextState, file.id); + + return nextState; + } + case UserTypes.LOGOUT_SUCCESS: return {}; default: diff --git a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/index.ts b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/index.ts index 2715e91631..68f75ae494 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/index.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/index.ts @@ -6,6 +6,7 @@ import {combineReducers} from 'redux'; import admin from './admin'; import apps from './apps'; import bots from './bots'; +import channelBookmarks from './channel_bookmarks'; import channelCategories from './channel_categories'; import channels from './channels'; import cloud from './cloud'; @@ -53,4 +54,5 @@ export default combineReducers({ cloud, usage, hostedCustomer, + channelBookmarks, }); diff --git a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/channel_bookmarks.ts b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/channel_bookmarks.ts new file mode 100644 index 0000000000..1fd77d7fad --- /dev/null +++ b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/channel_bookmarks.ts @@ -0,0 +1,21 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {ChannelBookmarksState} from '@mattermost/types/channel_bookmarks'; +import type {GlobalState} from '@mattermost/types/store'; + +const EMPTY_BOOKMARKS = {}; + +export const getChannelBookmarks = (state: GlobalState, channelId: string): ChannelBookmarksState['byChannelId'][string] => { + const bookmarks = state.entities.channelBookmarks.byChannelId[channelId]; + + if (!bookmarks) { + return EMPTY_BOOKMARKS; + } + + return bookmarks; +}; + +export const getChannelBookmark = (state: GlobalState, channelId: string, bookmarkId: string) => { + return getChannelBookmarks(state, channelId)[bookmarkId]; +}; diff --git a/webapp/channels/src/packages/mattermost-redux/src/store/initial_state.ts b/webapp/channels/src/packages/mattermost-redux/src/store/initial_state.ts index 648c90ef89..34f3c64a3f 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/store/initial_state.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/store/initial_state.ts @@ -65,6 +65,9 @@ const state: GlobalState = { messageCounts: {}, channelsMemberCount: {}, }, + channelBookmarks: { + byChannelId: {}, + }, posts: { posts: {}, postsReplies: {}, diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index de1a641f5c..cf9e92bb07 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -459,6 +459,8 @@ export const ModalIdentifiers = { EXPORT_USER_DATA_MODAL: 'export_user_data_modal', UPGRADE_EXPORT_DATA_MODAL: 'upgrade_export_data_modal', EXPORT_ERROR_MODAL: 'export_error_modal', + CHANNEL_BOOKMARK_DELETE: 'channel_bookmark_delete', + CHANNEL_BOOKMARK_CREATE: 'channel_bookmark_create', }; export const UserStatuses = { @@ -584,6 +586,10 @@ export const SocketEvents = { CHANNEL_DELETED: 'channel_deleted', CHANNEL_UNARCHIVED: 'channel_restored', CHANNEL_UPDATED: 'channel_updated', + CHANNEL_BOOKMARK_CREATED: 'channel_bookmark_created', + CHANNEL_BOOKMARK_DELETED: 'channel_bookmark_deleted', + CHANNEL_BOOKMARK_UPDATED: 'channel_bookmark_updated', + CHANNEL_BOOKMARK_SORTED: 'channel_bookmark_sorted', MULTIPLE_CHANNELS_VIEWED: 'multiple_channels_viewed', CHANNEL_MEMBER_UPDATED: 'channel_member_updated', CHANNEL_SCHEME_UPDATED: 'channel_scheme_updated', diff --git a/webapp/channels/src/utils/emoji_utils.tsx b/webapp/channels/src/utils/emoji_utils.tsx index 2fda7f0274..52a4321049 100644 --- a/webapp/channels/src/utils/emoji_utils.tsx +++ b/webapp/channels/src/utils/emoji_utils.tsx @@ -179,6 +179,10 @@ export function getSkin(emoji: Emoji) { return null; } +export function trimmedEmojiName(emojiName: string) { + return emojiName.startsWith(':') && emojiName.endsWith(':') ? emojiName.slice(1, -1) : emojiName; +} + export function emojiMatchesSkin(emoji: Emoji, skin: string) { const emojiSkin = getSkin(emoji); return !emojiSkin || emojiSkin === skin; diff --git a/webapp/channels/src/utils/file_utils.tsx b/webapp/channels/src/utils/file_utils.tsx index bbfdfa5ae1..2cdc4dd1d0 100644 --- a/webapp/channels/src/utils/file_utils.tsx +++ b/webapp/channels/src/utils/file_utils.tsx @@ -17,8 +17,8 @@ export const FileSizes = { }; export function canUploadFiles(config: Partial): boolean { - const enableFileAttachments = config.EnableFileAttachments === 'true'; - const enableMobileFileUpload = config.EnableMobileFileUpload === 'true'; + const enableFileAttachments = isFileAttachmentsEnabled(config); + const enableMobileFileUpload = isMobileFileUploadsEnabled(config); if (!enableFileAttachments) { return false; @@ -35,6 +35,14 @@ export function isFileAttachmentsEnabled(config: Partial): boolean return config.EnableFileAttachments === 'true'; } +export function isMobileFileUploadsEnabled(config: Partial): boolean { + return config.EnableMobileFileUpload === 'true'; +} + +export function isPublicLinksEnabled(config: Partial): boolean { + return config.EnablePublicLink === 'true'; +} + export function canDownloadFiles(config: Partial): boolean { if (UserAgent.isMobileApp()) { return config.EnableMobileFileDownload === 'true'; diff --git a/webapp/channels/src/utils/markdown/renderer.tsx b/webapp/channels/src/utils/markdown/renderer.tsx index b3671d9066..fe7de72037 100644 --- a/webapp/channels/src/utils/markdown/renderer.tsx +++ b/webapp/channels/src/utils/markdown/renderer.tsx @@ -273,7 +273,7 @@ export default class Renderer extends marked.Renderer { // Marked helper functions that should probably just be exported -function unescapeHtmlEntities(html: string) { +export function unescapeHtmlEntities(html: string) { return html.replace(/&([#\w]+);/g, (_, m) => { const n = m.toLowerCase(); if (n === 'colon') { diff --git a/webapp/channels/src/utils/url.tsx b/webapp/channels/src/utils/url.tsx index 79582b1e6e..9b7036d3dc 100644 --- a/webapp/channels/src/utils/url.tsx +++ b/webapp/channels/src/utils/url.tsx @@ -10,6 +10,8 @@ import Constants from 'utils/constants'; import {latinise} from 'utils/latinise'; import * as TextFormatting from 'utils/text_formatting'; +import {unescapeHtmlEntities} from './markdown/renderer'; + type WindowObject = { location: { origin: string; @@ -274,6 +276,11 @@ export function isPermalinkURL(url: string): boolean { return isInternalURL(url, siteURL) && (regexp.test(url)); } +export function isValidUrl(url = '') { + const regex = /^https?:\/\//i; + return regex.test(url); +} + export function isStringContainingUrl(text: string): boolean { const regex = new RegExp('(https?://|www.)'); return regex.test(text); @@ -326,3 +333,20 @@ export function channelNameToUrl(channelName: string): UrlValidationCheck { return {url, error: false}; } + +export function parseLink(href: string) { + let outHref = href; + + if (!href.startsWith('/')) { + const scheme = getScheme(href); + if (!scheme) { + outHref = `http://${outHref}`; + } + } + + if (!isUrlSafe(unescapeHtmlEntities(href))) { + return undefined; + } + + return outHref; +} diff --git a/webapp/platform/client/src/client4.ts b/webapp/platform/client/src/client4.ts index 615ff492bb..1f484b7cb1 100644 --- a/webapp/platform/client/src/client4.ts +++ b/webapp/platform/client/src/client4.ts @@ -10,6 +10,7 @@ import type {AppBinding, AppCallRequest, AppCallResponse} from '@mattermost/type import type {Audit} from '@mattermost/types/audits'; import type {UserAutocomplete, AutocompleteSuggestion} from '@mattermost/types/autocomplete'; import type {Bot, BotPatch} from '@mattermost/types/bots'; +import type {ChannelBookmark, ChannelBookmarkCreate, ChannelBookmarkPatch} from '@mattermost/types/channel_bookmarks'; import type {ChannelCategory, OrderedChannelCategories} from '@mattermost/types/channel_categories'; import type { Channel, @@ -314,6 +315,12 @@ export default class Client4 { getChannelSchemeRoute(channelId: string) { return `${this.getChannelRoute(channelId)}/scheme`; } + getChannelBookmarksRoute(channelId: string) { + return `${this.getChannelRoute(channelId)}/bookmarks`; + } + getChannelBookmarkRoute(channelId: string, bookmarkId: string) { + return `${this.getChannelRoute(channelId)}/bookmarks/${bookmarkId}`; + } getChannelCategoriesRoute(userId: string, teamId: string) { return `${this.getBaseRoute()}/users/${userId}/teams/${teamId}/channels/categories`; @@ -1605,7 +1612,15 @@ export default class Client4 { includeDeleted: boolean | undefined, excludePolicyConstrained: boolean | undefined ): Promise; - getAllChannels(page = 0, perPage = PER_PAGE_DEFAULT, notAssociatedToGroup = '', excludeDefaultChannels = false, includeTotalCount = false, includeDeleted = false, excludePolicyConstrained = false) { + getAllChannels( + page = 0, + perPage = PER_PAGE_DEFAULT, + notAssociatedToGroup = '', + excludeDefaultChannels = false, + includeTotalCount = false, + includeDeleted = false, + excludePolicyConstrained = false, + ) { const queryData = { page, per_page: perPage, @@ -1961,7 +1976,44 @@ export default class Client4 { ); }; - // Channel Category Routes + // Channel Bookmark Routes + + getChannelBookmarks = (channelId: string, bookmarksSince?: number) => { + return this.doFetch( + `${this.getChannelBookmarksRoute(channelId)}${buildQueryString({bookmarks_since: bookmarksSince})}`, + {method: 'get'}, + ); + }; + + createChannelBookmark = (channelId: string, channelBookmark: ChannelBookmarkCreate, connectionId: string) => { + return this.doFetch( + `${this.getChannelBookmarksRoute(channelId)}`, + {method: 'post', body: JSON.stringify(channelBookmark), headers: {'Connection-Id': connectionId}}, + ); + }; + + deleteChannelBookmark = (channelId: string, channelBookmarkId: string, connectionId: string) => { + return this.doFetch( + `${this.getChannelBookmarkRoute(channelId, channelBookmarkId)}`, + {method: 'delete', headers: {'Connection-Id': connectionId}}, + ); + }; + + updateChannelBookmark = (channelId: string, channelBookmarkId: string, patch: ChannelBookmarkPatch, connectionId: string) => { + return this.doFetch<{updated: ChannelBookmark; deleted: ChannelBookmark}>( + `${this.getChannelBookmarkRoute(channelId, channelBookmarkId)}`, + {method: 'PATCH', body: JSON.stringify(patch), headers: {'Connection-Id': connectionId}}, + ); + }; + + updateChannelBookmarkSortOrder = (channelId: string, channelBookmarkId: string, newOrder: number, connectionId: string) => { + return this.doFetch( + `${this.getChannelBookmarksRoute(channelId)}/${channelBookmarkId}/sort_order`, + {method: 'post', body: JSON.stringify(newOrder), headers: {'Connection-Id': connectionId}}, + ); + }; + + // Channel Category Routes getChannelCategories = (userId: string, teamId: string) => { return this.doFetch( @@ -2383,7 +2435,7 @@ export default class Client4 { return url; } - uploadFile = (fileFormData: any) => { + uploadFile = (fileFormData: any, isBookmark?: boolean) => { this.trackEvent('api', 'api_files_upload'); const request: any = { method: 'post', @@ -2391,7 +2443,7 @@ export default class Client4 { }; return this.doFetch( - `${this.getFilesRoute()}`, + `${this.getFilesRoute()}${buildQueryString({bookmark: isBookmark})}`, request, ); }; diff --git a/webapp/platform/types/src/channel_bookmarks.ts b/webapp/platform/types/src/channel_bookmarks.ts new file mode 100644 index 0000000000..69f6469736 --- /dev/null +++ b/webapp/platform/types/src/channel_bookmarks.ts @@ -0,0 +1,57 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {Channel} from './channels'; +import type {FileInfo} from './files'; +import type {IDMappedObjects} from './utilities'; + +type ChannelBookmarkType = 'link' | 'file'; + +export type ChannelBookmark = { + id: string; + create_at: number; + update_at: number; + delete_at: number; + channel_id: string; + owner_id: string; + file_id?: string; + file?: FileInfo; + display_name: string; + sort_order: number; + link_url?: string; + image_url?: string; + emoji?: string; + type: ChannelBookmarkType; + original_id?: string; + parent_id?: string; +} + +export type ChannelBookmarkCreate = { + display_name: string; + image_url?: string; + emoji?: string; + type: ChannelBookmarkType; +} & ({ + type: 'link'; + link_url: string; +} | { + type: 'file'; + file_id: string; +}) + +export type ChannelBookmarkPatch = { + file_id?: string; + display_name?: string; + sort_order?: number; + link_url?: string; + image_url?: string; + emoji?: string; +} + +export type ChannelBookmarkWithFileInfo = ChannelBookmark & { + file: FileInfo; +} + +export type ChannelBookmarksState = { + byChannelId: {[channelId: Channel['id']]: IDMappedObjects}; +} diff --git a/webapp/platform/types/src/client4.ts b/webapp/platform/types/src/client4.ts index 150d9cf0c9..eb5840ec62 100644 --- a/webapp/platform/types/src/client4.ts +++ b/webapp/platform/types/src/client4.ts @@ -20,6 +20,7 @@ export type Options = { url?: string; credentials?: 'omit' | 'same-origin' | 'include'; body?: any; + signal?: RequestInit['signal']; ignoreStatus?: boolean; /** If true, status codes > 300 are ignored and don't cause an error */ }; diff --git a/webapp/platform/types/src/store.ts b/webapp/platform/types/src/store.ts index a0cf50e1a3..7d19233db0 100644 --- a/webapp/platform/types/src/store.ts +++ b/webapp/platform/types/src/store.ts @@ -4,6 +4,7 @@ import type {AdminState} from './admin'; import type {AppsState} from './apps'; import type {Bot} from './bots'; +import type {ChannelBookmarksState} from './channel_bookmarks'; import type {ChannelCategoriesState} from './channel_categories'; import type {ChannelsState} from './channels'; import type {CloudState, CloudUsage} from './cloud'; @@ -38,6 +39,7 @@ export type GlobalState = { limits: LimitsState; teams: TeamsState; channels: ChannelsState; + channelBookmarks: ChannelBookmarksState; posts: PostsState; threads: ThreadsState; bots: {