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 index ee08e6366b..8c5b79ce4c 100644 --- a/e2e-tests/cypress/tests/integration/channels/channel/channel_bookmarks_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/channel/channel_bookmarks_spec.ts @@ -14,6 +14,9 @@ import {getRandomId} from '../../../utils'; import * as TIMEOUTS from '../../../fixtures/timeouts'; describe('Channel Bookmarks', () => { + const SpaceKeyCode = 32; + const RightArrowKeyCode = 39; + let testTeam: Cypress.Team; // 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', () => { - 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(() => { // * 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'}); // * 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 cy.get('@link').click(); @@ -114,15 +117,15 @@ describe('Channel Bookmarks', () => { editModalCreate(); // * Verify bookmark created - cy.findAllByRole('link', {name: file}); + cy.findByRole('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'}); + 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 - cy.findAllByRole('link', {name: `:${emojiName}: ${displayName}`}).click(); + cy.findByRole('link', {name: `:${emojiName}: ${displayName}`}).click(); // * Verify preview opened 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); }); + 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', () => { const {displayName} = createLinkBookmark(); @@ -174,6 +198,30 @@ describe('Channel Bookmarks', () => { // * Verify bookmark deleted 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() { @@ -190,7 +238,7 @@ function openDotMenu(name: string) { cy.findByTestId('channel-bookmarks-container').within(() => { // # open menu 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); } +/** + * + * @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) { cy.findByRole('button', {name: 'select an emoji'}).click(); cy.focused().type(`${emojiName}{downArrow}{enter}`); diff --git a/server/public/model/feature_flags.go b/server/public/model/feature_flags.go index 98e668fc17..6651bd499d 100644 --- a/server/public/model/feature_flags.go +++ b/server/public/model/feature_flags.go @@ -70,7 +70,7 @@ func (f *FeatureFlags) SetDefaults() { f.ConsumePostHook = false f.CloudAnnualRenewals = false f.CloudDedicatedExportUI = false - f.ChannelBookmarks = false + f.ChannelBookmarks = true f.WebSocketEventScope = true f.NotificationMonitoring = true f.ExperimentalAuditSettingsSystemConsoleUI = false diff --git a/webapp/channels/src/actions/channel_bookmarks.ts b/webapp/channels/src/actions/channel_bookmarks.ts index 4ed49f7026..c042ef2c61 100644 --- a/webapp/channels/src/actions/channel_bookmarks.ts +++ b/webapp/channels/src/actions/channel_bookmarks.ts @@ -1,39 +1,47 @@ // 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 type {ChannelBookmark, 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 * as Actions from 'mattermost-redux/actions/channel_bookmarks'; +import type {ActionFuncAsync} 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; +export function deleteBookmark(channelId: string, id: string): ActionFuncAsync { + return (dispatch, getState) => { + const state = getState(); 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) { - return async (dispatch: DispatchFunc, getState: GetStateFunc) => { - const state = getState() as GlobalState; +export function createBookmark(channelId: string, bookmark: ChannelBookmarkCreate): ActionFuncAsync { + return (dispatch, getState) => { + const state = getState(); 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) { - return async (dispatch: DispatchFunc, getState: GetStateFunc) => { - const state = getState() as GlobalState; +export function editBookmark(channelId: string, id: string, patch: ChannelBookmarkPatch): ActionFuncAsync { + return async (dispatch, getState) => { + const state = getState(); 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) { - return ChannelBookmarkActions.fetchChannelBookmarks(channelId); +export function reorderBookmark(channelId: string, id: string, newOrder: number): ActionFuncAsync { + return (dispatch, getState) => { + const state = getState(); + const connectionId = getConnectionId(state); + return dispatch(Actions.reorderBookmark(channelId, id, newOrder, connectionId)); + }; +} + +export function fetchChannelBookmarks(channelId: string): ActionFuncAsync { + return Actions.fetchChannelBookmarks(channelId); } diff --git a/webapp/channels/src/components/channel_bookmarks/bookmark_item.tsx b/webapp/channels/src/components/channel_bookmarks/bookmark_item.tsx index c69adf66f1..fc0ae5c0d9 100644 --- a/webapp/channels/src/components/channel_bookmarks/bookmark_item.tsx +++ b/webapp/channels/src/components/channel_bookmarks/bookmark_item.tsx @@ -1,8 +1,9 @@ // 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 type {AnchorHTMLAttributes} from 'react'; +import React, {cloneElement, forwardRef, useRef} from 'react'; +import type {DraggableProvided} from 'react-beautiful-dnd'; import {useDispatch, useSelector} from 'react-redux'; import {Link} from 'react-router-dom'; import styled, {css} from 'styled-components'; @@ -27,9 +28,8 @@ 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 useBookmarkLink = (bookmark: ChannelBookmark) => { + const linkRef = useRef(null); const dispatch = useDispatch(); const fileInfo: FileInfo | undefined = useSelector((state: GlobalState) => (bookmark?.file_id && getFile(state, bookmark.file_id)) || undefined); @@ -88,18 +88,38 @@ const BookmarkItem = ({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 ( - - {link} + + {link && cloneElement(link, {...drag.dragHandleProps, role: 'link'})} ); -}; +}); -const Chip = styled.div` +const Chip = styled.div<{$disableInteractions: boolean}>` position: relative; border-radius: 12px; overflow: hidden; @@ -115,47 +135,49 @@ const Chip = styled.div` 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)); + ${({$disableInteractions}) => !$disableInteractions && css` + &:hover, + &:focus-within, + &:has([aria-expanded="true"]) { + button { + visibility: visible; } } - } + &: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` @@ -167,8 +189,18 @@ const Label = styled.span` 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) => { +type DynamicLinkProps = AnchorHTMLAttributes & { + href: string; + children: React.ReactNode; + isFile: boolean; +}; +const DynamicLink = forwardRef(({ + href, + children, + isFile, + onClick, + ...otherProps +}, ref) => { const siteURL = getSiteURL(); const openInNewTab = shouldOpenInNewTab(href, siteURL); @@ -177,6 +209,7 @@ const DynamicLink = forwardRef(({href, chil if (prefixed || openInNewTab) { return ( (({href, chil if (href.startsWith(siteURL) && !isFile) { return ( {children} @@ -202,6 +235,7 @@ const DynamicLink = forwardRef(({href, chil return ( { - const {order, bookmarks} = useChannelBookmarks(channelId); +}: Props) { + const {order, bookmarks, reorder} = useChannelBookmarks(channelId); const canUploadFiles = useCanUploadFiles(); const canAdd = useChannelBookmarkPermission(channelId, 'add'); const hasBookmarks = Boolean(order?.length); + const limitReached = order.length >= MAX_BOOKMARKS_PER_CHANNEL; if (!hasBookmarks && !canAdd) { return null; } + const handleOnDragEnd: ComponentProps['onDragEnd'] = ({source, destination, draggableId}) => { + if (destination) { + reorder(draggableId, source.index, destination.index); + } + }; + return ( - - {order.map((id) => { + + + {(drop, snap) => { + return ( + + {order.map(makeItemRenderer(bookmarks, snap.isDraggingOver))} + {drop.placeholder} + + + ); + }} + + + ); +} + +const makeItemRenderer = (bookmarks: IDMappedObjects, disableInteractions: boolean) => (id: string, index: number) => { + return ( + + {(drag, snap) => { return ( ); - })} - {canAdd && ( - = MAX_BOOKMARKS_PER_CHANNEL} - canUploadFiles={canUploadFiles} - /> - )} - + }} + ); }; @@ -52,11 +95,11 @@ export default ChannelBookmarks; const Container = styled.div` display: flex; - padding: 8px 6px; + padding: 0 6px; padding-right: 0; + min-height: 38px; align-items: center; border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.12); overflow-x: auto; - overflow-y: hidden; - overflow-y: clip; + max-width: 100vw; `; 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 index b17ff25522..d8e278dc90 100644 --- a/webapp/channels/src/components/channel_bookmarks/channel_bookmarks_create_modal.tsx +++ b/webapp/channels/src/components/channel_bookmarks/channel_bookmarks_create_modal.tsx @@ -1,18 +1,17 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // 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 {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 {PencilOutlineIcon, CheckIcon} 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'; @@ -24,10 +23,11 @@ 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 LoadingSpinner from 'components/widgets/loading/loading_spinner'; import Constants from 'utils/constants'; import {isKeyPressed} from 'utils/keyboard'; -import {isValidUrl, parseLink} from 'utils/url'; +import {isValidUrl, parseLink, removeScheme} from 'utils/url'; import {generateId} from 'utils/utils'; import type {GlobalState} from 'types/store'; @@ -51,23 +51,6 @@ type Props = { 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, @@ -104,52 +87,34 @@ function ChannelBookmarkCreateModal({ }, [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 icon = bookmark?.image_url; + const [link, setLinkInner] = useState(bookmark?.link_url ?? ''); + const prevLink = useRef(link); + const [validatedLink, setValidatedLink] = useState(); + const setLink = useCallback((value: string) => { + setLinkInner((currentLink) => { + prevLink.current = currentLink; + return value; + }); + if (!value) { + setValidatedLink(undefined); + } + }, []); - const handleLinkChange = useCallback(({target: {value}}: ChangeEvent) => { - setLinkInputValue(value); + const [linkError, {loading: checkingLink, suppressed: linkErrorBypass}] = useBookmarkLinkValidation(link === prevLink.current ? '' : link, (validatedLink, forced) => { + if (!forced) { + setValidatedLink(validatedLink); + } + const parsed = removeScheme(validatedLink); + setParsedDisplayName(parsed); + setDisplayName(parsed); + }); + + const handleLinkChange = useCallback((e: ChangeEvent) => { + const {value} = e.target; 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(); @@ -303,17 +268,26 @@ function ChannelBookmarkCreateModal({ const isValid = (() => { if (type === 'link') { if (!link || linkError) { + if (link && linkError && linkErrorBypass) { + return true; + } return false; } + + if (validatedLink || link === bookmark?.link_url) { + return true; + } } if (type === 'file') { if (!fileInfo || !displayNameValue || fileError) { return false; } + + return true; } - return true; + return undefined; })(); const showControls = type === 'file' || (isValid || bookmark); @@ -374,6 +348,15 @@ function ChannelBookmarkCreateModal({ const confirmDisabled = saving || !isValid || !hasChanges; + let linkStatusIndicator; + if (checkingLink) { + // loading + linkStatusIndicator = ; + } else if (validatedLink && !linkError) { + // validated + linkStatusIndicator = checkedIcon; + } + return ( <> {type === 'link' ? ( - + <> + + ) : ( <> @@ -489,6 +474,21 @@ const TitleWrapper = styled.div` margin-top: 20px; `; +const CheckWrapper = styled.span` + padding: 0px 12px; + display: flex; + align-items: center; +`; + +const checkedIcon = ( + + + +); + const FieldLabel = styled.span` display: inline-block; margin-bottom: 8px; @@ -558,6 +558,48 @@ const FileInputContainer = styled.div` } `; +const continuableLinkErr = (url: URL, confirm?: () => void) => { + if (!confirm) { + return ( + + ); + } + + return ( + ( + { + if (e.key === 'Enter') { + confirm(); + } + }} + > + {msg} + + ), + }} + /> + ); +}; + +const LinkErrContinue = styled.a` + color: unset !important; + text-decoration: underline; +`; + const FileItemContainer = styled.div` display: flex; flex: 1 1 auto; @@ -576,6 +618,98 @@ const msg = defineMessages({ 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'}, + 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.'}, }); + +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(); + const [error, setError] = useState(); + const [suppressed, setSuppressed] = useState(false); + const abort = useRef(); + + 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; +}; diff --git a/webapp/channels/src/components/channel_bookmarks/channel_bookmarks_plus_menu.tsx b/webapp/channels/src/components/channel_bookmarks/channel_bookmarks_menu.tsx similarity index 95% rename from webapp/channels/src/components/channel_bookmarks/channel_bookmarks_plus_menu.tsx rename to webapp/channels/src/components/channel_bookmarks/channel_bookmarks_menu.tsx index e8dbebe501..3a24a22508 100644 --- a/webapp/channels/src/components/channel_bookmarks/channel_bookmarks_plus_menu.tsx +++ b/webapp/channels/src/components/channel_bookmarks/channel_bookmarks_menu.tsx @@ -28,18 +28,17 @@ import {clearFileInput} from 'utils/utils'; import ChannelBookmarkCreateModal from './channel_bookmarks_create_modal'; import {MAX_BOOKMARKS_PER_CHANNEL} from './utils'; -type PlusMenuProps = { +type BookmarksMenuProps = { channelId: string; hasBookmarks: boolean; limitReached: boolean; - canUploadFiles: boolean; -}; -const PlusMenu = ({ + canUploadFiles: boolean;}; +export default ({ channelId, hasBookmarks, limitReached, canUploadFiles, -}: PlusMenuProps) => { +}: BookmarksMenuProps) => { const {formatMessage} = useIntl(); const dispatch = useDispatch(); const showLabel = !hasBookmarks; @@ -98,7 +97,9 @@ const PlusMenu = ({ const attachFileLabel = formatMessage({id: 'channel_bookmarks.attachFile', defaultMessage: 'Attach a file'}); return ( - + {fileInput} - + ); }; -export default PlusMenu; - -const PlusButtonContainer = styled.div<{withLabel: boolean}>` +const MenuButtonContainer = styled.div<{withLabel: boolean}>` position: sticky; right: 0; ${({withLabel}) => !withLabel && css`padding: 0 1rem;`} diff --git a/webapp/channels/src/components/channel_bookmarks/utils.ts b/webapp/channels/src/components/channel_bookmarks/utils.ts index 21da6797d5..9c4325de8c 100644 --- a/webapp/channels/src/components/channel_bookmarks/utils.ts +++ b/webapp/channels/src/components/channel_bookmarks/utils.ts @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {useEffect, useMemo} from 'react'; +import {useEffect, useMemo, useState} from 'react'; import {useDispatch, useSelector} from 'react-redux'; 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 {getConfig, getFeatureFlagValue, getLicense} from 'mattermost-redux/selectors/entities/general'; 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 Constants from 'utils/constants'; @@ -103,6 +104,13 @@ export const useChannelBookmarks = (channelId: string) => { const order = useMemo(() => { return Object.keys(bookmarks).sort((a, b) => bookmarks[a].sort_order - bookmarks[b].sort_order); }, [bookmarks]); + const [tempOrder, setTempOrder] = useState(); + + useEffect(() => { + if (tempOrder) { + setTempOrder(undefined); + } + }, [order]); useEffect(() => { if (channelId) { @@ -124,8 +132,19 @@ export const useChannelBookmarks = (channelId: string) => { } }, [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 { bookmarks, - order, - }; + order: tempOrder ?? order, + reorder, + } as const; }; + diff --git a/webapp/channels/src/components/external_link/index.tsx b/webapp/channels/src/components/external_link/index.tsx index 8f0b8db808..eea5d8b8bf 100644 --- a/webapp/channels/src/components/external_link/index.tsx +++ b/webapp/channels/src/components/external_link/index.tsx @@ -27,7 +27,7 @@ type Props = React.AnchorHTMLAttributes & { queryParams?: ExternalLinkQueryParams; location: string; children: React.ReactNode; -}; +} const ExternalLink = forwardRef((props, ref) => { const userId = useSelector(getCurrentUserId); diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index a56353480c..a5540b9e71 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -3059,7 +3059,9 @@ "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.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 continue anyway.", + "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.label": "Attachment", "channel_bookmarks.create.link_info": "Add a link to any post, file, or any external link", 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 index 427cc7d991..314fe680fc 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/actions/channel_bookmarks.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/channel_bookmarks.ts @@ -1,18 +1,18 @@ // 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 type {ChannelBookmark, 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 type {ActionFuncAsync, DispatchFunc} 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) => { +export function deleteBookmark(channelId: string, id: string, connectionId: string): ActionFuncAsync { + return async (dispatch, getState) => { const state = getState(); 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 { return async (dispatch: DispatchFunc) => { try { 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 { return async (dispatch: DispatchFunc) => { try { 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) { - return async (dispatch: DispatchFunc, getState: GetStateFunc) => { +export function reorderBookmark(channelId: string, id: string, newOrder: number, connectionId: string): ActionFuncAsync { + 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 { + return async (dispatch, getState) => { let bookmarks; try { bookmarks = await Client4.getChannelBookmarks(channelId); diff --git a/webapp/channels/src/packages/mattermost-redux/src/types/actions.ts b/webapp/channels/src/packages/mattermost-redux/src/types/actions.ts index 2eff33f8cf..633e221985 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/types/actions.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/types/actions.ts @@ -14,7 +14,7 @@ import type {GlobalState} from '@mattermost/types/store'; import 'redux-thunk/extend-redux'; export type DispatchFunc = Dispatch; -export type GetStateFunc = () => GlobalState; +export type GetStateFunc = () => State; /** * ActionResult should be the return value of most Thunk action creators. diff --git a/webapp/channels/src/utils/url.tsx b/webapp/channels/src/utils/url.tsx index 9b7036d3dc..685eb36bba 100644 --- a/webapp/channels/src/utils/url.tsx +++ b/webapp/channels/src/utils/url.tsx @@ -102,11 +102,15 @@ export function makeUrlSafe(url: string, defaultUrl = ''): string { } 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]; } +export function removeScheme(url: string) { + return url.replace(/^([a-z0-9+.-]+):\/\//i, ''); +} + function formattedError(message: MessageDescriptor, intl?: IntlShape): React.ReactElement | string { if (intl) { return intl.formatMessage(message); @@ -334,13 +338,13 @@ export function channelNameToUrl(channelName: string): UrlValidationCheck { return {url, error: false}; } -export function parseLink(href: string) { +export function parseLink(href: string, defaultSecure = location.protocol === 'https:') { let outHref = href; if (!href.startsWith('/')) { const scheme = getScheme(href); if (!scheme) { - outHref = `http://${outHref}`; + outHref = `${defaultSecure ? 'https' : 'http'}://${outHref}`; } }