mirror of
https://github.com/mattermost/mattermost.git
synced 2025-02-25 18:55:24 -06:00
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:
parent
075681a412
commit
d99961f106
@ -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}`);
|
||||
|
@ -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
|
||||
|
@ -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<boolean, GlobalState> {
|
||||
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<boolean, GlobalState> {
|
||||
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<boolean, GlobalState> {
|
||||
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<boolean, GlobalState> {
|
||||
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);
|
||||
}
|
||||
|
@ -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 = <T extends HTMLAnchorElement>({bookmark}: Props) => {
|
||||
const linkRef = useRef<T>(null);
|
||||
const useBookmarkLink = (bookmark: ChannelBookmark) => {
|
||||
const linkRef = useRef<HTMLAnchorElement>(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 = <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 (
|
||||
<Chip>
|
||||
{link}
|
||||
<Chip
|
||||
ref={drag.innerRef}
|
||||
{...drag.draggableProps}
|
||||
$disableInteractions={disableInteractions}
|
||||
>
|
||||
{link && cloneElement(link, {...drag.dragHandleProps, role: 'link'})}
|
||||
<BookmarkItemDotMenu
|
||||
bookmark={bookmark}
|
||||
open={open}
|
||||
/>
|
||||
</Chip>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
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<HTMLAnchorElement>['onClick']};
|
||||
const DynamicLink = forwardRef<HTMLAnchorElement, DynamicLinkProps>(({href, children, isFile, onClick}, ref) => {
|
||||
type DynamicLinkProps = AnchorHTMLAttributes<HTMLAnchorElement> & {
|
||||
href: string;
|
||||
children: React.ReactNode;
|
||||
isFile: boolean;
|
||||
};
|
||||
const DynamicLink = forwardRef<HTMLAnchorElement, DynamicLinkProps>(({
|
||||
href,
|
||||
children,
|
||||
isFile,
|
||||
onClick,
|
||||
...otherProps
|
||||
}, ref) => {
|
||||
const siteURL = getSiteURL();
|
||||
const openInNewTab = shouldOpenInNewTab(href, siteURL);
|
||||
|
||||
@ -177,6 +209,7 @@ const DynamicLink = forwardRef<HTMLAnchorElement, DynamicLinkProps>(({href, chil
|
||||
if (prefixed || openInNewTab) {
|
||||
return (
|
||||
<StyledExternalLink
|
||||
{...otherProps}
|
||||
href={prefixed ? href.substring(1) : href}
|
||||
rel='noopener noreferrer'
|
||||
target='_blank'
|
||||
@ -191,9 +224,9 @@ const DynamicLink = forwardRef<HTMLAnchorElement, DynamicLinkProps>(({href, chil
|
||||
if (href.startsWith(siteURL) && !isFile) {
|
||||
return (
|
||||
<StyledLink
|
||||
{...otherProps}
|
||||
to={href.slice(siteURL.length)}
|
||||
ref={ref}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</StyledLink>
|
||||
@ -202,6 +235,7 @@ const DynamicLink = forwardRef<HTMLAnchorElement, DynamicLinkProps>(({href, chil
|
||||
|
||||
return (
|
||||
<StyledAnchor
|
||||
{...otherProps}
|
||||
href={href}
|
||||
ref={ref}
|
||||
onClick={onClick}
|
||||
|
@ -1,11 +1,16 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {ComponentProps} from 'react';
|
||||
import React from 'react';
|
||||
import {DragDropContext, Draggable, Droppable} from 'react-beautiful-dnd';
|
||||
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 PlusMenu from './channel_bookmarks_plus_menu';
|
||||
import BookmarksMenu from './channel_bookmarks_menu';
|
||||
import {useChannelBookmarkPermission, useChannelBookmarks, MAX_BOOKMARKS_PER_CHANNEL, useCanUploadFiles} from './utils';
|
||||
|
||||
import './channel_bookmarks.scss';
|
||||
@ -14,37 +19,75 @@ type Props = {
|
||||
channelId: string;
|
||||
};
|
||||
|
||||
const ChannelBookmarks = ({
|
||||
function ChannelBookmarks({
|
||||
channelId,
|
||||
}: Props) => {
|
||||
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<typeof DragDropContext>['onDragEnd'] = ({source, destination, draggableId}) => {
|
||||
if (destination) {
|
||||
reorder(draggableId, source.index, destination.index);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container data-testid='channel-bookmarks-container'>
|
||||
{order.map((id) => {
|
||||
<DragDropContext
|
||||
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 (
|
||||
<BookmarkItem
|
||||
key={id}
|
||||
drag={drag}
|
||||
isDragging={snap.isDragging}
|
||||
disableInteractions={snap.isDragging || disableInteractions}
|
||||
bookmark={bookmarks[id]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{canAdd && (
|
||||
<PlusMenu
|
||||
channelId={channelId}
|
||||
hasBookmarks={hasBookmarks}
|
||||
limitReached={order.length >= MAX_BOOKMARKS_PER_CHANNEL}
|
||||
canUploadFiles={canUploadFiles}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
}}
|
||||
</Draggable>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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;
|
||||
`;
|
||||
|
@ -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<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({
|
||||
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<string>();
|
||||
const setLink = useCallback((value: string) => {
|
||||
setLinkInner((currentLink) => {
|
||||
prevLink.current = currentLink;
|
||||
return value;
|
||||
});
|
||||
if (!value) {
|
||||
setValidatedLink(undefined);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleLinkChange = useCallback(({target: {value}}: ChangeEvent<HTMLInputElement>) => {
|
||||
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<HTMLInputElement>) => {
|
||||
const {value} = e.target;
|
||||
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'
|
||||
const canUploadFiles = useCanUploadFiles();
|
||||
const [pendingFile, setPendingFile] = useState<FilePreviewInfo | null>();
|
||||
@ -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 = <LoadingSpinner/>;
|
||||
} else if (validatedLink && !linkError) {
|
||||
// validated
|
||||
linkStatusIndicator = checkedIcon;
|
||||
}
|
||||
|
||||
return (
|
||||
<GenericModal
|
||||
enforceFocus={!showEmojiPicker}
|
||||
@ -392,19 +375,21 @@ function ChannelBookmarkCreateModal({
|
||||
>
|
||||
<>
|
||||
{type === 'link' ? (
|
||||
<Input
|
||||
type='text'
|
||||
name='bookmark-link'
|
||||
containerClassName='linkInput'
|
||||
placeholder={formatMessage(msg.linkPlaceholder)}
|
||||
onChange={handleLinkChange}
|
||||
onBlur={handleLinkBlur}
|
||||
onPaste={handleLinkPasted}
|
||||
value={linkInputValue}
|
||||
data-testid='linkInput'
|
||||
autoFocus={true}
|
||||
customMessage={linkError ? {type: 'error', value: linkError} : {value: formatMessage(msg.linkInfoMessage)}}
|
||||
/>
|
||||
<>
|
||||
<Input
|
||||
type='text'
|
||||
name='bookmark-link'
|
||||
containerClassName='linkInput'
|
||||
placeholder={formatMessage(msg.linkPlaceholder)}
|
||||
onChange={handleLinkChange}
|
||||
hasError={Boolean(linkError)}
|
||||
value={link}
|
||||
data-testid='linkInput'
|
||||
autoFocus={true}
|
||||
addon={linkStatusIndicator}
|
||||
customMessage={linkError ? {type: 'error', value: linkError} : {value: formatMessage(msg.linkInfoMessage)}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FieldLabel>
|
||||
@ -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 = (
|
||||
<CheckWrapper>
|
||||
<CheckIcon
|
||||
size={20}
|
||||
color='var(--sys-online-indicator)'
|
||||
/>
|
||||
</CheckWrapper>
|
||||
);
|
||||
|
||||
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 (
|
||||
<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`
|
||||
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<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;
|
||||
};
|
||||
|
@ -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 (
|
||||
<PlusButtonContainer withLabel={showLabel}>
|
||||
<MenuButtonContainer
|
||||
withLabel={showLabel}
|
||||
>
|
||||
<Menu.Container
|
||||
anchorOrigin={{vertical: 'bottom', horizontal: 'left'}}
|
||||
transformOrigin={{vertical: 'top', horizontal: 'left'}}
|
||||
@ -142,13 +143,11 @@ const PlusMenu = ({
|
||||
)}
|
||||
</Menu.Container>
|
||||
{fileInput}
|
||||
</PlusButtonContainer>
|
||||
</MenuButtonContainer>
|
||||
);
|
||||
};
|
||||
|
||||
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;`}
|
@ -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<typeof order>();
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
|
@ -27,7 +27,7 @@ type Props = React.AnchorHTMLAttributes<HTMLAnchorElement> & {
|
||||
queryParams?: ExternalLinkQueryParams;
|
||||
location: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
}
|
||||
|
||||
const ExternalLink = forwardRef<HTMLAnchorElement, Props>((props, ref) => {
|
||||
const userId = useSelector(getCurrentUserId);
|
||||
|
@ -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 <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.label": "Attachment",
|
||||
"channel_bookmarks.create.link_info": "Add a link to any post, file, or any external link",
|
||||
|
@ -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<boolean> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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;
|
||||
try {
|
||||
bookmarks = await Client4.getChannelBookmarks(channelId);
|
||||
|
@ -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 = GlobalState> = () => State;
|
||||
|
||||
/**
|
||||
* ActionResult should be the return value of most Thunk action creators.
|
||||
|
@ -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}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user