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';
|
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}`);
|
||||||
|
@ -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
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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}
|
||||||
|
@ -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;
|
|
||||||
`;
|
`;
|
||||||
|
@ -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;
|
||||||
|
};
|
||||||
|
@ -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;`}
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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);
|
||||||
|
@ -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",
|
||||||
|
@ -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);
|
||||||
|
@ -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.
|
||||||
|
@ -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}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user