Channel Bookmarks: adds enable, reordering, and other fixes (MM-56286, MM-59807, MM-59808, MM-60031, MM-59872) (#28098)

Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
Caleb Roseland 2024-09-17 11:50:34 -05:00 committed by GitHub
parent 075681a412
commit d99961f106
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 516 additions and 200 deletions

View File

@ -14,6 +14,9 @@ import {getRandomId} from '../../../utils';
import * as TIMEOUTS from '../../../fixtures/timeouts'; import * as TIMEOUTS from '../../../fixtures/timeouts';
describe('Channel Bookmarks', () => { describe('Channel Bookmarks', () => {
const SpaceKeyCode = 32;
const RightArrowKeyCode = 39;
let testTeam: Cypress.Team; let testTeam: Cypress.Team;
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
@ -52,11 +55,11 @@ describe('Channel Bookmarks', () => {
}); });
it('create link bookmark, with emoji and custom title', () => { it('create link bookmark, with emoji and custom title', () => {
const {realLink, displayName, emojiName} = createLinkBookmark({displayName: 'custom display name', emojiName: 'smile'}); const {realLink, displayName, emojiName} = createLinkBookmark({displayName: 'custom display name', emojiName: 'smiling_face_with_3_hearts'});
cy.findByTestId('channel-bookmarks-container').within(() => { cy.findByTestId('channel-bookmarks-container').within(() => {
// * Verify emoji, displayname, and href // * Verify emoji, displayname, and href
cy.findAllByRole('link', {name: `:${emojiName}: ${displayName}`}).should('have.attr', 'href', realLink); cy.findByRole('link', {name: `:${emojiName}: ${displayName}`}).should('have.attr', 'href', realLink);
}); });
}); });
@ -65,7 +68,7 @@ describe('Channel Bookmarks', () => {
const {file} = createFileBookmark({file: 'small-image.png'}); const {file} = createFileBookmark({file: 'small-image.png'});
// * Verify preview icon // * Verify preview icon
cy.findAllByRole('link', {name: file}).as('link').find('.file-icon.image'); cy.findByRole('link', {name: file}).as('link').find('.file-icon.image');
// # Open preview // # Open preview
cy.get('@link').click(); cy.get('@link').click();
@ -114,15 +117,15 @@ describe('Channel Bookmarks', () => {
editModalCreate(); editModalCreate();
// * Verify bookmark created // * Verify bookmark created
cy.findAllByRole('link', {name: file}); cy.findByRole('link', {name: file});
}); });
it('create file bookmark, with emoji and custom title', () => { it('create file bookmark, with emoji and custom title', () => {
// # Create bookmark // # Create bookmark
const {file, displayName, emojiName} = createFileBookmark({file: 'm4a-audio-file.m4a', displayName: 'custom displayname small-image', emojiName: 'smile'}); const {file, displayName, emojiName} = createFileBookmark({file: 'm4a-audio-file.m4a', displayName: 'custom displayname small-image', emojiName: 'smiling_face_with_3_hearts'});
// * Verify emoji and custom display name // * Verify emoji and custom display name
cy.findAllByRole('link', {name: `:${emojiName}: ${displayName}`}).click(); cy.findByRole('link', {name: `:${emojiName}: ${displayName}`}).click();
// * Verify preview opened // * Verify preview opened
cy.get('.file-preview-modal').findByRole('heading', {name: file}); cy.get('.file-preview-modal').findByRole('heading', {name: file});
@ -153,6 +156,27 @@ describe('Channel Bookmarks', () => {
cy.findAllByRole('link', {name: `:${nextEmojiName}: ${nextDisplayName}`}).should('have.attr', 'href', realNextLink); cy.findAllByRole('link', {name: `:${nextEmojiName}: ${nextDisplayName}`}).should('have.attr', 'href', realNextLink);
}); });
it('edit link bookmark, only display name and emoji', () => {
// # Create link
const {displayName, realLink} = createLinkBookmark();
const nextDisplayName = 'Next custom display name 2';
const nextEmojiName = 'handshake';
// # Open edit
openEditModal(displayName);
// # Change link, displayname, emoji
editTextInput('titleInput', nextDisplayName);
selectEmoji(nextEmojiName);
// # Save
editModalSave();
// * Verify changes
cy.findAllByRole('link', {name: `:${nextEmojiName}: ${nextDisplayName}`}).should('have.attr', 'href', realLink);
});
it('delete bookmark', () => { it('delete bookmark', () => {
const {displayName} = createLinkBookmark(); const {displayName} = createLinkBookmark();
@ -174,6 +198,30 @@ describe('Channel Bookmarks', () => {
// * Verify bookmark deleted // * Verify bookmark deleted
cy.findByRole('link', {name: displayName}).should('not.exist'); cy.findByRole('link', {name: displayName}).should('not.exist');
}); });
it('reorder bookmark', () => {
const {displayName: name1} = createFileBookmark({file: 'm4a-audio-file.m4a', displayName: 'custom displayname 1'});
const {displayName: name2} = createFileBookmark({file: 'm4a-audio-file.m4a', displayName: 'custom displayname 2'});
// # Start reorder bookmark flow
cy.findByTestId('channel-bookmarks-container').within(() => {
cy.findAllByRole('link').should('be.visible').as('bookmarks');
cy.get('@bookmarks').eq(-1).scrollIntoView();
cy.get('@bookmarks').eq(-2).should('contain', name1);
cy.get('@bookmarks').eq(-1).should('contain', name2);
// # Perform drag using keyboard
cy.get(`a:contains(${name1})`).
trigger('keydown', {keyCode: SpaceKeyCode}).
trigger('keydown', {keyCode: RightArrowKeyCode, force: true}).wait(TIMEOUTS.THREE_SEC).
trigger('keydown', {keyCode: SpaceKeyCode, force: true}).wait(TIMEOUTS.THREE_SEC);
// * Verify correct order
cy.findAllByRole('link').should('be.visible').as('bookmarks-after');
cy.get('@bookmarks-after').eq(-2).should('contain', name2);
cy.get('@bookmarks-after').eq(-1).should('contain', name1);
});
});
}); });
function promptAddLink() { function promptAddLink() {
@ -190,7 +238,7 @@ function openDotMenu(name: string) {
cy.findByTestId('channel-bookmarks-container').within(() => { cy.findByTestId('channel-bookmarks-container').within(() => {
// # open menu // # open menu
cy.findByRole('link', {name}).scrollIntoView().focus(). cy.findByRole('link', {name}).scrollIntoView().focus().
parent('div').find('button').click(); parent('div').findByRole('button', {name: 'Bookmark menu'}).click();
}); });
} }
@ -271,6 +319,11 @@ function editTextInput(testid: string, nextValue: string) {
should('have.value', nextValue); should('have.value', nextValue);
} }
/**
*
* @param emojiName Name of emoji to select. Be overly specific
* e.g `smile` will have overlapping results, but `smiling_face_with_3_hearts` is unique with no overlapping results
*/
function selectEmoji(emojiName: string) { function selectEmoji(emojiName: string) {
cy.findByRole('button', {name: 'select an emoji'}).click(); cy.findByRole('button', {name: 'select an emoji'}).click();
cy.focused().type(`${emojiName}{downArrow}{enter}`); cy.focused().type(`${emojiName}{downArrow}{enter}`);

View File

@ -70,7 +70,7 @@ func (f *FeatureFlags) SetDefaults() {
f.ConsumePostHook = false f.ConsumePostHook = false
f.CloudAnnualRenewals = false f.CloudAnnualRenewals = false
f.CloudDedicatedExportUI = false f.CloudDedicatedExportUI = false
f.ChannelBookmarks = false f.ChannelBookmarks = true
f.WebSocketEventScope = true f.WebSocketEventScope = true
f.NotificationMonitoring = true f.NotificationMonitoring = true
f.ExperimentalAuditSettingsSystemConsoleUI = false f.ExperimentalAuditSettingsSystemConsoleUI = false

View File

@ -1,39 +1,47 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import type {ChannelBookmarkCreate, ChannelBookmarkPatch} from '@mattermost/types/channel_bookmarks'; import type {ChannelBookmark, ChannelBookmarkCreate, ChannelBookmarkPatch} from '@mattermost/types/channel_bookmarks';
import * as ChannelBookmarkActions from 'mattermost-redux/actions/channel_bookmarks'; import * as Actions from 'mattermost-redux/actions/channel_bookmarks';
import type {DispatchFunc, GetStateFunc} from 'mattermost-redux/types/actions'; import type {ActionFuncAsync} from 'mattermost-redux/types/actions';
import {getConnectionId} from 'selectors/general'; import {getConnectionId} from 'selectors/general';
import type {GlobalState} from 'types/store'; import type {GlobalState} from 'types/store';
export function deleteBookmark(channelId: string, id: string) { export function deleteBookmark(channelId: string, id: string): ActionFuncAsync<boolean, GlobalState> {
return async (dispatch: DispatchFunc, getState: GetStateFunc) => { return (dispatch, getState) => {
const state = getState() as GlobalState; const state = getState();
const connectionId = getConnectionId(state); const connectionId = getConnectionId(state);
return dispatch(ChannelBookmarkActions.deleteBookmark(channelId, id, connectionId)); return dispatch(Actions.deleteBookmark(channelId, id, connectionId));
}; };
} }
export function createBookmark(channelId: string, bookmark: ChannelBookmarkCreate) { export function createBookmark(channelId: string, bookmark: ChannelBookmarkCreate): ActionFuncAsync<boolean, GlobalState> {
return async (dispatch: DispatchFunc, getState: GetStateFunc) => { return (dispatch, getState) => {
const state = getState() as GlobalState; const state = getState();
const connectionId = getConnectionId(state); const connectionId = getConnectionId(state);
return dispatch(ChannelBookmarkActions.createBookmark(channelId, bookmark, connectionId)); return dispatch(Actions.createBookmark(channelId, bookmark, connectionId));
}; };
} }
export function editBookmark(channelId: string, id: string, patch: ChannelBookmarkPatch) { export function editBookmark(channelId: string, id: string, patch: ChannelBookmarkPatch): ActionFuncAsync<boolean, GlobalState> {
return async (dispatch: DispatchFunc, getState: GetStateFunc) => { return async (dispatch, getState) => {
const state = getState() as GlobalState; const state = getState();
const connectionId = getConnectionId(state); const connectionId = getConnectionId(state);
return dispatch(ChannelBookmarkActions.editBookmark(channelId, id, patch, connectionId)); return dispatch(Actions.editBookmark(channelId, id, patch, connectionId));
}; };
} }
export function fetchChannelBookmarks(channelId: string) { export function reorderBookmark(channelId: string, id: string, newOrder: number): ActionFuncAsync<boolean, GlobalState> {
return ChannelBookmarkActions.fetchChannelBookmarks(channelId); return (dispatch, getState) => {
const state = getState();
const connectionId = getConnectionId(state);
return dispatch(Actions.reorderBookmark(channelId, id, newOrder, connectionId));
};
}
export function fetchChannelBookmarks(channelId: string): ActionFuncAsync<ChannelBookmark[], GlobalState> {
return Actions.fetchChannelBookmarks(channelId);
} }

View File

@ -1,8 +1,9 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import type {HTMLAttributes} from 'react'; import type {AnchorHTMLAttributes} from 'react';
import React, {forwardRef, useRef} from 'react'; import React, {cloneElement, forwardRef, useRef} from 'react';
import type {DraggableProvided} from 'react-beautiful-dnd';
import {useDispatch, useSelector} from 'react-redux'; import {useDispatch, useSelector} from 'react-redux';
import {Link} from 'react-router-dom'; import {Link} from 'react-router-dom';
import styled, {css} from 'styled-components'; import styled, {css} from 'styled-components';
@ -27,9 +28,8 @@ import type {GlobalState} from 'types/store';
import BookmarkItemDotMenu from './bookmark_dot_menu'; import BookmarkItemDotMenu from './bookmark_dot_menu';
import BookmarkIcon from './bookmark_icon'; import BookmarkIcon from './bookmark_icon';
type Props = {bookmark: ChannelBookmark}; const useBookmarkLink = (bookmark: ChannelBookmark) => {
const BookmarkItem = <T extends HTMLAnchorElement>({bookmark}: Props) => { const linkRef = useRef<HTMLAnchorElement>(null);
const linkRef = useRef<T>(null);
const dispatch = useDispatch(); const dispatch = useDispatch();
const fileInfo: FileInfo | undefined = useSelector((state: GlobalState) => (bookmark?.file_id && getFile(state, bookmark.file_id)) || undefined); const fileInfo: FileInfo | undefined = useSelector((state: GlobalState) => (bookmark?.file_id && getFile(state, bookmark.file_id)) || undefined);
@ -88,18 +88,38 @@ const BookmarkItem = <T extends HTMLAnchorElement>({bookmark}: Props) => {
); );
} }
return {
link,
icon,
open,
} as const;
};
type Props = {
bookmark: ChannelBookmark;
drag: DraggableProvided;
isDragging: boolean;
disableInteractions: boolean;
};
const BookmarkItem = (({bookmark, drag, disableInteractions}: Props) => {
const {link, open} = useBookmarkLink(bookmark);
return ( return (
<Chip> <Chip
{link} ref={drag.innerRef}
{...drag.draggableProps}
$disableInteractions={disableInteractions}
>
{link && cloneElement(link, {...drag.dragHandleProps, role: 'link'})}
<BookmarkItemDotMenu <BookmarkItemDotMenu
bookmark={bookmark} bookmark={bookmark}
open={open} open={open}
/> />
</Chip> </Chip>
); );
}; });
const Chip = styled.div` const Chip = styled.div<{$disableInteractions: boolean}>`
position: relative; position: relative;
border-radius: 12px; border-radius: 12px;
overflow: hidden; overflow: hidden;
@ -115,47 +135,49 @@ const Chip = styled.div`
top: 3px; top: 3px;
} }
&:hover, ${({$disableInteractions}) => !$disableInteractions && css`
&:focus-within, &:hover,
&:has([aria-expanded="true"]) { &:focus-within,
button { &:has([aria-expanded="true"]) {
visibility: visible; 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));
} }
} }
} &:hover,
&:focus-within {
a {
text-decoration: none;
cursor: pointer;
}
}
&: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` const Label = styled.span`
@ -167,8 +189,18 @@ const Label = styled.span`
const TARGET_BLANK_URL_PREFIX = '!'; const TARGET_BLANK_URL_PREFIX = '!';
type DynamicLinkProps = {href: string; children: React.ReactNode; isFile: boolean; onClick?: HTMLAttributes<HTMLAnchorElement>['onClick']}; type DynamicLinkProps = AnchorHTMLAttributes<HTMLAnchorElement> & {
const DynamicLink = forwardRef<HTMLAnchorElement, DynamicLinkProps>(({href, children, isFile, onClick}, ref) => { href: string;
children: React.ReactNode;
isFile: boolean;
};
const DynamicLink = forwardRef<HTMLAnchorElement, DynamicLinkProps>(({
href,
children,
isFile,
onClick,
...otherProps
}, ref) => {
const siteURL = getSiteURL(); const siteURL = getSiteURL();
const openInNewTab = shouldOpenInNewTab(href, siteURL); const openInNewTab = shouldOpenInNewTab(href, siteURL);
@ -177,6 +209,7 @@ const DynamicLink = forwardRef<HTMLAnchorElement, DynamicLinkProps>(({href, chil
if (prefixed || openInNewTab) { if (prefixed || openInNewTab) {
return ( return (
<StyledExternalLink <StyledExternalLink
{...otherProps}
href={prefixed ? href.substring(1) : href} href={prefixed ? href.substring(1) : href}
rel='noopener noreferrer' rel='noopener noreferrer'
target='_blank' target='_blank'
@ -191,9 +224,9 @@ const DynamicLink = forwardRef<HTMLAnchorElement, DynamicLinkProps>(({href, chil
if (href.startsWith(siteURL) && !isFile) { if (href.startsWith(siteURL) && !isFile) {
return ( return (
<StyledLink <StyledLink
{...otherProps}
to={href.slice(siteURL.length)} to={href.slice(siteURL.length)}
ref={ref} ref={ref}
onClick={onClick}
> >
{children} {children}
</StyledLink> </StyledLink>
@ -202,6 +235,7 @@ const DynamicLink = forwardRef<HTMLAnchorElement, DynamicLinkProps>(({href, chil
return ( return (
<StyledAnchor <StyledAnchor
{...otherProps}
href={href} href={href}
ref={ref} ref={ref}
onClick={onClick} onClick={onClick}

View File

@ -1,11 +1,16 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import type {ComponentProps} from 'react';
import React from 'react'; import React from 'react';
import {DragDropContext, Draggable, Droppable} from 'react-beautiful-dnd';
import styled from 'styled-components'; import styled from 'styled-components';
import type {ChannelBookmark} from '@mattermost/types/channel_bookmarks';
import type {IDMappedObjects} from '@mattermost/types/utilities';
import BookmarkItem from './bookmark_item'; import BookmarkItem from './bookmark_item';
import PlusMenu from './channel_bookmarks_plus_menu'; import BookmarksMenu from './channel_bookmarks_menu';
import {useChannelBookmarkPermission, useChannelBookmarks, MAX_BOOKMARKS_PER_CHANNEL, useCanUploadFiles} from './utils'; import {useChannelBookmarkPermission, useChannelBookmarks, MAX_BOOKMARKS_PER_CHANNEL, useCanUploadFiles} from './utils';
import './channel_bookmarks.scss'; import './channel_bookmarks.scss';
@ -14,37 +19,75 @@ type Props = {
channelId: string; channelId: string;
}; };
const ChannelBookmarks = ({ function ChannelBookmarks({
channelId, channelId,
}: Props) => { }: Props) {
const {order, bookmarks} = useChannelBookmarks(channelId); const {order, bookmarks, reorder} = useChannelBookmarks(channelId);
const canUploadFiles = useCanUploadFiles(); const canUploadFiles = useCanUploadFiles();
const canAdd = useChannelBookmarkPermission(channelId, 'add'); const canAdd = useChannelBookmarkPermission(channelId, 'add');
const hasBookmarks = Boolean(order?.length); const hasBookmarks = Boolean(order?.length);
const limitReached = order.length >= MAX_BOOKMARKS_PER_CHANNEL;
if (!hasBookmarks && !canAdd) { if (!hasBookmarks && !canAdd) {
return null; return null;
} }
const handleOnDragEnd: ComponentProps<typeof DragDropContext>['onDragEnd'] = ({source, destination, draggableId}) => {
if (destination) {
reorder(draggableId, source.index, destination.index);
}
};
return ( return (
<Container data-testid='channel-bookmarks-container'> <DragDropContext
{order.map((id) => { onDragEnd={handleOnDragEnd}
>
<Droppable
droppableId='channel-bookmarks'
direction='horizontal'
>
{(drop, snap) => {
return (
<Container
ref={drop.innerRef}
data-testid='channel-bookmarks-container'
{...drop.droppableProps}
>
{order.map(makeItemRenderer(bookmarks, snap.isDraggingOver))}
{drop.placeholder}
<BookmarksMenu
channelId={channelId}
hasBookmarks={hasBookmarks}
limitReached={limitReached}
canUploadFiles={canUploadFiles}
/>
</Container>
);
}}
</Droppable>
</DragDropContext>
);
}
const makeItemRenderer = (bookmarks: IDMappedObjects<ChannelBookmark>, disableInteractions: boolean) => (id: string, index: number) => {
return (
<Draggable
key={id}
draggableId={id}
index={index}
>
{(drag, snap) => {
return ( return (
<BookmarkItem <BookmarkItem
key={id} key={id}
drag={drag}
isDragging={snap.isDragging}
disableInteractions={snap.isDragging || disableInteractions}
bookmark={bookmarks[id]} bookmark={bookmarks[id]}
/> />
); );
})} }}
{canAdd && ( </Draggable>
<PlusMenu
channelId={channelId}
hasBookmarks={hasBookmarks}
limitReached={order.length >= MAX_BOOKMARKS_PER_CHANNEL}
canUploadFiles={canUploadFiles}
/>
)}
</Container>
); );
}; };
@ -52,11 +95,11 @@ export default ChannelBookmarks;
const Container = styled.div` const Container = styled.div`
display: flex; display: flex;
padding: 8px 6px; padding: 0 6px;
padding-right: 0; padding-right: 0;
min-height: 38px;
align-items: center; align-items: center;
border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.12); border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.12);
overflow-x: auto; overflow-x: auto;
overflow-y: hidden; max-width: 100vw;
overflow-y: clip;
`; `;

View File

@ -1,18 +1,17 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import type {ChangeEvent, ClipboardEventHandler, FocusEventHandler, MouseEvent} from 'react'; import type {ChangeEvent, MouseEvent, ReactNode} from 'react';
import React, {useCallback, useEffect, useRef, useState} from 'react'; import React, {useCallback, useEffect, useRef, useState} from 'react';
import {FormattedMessage, defineMessages, useIntl} from 'react-intl'; import {FormattedMessage, defineMessages, useIntl} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux'; import {useDispatch, useSelector} from 'react-redux';
import styled from 'styled-components'; import styled from 'styled-components';
import {PencilOutlineIcon} from '@mattermost/compass-icons/components'; import {PencilOutlineIcon, CheckIcon} from '@mattermost/compass-icons/components';
import {GenericModal} from '@mattermost/components'; import {GenericModal} from '@mattermost/components';
import type {ChannelBookmark, ChannelBookmarkCreate, ChannelBookmarkPatch} from '@mattermost/types/channel_bookmarks'; import type {ChannelBookmark, ChannelBookmarkCreate, ChannelBookmarkPatch} from '@mattermost/types/channel_bookmarks';
import type {FileInfo} from '@mattermost/types/files'; import type {FileInfo} from '@mattermost/types/files';
import {debounce} from 'mattermost-redux/actions/helpers';
import {getFile} from 'mattermost-redux/selectors/entities/files'; import {getFile} from 'mattermost-redux/selectors/entities/files';
import {getConfig} from 'mattermost-redux/selectors/entities/general'; import {getConfig} from 'mattermost-redux/selectors/entities/general';
import type {ActionResult} from 'mattermost-redux/types/actions'; import type {ActionResult} from 'mattermost-redux/types/actions';
@ -24,10 +23,11 @@ import FileAttachment from 'components/file_attachment';
import type {FilePreviewInfo} from 'components/file_preview/file_preview'; import type {FilePreviewInfo} from 'components/file_preview/file_preview';
import FileProgressPreview from 'components/file_preview/file_progress_preview'; import FileProgressPreview from 'components/file_preview/file_progress_preview';
import Input from 'components/widgets/inputs/input/input'; import Input from 'components/widgets/inputs/input/input';
import LoadingSpinner from 'components/widgets/loading/loading_spinner';
import Constants from 'utils/constants'; import Constants from 'utils/constants';
import {isKeyPressed} from 'utils/keyboard'; import {isKeyPressed} from 'utils/keyboard';
import {isValidUrl, parseLink} from 'utils/url'; import {isValidUrl, parseLink, removeScheme} from 'utils/url';
import {generateId} from 'utils/utils'; import {generateId} from 'utils/utils';
import type {GlobalState} from 'types/store'; import type {GlobalState} from 'types/store';
@ -51,23 +51,6 @@ type Props = {
onConfirm: (data: ChannelBookmarkCreate) => Promise<ActionResult<boolean, any>> | ActionResult<boolean, any>; onConfirm: (data: ChannelBookmarkCreate) => Promise<ActionResult<boolean, any>> | ActionResult<boolean, any>;
}); });
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({ function ChannelBookmarkCreateModal({
bookmark, bookmark,
bookmarkType, bookmarkType,
@ -104,52 +87,34 @@ function ChannelBookmarkCreateModal({
}, [handleKeyDown]); }, [handleKeyDown]);
// type === 'link' // type === 'link'
const [linkInputValue, setLinkInputValue] = useState(bookmark?.link_url ?? ''); const icon = bookmark?.image_url;
const [link, setLinkImmediately] = useState(linkInputValue); const [link, setLinkInner] = useState(bookmark?.link_url ?? '');
const [linkError, setLinkError] = useState(''); const prevLink = useRef(link);
const [icon, setIcon] = useState(bookmark?.image_url); const [validatedLink, setValidatedLink] = useState<string>();
const setLink = useCallback((value: string) => {
setLinkInner((currentLink) => {
prevLink.current = currentLink;
return value;
});
if (!value) {
setValidatedLink(undefined);
}
}, []);
const handleLinkChange = useCallback(({target: {value}}: ChangeEvent<HTMLInputElement>) => { const [linkError, {loading: checkingLink, suppressed: linkErrorBypass}] = useBookmarkLinkValidation(link === prevLink.current ? '' : link, (validatedLink, forced) => {
setLinkInputValue(value); if (!forced) {
setValidatedLink(validatedLink);
}
const parsed = removeScheme(validatedLink);
setParsedDisplayName(parsed);
setDisplayName(parsed);
});
const handleLinkChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
const {value} = e.target;
setLink(value); setLink(value);
}, []); }, []);
const setLink = debounce((val: string) => {
setLinkImmediately(val);
}, 250);
const handleLinkBlur: FocusEventHandler<HTMLInputElement> = useCallback(({target: {value}}) => {
setLinkImmediately(value);
}, []);
const handleLinkPasted: ClipboardEventHandler<HTMLInputElement> = 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' // type === 'file'
const canUploadFiles = useCanUploadFiles(); const canUploadFiles = useCanUploadFiles();
const [pendingFile, setPendingFile] = useState<FilePreviewInfo | null>(); const [pendingFile, setPendingFile] = useState<FilePreviewInfo | null>();
@ -303,17 +268,26 @@ function ChannelBookmarkCreateModal({
const isValid = (() => { const isValid = (() => {
if (type === 'link') { if (type === 'link') {
if (!link || linkError) { if (!link || linkError) {
if (link && linkError && linkErrorBypass) {
return true;
}
return false; return false;
} }
if (validatedLink || link === bookmark?.link_url) {
return true;
}
} }
if (type === 'file') { if (type === 'file') {
if (!fileInfo || !displayNameValue || fileError) { if (!fileInfo || !displayNameValue || fileError) {
return false; return false;
} }
return true;
} }
return true; return undefined;
})(); })();
const showControls = type === 'file' || (isValid || bookmark); const showControls = type === 'file' || (isValid || bookmark);
@ -374,6 +348,15 @@ function ChannelBookmarkCreateModal({
const confirmDisabled = saving || !isValid || !hasChanges; const confirmDisabled = saving || !isValid || !hasChanges;
let linkStatusIndicator;
if (checkingLink) {
// loading
linkStatusIndicator = <LoadingSpinner/>;
} else if (validatedLink && !linkError) {
// validated
linkStatusIndicator = checkedIcon;
}
return ( return (
<GenericModal <GenericModal
enforceFocus={!showEmojiPicker} enforceFocus={!showEmojiPicker}
@ -392,19 +375,21 @@ function ChannelBookmarkCreateModal({
> >
<> <>
{type === 'link' ? ( {type === 'link' ? (
<Input <>
type='text' <Input
name='bookmark-link' type='text'
containerClassName='linkInput' name='bookmark-link'
placeholder={formatMessage(msg.linkPlaceholder)} containerClassName='linkInput'
onChange={handleLinkChange} placeholder={formatMessage(msg.linkPlaceholder)}
onBlur={handleLinkBlur} onChange={handleLinkChange}
onPaste={handleLinkPasted} hasError={Boolean(linkError)}
value={linkInputValue} value={link}
data-testid='linkInput' data-testid='linkInput'
autoFocus={true} autoFocus={true}
customMessage={linkError ? {type: 'error', value: linkError} : {value: formatMessage(msg.linkInfoMessage)}} addon={linkStatusIndicator}
/> customMessage={linkError ? {type: 'error', value: linkError} : {value: formatMessage(msg.linkInfoMessage)}}
/>
</>
) : ( ) : (
<> <>
<FieldLabel> <FieldLabel>
@ -489,6 +474,21 @@ const TitleWrapper = styled.div`
margin-top: 20px; margin-top: 20px;
`; `;
const CheckWrapper = styled.span`
padding: 0px 12px;
display: flex;
align-items: center;
`;
const checkedIcon = (
<CheckWrapper>
<CheckIcon
size={20}
color='var(--sys-online-indicator)'
/>
</CheckWrapper>
);
const FieldLabel = styled.span` const FieldLabel = styled.span`
display: inline-block; display: inline-block;
margin-bottom: 8px; margin-bottom: 8px;
@ -558,6 +558,48 @@ const FileInputContainer = styled.div`
} }
`; `;
const continuableLinkErr = (url: URL, confirm?: () => void) => {
if (!confirm) {
return (
<FormattedMessage
id='channel_bookmarks.create.error.invalid_url.continuing_anyway'
defaultMessage='Could not find: {url}. Please enter a valid link.'
values={{
url: url.toString(),
}}
/>
);
}
return (
<FormattedMessage
id='channel_bookmarks.create.error.invalid_url.continue_anyway'
defaultMessage='Could not find: {url}. Please enter a valid link, or <Confirm>continue anyway</Confirm>.'
values={{
url: url.toString(),
Confirm: (msg) => (
<LinkErrContinue
tabIndex={0}
onClick={confirm}
onKeyDown={(e) => {
if (e.key === 'Enter') {
confirm();
}
}}
>
{msg}
</LinkErrContinue>
),
}}
/>
);
};
const LinkErrContinue = styled.a`
color: unset !important;
text-decoration: underline;
`;
const FileItemContainer = styled.div` const FileItemContainer = styled.div`
display: flex; display: flex;
flex: 1 1 auto; flex: 1 1 auto;
@ -576,6 +618,98 @@ const msg = defineMessages({
addBookmarkText: {id: 'channel_bookmarks.create.confirm_add.button', defaultMessage: 'Add bookmark'}, addBookmarkText: {id: 'channel_bookmarks.create.confirm_add.button', defaultMessage: 'Add bookmark'},
saveText: {id: 'channel_bookmarks.create.confirm_save.button', defaultMessage: 'Save bookmark'}, saveText: {id: 'channel_bookmarks.create.confirm_save.button', defaultMessage: 'Save bookmark'},
fileInputEdit: {id: 'channel_bookmarks.create.file_input.edit', defaultMessage: 'Edit'}, fileInputEdit: {id: 'channel_bookmarks.create.file_input.edit', defaultMessage: 'Edit'},
linkInvalid: {id: 'channel_bookmarks.create.error.invalid_url', defaultMessage: 'Please enter a valid link'}, linkInvalid: {id: 'channel_bookmarks.create.error.invalid_url', defaultMessage: 'Please enter a valid link. Could not parse: {link}.'},
saveError: {id: 'channel_bookmarks.create.error.generic_save', defaultMessage: 'There was an error trying to save the bookmark.'}, saveError: {id: 'channel_bookmarks.create.error.generic_save', defaultMessage: 'There was an error trying to save the bookmark.'},
}); });
const TYPING_DELAY_MS = 250;
const REQUEST_TIMEOUT = 10000;
export const useBookmarkLinkValidation = (link: string, onValidated: (validatedLink: string, forced?: boolean) => void) => {
const {formatMessage} = useIntl();
const [loading, setLoading] = useState<URL>();
const [error, setError] = useState<ReactNode>();
const [suppressed, setSuppressed] = useState(false);
const abort = useRef<AbortController>();
const start = useCallback((url: URL) => {
setLoading(url);
setError(undefined);
setSuppressed(false);
abort.current = new AbortController();
return abort.current.signal;
}, []);
const cancel = useCallback(() => {
abort.current?.abort('stale request');
abort.current = undefined;
setLoading(undefined);
}, []);
useEffect(() => {
const handler = setTimeout(async () => {
cancel();
if (!link) {
return;
}
const url = validHttpUrl(link);
if (!url) {
setError(formatMessage(msg.linkInvalid, {link}));
setLoading(undefined);
return;
}
const signal = start(url);
try {
await fetch(url, {
mode: 'no-cors',
// @ts-expect-error AbortSignal.any exists; remove this directive when next TS upgrade
signal: AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT)]),
});
onValidated(link);
} catch (err) {
if (signal === abort.current?.signal) {
setError(continuableLinkErr(url, () => {
onValidated(link, true);
setSuppressed(true);
setError(continuableLinkErr(url));
}));
}
} finally {
setLoading((currentUrl) => {
if (currentUrl !== url) {
// trailing effect of cancelled
return currentUrl;
}
return undefined;
});
}
}, TYPING_DELAY_MS);
return () => clearTimeout(handler);
}, [link, start, cancel]);
return [error, {loading: Boolean(loading), suppressed}] as const;
};
export const 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;
};

View File

@ -28,18 +28,17 @@ import {clearFileInput} from 'utils/utils';
import ChannelBookmarkCreateModal from './channel_bookmarks_create_modal'; import ChannelBookmarkCreateModal from './channel_bookmarks_create_modal';
import {MAX_BOOKMARKS_PER_CHANNEL} from './utils'; import {MAX_BOOKMARKS_PER_CHANNEL} from './utils';
type PlusMenuProps = { type BookmarksMenuProps = {
channelId: string; channelId: string;
hasBookmarks: boolean; hasBookmarks: boolean;
limitReached: boolean; limitReached: boolean;
canUploadFiles: boolean; canUploadFiles: boolean;};
}; export default ({
const PlusMenu = ({
channelId, channelId,
hasBookmarks, hasBookmarks,
limitReached, limitReached,
canUploadFiles, canUploadFiles,
}: PlusMenuProps) => { }: BookmarksMenuProps) => {
const {formatMessage} = useIntl(); const {formatMessage} = useIntl();
const dispatch = useDispatch(); const dispatch = useDispatch();
const showLabel = !hasBookmarks; const showLabel = !hasBookmarks;
@ -98,7 +97,9 @@ const PlusMenu = ({
const attachFileLabel = formatMessage({id: 'channel_bookmarks.attachFile', defaultMessage: 'Attach a file'}); const attachFileLabel = formatMessage({id: 'channel_bookmarks.attachFile', defaultMessage: 'Attach a file'});
return ( return (
<PlusButtonContainer withLabel={showLabel}> <MenuButtonContainer
withLabel={showLabel}
>
<Menu.Container <Menu.Container
anchorOrigin={{vertical: 'bottom', horizontal: 'left'}} anchorOrigin={{vertical: 'bottom', horizontal: 'left'}}
transformOrigin={{vertical: 'top', horizontal: 'left'}} transformOrigin={{vertical: 'top', horizontal: 'left'}}
@ -142,13 +143,11 @@ const PlusMenu = ({
)} )}
</Menu.Container> </Menu.Container>
{fileInput} {fileInput}
</PlusButtonContainer> </MenuButtonContainer>
); );
}; };
export default PlusMenu; const MenuButtonContainer = styled.div<{withLabel: boolean}>`
const PlusButtonContainer = styled.div<{withLabel: boolean}>`
position: sticky; position: sticky;
right: 0; right: 0;
${({withLabel}) => !withLabel && css`padding: 0 1rem;`} ${({withLabel}) => !withLabel && css`padding: 0 1rem;`}

View File

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import {useEffect, useMemo} from 'react'; import {useEffect, useMemo, useState} from 'react';
import {useDispatch, useSelector} from 'react-redux'; import {useDispatch, useSelector} from 'react-redux';
import type {Channel} from '@mattermost/types/channels'; import type {Channel} from '@mattermost/types/channels';
@ -12,8 +12,9 @@ import {getChannelBookmarks} from 'mattermost-redux/selectors/entities/channel_b
import {getChannel, getMyChannelMember} from 'mattermost-redux/selectors/entities/channels'; import {getChannel, getMyChannelMember} from 'mattermost-redux/selectors/entities/channels';
import {getConfig, getFeatureFlagValue, getLicense} from 'mattermost-redux/selectors/entities/general'; import {getConfig, getFeatureFlagValue, getLicense} from 'mattermost-redux/selectors/entities/general';
import {haveIChannelPermission} from 'mattermost-redux/selectors/entities/roles'; import {haveIChannelPermission} from 'mattermost-redux/selectors/entities/roles';
import {insertWithoutDuplicates} from 'mattermost-redux/utils/array_utils';
import {fetchChannelBookmarks} from 'actions/channel_bookmarks'; import {fetchChannelBookmarks, reorderBookmark} from 'actions/channel_bookmarks';
import {loadCustomEmojisIfNeeded} from 'actions/emoji_actions'; import {loadCustomEmojisIfNeeded} from 'actions/emoji_actions';
import Constants from 'utils/constants'; import Constants from 'utils/constants';
@ -103,6 +104,13 @@ export const useChannelBookmarks = (channelId: string) => {
const order = useMemo(() => { const order = useMemo(() => {
return Object.keys(bookmarks).sort((a, b) => bookmarks[a].sort_order - bookmarks[b].sort_order); return Object.keys(bookmarks).sort((a, b) => bookmarks[a].sort_order - bookmarks[b].sort_order);
}, [bookmarks]); }, [bookmarks]);
const [tempOrder, setTempOrder] = useState<typeof order>();
useEffect(() => {
if (tempOrder) {
setTempOrder(undefined);
}
}, [order]);
useEffect(() => { useEffect(() => {
if (channelId) { if (channelId) {
@ -124,8 +132,19 @@ export const useChannelBookmarks = (channelId: string) => {
} }
}, [bookmarks]); }, [bookmarks]);
const reorder = async (id: string, prevOrder: number, nextOrder: number) => {
setTempOrder(insertWithoutDuplicates(order, id, nextOrder));
const {error} = await dispatch(reorderBookmark(channelId, id, nextOrder));
if (error) {
setTempOrder(undefined);
}
};
return { return {
bookmarks, bookmarks,
order, order: tempOrder ?? order,
}; reorder,
} as const;
}; };

View File

@ -27,7 +27,7 @@ type Props = React.AnchorHTMLAttributes<HTMLAnchorElement> & {
queryParams?: ExternalLinkQueryParams; queryParams?: ExternalLinkQueryParams;
location: string; location: string;
children: React.ReactNode; children: React.ReactNode;
}; }
const ExternalLink = forwardRef<HTMLAnchorElement, Props>((props, ref) => { const ExternalLink = forwardRef<HTMLAnchorElement, Props>((props, ref) => {
const userId = useSelector(getCurrentUserId); const userId = useSelector(getCurrentUserId);

View File

@ -3059,7 +3059,9 @@
"channel_bookmarks.create.confirm_save.button": "Save bookmark", "channel_bookmarks.create.confirm_save.button": "Save bookmark",
"channel_bookmarks.create.edit.title": "Edit 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.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.error.invalid_url": "Please enter a valid link. Could not parse: {link}.",
"channel_bookmarks.create.error.invalid_url.continue_anyway": "Could not find: {url}. Please enter a valid link, or <Confirm>continue anyway</Confirm>.",
"channel_bookmarks.create.error.invalid_url.continuing_anyway": "Could not find: {url}. Please enter a valid link.",
"channel_bookmarks.create.file_input.edit": "Edit", "channel_bookmarks.create.file_input.edit": "Edit",
"channel_bookmarks.create.file_input.label": "Attachment", "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_info": "Add a link to any post, file, or any external link",

View File

@ -1,18 +1,18 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import type {ChannelBookmarkCreate, ChannelBookmarkPatch} from '@mattermost/types/channel_bookmarks'; import type {ChannelBookmark, ChannelBookmarkCreate, ChannelBookmarkPatch} from '@mattermost/types/channel_bookmarks';
import {ChannelBookmarkTypes} from 'mattermost-redux/action_types'; import {ChannelBookmarkTypes} from 'mattermost-redux/action_types';
import {Client4} from 'mattermost-redux/client'; import {Client4} from 'mattermost-redux/client';
import {getChannelBookmark} from 'mattermost-redux/selectors/entities/channel_bookmarks'; import {getChannelBookmark} from 'mattermost-redux/selectors/entities/channel_bookmarks';
import type {DispatchFunc, GetStateFunc} from 'mattermost-redux/types/actions'; import type {ActionFuncAsync, DispatchFunc} from 'mattermost-redux/types/actions';
import {logError} from './errors'; import {logError} from './errors';
import {forceLogoutIfNecessary} from './helpers'; import {forceLogoutIfNecessary} from './helpers';
export function deleteBookmark(channelId: string, id: string, connectionId: string) { export function deleteBookmark(channelId: string, id: string, connectionId: string): ActionFuncAsync<boolean> {
return async (dispatch: DispatchFunc, getState: GetStateFunc) => { return async (dispatch, getState) => {
const state = getState(); const state = getState();
const bookmark = getChannelBookmark(state, channelId, id); const bookmark = getChannelBookmark(state, channelId, id);
@ -34,7 +34,7 @@ export function deleteBookmark(channelId: string, id: string, connectionId: stri
}; };
} }
export function createBookmark(channelId: string, bookmark: ChannelBookmarkCreate, connectionId: string) { export function createBookmark(channelId: string, bookmark: ChannelBookmarkCreate, connectionId: string): ActionFuncAsync<boolean> {
return async (dispatch: DispatchFunc) => { return async (dispatch: DispatchFunc) => {
try { try {
const createdBookmark = await Client4.createChannelBookmark(channelId, bookmark, connectionId); const createdBookmark = await Client4.createChannelBookmark(channelId, bookmark, connectionId);
@ -54,7 +54,7 @@ export function createBookmark(channelId: string, bookmark: ChannelBookmarkCreat
}; };
} }
export function editBookmark(channelId: string, id: string, patch: ChannelBookmarkPatch, connectionId: string) { export function editBookmark(channelId: string, id: string, patch: ChannelBookmarkPatch, connectionId: string): ActionFuncAsync<boolean> {
return async (dispatch: DispatchFunc) => { return async (dispatch: DispatchFunc) => {
try { try {
const {updated, deleted} = await Client4.updateChannelBookmark(channelId, id, patch, connectionId); const {updated, deleted} = await Client4.updateChannelBookmark(channelId, id, patch, connectionId);
@ -83,8 +83,28 @@ export function editBookmark(channelId: string, id: string, patch: ChannelBookma
}; };
} }
export function fetchChannelBookmarks(channelId: string) { export function reorderBookmark(channelId: string, id: string, newOrder: number, connectionId: string): ActionFuncAsync<boolean> {
return async (dispatch: DispatchFunc, getState: GetStateFunc) => { return async (dispatch: DispatchFunc) => {
try {
const bookmarks = await Client4.updateChannelBookmarkSortOrder(channelId, id, newOrder, connectionId);
dispatch({
type: ChannelBookmarkTypes.RECEIVED_BOOKMARKS,
data: {channelId, bookmarks},
});
} catch (error) {
return {
data: false,
error,
};
}
return {data: true};
};
}
export function fetchChannelBookmarks(channelId: string): ActionFuncAsync<ChannelBookmark[]> {
return async (dispatch, getState) => {
let bookmarks; let bookmarks;
try { try {
bookmarks = await Client4.getChannelBookmarks(channelId); bookmarks = await Client4.getChannelBookmarks(channelId);

View File

@ -14,7 +14,7 @@ import type {GlobalState} from '@mattermost/types/store';
import 'redux-thunk/extend-redux'; import 'redux-thunk/extend-redux';
export type DispatchFunc = Dispatch; export type DispatchFunc = Dispatch;
export type GetStateFunc = () => GlobalState; export type GetStateFunc<State = GlobalState> = () => State;
/** /**
* ActionResult should be the return value of most Thunk action creators. * ActionResult should be the return value of most Thunk action creators.

View File

@ -102,11 +102,15 @@ export function makeUrlSafe(url: string, defaultUrl = ''): string {
} }
export function getScheme(url: string): string | null { export function getScheme(url: string): string | null {
const match = (/([a-z0-9+.-]+):/i).exec(url); const match = (/^!?([a-z0-9+.-]+):/i).exec(url);
return match && match[1]; return match && match[1];
} }
export function removeScheme(url: string) {
return url.replace(/^([a-z0-9+.-]+):\/\//i, '');
}
function formattedError(message: MessageDescriptor, intl?: IntlShape): React.ReactElement | string { function formattedError(message: MessageDescriptor, intl?: IntlShape): React.ReactElement | string {
if (intl) { if (intl) {
return intl.formatMessage(message); return intl.formatMessage(message);
@ -334,13 +338,13 @@ export function channelNameToUrl(channelName: string): UrlValidationCheck {
return {url, error: false}; return {url, error: false};
} }
export function parseLink(href: string) { export function parseLink(href: string, defaultSecure = location.protocol === 'https:') {
let outHref = href; let outHref = href;
if (!href.startsWith('/')) { if (!href.startsWith('/')) {
const scheme = getScheme(href); const scheme = getScheme(href);
if (!scheme) { if (!scheme) {
outHref = `http://${outHref}`; outHref = `${defaultSecure ? 'https' : 'http'}://${outHref}`;
} }
} }