MM-54416: Channel Bookmarks Web UI (#25889)

Co-authored-by: Mattermost Build <build@mattermost.com>
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
Co-authored-by: Miguel de la Cruz <miguel@mcrx.me>
Co-authored-by: Scott Bishel <scott.bishel@mattermost.com>
Этот коммит содержится в:
Caleb Roseland
2024-06-28 16:58:14 -04:00
коммит произвёл GitHub
родитель a5d263f26c
Коммит f12eb75d25
64 изменённых файлов: 2924 добавлений и 438 удалений

Просмотреть файл

@@ -0,0 +1,277 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// ***************************************************************
// - [#] indicates a test step (e.g. # Go to a page)
// - [*] indicates an assertion (e.g. * Check the title)
// - Use element ID when selecting an element. Create one if none.
// ***************************************************************
// Group: @channels @channel @channel_bookmarks
// node run_tests.js --group='@channel'
import {getRandomId} from '../../../utils';
import * as TIMEOUTS from '../../../fixtures/timeouts';
describe('Channel Bookmarks', () => {
let testTeam: Cypress.Team;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
let user1: Cypress.UserProfile;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
let admin: Cypress.UserProfile;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
let channel: Cypress.Channel;
before(() => {
cy.apiGetMe().then(({user: adminUser}) => {
admin = adminUser;
cy.apiInitSetup().then(({team, user}) => {
testTeam = team;
user1 = user;
cy.visit(`/${testTeam.name}/channels/town-square`);
cy.getCurrentChannelId().then((channelId) => {
cy.makeClient().then(async (client) => {
channel = await client.getChannel(channelId);
});
});
});
});
});
it('create link bookmark', () => {
// # Create link
const {link, realLink} = createLinkBookmark();
cy.findByTestId('channel-bookmarks-container').within(() => {
// * Verify href
cy.findByRole('link', {name: link}).should('have.attr', 'href', realLink);
});
});
it('create link bookmark, with emoji and custom title', () => {
const {realLink, displayName, emojiName} = createLinkBookmark({displayName: 'custom display name', emojiName: 'smile'});
cy.findByTestId('channel-bookmarks-container').within(() => {
// * Verify emoji, displayname, and href
cy.findAllByRole('link', {name: `:${emojiName}: ${displayName}`}).should('have.attr', 'href', realLink);
});
});
it('create file bookmark, and open preview', () => {
// # Create bookmark
const {file} = createFileBookmark({file: 'small-image.png'});
// * Verify preview icon
cy.findAllByRole('link', {name: file}).as('link').find('.file-icon.image');
// # Open preview
cy.get('@link').click();
// * Verify preview opened
cy.get('.file-preview-modal').findByRole('heading', {name: file});
cy.get('.file-preview-modal__file-details-user-name').should('have.text', admin.username);
cy.get('.file-preview-modal__channel').should('have.text', `Shared in ~${channel.display_name}`);
cy.get('.icon-close').click();
});
it('create file bookmark, progress and cancel upload', () => {
const file = 'powerpointx-file.pptx';
cy.intercept(
{method: 'POST', pathname: 'files', middleware: true, times: 1},
() => {
return new Promise((resolve) =>
setTimeout(() => resolve(), 2000),
);
}).as('uploadRequest');
// # Create bookmark
createFileBookmark({file, save: false});
// # Cancel upload
cy.get('a.file-preview__remove').click();
// * Verify empty preview container
cy.get('.file-preview__container.empty');
// * Verify upload cancelled
cy.wait('@uploadRequest').its('state').should('eq', 'Errored');
// * Verify cannot save
cy.findByRole('button', {name: 'Add bookmark'}).should('be.disabled');
// # Try upload file again
cy.get('#bookmark-create-file-input-in-modal').attachFile(file);
// * Verify uploaded
cy.findByTestId('titleInput').should('have.value', file);
cy.findByRole('link', {name: `file thumbnail ${file}`});
// # Save
editModalCreate();
// * Verify bookmark created
cy.findAllByRole('link', {name: file});
});
it('create file bookmark, with emoji and custom title', () => {
// # Create bookmark
const {file, displayName, emojiName} = createFileBookmark({file: 'm4a-audio-file.m4a', displayName: 'custom displayname small-image', emojiName: 'smile'});
// * Verify emoji and custom display name
cy.findAllByRole('link', {name: `:${emojiName}: ${displayName}`}).click();
// * Verify preview opened
cy.get('.file-preview-modal').findByRole('heading', {name: file});
cy.get('.icon-close').click();
});
it('edit link bookmark', () => {
// # Create link
const {displayName} = createLinkBookmark();
const nextLink = 'google.com/q=test001';
const realNextLink = `http://${nextLink}`;
const nextDisplayName = 'Next custom display name';
const nextEmojiName = 'handshake';
// # Open edit
openEditModal(displayName);
// # Change link, displayname, emoji
editTextInput('linkInput', nextLink);
editTextInput('titleInput', nextDisplayName);
selectEmoji(nextEmojiName);
// # Save
editModalSave();
// * Verify changes
cy.findAllByRole('link', {name: `:${nextEmojiName}: ${nextDisplayName}`}).should('have.attr', 'href', realNextLink);
});
it('delete bookmark', () => {
const {displayName} = createLinkBookmark();
// * Verify bookmark exists
cy.findByRole('link', {name: displayName});
// # Start delete bookmark flow
openDotMenu(displayName);
cy.findByRole('menuitem', {name: 'Delete'}).click();
cy.findByRole('dialog', {name: 'Delete bookmark'}).within(() => {
// * Verify delete dialog contents
cy.findByRole('heading', {name: 'Delete bookmark'});
cy.contains(`Are you sure you want to delete the bookmark ${displayName}?`);
// # Delete bookmark
cy.findByRole('button', {name: 'Yes, delete'}).click();
});
// * Verify bookmark deleted
cy.findByRole('link', {name: displayName}).should('not.exist');
});
});
function promptAddLink() {
cy.get('#channelBookmarksPlusMenuButton').click();
cy.get('#channelBookmarksAddLink').click();
}
function openEditModal(name: string) {
openDotMenu(name);
cy.findByRole('menuitem', {name: 'Edit'}).click();
}
function openDotMenu(name: string) {
cy.findByTestId('channel-bookmarks-container').within(() => {
// # open menu
cy.findByRole('link', {name}).scrollIntoView().focus().
parent('div').find('button').click();
});
}
function editModalSave() {
cy.findByRole('button', {name: 'Save bookmark'}).click();
}
function editModalCreate() {
cy.findByRole('button', {name: 'Add bookmark'}).click();
}
function createLinkBookmark({
link = `google.com/?q=test${getRandomId(7)}`,
displayName = '',
emojiName = '', // e.g. smile
save = true,
} = {}) {
const realLink = `http://${link}`;
// # Add link
promptAddLink();
// # Enter link
editTextInput('linkInput', link);
if (displayName) {
// # Enter displayname
editTextInput('titleInput', displayName);
}
if (emojiName) {
// # Select emoji
selectEmoji(emojiName);
}
if (save) {
// # Save
editModalCreate();
}
return {link, realLink, displayName: displayName || link, emojiName};
}
function createFileBookmark({
file = 'small-image.png',
displayName = '',
emojiName = '', // e.g. smile
save = true,
} = {}) {
cy.get('#bookmark-create-file-input').attachFile(file);
if (displayName) {
// # Enter displayname
cy.findByTestId('titleInput').should('have.value', file);
editTextInput('titleInput', displayName);
}
if (emojiName) {
// # Select emoji
selectEmoji(emojiName);
}
if (save) {
// # Save
cy.findByRole('button', {name: 'Add bookmark'}).click();
}
return {file, displayName, emojiName};
}
function editTextInput(testid: string, nextValue: string) {
cy.findByTestId(testid).
focus().
clear().
wait(TIMEOUTS.HALF_SEC).
type(nextValue).
wait(TIMEOUTS.HALF_SEC).
should('have.value', nextValue);
}
function selectEmoji(emojiName: string) {
cy.findByRole('button', {name: 'select an emoji'}).click();
cy.focused().type(`${emojiName}{downArrow}{enter}`);
}

Просмотреть файл

@@ -174,3 +174,4 @@ export function muteChannel(userId: UserProfile['id'], channelId: Channel['id'])
mark_unread: NotificationLevels.MENTION,
});
}

Просмотреть файл

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

Просмотреть файл

@@ -28,13 +28,18 @@ export interface UploadFile {
onError: (err: string | ServerError, clientId: string, channelId: string, rootId: string) => void;
}
export function uploadFile({file, name, type, rootId, channelId, clientId, onProgress, onSuccess, onError}: UploadFile): ThunkActionFunc<XMLHttpRequest> {
export function uploadFile({file, name, type, rootId, channelId, clientId, onProgress, onSuccess, onError}: UploadFile, isBookmark?: boolean): ThunkActionFunc<XMLHttpRequest> {
return (dispatch, getState) => {
dispatch({type: FileTypes.UPLOAD_FILES_REQUEST});
let url = Client4.getFilesRoute();
if (isBookmark) {
url += '?bookmark=true';
}
const xhr = new XMLHttpRequest();
xhr.open('POST', Client4.getFilesRoute(), true);
xhr.open('POST', url, true);
const client4Headers = Client4.getOptions({method: 'POST'}).headers;
Object.keys(client4Headers).forEach((client4Header) => {

Просмотреть файл

@@ -20,6 +20,7 @@ import {
AppsTypes,
CloudTypes,
HostedCustomerTypes,
ChannelBookmarkTypes,
} from 'mattermost-redux/action_types';
import {getStandardAnalytics} from 'mattermost-redux/actions/admin';
import {fetchAppBindings, fetchRHSAppsBindings} from 'mattermost-redux/actions/apps';
@@ -432,6 +433,22 @@ export function handleEvent(msg) {
handleChannelMemberUpdatedEvent(msg);
break;
case SocketEvents.CHANNEL_BOOKMARK_CREATED:
dispatch(handleChannelBookmarkCreated(msg));
break;
case SocketEvents.CHANNEL_BOOKMARK_UPDATED:
dispatch(handleChannelBookmarkUpdated(msg));
break;
case SocketEvents.CHANNEL_BOOKMARK_DELETED:
dispatch(handleChannelBookmarkDeleted(msg));
break;
case SocketEvents.CHANNEL_BOOKMARK_SORTED:
dispatch(handleChannelBookmarkSorted(msg));
break;
case SocketEvents.DIRECT_ADDED:
dispatch(handleDirectAddedEvent(msg));
break;
@@ -1755,3 +1772,50 @@ function handleHostedCustomerSignupProgressUpdated(msg) {
data: msg.data.progress,
};
}
function handleChannelBookmarkCreated(msg) {
const bookmark = JSON.parse(msg.data.bookmark);
return {
type: ChannelBookmarkTypes.RECEIVED_BOOKMARK,
data: bookmark,
};
}
function handleChannelBookmarkUpdated(msg) {
return async (doDispatch) => {
const {updated, deleted} = JSON.parse(msg.data.bookmarks);
if (updated) {
doDispatch({
type: ChannelBookmarkTypes.RECEIVED_BOOKMARK,
data: updated,
});
}
if (deleted) {
doDispatch({
type: ChannelBookmarkTypes.BOOKMARK_DELETED,
data: deleted,
});
}
};
}
function handleChannelBookmarkDeleted(msg) {
const bookmark = JSON.parse(msg.data.bookmark);
return {
type: ChannelBookmarkTypes.BOOKMARK_DELETED,
data: bookmark,
};
}
function handleChannelBookmarkSorted(msg) {
const bookmarks = JSON.parse(msg.data.bookmarks);
return {
type: ChannelBookmarkTypes.RECEIVED_BOOKMARKS,
data: {channelId: msg.broadcast.channel_id, bookmarks},
};
}

Просмотреть файл

@@ -63,21 +63,21 @@ exports[`components/admin_console/license_settings/LicenseSettings load screen a
className="terms-and-policy"
>
See also
<ExternalLink
<ForwardRef
href="https://mattermost.com/pl/terms-of-use/"
id="privacyLink"
location="license_settings"
>
Enterprise Edition Terms of Use
</ExternalLink>
</ForwardRef>
and
<ExternalLink
<ForwardRef
href="https://mattermost.com/pl/privacy-policy/"
id="privacyLink"
location="license_settings"
>
Privacy Policy
</ExternalLink>
</ForwardRef>
</div>
</div>
<div
@@ -167,21 +167,21 @@ exports[`components/admin_console/license_settings/LicenseSettings load screen w
className="terms-and-policy"
>
See also
<ExternalLink
<ForwardRef
href="https://mattermost.com/pl/terms-of-use/"
id="privacyLink"
location="license_settings"
>
Enterprise Edition Terms of Use
</ExternalLink>
</ForwardRef>
and
<ExternalLink
<ForwardRef
href="https://mattermost.com/pl/privacy-policy/"
id="privacyLink"
location="license_settings"
>
Privacy Policy
</ExternalLink>
</ForwardRef>
</div>
</div>
<div
@@ -285,21 +285,21 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
className="terms-and-policy"
>
See also
<ExternalLink
<ForwardRef
href="https://mattermost.com/pl/terms-of-use/"
id="privacyLink"
location="license_settings"
>
Enterprise Edition Terms of Use
</ExternalLink>
</ForwardRef>
and
<ExternalLink
<ForwardRef
href="https://mattermost.com/pl/privacy-policy/"
id="privacyLink"
location="license_settings"
>
Privacy Policy
</ExternalLink>
</ForwardRef>
</div>
</div>
<div
@@ -328,13 +328,13 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
className="compare-plans-text"
>
Curious about upgrading?
<ExternalLink
<ForwardRef
href="https://mattermost.com/pl/pricing/"
id="privacyLink"
location="license_settings"
>
Compare Plans
</ExternalLink>
</ForwardRef>
</div>
</div>
</div>
@@ -420,21 +420,21 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
className="terms-and-policy"
>
See also
<ExternalLink
<ForwardRef
href="https://mattermost.com/pl/terms-of-use/"
id="privacyLink"
location="license_settings"
>
Enterprise Edition Terms of Use
</ExternalLink>
</ForwardRef>
and
<ExternalLink
<ForwardRef
href="https://mattermost.com/pl/privacy-policy/"
id="privacyLink"
location="license_settings"
>
Privacy Policy
</ExternalLink>
</ForwardRef>
</div>
</div>
<div
@@ -543,21 +543,21 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
className="terms-and-policy"
>
See also
<ExternalLink
<ForwardRef
href="https://mattermost.com/pl/terms-of-use/"
id="privacyLink"
location="license_settings"
>
Enterprise Edition Terms of Use
</ExternalLink>
</ForwardRef>
and
<ExternalLink
<ForwardRef
href="https://mattermost.com/pl/privacy-policy/"
id="privacyLink"
location="license_settings"
>
Privacy Policy
</ExternalLink>
</ForwardRef>
</div>
</div>
<div
@@ -666,21 +666,21 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
className="terms-and-policy"
>
See also
<ExternalLink
<ForwardRef
href="https://mattermost.com/pl/terms-of-use/"
id="privacyLink"
location="license_settings"
>
Enterprise Edition Terms of Use
</ExternalLink>
</ForwardRef>
and
<ExternalLink
<ForwardRef
href="https://mattermost.com/pl/privacy-policy/"
id="privacyLink"
location="license_settings"
>
Privacy Policy
</ExternalLink>
</ForwardRef>
</div>
</div>
<div
@@ -789,21 +789,21 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
className="terms-and-policy"
>
See also
<ExternalLink
<ForwardRef
href="https://mattermost.com/pl/terms-of-use/"
id="privacyLink"
location="license_settings"
>
Enterprise Edition Terms of Use
</ExternalLink>
</ForwardRef>
and
<ExternalLink
<ForwardRef
href="https://mattermost.com/pl/privacy-policy/"
id="privacyLink"
location="license_settings"
>
Privacy Policy
</ExternalLink>
</ForwardRef>
</div>
</div>
<div
@@ -912,21 +912,21 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
className="terms-and-policy"
>
See also
<ExternalLink
<ForwardRef
href="https://mattermost.com/pl/terms-of-use/"
id="privacyLink"
location="license_settings"
>
Enterprise Edition Terms of Use
</ExternalLink>
</ForwardRef>
and
<ExternalLink
<ForwardRef
href="https://mattermost.com/pl/privacy-policy/"
id="privacyLink"
location="license_settings"
>
Privacy Policy
</ExternalLink>
</ForwardRef>
</div>
</div>
<div
@@ -1044,13 +1044,13 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
className="compare-plans-text"
>
Curious about upgrading?
<ExternalLink
<ForwardRef
href="https://mattermost.com/pl/pricing/"
id="privacyLink"
location="license_settings"
>
Compare Plans
</ExternalLink>
</ForwardRef>
</div>
</div>
</div>
@@ -1127,21 +1127,21 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
className="terms-and-policy"
>
See also
<ExternalLink
<ForwardRef
href="https://mattermost.com/pl/terms-of-use/"
id="privacyLink"
location="license_settings"
>
Enterprise Edition Terms of Use
</ExternalLink>
</ForwardRef>
and
<ExternalLink
<ForwardRef
href="https://mattermost.com/pl/privacy-policy/"
id="privacyLink"
location="license_settings"
>
Privacy Policy
</ExternalLink>
</ForwardRef>
</div>
</div>
<div
@@ -1156,13 +1156,13 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
className="compare-plans-text"
>
Curious about upgrading?
<ExternalLink
<ForwardRef
href="https://mattermost.com/pl/pricing/"
id="privacyLink"
location="license_settings"
>
Compare Plans
</ExternalLink>
</ForwardRef>
</div>
</div>
</div>
@@ -1239,21 +1239,21 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
className="terms-and-policy"
>
See also
<ExternalLink
<ForwardRef
href="https://mattermost.com/pl/terms-of-use/"
id="privacyLink"
location="license_settings"
>
Enterprise Edition Terms of Use
</ExternalLink>
</ForwardRef>
and
<ExternalLink
<ForwardRef
href="https://mattermost.com/pl/privacy-policy/"
id="privacyLink"
location="license_settings"
>
Privacy Policy
</ExternalLink>
</ForwardRef>
</div>
</div>
<div
@@ -1268,13 +1268,13 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
className="compare-plans-text"
>
Curious about upgrading?
<ExternalLink
<ForwardRef
href="https://mattermost.com/pl/pricing/"
id="privacyLink"
location="license_settings"
>
Compare Plans
</ExternalLink>
</ForwardRef>
</div>
</div>
</div>
@@ -1346,21 +1346,21 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
className="terms-and-policy"
>
See also
<ExternalLink
<ForwardRef
href="https://mattermost.com/pl/terms-of-use/"
id="privacyLink"
location="license_settings"
>
Enterprise Edition Terms of Use
</ExternalLink>
</ForwardRef>
and
<ExternalLink
<ForwardRef
href="https://mattermost.com/pl/privacy-policy/"
id="privacyLink"
location="license_settings"
>
Privacy Policy
</ExternalLink>
</ForwardRef>
</div>
</div>
<div
@@ -1448,21 +1448,21 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
className="terms-and-policy"
>
See also
<ExternalLink
<ForwardRef
href="https://mattermost.com/pl/terms-of-use/"
id="privacyLink"
location="license_settings"
>
Enterprise Edition Terms of Use
</ExternalLink>
</ForwardRef>
and
<ExternalLink
<ForwardRef
href="https://mattermost.com/pl/privacy-policy/"
id="privacyLink"
location="license_settings"
>
Privacy Policy
</ExternalLink>
</ForwardRef>
</div>
</div>
<div
@@ -1486,13 +1486,13 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
className="compare-plans-text"
>
Curious about upgrading?
<ExternalLink
<ForwardRef
href="https://mattermost.com/pl/pricing/"
id="privacyLink"
location="license_settings"
>
Compare Plans
</ExternalLink>
</ForwardRef>
</div>
</div>
</div>
@@ -1554,21 +1554,21 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
className="terms-and-policy"
>
See also
<ExternalLink
<ForwardRef
href="https://mattermost.com/pl/terms-of-use/"
id="privacyLink"
location="license_settings"
>
Enterprise Edition Terms of Use
</ExternalLink>
</ForwardRef>
and
<ExternalLink
<ForwardRef
href="https://mattermost.com/pl/privacy-policy/"
id="privacyLink"
location="license_settings"
>
Privacy Policy
</ExternalLink>
</ForwardRef>
</div>
</div>
<div
@@ -1583,13 +1583,13 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
className="compare-plans-text"
>
Curious about upgrading?
<ExternalLink
<ForwardRef
href="https://mattermost.com/pl/pricing/"
id="privacyLink"
location="license_settings"
>
Compare Plans
</ExternalLink>
</ForwardRef>
</div>
</div>
</div>
@@ -1659,21 +1659,21 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
className="terms-and-policy"
>
See also
<ExternalLink
<ForwardRef
href="https://mattermost.com/pl/terms-of-use/"
id="privacyLink"
location="license_settings"
>
Enterprise Edition Terms of Use
</ExternalLink>
</ForwardRef>
and
<ExternalLink
<ForwardRef
href="https://mattermost.com/pl/privacy-policy/"
id="privacyLink"
location="license_settings"
>
Privacy Policy
</ExternalLink>
</ForwardRef>
</div>
</div>
<div
@@ -1783,21 +1783,21 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
className="terms-and-policy"
>
See also
<ExternalLink
<ForwardRef
href="https://mattermost.com/pl/terms-of-use/"
id="privacyLink"
location="license_settings"
>
Enterprise Edition Terms of Use
</ExternalLink>
</ForwardRef>
and
<ExternalLink
<ForwardRef
href="https://mattermost.com/pl/privacy-policy/"
id="privacyLink"
location="license_settings"
>
Privacy Policy
</ExternalLink>
</ForwardRef>
</div>
</div>
<div

Просмотреть файл

@@ -35,7 +35,7 @@ exports[`components/OpenIdConvert should match snapshot 1`] = `
id="admin.openIdConvert.text"
/>
</button>
<ExternalLink
<ForwardRef
className="btn-secondary"
data-testid="openIdLearnMore"
href="https://www.mattermost.com/default-openid-docs"
@@ -45,7 +45,7 @@ exports[`components/OpenIdConvert should match snapshot 1`] = `
defaultMessage="Learn more"
id="admin.openIdConvert.help"
/>
</ExternalLink>
</ForwardRef>
<div
className="error-message"
data-testid="errorMessage"

Просмотреть файл

@@ -0,0 +1,20 @@
.app__body .modal .GenericModal.channel-bookmarks-create-modal .modal-body .form-control {
height: 40px;
border: unset;
}
.channel-bookmarks-create-modal {
.linkInput {
margin-bottom: 3rem;
color: rgba(var(--center-channel-color-rgb), 0.72);
}
.Input___customMessage {
line-height: 14px;
}
.LoadingSpinner {
padding: 10px 12px;
font-size: 16px;
}
}

Просмотреть файл

@@ -0,0 +1,62 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {GenericModal} from '@mattermost/components';
type Props = {
displayName: string;
onConfirm: () => void;
onCancel?: () => void;
onExited: () => void;
}
const noop = () => {};
function BookmarkDeleteModal({
displayName,
onExited,
onCancel,
onConfirm,
}: Props) {
const {formatMessage} = useIntl();
const title = formatMessage({
id: 'channel_bookmarks.confirm.delete.title',
defaultMessage: 'Delete bookmark',
});
const confirmButtonText = formatMessage({
id: 'channel_bookmarks.confirm.delete.button',
defaultMessage: 'Yes, delete',
});
const message = (
<FormattedMessage
id={'channel_bookmarks.confirm.delete.text'}
defaultMessage={'Are you sure you want to delete the bookmark <strong>{displayName}</strong>?'}
values={{
strong: (chunk: string) => <strong>{chunk}</strong>,
displayName,
}}
/>
);
return (
<GenericModal
confirmButtonText={confirmButtonText}
handleCancel={onCancel ?? noop}
handleConfirm={onConfirm}
modalHeaderText={title}
onExited={onExited}
compassDesign={true}
isDeleteModal={true}
>
{message}
</GenericModal>
);
}
export default BookmarkDeleteModal;

Просмотреть файл

@@ -0,0 +1,176 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {useDispatch} from 'react-redux';
import {
DotsHorizontalIcon,
PencilOutlineIcon,
LinkVariantIcon,
TrashCanOutlineIcon,
ArrowExpandIcon,
OpenInNewIcon,
BookOutlineIcon,
} from '@mattermost/compass-icons/components';
import type {ChannelBookmark, ChannelBookmarkPatch} from '@mattermost/types/channel_bookmarks';
import type {ActionResult} from 'mattermost-redux/types/actions';
import {getFileDownloadUrl} from 'mattermost-redux/utils/file_utils';
import {editBookmark, deleteBookmark} from 'actions/channel_bookmarks';
import {openModal} from 'actions/views/modals';
import GetPublicModal from 'components/get_public_link_modal';
import * as Menu from 'components/menu';
import {ModalIdentifiers} from 'utils/constants';
import {getSiteURL, shouldOpenInNewTab} from 'utils/url';
import {copyToClipboard} from 'utils/utils';
import BookmarkDeleteModal from './bookmark_delete_modal';
import ChannelBookmarksCreateModal from './channel_bookmarks_create_modal';
import {useCanGetPublicLink, useChannelBookmarkPermission} from './utils';
type Props = {bookmark: ChannelBookmark; open: () => void};
const BookmarkItemDotMenu = ({
bookmark,
open,
}: Props) => {
const {formatMessage} = useIntl();
const dispatch = useDispatch();
const siteURL = getSiteURL();
const openInNewTab = bookmark.type === 'link' && bookmark.link_url && shouldOpenInNewTab(bookmark.link_url, siteURL);
let openIcon;
if (bookmark.type === 'file') {
openIcon = <ArrowExpandIcon size={18}/>;
} else if (bookmark.link_url) {
openIcon = openInNewTab ? <OpenInNewIcon size={18}/> : <BookOutlineIcon size={18}/>;
}
const canEdit = useChannelBookmarkPermission(bookmark.channel_id, 'edit');
const canDelete = useChannelBookmarkPermission(bookmark.channel_id, 'delete');
const canGetPublicLink = useCanGetPublicLink();
const editLabel = formatMessage({id: 'channel_bookmarks.edit', defaultMessage: 'Edit'});
const openLabel = formatMessage({id: 'channel_bookmarks.open', defaultMessage: 'Open'});
const copyLinkLabel = formatMessage({id: 'channel_bookmarks.copy', defaultMessage: 'Copy link'});
const copyFileLabel = formatMessage({id: 'channel_bookmarks.copyFilePublicLink', defaultMessage: 'Get a public link'});
const deleteLabel = formatMessage({id: 'channel_bookmarks.delete', defaultMessage: 'Delete'});
const handleEdit = useCallback(() => {
dispatch(openModal({
modalId: ModalIdentifiers.CHANNEL_BOOKMARK_CREATE,
dialogType: ChannelBookmarksCreateModal,
dialogProps: {
bookmark,
channelId: bookmark.channel_id,
onConfirm: async (data: ChannelBookmarkPatch) => dispatch(editBookmark(bookmark.channel_id, bookmark.id, data)) as ActionResult<boolean>,
},
}));
}, [editBookmark, dispatch, bookmark]);
const copyLink = useCallback(() => {
if (bookmark.type === 'link' && bookmark.link_url) {
copyToClipboard(bookmark.link_url);
} else if (bookmark.type === 'file' && bookmark.file_id) {
copyToClipboard(getFileDownloadUrl(bookmark.file_id));
}
}, [bookmark.type, bookmark.link_url, bookmark.file_id]);
const handleDelete = useCallback(() => {
dispatch(openModal({
modalId: ModalIdentifiers.CHANNEL_BOOKMARK_DELETE,
dialogType: BookmarkDeleteModal,
dialogProps: {
displayName: bookmark.display_name,
onConfirm: () => dispatch(deleteBookmark(bookmark.channel_id, bookmark.id)),
},
}));
}, [deleteBookmark, dispatch, bookmark]);
const handleGetPublicLink = useCallback(() => {
if (!bookmark.file_id) {
return;
}
dispatch(openModal({
modalId: ModalIdentifiers.GET_PUBLIC_LINK_MODAL,
dialogType: GetPublicModal,
dialogProps: {
fileId: bookmark.file_id,
},
}));
}, [bookmark.file_id, dispatch]);
return (
<Menu.Container
anchorOrigin={{vertical: 'bottom', horizontal: 'right'}}
transformOrigin={{vertical: 'top', horizontal: 'right'}}
menuButton={{
id: `channelBookmarksDotMenuButton-${bookmark.id}`,
class: 'channelBookmarksDotMenuButton',
children: <DotsHorizontalIcon size={18}/>,
'aria-label': formatMessage({id: 'channel_bookmarks.editBookmarkLabel', defaultMessage: 'Bookmark menu'}),
}}
menu={{
id: 'channelBookmarksDotMenuDropdown',
}}
>
<Menu.Item
key='channelBookmarksOpen'
id='channelBookmarksOpen'
onClick={open}
leadingElement={openIcon}
labels={<span>{openLabel}</span>}
aria-label={openLabel}
/>
{canEdit && (
<Menu.Item
key='channelBookmarksEdit'
id='channelBookmarksEdit'
onClick={handleEdit}
leadingElement={<PencilOutlineIcon size={18}/>}
labels={<span>{editLabel}</span>}
aria-label={editLabel}
/>
)}
{bookmark.type === 'link' && (
<Menu.Item
key='channelBookmarksLinkCopy'
id='channelBookmarksLinkCopy'
onClick={copyLink}
leadingElement={<LinkVariantIcon size={18}/>}
labels={<span>{copyLinkLabel}</span>}
aria-label={copyLinkLabel}
/>
)}
{bookmark.type === 'file' && canGetPublicLink && (
<Menu.Item
key='channelBookmarksFileCopy'
id='channelBookmarksFileCopy'
onClick={handleGetPublicLink}
leadingElement={<LinkVariantIcon size={18}/>}
labels={<span>{copyFileLabel}</span>}
aria-label={copyFileLabel}
/>
)}
{canDelete && (
<Menu.Item
key='channelBookmarksDelete'
id='channelBookmarksDelete'
onClick={handleDelete}
leadingElement={<TrashCanOutlineIcon size={18}/>}
labels={<span>{deleteLabel}</span>}
aria-label={deleteLabel}
isDestructive={true}
/>
)}
</Menu.Container>
);
};
export default BookmarkItemDotMenu;

Просмотреть файл

@@ -0,0 +1,92 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useSelector} from 'react-redux';
import styled from 'styled-components';
import {FileGenericOutlineIcon, BookOutlineIcon} from '@mattermost/compass-icons/components';
import type {ChannelBookmark} from '@mattermost/types/channel_bookmarks';
import type {FileInfo} from '@mattermost/types/files';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import RenderEmoji from 'components/emoji/render_emoji';
import FileThumbnail from 'components/file_attachment/file_thumbnail';
import type {FilePreviewInfo} from 'components/file_preview/file_preview';
import {trimmedEmojiName} from 'utils/emoji_utils';
import {getImageSrc} from 'utils/post_utils';
import type {GlobalState} from 'types/store';
type Props = {
type: ChannelBookmark['type'];
emoji?: string;
imageUrl?: string;
fileInfo?: FileInfo | FilePreviewInfo;
size?: 16 | 24;
}
const BookmarkIcon = ({
type,
emoji,
imageUrl,
fileInfo,
size = 16,
}: Props) => {
let icon = type === 'link' ? <BookOutlineIcon size={size}/> : <FileGenericOutlineIcon size={size}/>;
const emojiName = emoji && trimmedEmojiName(emoji);
const hasImageProxy = useSelector((state: GlobalState) => getConfig(state).HasImageProxy === 'true');
if (emojiName) {
icon = (
<RenderEmoji
emojiName={emojiName}
size={size}
/>
);
} else if (imageUrl) {
icon = (
<BookmarkIconImg
src={getImageSrc(imageUrl, hasImageProxy)}
size={size}
/>
);
} else if (fileInfo) {
icon = (
<FileThumbnail
fileInfo={fileInfo}
disablePreview={true}
/>
);
}
return (
<Icon $size={size}>
{icon}
</Icon>
);
};
export default BookmarkIcon;
const Icon = styled.div<{$size: number}>`
padding: 3px 1px 3px 2px;
flex-shrink: 0;
display: flex;
align-items: center;
.file-icon {
width: ${({$size: size}) => size}px;
height: ${({$size: size}) => size}px;
background-size: ${({$size: size}) => size * 0.8}px ${({$size: size}) => size}px;
margin-top: 1px;
}
`;
const BookmarkIconImg = styled.img<{size: number}>`
width: ${({size}) => size}px;
height: ${({size}) => size}px;
`;

Просмотреть файл

@@ -0,0 +1,245 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {HTMLAttributes} from 'react';
import React, {forwardRef, useRef} from 'react';
import {useDispatch, useSelector} from 'react-redux';
import {Link} from 'react-router-dom';
import styled, {css} from 'styled-components';
import type {ChannelBookmark} from '@mattermost/types/channel_bookmarks';
import type {FileInfo} from '@mattermost/types/files';
import type {Post} from '@mattermost/types/posts';
import {getFile} from 'mattermost-redux/selectors/entities/files';
import {getFileDownloadUrl} from 'mattermost-redux/utils/file_utils';
import {openModal} from 'actions/views/modals';
import ExternalLink from 'components/external_link';
import FilePreviewModal from 'components/file_preview_modal';
import {ModalIdentifiers} from 'utils/constants';
import {getSiteURL, shouldOpenInNewTab} from 'utils/url';
import type {GlobalState} from 'types/store';
import BookmarkItemDotMenu from './bookmark_dot_menu';
import BookmarkIcon from './bookmark_icon';
type Props = {bookmark: ChannelBookmark};
const BookmarkItem = <T extends HTMLAnchorElement>({bookmark}: Props) => {
const linkRef = useRef<T>(null);
const dispatch = useDispatch();
const fileInfo: FileInfo | undefined = useSelector((state: GlobalState) => (bookmark?.file_id && getFile(state, bookmark.file_id)) || undefined);
const open = () => {
linkRef.current?.click();
};
const handleOpenFile = (e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault();
if (fileInfo) {
dispatch(openModal({
modalId: ModalIdentifiers.FILE_PREVIEW_MODAL,
dialogType: FilePreviewModal,
dialogProps: {
post: {user_id: bookmark.owner_id, channel_id: bookmark.channel_id} as Post,
fileInfos: [fileInfo],
startIndex: 0,
},
}));
}
};
const icon = (
<BookmarkIcon
type={bookmark.type}
emoji={bookmark.emoji}
imageUrl={bookmark.image_url}
fileInfo={fileInfo}
/>
);
let link;
if (bookmark.type === 'link' && bookmark.link_url) {
link = (
<DynamicLink
href={bookmark.link_url}
ref={linkRef}
isFile={false}
>
{icon}
<Label>{bookmark.display_name}</Label>
</DynamicLink>
);
} else if (bookmark.type === 'file' && bookmark.file_id) {
link = (
<DynamicLink
href={getFileDownloadUrl(bookmark.file_id)}
onClick={handleOpenFile}
ref={linkRef}
isFile={true}
>
{icon}
<Label>{bookmark.display_name}</Label>
</DynamicLink>
);
}
return (
<Chip>
{link}
<BookmarkItemDotMenu
bookmark={bookmark}
open={open}
/>
</Chip>
);
};
const Chip = styled.div`
position: relative;
border-radius: 12px;
overflow: hidden;
margin: 1px 0;
flex-shrink: 0;
min-width: 5rem;
max-width: 25rem;
button {
position: absolute;
visibility: hidden;
right: 6px;
top: 3px;
}
&:hover,
&:focus-within,
&:has([aria-expanded="true"]) {
button {
visibility: visible;
}
}
&:hover,
&:focus-within {
a {
text-decoration: none;
}
}
&:hover,
&:focus-within,
&:has([aria-expanded="true"]) {
a {
background: rgba(var(--center-channel-color-rgb), 0.08);
color: rgba(var(--center-channel-color-rgb), 1);
}
}
&:active:not(:has(button:active)),
&--active,
&--active:hover {
a {
background: rgba(var(--button-bg-rgb), 0.08);
color: rgb(var(--button-bg-rgb)) !important;
.icon__text {
color: rgb(var(--button-bg));
}
.icon {
color: rgb(var(--button-bg));
}
}
}
`;
const Label = styled.span`
white-space: nowrap;
padding: 4px 0;
text-overflow: ellipsis;
overflow: hidden;
`;
const TARGET_BLANK_URL_PREFIX = '!';
type DynamicLinkProps = {href: string; children: React.ReactNode; isFile: boolean; onClick?: HTMLAttributes<HTMLAnchorElement>['onClick']};
const DynamicLink = forwardRef<HTMLAnchorElement, DynamicLinkProps>(({href, children, isFile, onClick}, ref) => {
const siteURL = getSiteURL();
const openInNewTab = shouldOpenInNewTab(href, siteURL);
const prefixed = href[0] === TARGET_BLANK_URL_PREFIX;
if (prefixed || openInNewTab) {
return (
<StyledExternalLink
href={prefixed ? href.substring(1) : href}
rel='noopener noreferrer'
target='_blank'
location='channel_bookmarks.item'
ref={ref}
>
{children}
</StyledExternalLink>
);
}
if (href.startsWith(siteURL) && !isFile) {
return (
<StyledLink
to={href.slice(siteURL.length)}
ref={ref}
onClick={onClick}
>
{children}
</StyledLink>
);
}
return (
<StyledAnchor
href={href}
ref={ref}
onClick={onClick}
>
{children}
</StyledAnchor>
);
});
const linkStyles = css`
display: flex;
padding: 0 12px 0 6px;
gap: 5px;
color: rgba(var(--center-channel-color-rgb), 1);
font-family: Open Sans;
font-size: 12px;
font-style: normal;
font-weight: 600;
line-height: 16px;
`;
const StyledAnchor = styled.a`
&&&& {
${linkStyles}
}
`;
const StyledLink = styled(Link)`
&&&& {
${linkStyles}
}
`;
const StyledExternalLink = styled(ExternalLink)`
&&&& {
${linkStyles}
}
`;
export default BookmarkItem;

Просмотреть файл

@@ -0,0 +1,69 @@
.channelBookmarksMenuButton {
position: relative;
display: flex;
padding: 4px;
border: none;
border-radius: 4px;
background: transparent;
color: rgba(var(--center-channel-color-rgb), 0.56);
font-family: Open Sans;
font-size: 12px;
font-weight: 600;
gap: 5px;
&.withLabel {
padding: 4px 12px 4px 6px;
border-radius: 12px;
}
&[disabled] {
background: rgba(var(--center-channel-color-rgb), 0.04);
color: rgba(var(--center-channel-color-rgb), 0.56);
}
&:hover:enabled {
background: rgba(var(--center-channel-color-rgb), 0.08);
color: rgba(var(--center-channel-color-rgb), 0.72);
}
&:active:enabled,
&[aria-expanded="true"] {
background: rgba(var(--button-bg-rgb), 0.08);
color: rgb(var(--button-bg-rgb));
}
}
.channelBookmarksDotMenuButton {
display: flex;
width: 18px;
height: 18px;
align-items: center;
padding: 0;
border: none;
border-radius: 4px;
background: rgba(var(--center-channel-color-rgb), 8%);
background: color-mix(in sRGB, var(--center-channel-bg), var(--center-channel-color) 8%);
color: rgba(var(--center-channel-color-rgb), 0.56);
font-family: Open Sans;
font-size: 12px;
font-weight: 600;
&:hover {
background: rgba(var(--button-bg-rgb), 16%);
background: color-mix(in sRGB, var(--center-channel-bg), var(--center-channel-color) 16%);
color: rgba(var(--center-channel-color-rgb), 0.72);
}
&:active,
&[aria-expanded="true"] {
background: rgba(var(--button-bg-rgb), 16%);
background: color-mix(in sRGB, var(--center-channel-bg), var(--button-bg) 16%);
color: rgb(var(--button-bg-rgb));
}
}
#bookmark-create-file-input,
#bookmark-create-file-input-in-modal {
display: none;
}

Просмотреть файл

@@ -0,0 +1,63 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import styled from 'styled-components';
import BookmarkItem from './bookmark_item';
import PlusMenu from './channel_bookmarks_plus_menu';
import {useChannelBookmarkPermission, useChannelBookmarks, useIsChannelBookmarksEnabled, MAX_BOOKMARKS_PER_CHANNEL, useCanUploadFiles} from './utils';
import './channel_bookmarks.scss';
type Props = {
channelId: string;
};
const ChannelBookmarks = ({
channelId,
}: Props) => {
const show = useIsChannelBookmarksEnabled();
const {order, bookmarks} = useChannelBookmarks(channelId);
const canUploadFiles = useCanUploadFiles();
const canAdd = useChannelBookmarkPermission(channelId, 'add');
const hasBookmarks = Boolean(order?.length);
if (!show || (!hasBookmarks && !canAdd)) {
return null;
}
return (
<Container data-testid='channel-bookmarks-container'>
{order.map((id) => {
return (
<BookmarkItem
key={id}
bookmark={bookmarks[id]}
/>
);
})}
{canAdd && (
<PlusMenu
channelId={channelId}
hasBookmarks={hasBookmarks}
limitReached={order.length >= MAX_BOOKMARKS_PER_CHANNEL}
canUploadFiles={canUploadFiles}
/>
)}
</Container>
);
};
export default ChannelBookmarks;
const Container = styled.div`
display: flex;
padding: 8px 6px;
padding-right: 0;
align-items: center;
border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.12);
overflow-x: auto;
overflow-y: hidden;
overflow-y: clip;
`;

Просмотреть файл

@@ -0,0 +1,581 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {ChangeEvent, ClipboardEventHandler, FocusEventHandler, MouseEvent} from 'react';
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {FormattedMessage, defineMessages, useIntl} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import styled from 'styled-components';
import {PencilOutlineIcon} from '@mattermost/compass-icons/components';
import {GenericModal} from '@mattermost/components';
import type {ChannelBookmark, ChannelBookmarkCreate, ChannelBookmarkPatch} from '@mattermost/types/channel_bookmarks';
import type {FileInfo} from '@mattermost/types/files';
import {debounce} from 'mattermost-redux/actions/helpers';
import {getFile} from 'mattermost-redux/selectors/entities/files';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import type {ActionResult} from 'mattermost-redux/types/actions';
import type {UploadFile} from 'actions/file_actions';
import {uploadFile} from 'actions/file_actions';
import FileAttachment from 'components/file_attachment';
import type {FilePreviewInfo} from 'components/file_preview/file_preview';
import FileProgressPreview from 'components/file_preview/file_progress_preview';
import Input from 'components/widgets/inputs/input/input';
import Constants from 'utils/constants';
import {isKeyPressed} from 'utils/keyboard';
import {isValidUrl, parseLink} from 'utils/url';
import {generateId} from 'utils/utils';
import type {GlobalState} from 'types/store';
import './bookmark_create_modal.scss';
import CreateModalNameInput from './create_modal_name_input';
import {useCanUploadFiles} from './utils';
type Props = {
channelId: string;
bookmarkType?: ChannelBookmark['type'];
file?: File;
onExited: () => void;
onHide: () => void;
} & ({
bookmark: ChannelBookmark;
onConfirm: (data: ChannelBookmarkPatch) => Promise<ActionResult<boolean, any>> | ActionResult<boolean, any>;
} | {
bookmark?: never;
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,
file: promptedFile,
channelId,
onExited,
onConfirm,
onHide,
}: Props) {
const {formatMessage} = useIntl();
const dispatch = useDispatch();
// common
const type = bookmark?.type ?? bookmarkType ?? 'link';
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const [emoji, setEmoji] = useState(bookmark?.emoji ?? '');
const [displayName, setDisplayName] = useState<string | undefined>(bookmark?.display_name);
const [parsedDisplayName, setParsedDisplayName] = useState<string | undefined>();
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState('');
const handleKeyDown = useCallback((event: KeyboardEvent) => {
if (isKeyPressed(event, Constants.KeyCodes.ESCAPE) && !showEmojiPicker) {
onHide();
}
}, [showEmojiPicker, onHide]);
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [handleKeyDown]);
// type === 'link'
const [linkInputValue, setLinkInputValue] = useState(bookmark?.link_url ?? '');
const [link, setLinkImmediately] = useState(linkInputValue);
const [linkError, setLinkError] = useState('');
const [icon, setIcon] = useState(bookmark?.image_url);
const handleLinkChange = useCallback(({target: {value}}: ChangeEvent<HTMLInputElement>) => {
setLinkInputValue(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'
const canUploadFiles = useCanUploadFiles();
const [pendingFile, setPendingFile] = useState<FilePreviewInfo | null>();
const [fileError, setFileError] = useState('');
const [fileId, setFileId] = useState(bookmark?.file_id);
const uploadRequestRef = useRef<XMLHttpRequest>();
const fileInfo: FileInfo | undefined = useSelector((state: GlobalState) => (fileId && getFile(state, fileId)) || undefined);
const maxFileSize = useSelector((state: GlobalState) => {
const config = getConfig(state);
return parseInt(config.MaxFileSize || '', 10);
});
const maxFileSizeMB = maxFileSize / 1048576;
const handleEditFileClick = (e: MouseEvent<HTMLDivElement>) => {
const innerClick = document.querySelector(`
.channel-bookmarks-create-modal .post-image__download a,
.channel-bookmarks-create-modal a.file-preview__remove
`);
if (
innerClick === e.target ||
innerClick?.contains(e.target as HTMLElement)
) {
return;
}
fileInputRef.current?.click();
};
const handleFileChanged = useCallback((e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) {
return;
}
doUploadFile(file);
}, []);
const handleFileRemove = () => {
setPendingFile(null);
setFileId(bookmark?.file_id);
setParsedDisplayName(undefined);
uploadRequestRef.current?.abort();
};
const fileInputRef = useRef<HTMLInputElement>(null);
const fileInput = (
<input
type='file'
id='bookmark-create-file-input-in-modal'
className='bookmark-create-file-input'
ref={fileInputRef}
onChange={handleFileChanged}
/>
);
const onProgress: UploadFile['onProgress'] = (preview) => {
setPendingFile(preview);
};
const onSuccess: UploadFile['onSuccess'] = ({file_infos: fileInfos}) => {
setPendingFile(null);
const newFile: FileInfo = fileInfos?.[0];
if (newFile) {
setFileId(newFile.id);
}
setFileError('');
};
const onError: UploadFile['onError'] = () => {
setPendingFile(null);
setFileError(formatMessage({id: 'file_upload.generic_error_file', defaultMessage: 'There was a problem uploading your file.'}));
};
const displayNameValue = displayName || parsedDisplayName || (type === 'file' ? fileInfo?.name : bookmark?.link_url) || '';
const doUploadFile = (file: File) => {
setPendingFile(null);
setFileId('');
if (file.size > maxFileSize) {
setFileError(formatMessage({
id: 'file_upload.fileAbove',
defaultMessage: 'File above {max}MB could not be uploaded: {filename}',
}, {max: maxFileSizeMB, filename: file.name}));
return;
}
if (file.size === 0) {
setFileError(formatMessage({
id: 'file_upload.zeroBytesFile',
defaultMessage: 'You are uploading an empty file: {filename}',
}, {filename: file.name}));
return;
}
setFileError('');
if (displayNameValue === fileInfo?.name) {
setDisplayName(file.name);
}
setParsedDisplayName(file.name);
const clientId = generateId();
uploadRequestRef.current = dispatch(uploadFile({
file,
name: file.name,
type: file.type,
rootId: '',
channelId,
clientId,
onProgress,
onSuccess,
onError,
}, true)) as unknown as XMLHttpRequest;
};
useEffect(() => {
if (promptedFile) {
doUploadFile(promptedFile);
}
}, [promptedFile]);
const handleOnExited = useCallback(() => {
uploadRequestRef.current?.abort();
onExited?.();
}, [onExited]);
// controls logic
const hasChanges = (() => {
if (displayNameValue !== bookmark?.display_name) {
return true;
}
if ((emoji || bookmark?.emoji) && emoji !== bookmark?.emoji) {
return true;
}
if (type === 'file') {
if (fileId && fileId !== bookmark?.file_id) {
return true;
}
}
if (type === 'link') {
return Boolean(link && link !== bookmark?.link_url);
}
return false;
})();
const isValid = (() => {
if (type === 'link') {
if (!link || linkError) {
return false;
}
}
if (type === 'file') {
if (!fileInfo || !displayNameValue || fileError) {
return false;
}
}
return true;
})();
const showControls = type === 'file' || (isValid || bookmark);
const cancel = useCallback(() => {
if (type === 'file') {
uploadRequestRef.current?.abort();
}
}, [type]);
const confirm = useCallback(async () => {
setSaving(true);
if (type === 'link') {
const url = validHttpUrl(link);
if (!url) {
setSaveError(formatMessage(msg.linkInvalid));
return;
}
let validLink = url.toString();
if (validLink.endsWith('/')) {
validLink = validLink.slice(0, -1);
}
const {data: success} = await onConfirm({
image_url: icon,
link_url: validLink,
emoji,
display_name: displayNameValue,
type: 'link',
});
setSaving(false);
if (success) {
setSaveError('');
onHide();
} else {
setSaveError(formatMessage(msg.saveError));
}
} else if (fileInfo) {
const {data: success} = await onConfirm({
file_id: fileInfo.id,
display_name: displayNameValue,
type: 'file',
emoji,
});
if (success) {
setSaveError('');
onHide();
} else {
setSaveError(formatMessage(msg.saveError));
}
}
}, [type, link, onConfirm, onHide, fileInfo, displayNameValue, emoji, icon]);
const confirmDisabled = saving || !isValid || !hasChanges;
return (
<GenericModal
enforceFocus={!showEmojiPicker}
keyboardEscape={false}
className='channel-bookmarks-create-modal'
modalHeaderText={formatMessage(bookmark ? msg.editHeading : msg.heading)}
confirmButtonText={formatMessage(bookmark ? msg.saveText : msg.addBookmarkText)}
handleCancel={(showControls && cancel) || undefined}
handleConfirm={(showControls && confirm) || undefined}
handleEnterKeyPress={(!confirmDisabled && confirm) || undefined}
onExited={handleOnExited}
compassDesign={true}
isConfirmDisabled={confirmDisabled}
autoCloseOnConfirmButton={false}
errorText={saveError}
>
<>
{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)}}
/>
) : (
<>
<FieldLabel>
<FormattedMessage
id='channel_bookmarks.create.file_input.label'
defaultMessage='Attachment'
/>
</FieldLabel>
<FileInputContainer
tabIndex={0}
role='button'
disabled={!canUploadFiles}
onClick={(canUploadFiles && handleEditFileClick) || undefined}
>
{!pendingFile && fileInfo && (
<FileItemContainer>
<FileAttachment
key={fileInfo.id}
fileInfo={fileInfo}
index={0}
/>
</FileItemContainer>
)}
{pendingFile && (
<FileProgressPreview
key={pendingFile.clientId}
clientId={pendingFile.clientId}
fileInfo={pendingFile}
handleRemove={handleFileRemove}
/>
)}
{!fileInfo && !pendingFile && (
<div className='file-preview__container empty'/>
)}
<VisualButton>
<PencilOutlineIcon size={24}/>
{formatMessage(msg.fileInputEdit)}
</VisualButton>
{fileInput}
</FileInputContainer>
{fileError && (
<div className='Input___customMessage Input___error'>
<i className='icon error icon-alert-circle-outline'/>
<span>{fileError}</span>
</div>
)}
</>
)}
{showControls && (
<TitleWrapper>
<FieldLabel>
<FormattedMessage
id='channel_bookmarks.create.title_input.label'
defaultMessage='Title'
/>
</FieldLabel>
<CreateModalNameInput
type={type}
imageUrl={icon}
fileInfo={pendingFile || fileInfo}
emoji={emoji}
setEmoji={setEmoji}
displayName={displayName}
placeholder={displayNameValue}
setDisplayName={setDisplayName}
onAddCustomEmojiClick={onHide}
showEmojiPicker={showEmojiPicker}
setShowEmojiPicker={setShowEmojiPicker}
/>
</TitleWrapper>
)}
</>
</GenericModal>
);
}
export default ChannelBookmarkCreateModal;
const TitleWrapper = styled.div`
margin-top: 20px;
`;
const FieldLabel = styled.span`
display: inline-block;
margin-bottom: 8px;
font-family: Open Sans;
font-size: 14px;
line-height: 16px;
font-style: normal;
font-weight: 600;
line-height: 20px;
`;
const VisualButton = styled.div`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding: 10px 24px;
color: rgba(var(--center-channel-color-rgb), 0.56);
font-size: 11px;
font-weight: 600;
font-family: Open Sans;
`;
const FileInputContainer = styled.div`
display: block;
background: rgba(var(--center-channel-color-rgb), 0.04);
padding: 12px;
border-radius: 8px;
display: flex;
&:hover:not([disabled]) {
background: rgba(var(--center-channel-color-rgb), 0.08);
color: rgba(var(--center-channel-color-rgb), 0.72);
cursor: pointer;
}
&:disabled {
cursor: default;
${VisualButton} {
opacity: 0.4;
}
}
input[type="file"] {
opacity: 0;
width: 0;
height: 0;
}
.file-preview__container,
.file-preview {
width: auto;
height: auto;
flex: 1 1 auto;
padding: 0;
&.empty {
border: 2px dashed rgba(var(--center-channel-color-rgb), 0.16);
border-radius : 4px;
}
.post-image__column {
width: 100%;
margin: 0;
}
}
`;
const FileItemContainer = styled.div`
display: flex;
flex: 1 1 auto;
> div {
width: 100%;
margin: 0;
}
`;
const msg = defineMessages({
heading: {id: 'channel_bookmarks.create.title', defaultMessage: 'Add a bookmark'},
editHeading: {id: 'channel_bookmarks.create.edit.title', defaultMessage: 'Edit bookmark'},
linkPlaceholder: {id: 'channel_bookmarks.create.link_placeholder', defaultMessage: 'Link'},
linkInfoMessage: {id: 'channel_bookmarks.create.link_info', defaultMessage: 'Add a link to any post, file, or any external link'},
addBookmarkText: {id: 'channel_bookmarks.create.confirm_add.button', defaultMessage: 'Add bookmark'},
saveText: {id: 'channel_bookmarks.create.confirm_save.button', defaultMessage: 'Save bookmark'},
fileInputEdit: {id: 'channel_bookmarks.create.file_input.edit', defaultMessage: 'Edit'},
linkInvalid: {id: 'channel_bookmarks.create.error.invalid_url', defaultMessage: 'Please enter a valid link'},
saveError: {id: 'channel_bookmarks.create.error.generic_save', defaultMessage: 'There was an error trying to save the bookmark.'},
});

Просмотреть файл

@@ -0,0 +1,156 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import type {ChangeEvent} from 'react';
import React, {useCallback, useRef} from 'react';
import {useIntl} from 'react-intl';
import {useDispatch} from 'react-redux';
import styled, {css} from 'styled-components';
import {
LinkVariantIcon,
PaperclipIcon,
PlusIcon,
} from '@mattermost/compass-icons/components';
import type {ChannelBookmarkCreate} from '@mattermost/types/channel_bookmarks';
import type {ActionResult} from 'mattermost-redux/types/actions';
import {createBookmark} from 'actions/channel_bookmarks';
import {openModal} from 'actions/views/modals';
import * as Menu from 'components/menu';
import {ModalIdentifiers} from 'utils/constants';
import {clearFileInput} from 'utils/utils';
import ChannelBookmarkCreateModal from './channel_bookmarks_create_modal';
import {MAX_BOOKMARKS_PER_CHANNEL} from './utils';
type PlusMenuProps = {
channelId: string;
hasBookmarks: boolean;
limitReached: boolean;
canUploadFiles: boolean;
};
const PlusMenu = ({
channelId,
hasBookmarks,
limitReached,
canUploadFiles,
}: PlusMenuProps) => {
const {formatMessage} = useIntl();
const dispatch = useDispatch();
const showLabel = !hasBookmarks;
const handleCreate = useCallback((file?: File) => {
dispatch(openModal({
modalId: ModalIdentifiers.CHANNEL_BOOKMARK_CREATE,
dialogType: ChannelBookmarkCreateModal,
dialogProps: {
channelId,
bookmarkType: file ? 'file' : 'link',
file,
onConfirm: async (data: ChannelBookmarkCreate) => dispatch(createBookmark(channelId, data)) as ActionResult<boolean>,
},
}));
}, [channelId, dispatch]);
const handleFileChanged = useCallback((e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files?.length) {
const [file] = e.target.files;
handleCreate(file);
clearFileInput(e.target);
}
}, [handleCreate]);
const fileInputRef = useRef<HTMLInputElement>(null);
const fileInput = (
<input
type='file'
id='bookmark-create-file-input'
className='bookmark-create-file-input'
ref={fileInputRef}
onChange={handleFileChanged}
/>
);
const handleCreateLink = useCallback(() => {
handleCreate();
}, [handleCreate]);
const handleCreateFile = useCallback(() => {
fileInputRef.current?.click();
}, [fileInputRef.current]);
const addBookmarkLabel = formatMessage({id: 'channel_bookmarks.addBookmark', defaultMessage: 'Add a bookmark'});
const addBookmarkLimitReached = formatMessage({id: 'channel_bookmarks.addBookmarkLimitReached', defaultMessage: 'Cannot add more than {limit} bookmarks'}, {limit: MAX_BOOKMARKS_PER_CHANNEL});
let addBookmarkTooltipText;
if (limitReached) {
addBookmarkTooltipText = addBookmarkLimitReached;
} else if (hasBookmarks) {
addBookmarkTooltipText = addBookmarkLabel;
}
const addLinkLabel = formatMessage({id: 'channel_bookmarks.addLink', defaultMessage: 'Add a link'});
const attachFileLabel = formatMessage({id: 'channel_bookmarks.attachFile', defaultMessage: 'Attach a file'});
return (
<PlusButtonContainer withLabel={showLabel}>
<Menu.Container
anchorOrigin={{vertical: 'bottom', horizontal: 'left'}}
transformOrigin={{vertical: 'top', horizontal: 'left'}}
menuButton={{
id: 'channelBookmarksPlusMenuButton',
class: classNames('channelBookmarksMenuButton', {withLabel: showLabel, disabled: limitReached}),
children: (
<>
<PlusIcon size={showLabel ? 16 : 18}/>
{showLabel && <span>{addBookmarkLabel}</span>}
</>
),
'aria-label': addBookmarkLabel,
disabled: limitReached,
}}
menu={{
id: 'channelBookmarksPlusMenuDropdown',
}}
menuButtonTooltip={addBookmarkTooltipText ? {
id: 'channelBookmarksPlusMenuButtonTooltip',
text: addBookmarkTooltipText,
} : undefined}
>
<Menu.Item
key='channelBookmarksAddLink'
id='channelBookmarksAddLink'
onClick={handleCreateLink}
leadingElement={<LinkVariantIcon size={18}/>}
labels={<span>{addLinkLabel}</span>}
aria-label={addLinkLabel}
/>
{canUploadFiles && (
<Menu.Item
key='channelBookmarksAttachFile'
id='channelBookmarksAttachFile'
onClick={handleCreateFile}
leadingElement={<PaperclipIcon size={18}/>}
labels={<span>{attachFileLabel}</span>}
aria-label={attachFileLabel}
/>
)}
</Menu.Container>
{fileInput}
</PlusButtonContainer>
);
};
export default PlusMenu;
const PlusButtonContainer = styled.div<{withLabel: boolean}>`
position: sticky;
right: 0;
${({withLabel}) => !withLabel && css`padding: 0 1rem;`}
background: linear-gradient(to right, rgba(var(--center-channel-bg-rgb), .16), rgba(var(--center-channel-bg-rgb), 1) 25%);
`;

Просмотреть файл

@@ -0,0 +1,203 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {ComponentProps} from 'react';
import React, {useCallback, useRef} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import styled from 'styled-components';
import {ChevronDownIcon} from '@mattermost/compass-icons/components';
import type {ChannelBookmark} from '@mattermost/types/channel_bookmarks';
import type {Emoji} from '@mattermost/types/emojis';
import type {FileInfo} from '@mattermost/types/files';
import EmojiPickerOverlay from 'components/emoji_picker/emoji_picker_overlay';
import Input from 'components/widgets/inputs/input/input';
import Constants, {A11yCustomEventTypes, type A11yFocusEventDetail} from 'utils/constants';
import {isKeyPressed} from 'utils/keyboard';
import BookmarkIcon from './bookmark_icon';
type Props = {
type: ChannelBookmark['type'];
fileInfo: FileInfo | undefined;
imageUrl: string | undefined;
emoji: string | undefined;
setEmoji: React.Dispatch<React.SetStateAction<string>>;
placeholder: string | undefined;
displayName: string | undefined;
setDisplayName: React.Dispatch<React.SetStateAction<string | undefined>>;
showEmojiPicker: boolean;
setShowEmojiPicker: React.Dispatch<React.SetStateAction<boolean>>;
onAddCustomEmojiClick?: () => void;
}
const CreateModalNameInput = ({
type,
imageUrl,
fileInfo,
emoji,
setEmoji,
placeholder,
displayName,
setDisplayName,
showEmojiPicker,
setShowEmojiPicker,
onAddCustomEmojiClick,
}: Props) => {
const {formatMessage} = useIntl();
const targetRef = useRef<HTMLButtonElement>(null);
const getTargetRef = () => targetRef.current;
const icon = (
<BookmarkIcon
type={type}
size={24}
emoji={emoji}
fileInfo={fileInfo}
imageUrl={imageUrl}
/>
);
const refocusEmojiButton = () => {
if (!targetRef.current) {
return;
}
document.dispatchEvent(new CustomEvent<A11yFocusEventDetail>(
A11yCustomEventTypes.FOCUS, {
detail: {
target: targetRef.current,
keyboardOnly: true,
},
},
));
};
const toggleEmojiPicker = () => setShowEmojiPicker((prev) => !prev);
const handleEmojiClick = (selectedEmoji: Emoji) => {
setShowEmojiPicker(false);
const emojiName = ('short_name' in selectedEmoji) ? selectedEmoji.short_name : selectedEmoji.name;
setEmoji(`:${emojiName}:`);
refocusEmojiButton();
};
const handleEmojiClear = () => {
setEmoji('');
};
const handleEmojiClose = () => {
setShowEmojiPicker(false);
refocusEmojiButton();
};
const handleInputChange: ComponentProps<typeof Input>['onChange'] = useCallback((e) => {
setDisplayName(e.currentTarget.value);
}, []);
const handleEmojiKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
if (isKeyPressed(e, Constants.KeyCodes.ENTER)) {
e.stopPropagation();
}
};
const handleEmojiResetKeyDown = (e: React.KeyboardEvent<HTMLAnchorElement>) => {
if (isKeyPressed(e, Constants.KeyCodes.ENTER) || isKeyPressed(e, Constants.KeyCodes.SPACE)) {
e.stopPropagation();
handleEmojiClear();
}
};
return (
<>
<NameWrapper>
{showEmojiPicker && (
<EmojiPickerOverlay
target={getTargetRef}
show={showEmojiPicker}
onHide={handleEmojiClose}
onEmojiClick={handleEmojiClick}
placement='right'
onAddCustomEmojiClick={onAddCustomEmojiClick}
/>
)}
<button
ref={targetRef}
type='button'
onClick={toggleEmojiPicker}
onKeyDown={handleEmojiKeyDown}
aria-label={formatMessage({id: 'emoji_picker.emojiPicker.button.ariaLabel', defaultMessage: 'select an emoji'})}
aria-expanded={showEmojiPicker ? 'true' : 'false'}
className='channelBookmarksMenuButton emoji-picker__container BookmarkCreateModal__emoji-button'
>
{icon}
<ChevronDownIcon size={'12px'}/>
</button>
<Input
type='text'
name='bookmark-display-name'
onChange={handleInputChange}
value={displayName ?? placeholder ?? ''}
placeholder={placeholder}
data-testid='titleInput'
useLegend={false}
/>
<Clear
visible={Boolean(emoji)}
tabIndex={0}
onClick={handleEmojiClear}
onKeyDown={handleEmojiResetKeyDown}
>
<FormattedMessage
id='channel_bookmarks.create.title_input.clear_emoji'
defaultMessage='Remove emoji'
/>
</Clear>
</NameWrapper>
</>
);
};
const Clear = styled.a<{visible: boolean}>`
font-size: 12px;
visibility: ${({visible}) => (visible ? 'visible' : 'hidden')};
`;
const NameWrapper = styled.div`
position: relative;
> button {
position: absolute;
left: 1px;
top: 1px;
z-index: 5;
width: 57px;
height: 44px;
border-radius: 4px 0 0 4px;
border-right: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
align-items: center;
justify-content: center;
gap: 0;
padding-left: 6px;
padding-right: 2px;
svg {
flex-shrink: 0;
}
}
.Input_container {
}
.Input_wrapper {
padding-left: 7rem;
}
`;
export default CreateModalNameInput;

Просмотреть файл

@@ -0,0 +1,4 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export {default} from './channel_bookmarks';

Просмотреть файл

@@ -0,0 +1,135 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useEffect, useMemo} from 'react';
import {useDispatch, useSelector} from 'react-redux';
import type {Channel} from '@mattermost/types/channels';
import type {GlobalState} from '@mattermost/types/store';
import {Permissions} from 'mattermost-redux/constants';
import {getChannelBookmarks} from 'mattermost-redux/selectors/entities/channel_bookmarks';
import {getChannel, getMyChannelMember} from 'mattermost-redux/selectors/entities/channels';
import {getConfig, getFeatureFlagValue, getLicense} from 'mattermost-redux/selectors/entities/general';
import {haveIChannelPermission} from 'mattermost-redux/selectors/entities/roles';
import {fetchChannelBookmarks} from 'actions/channel_bookmarks';
import {loadCustomEmojisIfNeeded} from 'actions/emoji_actions';
import Constants from 'utils/constants';
import {trimmedEmojiName} from 'utils/emoji_utils';
import {canUploadFiles, isPublicLinksEnabled} from 'utils/file_utils';
export const MAX_BOOKMARKS_PER_CHANNEL = 50;
export const useIsChannelBookmarksEnabled = () => {
return useSelector(getIsChannelBookmarksEnabled);
};
const {OPEN_CHANNEL, PRIVATE_CHANNEL, GM_CHANNEL, DM_CHANNEL} = Constants as {OPEN_CHANNEL: 'O'; PRIVATE_CHANNEL: 'P'; GM_CHANNEL: 'G'; DM_CHANNEL: 'D'};
type TAction = 'add' | 'edit' | 'delete' | 'order';
type TActionKey = `${TAction}${typeof OPEN_CHANNEL | typeof PRIVATE_CHANNEL}`;
const key = (a: TAction, c: typeof OPEN_CHANNEL | typeof PRIVATE_CHANNEL): TActionKey => {
return `${a}${c}`;
};
const BOOKMARK_PERMISSION = {
// open channel
[key('add', OPEN_CHANNEL)]: Permissions.ADD_BOOKMARK_PUBLIC_CHANNEL,
[key('edit', OPEN_CHANNEL)]: Permissions.EDIT_BOOKMARK_PUBLIC_CHANNEL,
[key('delete', OPEN_CHANNEL)]: Permissions.DELETE_BOOKMARK_PUBLIC_CHANNEL,
[key('order', OPEN_CHANNEL)]: Permissions.ORDER_BOOKMARK_PUBLIC_CHANNEL,
// private channel
[key('add', PRIVATE_CHANNEL)]: Permissions.ADD_BOOKMARK_PRIVATE_CHANNEL,
[key('edit', PRIVATE_CHANNEL)]: Permissions.EDIT_BOOKMARK_PRIVATE_CHANNEL,
[key('delete', PRIVATE_CHANNEL)]: Permissions.DELETE_BOOKMARK_PRIVATE_CHANNEL,
[key('order', PRIVATE_CHANNEL)]: Permissions.ORDER_BOOKMARK_PRIVATE_CHANNEL,
} as const;
export const useChannelBookmarkPermission = (channelId: string, action: TAction) => {
return useSelector((state: GlobalState) => getHaveIChannelBookmarkPermission(state, channelId, action));
};
export const getHaveIChannelBookmarkPermission = (state: GlobalState, channelId: string, action: TAction) => {
const channel: Channel | undefined = getChannel(state, channelId);
if (!channel) {
return false;
}
const {type} = channel;
if (type === 'threads') {
return false;
}
if (type === GM_CHANNEL || type === DM_CHANNEL) {
const myMembership = getMyChannelMember(state, channelId);
return myMembership?.channel_id === channelId;
}
const permission = BOOKMARK_PERMISSION[key(action, type)];
return channel && permission && haveIChannelPermission(state, channel.team_id, channelId, permission);
};
export const useCanUploadFiles = () => {
return useSelector((state: GlobalState) => canUploadFiles(getConfig(state)));
};
export const useCanGetPublicLink = () => {
return useSelector((state: GlobalState) => isPublicLinksEnabled(getConfig(state)));
};
export const useCanGetLinkPreviews = () => {
return useSelector((state: GlobalState) => getConfig(state).EnableLinkPreviews === 'true');
};
export const getIsChannelBookmarksEnabled = (state: GlobalState) => {
const isEnabled = getFeatureFlagValue(state, 'ChannelBookmarks') === 'true';
if (!isEnabled) {
return false;
}
const license = getLicense(state);
return license?.IsLicensed === 'true';
};
export const useChannelBookmarks = (channelId: string) => {
const dispatch = useDispatch();
const bookmarks = useSelector((state: GlobalState) => getChannelBookmarks(state, channelId));
const order = useMemo(() => {
return Object.keys(bookmarks).sort((a, b) => bookmarks[a].sort_order - bookmarks[b].sort_order);
}, [bookmarks]);
useEffect(() => {
if (channelId) {
dispatch(fetchChannelBookmarks(channelId));
}
}, [channelId]);
useEffect(() => {
const emojis = Object.values(bookmarks).reduce<string[]>((result, {emoji}) => {
if (emoji) {
result.push(trimmedEmojiName(emoji));
}
return result;
}, []);
if (emojis.length) {
dispatch(loadCustomEmojisIfNeeded(emojis));
}
}, [bookmarks]);
return {
bookmarks,
order,
};
};

Просмотреть файл

@@ -28,6 +28,9 @@ exports[`components/channel_view Should match snapshot if channel is archived 1`
teamUrl="/team"
viewArchivedChannels={false}
/>
<ChannelBookmarks
channelId="channelId"
/>
<DeferredRenderWrapper
channelId="channelId"
/>
@@ -85,6 +88,9 @@ exports[`components/channel_view Should match snapshot if channel is deactivated
teamUrl="/team"
viewArchivedChannels={false}
/>
<ChannelBookmarks
channelId="channelId"
/>
<DeferredRenderWrapper
channelId="channelId"
/>
@@ -141,6 +147,9 @@ exports[`components/channel_view Should match snapshot with base props 1`] = `
teamUrl="/team"
viewArchivedChannels={false}
/>
<ChannelBookmarks
channelId="channelId"
/>
<DeferredRenderWrapper
channelId="channelId"
/>

Просмотреть файл

@@ -6,6 +6,7 @@ import {FormattedMessage} from 'react-intl';
import type {RouteComponentProps} from 'react-router-dom';
import AdvancedCreatePost from 'components/advanced_create_post';
import ChannelBookmarks from 'components/channel_bookmarks';
import ChannelHeader from 'components/channel_header';
import deferComponentRender from 'components/deferComponentRender';
import FileUploadOverlay from 'components/file_upload_overlay';
@@ -173,9 +174,8 @@ export default class ChannelView extends React.PureComponent<Props, State> {
className='app__content'
>
<FileUploadOverlay overlayType='center'/>
<ChannelHeader
{...this.props}
/>
<ChannelHeader {...this.props}/>
<ChannelBookmarks channelId={this.props.channelId}/>
<DeferredPostView
channelId={this.props.channelId}
focusedPostId={this.state.focusedPostId}

Просмотреть файл

@@ -12,10 +12,10 @@ import AnyTeamPermissionGate from 'components/permissions_gates/any_team_permiss
interface Props {
customEmojisEnabled: boolean;
currentTeamName: string;
handleEmojiPickerClose: () => void;
onClick: () => void;
}
function EmojiPickerCustomEmojiButton({customEmojisEnabled, currentTeamName, handleEmojiPickerClose}: Props) {
function EmojiPickerCustomEmojiButton({customEmojisEnabled, currentTeamName, onClick}: Props) {
if (!customEmojisEnabled) {
return null;
}
@@ -30,7 +30,7 @@ function EmojiPickerCustomEmojiButton({customEmojisEnabled, currentTeamName, han
<Link
className='btn btn-tertiary'
to={`/${currentTeamName}/emoji`}
onClick={handleEmojiPickerClose}
onClick={onClick}
>
<FormattedMessage
id='emoji_picker.custom_emoji'

Просмотреть файл

@@ -39,6 +39,7 @@ export interface Props extends PropsFromRedux {
onEmojiClick: (emoji: Emoji) => void;
handleFilterChange: (filter: string) => void;
handleEmojiPickerClose: () => void;
onAddCustomEmojiClick?: () => void;
}
const EmojiPicker = ({
@@ -46,6 +47,7 @@ const EmojiPicker = ({
onEmojiClick,
handleFilterChange,
handleEmojiPickerClose,
onAddCustomEmojiClick,
customEmojisEnabled = false,
customEmojiPage = 0,
emojiMap,
@@ -204,6 +206,11 @@ const EmojiPicker = ({
});
}, []);
const onAddCustomEmojiClickInner = useCallback(() => {
handleEmojiPickerClose();
onAddCustomEmojiClick?.();
}, []);
const [cursorCategory, cursorCategoryIndex, cursorEmojiIndex] = getCursorProperties(cursor.rowIndex, cursor.emojiId, categoryOrEmojisRows as EmojiRow[]);
function calculateNewCursorForUpArrow(cursorCategory: string, emojiPositions: EmojiPosition[], currentCursorsPositionIndex: number, categories: Categories, focusOnSearchInput: () => void) {
@@ -429,13 +436,11 @@ const EmojiPicker = ({
/>
)}
<div className='emoji-picker__footer'>
{areSearchResultsEmpty ? (<div/>) :
(<EmojiPickerPreview emoji={cursor.emoji}/>)
}
{areSearchResultsEmpty ? <div/> : <EmojiPickerPreview emoji={cursor.emoji}/>}
<EmojiPickerCustomEmojiButton
currentTeamName={currentTeamName}
customEmojisEnabled={customEmojisEnabled}
handleEmojiPickerClose={handleEmojiPickerClose}
onClick={onAddCustomEmojiClickInner}
/>
</div>
</div>

Просмотреть файл

@@ -3,7 +3,7 @@
import memoize from 'memoize-one';
import React from 'react';
import type {ReactNode} from 'react';
import type {ComponentProps, ReactNode} from 'react';
import {Overlay} from 'react-bootstrap';
import type {Emoji} from '@mattermost/types/emojis';
@@ -20,9 +20,11 @@ export interface Props extends PropsFromRedux {
target: () => ReactNode;
onEmojiClick: (emoji: Emoji) => void;
onGifClick?: (gif: string) => void;
onAddCustomEmojiClick?: () => void;
onHide: () => void;
onExited?: () => void;
show: boolean;
placement?: ComponentProps<typeof Overlay>['placement'];
topOffset?: number;
rightOffset?: number;
leftOffset?: number;
@@ -89,7 +91,7 @@ export default class EmojiPickerOverlay extends React.PureComponent<Props> {
return (
<Overlay
show={show}
placement={placement}
placement={this.props.placement ?? placement}
rootClose={!isMobileView}
container={this.props.container}
onHide={this.props.onHide}
@@ -105,6 +107,7 @@ export default class EmojiPickerOverlay extends React.PureComponent<Props> {
rightOffset={calculatedRightOffset}
topOffset={this.props.topOffset}
leftOffset={this.props.leftOffset}
onAddCustomEmojiClick={this.props.onAddCustomEmojiClick}
/>
</Overlay>
);

Просмотреть файл

@@ -26,6 +26,7 @@ export interface Props {
onEmojiClose: () => void;
onEmojiClick: (emoji: Emoji) => void;
onGifClick?: (gif: string) => void;
onAddCustomEmojiClick?: () => void;
enableGifPicker?: boolean;
}
@@ -176,6 +177,7 @@ export default class EmojiPickerTabs extends PureComponent<Props, State> {
onEmojiClick={this.props.onEmojiClick}
handleFilterChange={this.handleFilterChange}
handleEmojiPickerClose={this.handleEmojiPickerClose}
onAddCustomEmojiClick={this.props.onAddCustomEmojiClick}
/>
</div>
);

Просмотреть файл

@@ -1,7 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/error_page/ErrorLink should match snapshot 1`] = `
<ExternalLink
<ForwardRef
href="https://docs.mattermost.com/deployment/sso-gitlab.html"
location="error_link"
>
@@ -9,5 +9,5 @@ exports[`components/error_page/ErrorLink should match snapshot 1`] = `
defaultMessage="GitLab"
id="error.oauth_missing_code.gitlab.link"
/>
</ExternalLink>
</ForwardRef>
`;

Просмотреть файл

@@ -13,7 +13,7 @@ exports[`components/external_link should match snapshot 1`] = `
}
}
>
<ExternalLink
<ForwardRef
href="https://mattermost.com"
location="test"
>
@@ -26,6 +26,6 @@ exports[`components/external_link should match snapshot 1`] = `
>
Click Me
</a>
</ExternalLink>
</ForwardRef>
</Provider>
`;

Просмотреть файл

@@ -3,7 +3,7 @@
/* eslint-disable @mattermost/use-external-link */
import React from 'react';
import React, {forwardRef} from 'react';
import {useSelector} from 'react-redux';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/common';
@@ -29,7 +29,7 @@ type Props = React.AnchorHTMLAttributes<HTMLAnchorElement> & {
children: React.ReactNode;
};
export default function ExternalLink(props: Props) {
const ExternalLink = forwardRef<HTMLAnchorElement, Props>((props, ref) => {
const userId = useSelector(getCurrentUserId);
const config = useSelector(getConfig);
const license = useSelector(getLicense);
@@ -70,6 +70,7 @@ export default function ExternalLink(props: Props) {
return (
<a
{...props}
ref={ref}
target={props.target || '_blank'}
rel={props.rel || 'noopener noreferrer'}
onClick={handleClick}
@@ -78,4 +79,6 @@ export default function ExternalLink(props: Props) {
{props.children}
</a>
);
}
});
export default ExternalLink;

Просмотреть файл

@@ -37,14 +37,14 @@ exports[`components/file_attachment/FilenameOverlay should match snapshot, stand
placement="top"
title="Download"
>
<ExternalLink
<ForwardRef
aria-label="download"
download="test_filename"
href="/api/v4/files/thumbnail_id?download=1"
location="filename_overlay"
>
test_filename
</ExternalLink>
</ForwardRef>
</WithTooltip>
</div>
`;
@@ -58,14 +58,14 @@ exports[`components/file_attachment/FilenameOverlay should match snapshot, with
placement="top"
title="Download"
>
<ExternalLink
<ForwardRef
aria-label="download"
download="test_filename"
href="/api/v4/files/thumbnail_id?download=1"
location="filename_overlay"
>
<AttachmentIcon />
</ExternalLink>
</ForwardRef>
</WithTooltip>
</div>
`;

Просмотреть файл

@@ -52,6 +52,7 @@ interface Props extends PropsFromRedux {
* Display in compact format
*/
compactDisplay?: boolean;
disablePreview?: boolean;
handleFileDropdownOpened?: (open: boolean) => void;
}
@@ -190,6 +191,7 @@ export default function FileAttachment(props: Props) {
defaultItems.push(
<Menu.ItemAction
data-title='Public Image'
key={fileInfo.id + '_publiclinkmenuitem'}
onClick={handleGetPublicLink}
ariaLabel={formatMessage({id: 'view_image_popover.publicLink', defaultMessage: 'Get a public link'})}
text={formatMessage({id: 'view_image_popover.publicLink', defaultMessage: 'Get a public link'})}
@@ -284,7 +286,10 @@ export default function FileAttachment(props: Props) {
onClick={onAttachmentClick}
>
{loaded ? (
<FileThumbnail fileInfo={fileInfo}/>
<FileThumbnail
fileInfo={fileInfo}
disablePreview={props.disablePreview}
/>
) : (
<div className='post-image__load'/>
)}

Просмотреть файл

@@ -7,55 +7,72 @@ import type {FileInfo} from '@mattermost/types/files';
import {getFileThumbnailUrl, getFileUrl} from 'mattermost-redux/utils/file_utils';
import type {FilePreviewInfo} from 'components/file_preview/file_preview';
import Constants, {FileTypes} from 'utils/constants';
import {getFileTypeFromMime} from 'utils/file_utils';
import {
getFileType,
getIconClassName,
isGIFImage,
} from 'utils/utils';
type FilePreviewInfoLimited = Pick<FilePreviewInfo, 'clientId' | 'name' | 'percent' | 'type'>;
type Props = {
enableSVGs: boolean;
fileInfo: FileInfo;
}
fileInfo: FileInfo | FilePreviewInfo | FilePreviewInfoLimited;
disablePreview?: boolean;
};
const FileThumbnail = ({
fileInfo,
enableSVGs,
disablePreview,
}: Props) => {
const type = getFileType(fileInfo.extension);
const {id, extension, has_preview_image: hasPreviewImage, width = 0, height = 0} = (fileInfo as FileInfo);
const mimeType = (fileInfo as FileInfo).mime_type || (fileInfo as FilePreviewInfo | FilePreviewInfoLimited).type;
if (type === FileTypes.IMAGE) {
let className = 'post-image';
let type = FileTypes.OTHER;
if (extension) {
type = getFileType(extension);
} else if (mimeType) {
type = getFileTypeFromMime(mimeType);
}
if (fileInfo.width < Constants.THUMBNAIL_WIDTH && fileInfo.height < Constants.THUMBNAIL_HEIGHT) {
className += ' small';
} else {
className += ' normal';
if (id && !disablePreview) {
if (type === FileTypes.IMAGE) {
let className = 'post-image';
if (width < Constants.THUMBNAIL_WIDTH && height < Constants.THUMBNAIL_HEIGHT) {
className += ' small';
} else {
className += ' normal';
}
let thumbnailUrl = getFileThumbnailUrl(id);
if (extension && isGIFImage(extension) && !hasPreviewImage) {
thumbnailUrl = getFileUrl(id);
}
return (
<div
className={className}
style={{
backgroundImage: `url(${thumbnailUrl})`,
backgroundSize: 'cover',
}}
/>
);
} else if (extension === FileTypes.SVG && enableSVGs) {
return (
<img
alt={'file thumbnail image'}
className='post-image normal'
src={getFileUrl(id)}
/>
);
}
let thumbnailUrl = getFileThumbnailUrl(fileInfo.id);
if (isGIFImage(fileInfo.extension) && !fileInfo.has_preview_image) {
thumbnailUrl = getFileUrl(fileInfo.id);
}
return (
<div
className={className}
style={{
backgroundImage: `url(${thumbnailUrl})`,
backgroundSize: 'cover',
}}
/>
);
} else if (fileInfo.extension === FileTypes.SVG && enableSVGs) {
return (
<img
alt={'file thumbnail image'}
className='post-image normal'
src={getFileUrl(fileInfo.id)}
/>
);
}
return <div className={'file-icon ' + getIconClassName(type)}/>;

Просмотреть файл

@@ -75,57 +75,6 @@ exports[`components/file_search_result/FileSearchResultItem should match snapsho
/>
</div>
</div>
<OverlayTrigger
defaultOverlayShown={false}
delayShow={1000}
overlay={
<Tooltip
id="file-name__tooltip"
>
More Actions
</Tooltip>
}
placement="top"
trigger={
Array [
"hover",
"focus",
]
}
>
<MenuWrapper
animationComponent={[Function]}
className=""
onToggle={[Function]}
stopPropagationOnToggle={true}
>
<a
className="action-icon dots-icon"
href="#"
>
<i
className="icon icon-dots-vertical"
/>
</a>
<Menu
ariaLabel="file menu"
openLeft={true}
>
<MenuItemAction
ariaLabel="Open in channel"
onClick={[Function]}
show={true}
text="Open in channel"
/>
<MenuItemAction
ariaLabel="Copy link"
onClick={[Function]}
show={true}
text="Copy link"
/>
</Menu>
</MenuWrapper>
</OverlayTrigger>
<OverlayTrigger
defaultOverlayShown={false}
delayShow={1000}
@@ -242,57 +191,6 @@ exports[`components/file_search_result/FileSearchResultItem should match snapsho
/>
</div>
</div>
<OverlayTrigger
defaultOverlayShown={false}
delayShow={1000}
overlay={
<Tooltip
id="file-name__tooltip"
>
More Actions
</Tooltip>
}
placement="top"
trigger={
Array [
"hover",
"focus",
]
}
>
<MenuWrapper
animationComponent={[Function]}
className=""
onToggle={[Function]}
stopPropagationOnToggle={true}
>
<a
className="action-icon dots-icon"
href="#"
>
<i
className="icon icon-dots-vertical"
/>
</a>
<Menu
ariaLabel="file menu"
openLeft={true}
>
<MenuItemAction
ariaLabel="Open in channel"
onClick={[Function]}
show={true}
text="Open in channel"
/>
<MenuItemAction
ariaLabel="Copy link"
onClick={[Function]}
show={true}
text="Copy link"
/>
</Menu>
</MenuWrapper>
</OverlayTrigger>
<OverlayTrigger
defaultOverlayShown={false}
delayShow={1000}
@@ -409,57 +307,6 @@ exports[`components/file_search_result/FileSearchResultItem should match snapsho
/>
</div>
</div>
<OverlayTrigger
defaultOverlayShown={false}
delayShow={1000}
overlay={
<Tooltip
id="file-name__tooltip"
>
More Actions
</Tooltip>
}
placement="top"
trigger={
Array [
"hover",
"focus",
]
}
>
<MenuWrapper
animationComponent={[Function]}
className=""
onToggle={[Function]}
stopPropagationOnToggle={true}
>
<a
className="action-icon dots-icon"
href="#"
>
<i
className="icon icon-dots-vertical"
/>
</a>
<Menu
ariaLabel="file menu"
openLeft={true}
>
<MenuItemAction
ariaLabel="Open in channel"
onClick={[Function]}
show={true}
text="Open in channel"
/>
<MenuItemAction
ariaLabel="Copy link"
onClick={[Function]}
show={true}
text="Copy link"
/>
</Menu>
</MenuWrapper>
</OverlayTrigger>
<OverlayTrigger
defaultOverlayShown={false}
delayShow={1000}
@@ -571,57 +418,6 @@ exports[`components/file_search_result/FileSearchResultItem should match snapsho
/>
</div>
</div>
<OverlayTrigger
defaultOverlayShown={false}
delayShow={1000}
overlay={
<Tooltip
id="file-name__tooltip"
>
More Actions
</Tooltip>
}
placement="top"
trigger={
Array [
"hover",
"focus",
]
}
>
<MenuWrapper
animationComponent={[Function]}
className=""
onToggle={[Function]}
stopPropagationOnToggle={true}
>
<a
className="action-icon dots-icon"
href="#"
>
<i
className="icon icon-dots-vertical"
/>
</a>
<Menu
ariaLabel="file menu"
openLeft={true}
>
<MenuItemAction
ariaLabel="Open in channel"
onClick={[Function]}
show={true}
text="Open in channel"
/>
<MenuItemAction
ariaLabel="Copy link"
onClick={[Function]}
show={true}
text="Copy link"
/>
</Menu>
</MenuWrapper>
</OverlayTrigger>
<OverlayTrigger
defaultOverlayShown={false}
delayShow={1000}

Просмотреть файл

@@ -145,43 +145,45 @@ export default class FileSearchResultItem extends React.PureComponent<Props, Sta
/>
</div>
</div>
<OverlayTrigger
delayShow={1000}
placement='top'
overlay={
<Tooltip id='file-name__tooltip'>
{localizeMessage('file_search_result_item.more_actions', 'More Actions')}
</Tooltip>
}
>
<MenuWrapper
onToggle={this.keepOpen}
stopPropagationOnToggle={true}
{this.props.fileInfo.post_id && (
<OverlayTrigger
delayShow={1000}
placement='top'
overlay={
<Tooltip id='file-name__tooltip'>
{localizeMessage('file_search_result_item.more_actions', 'More Actions')}
</Tooltip>
}
>
<a
href='#'
className='action-icon dots-icon'
<MenuWrapper
onToggle={this.keepOpen}
stopPropagationOnToggle={true}
>
<i className='icon icon-dots-vertical'/>
</a>
<Menu
ariaLabel={'file menu'}
openLeft={true}
>
<Menu.ItemAction
onClick={this.jumpToConv}
ariaLabel={localizeMessage('file_search_result_item.open_in_channel', 'Open in channel')}
text={localizeMessage('file_search_result_item.open_in_channel', 'Open in channel')}
/>
<Menu.ItemAction
onClick={this.copyLink}
ariaLabel={localizeMessage('file_search_result_item.copy_link', 'Copy link')}
text={localizeMessage('file_search_result_item.copy_link', 'Copy link')}
/>
{this.renderPluginItems()}
</Menu>
</MenuWrapper>
</OverlayTrigger>
<a
href='#'
className='action-icon dots-icon'
>
<i className='icon icon-dots-vertical'/>
</a>
<Menu
ariaLabel={'file menu'}
openLeft={true}
>
<Menu.ItemAction
onClick={this.jumpToConv}
ariaLabel={localizeMessage('file_search_result_item.open_in_channel', 'Open in channel')}
text={localizeMessage('file_search_result_item.open_in_channel', 'Open in channel')}
/>
<Menu.ItemAction
onClick={this.copyLink}
ariaLabel={localizeMessage('file_search_result_item.copy_link', 'Copy link')}
text={localizeMessage('file_search_result_item.copy_link', 'Copy link')}
/>
{this.renderPluginItems()}
</Menu>
</MenuWrapper>
</OverlayTrigger>
)}
<OverlayTrigger
delayShow={1000}
placement='top'

Просмотреть файл

@@ -139,7 +139,7 @@ exports[`components/integrations/AbstractCommand should match snapshot 1`] = `
id="add_command.trigger.helpReserved"
values={
Object {
"link": <ExternalLink
"link": <ForwardRef
href="https://mattermost.com/pl/custom-slash-commands"
location="abstract_command"
>
@@ -147,7 +147,7 @@ exports[`components/integrations/AbstractCommand should match snapshot 1`] = `
defaultMessage="See built-in slash commands"
id="add_command.trigger.helpReservedLinkText"
/>
</ExternalLink>,
</ForwardRef>,
}
}
/>
@@ -588,7 +588,7 @@ exports[`components/integrations/AbstractCommand should match snapshot when head
id="add_command.trigger.helpReserved"
values={
Object {
"link": <ExternalLink
"link": <ForwardRef
href="https://mattermost.com/pl/custom-slash-commands"
location="abstract_command"
>
@@ -596,7 +596,7 @@ exports[`components/integrations/AbstractCommand should match snapshot when head
defaultMessage="See built-in slash commands"
id="add_command.trigger.helpReservedLinkText"
/>
</ExternalLink>,
</ForwardRef>,
}
}
/>
@@ -1037,7 +1037,7 @@ exports[`components/integrations/AbstractCommand should match snapshot, displays
id="add_command.trigger.helpReserved"
values={
Object {
"link": <ExternalLink
"link": <ForwardRef
href="https://mattermost.com/pl/custom-slash-commands"
location="abstract_command"
>
@@ -1045,7 +1045,7 @@ exports[`components/integrations/AbstractCommand should match snapshot, displays
defaultMessage="See built-in slash commands"
id="add_command.trigger.helpReservedLinkText"
/>
</ExternalLink>,
</ForwardRef>,
}
}
/>

Просмотреть файл

@@ -291,7 +291,7 @@ callbackUrl2.com
id="add_outgoing_webhook.callbackUrls.help"
values={
Object {
"link": <ExternalLink
"link": <ForwardRef
href="https://mattermost.com/pl/default-allow-untrusted-internal-connections"
location="abstract_outgoing_webhook"
>
@@ -299,7 +299,7 @@ callbackUrl2.com
defaultMessage="trusted internal connection"
id="add_outgoing_webhook.callbackUrls.helpLinkText"
/>
</ExternalLink>,
</ForwardRef>,
}
}
/>

Просмотреть файл

@@ -29,7 +29,7 @@ exports[`components/integrations/InstalledOAuthApps should match snapshot 1`] =
id="installed_oauth_apps.help"
values={
Object {
"appDirectory": <ExternalLink
"appDirectory": <ForwardRef
href="https://mattermost.com/marketplace/"
location="installed_oauth_apps"
>
@@ -37,8 +37,8 @@ exports[`components/integrations/InstalledOAuthApps should match snapshot 1`] =
defaultMessage="App Directory"
id="installed_oauth_apps.help.appDirectory"
/>
</ExternalLink>,
"oauthApplications": <ExternalLink
</ForwardRef>,
"oauthApplications": <ForwardRef
href="https://mattermost.com/pl/setup-oauth-2.0"
location="installed_oauth_apps"
>
@@ -46,7 +46,7 @@ exports[`components/integrations/InstalledOAuthApps should match snapshot 1`] =
defaultMessage="OAuth 2.0 applications"
id="installed_oauth_apps.help.oauthApplications"
/>
</ExternalLink>,
</ForwardRef>,
}
}
/>

Просмотреть файл

@@ -257,7 +257,7 @@ exports[`components/integrations/InstalledOutgoingWebhooks should match snapshot
id="installed_outgoing_webhooks.help"
values={
Object {
"appDirectory": <ExternalLink
"appDirectory": <ForwardRef
href="https://mattermost.com/marketplace"
location="installed_outgoing_webhooks"
>
@@ -265,8 +265,8 @@ exports[`components/integrations/InstalledOutgoingWebhooks should match snapshot
defaultMessage="App Directory"
id="installed_outgoing_webhooks.help.appDirectory"
/>
</ExternalLink>,
"buildYourOwn": <ExternalLink
</ForwardRef>,
"buildYourOwn": <ForwardRef
href="https://mattermost.com/pl/setup-outgoing-webhooks"
location="installed_outgoing_webhooks"
>
@@ -274,7 +274,7 @@ exports[`components/integrations/InstalledOutgoingWebhooks should match snapshot
defaultMessage="Build your own"
id="installed_outgoing_webhooks.help.buildYourOwn"
/>
</ExternalLink>,
</ForwardRef>,
}
}
/>

Просмотреть файл

@@ -85,7 +85,7 @@ exports[`components/integrations/InstalledOutgoingOAuthConnections should match
id="installed_outgoing_oauth_connections.help"
values={
Object {
"outgoingOauthConnections": <ExternalLink
"outgoingOauthConnections": <ForwardRef
href="https://mattermost.com/pl/setup-oauth-2.0"
location="installed_outgoing_oauth_connections"
>
@@ -93,7 +93,7 @@ exports[`components/integrations/InstalledOutgoingOAuthConnections should match
defaultMessage="Outgoing OAuth Connections"
id="installed_outgoing_oauth_connections.help.outgoingOauthConnections"
/>
</ExternalLink>,
</ForwardRef>,
}
}
/>
@@ -180,7 +180,7 @@ exports[`components/integrations/InstalledOutgoingOAuthConnections should match
id="installed_outgoing_oauth_connections.help"
values={
Object {
"outgoingOauthConnections": <ExternalLink
"outgoingOauthConnections": <ForwardRef
href="https://mattermost.com/pl/setup-oauth-2.0"
location="installed_outgoing_oauth_connections"
>
@@ -188,13 +188,13 @@ exports[`components/integrations/InstalledOutgoingOAuthConnections should match
defaultMessage="Outgoing OAuth Connections"
id="installed_outgoing_oauth_connections.help.outgoingOauthConnections"
/>
</ExternalLink>,
</ForwardRef>,
}
}
>
<span>
Create
<ExternalLink
<ForwardRef
href="https://mattermost.com/pl/setup-oauth-2.0"
key=".$.1"
location="installed_outgoing_oauth_connections"
@@ -215,7 +215,7 @@ exports[`components/integrations/InstalledOutgoingOAuthConnections should match
</span>
</FormattedMessage>
</a>
</ExternalLink>
</ForwardRef>
to securely integrate bots and third-party apps with Mattermost.
</span>
</FormattedMessage>

Просмотреть файл

@@ -64,11 +64,25 @@ type MenuProps = {
width?: string;
}
const defaultAnchorOrigin = {vertical: 'bottom', horizontal: 'left'};
const defaultTransformOrigin = {vertical: 'top', horizontal: 'left'};
type VerticalOrigin = 'top' | 'center' | 'bottom';
type HorizontalOrigin = 'left' | 'center' | 'right';
interface Props {
menuButton: MenuButtonProps;
menuButtonTooltip?: MenuButtonTooltipProps;
menu: MenuProps;
children: ReactNode[];
anchorOrigin?: {
vertical: VerticalOrigin;
horizontal: HorizontalOrigin;
};
transformOrigin?: {
vertical: VerticalOrigin;
horizontal: HorizontalOrigin;
};
}
/**
@@ -236,6 +250,8 @@ export function Menu(props: Props) {
onKeyDown={handleMenuKeyDown}
className={A11yClassNames.POPUP}
width={props.menu.width}
anchorOrigin={props.anchorOrigin || defaultAnchorOrigin}
transformOrigin={props.transformOrigin || defaultTransformOrigin}
disableAutoFocusItem={disableAutoFocusItem} // This is not anti-pattern, see handleMenuButtonMouseDown
MenuListProps={{
id: props.menu.id,

Просмотреть файл

@@ -1006,7 +1006,7 @@ exports[`components/MarketplaceItemPlugin UpdateDetails should render with relea
releaseNotesUrl="http://example.com/release"
version="0.0.2"
>
<ExternalLink
<ForwardRef
href="http://example.com/release"
location="marketplace_item_plugin"
>
@@ -1019,7 +1019,7 @@ exports[`components/MarketplaceItemPlugin UpdateDetails should render with relea
>
0.0.2
</a>
</ExternalLink>
</ForwardRef>
</UpdateVersion>
-
<b>

Просмотреть файл

@@ -24,7 +24,7 @@ exports[`components/post_view/MessageAttachment should call actions.doPostAction
}
}
>
<ExternalLink
<ForwardRef
href="author_link"
key="attachment__author-name"
location="message_attachment"
@@ -41,17 +41,17 @@ exports[`components/post_view/MessageAttachment should call actions.doPostAction
>
author_name
</span>
</ExternalLink>
</ForwardRef>
<h1
className="attachment__title"
>
<ExternalLink
<ForwardRef
className="attachment__title-link"
href="title_link"
location="message_attachment"
>
title
</ExternalLink>
</ForwardRef>
</h1>
<div>
<div
@@ -170,7 +170,7 @@ exports[`components/post_view/MessageAttachment should match snapshot 1`] = `
}
}
>
<ExternalLink
<ForwardRef
href="author_link"
key="attachment__author-name"
location="message_attachment"
@@ -187,17 +187,17 @@ exports[`components/post_view/MessageAttachment should match snapshot 1`] = `
>
author_name
</span>
</ExternalLink>
</ForwardRef>
<h1
className="attachment__title"
>
<ExternalLink
<ForwardRef
className="attachment__title-link"
href="title_link"
location="message_attachment"
>
title
</ExternalLink>
</ForwardRef>
</h1>
<div>
<div
@@ -312,7 +312,7 @@ exports[`components/post_view/MessageAttachment should match snapshot when no fo
}
}
>
<ExternalLink
<ForwardRef
href="author_link"
key="attachment__author-name"
location="message_attachment"
@@ -329,17 +329,17 @@ exports[`components/post_view/MessageAttachment should match snapshot when no fo
>
author_name
</span>
</ExternalLink>
</ForwardRef>
<h1
className="attachment__title"
>
<ExternalLink
<ForwardRef
className="attachment__title-link"
href="title_link"
location="message_attachment"
>
title
</ExternalLink>
</ForwardRef>
</h1>
<div>
<div

Просмотреть файл

@@ -85,7 +85,7 @@ exports[`ProductNoticesModal Match snapshot for user notice 1`] = `
message="descr"
/>
</span>
<ExternalLink
<ForwardRef
className="GenericModal__button actionButton"
href="http://download.com/path"
id="actionButton"
@@ -93,7 +93,7 @@ exports[`ProductNoticesModal Match snapshot for user notice 1`] = `
onClick={[Function]}
>
Download
</ExternalLink>
</ForwardRef>
<div
className="productNotices__imageDiv"
/>
@@ -157,7 +157,7 @@ exports[`ProductNoticesModal Should match snapshot for system admin notice 1`] =
message="your eyes only! [test](https://test.com)"
/>
</span>
<ExternalLink
<ForwardRef
className="GenericModal__button actionButton"
href="http://download.com/path"
id="actionButton"
@@ -165,7 +165,7 @@ exports[`ProductNoticesModal Should match snapshot for system admin notice 1`] =
onClick={[Function]}
>
Download
</ExternalLink>
</ForwardRef>
<div
className="productNotices__imageDiv"
>

Просмотреть файл

@@ -105,13 +105,13 @@ exports[`components/signup/Signup should match snapshot for all signup options e
className="link"
>
Sign up at
<ExternalLink
<ForwardRef
href="https://mattermost.com/security-updates/"
key=".1"
location="signup"
>
https://mattermost.com/security-updates/
</ExternalLink>
</ForwardRef>
.
</span>
</div>
@@ -279,13 +279,13 @@ exports[`components/signup/Signup should match snapshot for all signup options e
className="link"
>
Sign up at
<ExternalLink
<ForwardRef
href="https://mattermost.com/security-updates/"
key=".1"
location="signup"
>
https://mattermost.com/security-updates/
</ExternalLink>
</ForwardRef>
.
</span>
</div>

Просмотреть файл

@@ -19,7 +19,7 @@ export enum SIZE {
LARGE = 'large',
}
export type CustomMessageInputType = {type: 'info' | 'error' | 'warning' | 'success'; value: React.ReactNode} | null;
export type CustomMessageInputType = {type?: 'info' | 'error' | 'warning' | 'success'; value: React.ReactNode} | null;
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement> {
required?: boolean;
@@ -225,14 +225,15 @@ const Input = React.forwardRef((
</fieldset>
{customInputLabel && (
<div className={`Input___customMessage Input___${customInputLabel.type}`}>
<i
className={classNames(`icon ${customInputLabel.type}`, {
'icon-alert-outline': customInputLabel.type === ItemStatus.WARNING,
'icon-alert-circle-outline': customInputLabel.type === ItemStatus.ERROR,
'icon-information-outline': customInputLabel.type === ItemStatus.INFO,
'icon-check': customInputLabel.type === ItemStatus.SUCCESS,
})}
/>
{customInputLabel.type && (
<i
className={classNames(`icon ${customInputLabel.type}`, {
'icon-alert-outline': customInputLabel.type === ItemStatus.WARNING,
'icon-alert-circle-outline': customInputLabel.type === ItemStatus.ERROR,
'icon-information-outline': customInputLabel.type === ItemStatus.INFO,
'icon-check': customInputLabel.type === ItemStatus.SUCCESS,
})}
/>)}
<span>{customInputLabel.value}</span>
</div>
)}

Просмотреть файл

@@ -16,7 +16,7 @@ describe('components/MenuItemExternalLink', () => {
);
expect(wrapper).toMatchInlineSnapshot(`
<ExternalLink
<ForwardRef
href="http://test.com"
location="menu_item_external_link"
>
@@ -25,7 +25,7 @@ describe('components/MenuItemExternalLink', () => {
>
Whatever
</span>
</ExternalLink>
</ForwardRef>
`);
});
});

Просмотреть файл

@@ -44,7 +44,7 @@ exports[`YoutubeVideo should match init snapshot 1`] = `
<span
className="video-title"
>
<ExternalLink
<ForwardRef
href="https://www.youtube.com/watch?v=xqCoNej8Zxo"
location="youtube_video"
>
@@ -57,7 +57,7 @@ exports[`YoutubeVideo should match init snapshot 1`] = `
>
Youtube title
</a>
</ExternalLink>
</ForwardRef>
</span>
</h4>
<div
@@ -118,12 +118,12 @@ exports[`YoutubeVideo should match snapshot for playing state 1`] = `
<span
className="video-title"
>
<ExternalLink
<ForwardRef
href="https://www.youtube.com/watch?v=xqCoNej8Zxo"
location="youtube_video"
>
Youtube title
</ExternalLink>
</ForwardRef>
</span>
</h4>
<div

Просмотреть файл

@@ -3035,6 +3035,31 @@
"change_url.shorter": "URLs must have maximum 64 characters.",
"change_url.startAndEndWithLetter": "URLs must start and end with a lowercase letter or number.",
"change_url.startWithLetter": "URLs must start with a lowercase letter or number.",
"channel_bookmarks.addBookmark": "Add a bookmark",
"channel_bookmarks.addBookmarkLimitReached": "Cannot add more than {limit} bookmarks",
"channel_bookmarks.addLink": "Add a link",
"channel_bookmarks.attachFile": "Attach a file",
"channel_bookmarks.confirm.delete.button": "Yes, delete",
"channel_bookmarks.confirm.delete.text": "Are you sure you want to delete the bookmark <strong>{displayName}</strong>?",
"channel_bookmarks.confirm.delete.title": "Delete bookmark",
"channel_bookmarks.copy": "Copy link",
"channel_bookmarks.copyFilePublicLink": "Get a public link",
"channel_bookmarks.create.confirm_add.button": "Add bookmark",
"channel_bookmarks.create.confirm_save.button": "Save bookmark",
"channel_bookmarks.create.edit.title": "Edit bookmark",
"channel_bookmarks.create.error.generic_save": "There was an error trying to save the bookmark.",
"channel_bookmarks.create.error.invalid_url": "Please enter a valid link",
"channel_bookmarks.create.file_input.edit": "Edit",
"channel_bookmarks.create.file_input.label": "Attachment",
"channel_bookmarks.create.link_info": "Add a link to any post, file, or any external link",
"channel_bookmarks.create.link_placeholder": "Link",
"channel_bookmarks.create.title": "Add a bookmark",
"channel_bookmarks.create.title_input.clear_emoji": "Remove emoji",
"channel_bookmarks.create.title_input.label": "Title",
"channel_bookmarks.delete": "Delete",
"channel_bookmarks.edit": "Edit",
"channel_bookmarks.editBookmarkLabel": "Bookmark menu",
"channel_bookmarks.open": "Open",
"channel_groups": "{channel} Groups",
"channel_header.addChannelHeader": "Add a channel header",
"channel_header.channelFiles": "Channel files",
@@ -3617,6 +3642,7 @@
"file_upload.fileAbove": "File above {max}MB could not be uploaded: {filename}",
"file_upload.filesAbove": "Files above {max}MB could not be uploaded: {filenames}",
"file_upload.generic_error": "There was a problem uploading your files.",
"file_upload.generic_error_file": "There was a problem uploading your file.",
"file_upload.limited": "Uploads limited to {count, number} files maximum. Please use additional posts for more files.",
"file_upload.menuAriaLabel": "Upload type selector",
"file_upload.pasted": "Image Pasted at ",

Просмотреть файл

@@ -0,0 +1,11 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import keyMirror from 'mattermost-redux/utils/key_mirror';
export default keyMirror({
RECEIVED_BOOKMARK: null,
RECEIVED_BOOKMARKS: null,
BOOKMARK_DELETED: null,
});

Просмотреть файл

@@ -4,6 +4,7 @@
import AdminTypes from './admin';
import AppsTypes from './apps';
import BotTypes from './bots';
import ChannelBookmarkTypes from './channel_bookmarks';
import ChannelCategoryTypes from './channel_categories';
import ChannelTypes from './channels';
import CloudTypes from './cloud';
@@ -55,4 +56,5 @@ export {
HostedCustomerTypes,
DraftTypes,
PlaybookType,
ChannelBookmarkTypes,
};

Просмотреть файл

@@ -0,0 +1,104 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {ChannelBookmarkCreate, ChannelBookmarkPatch} from '@mattermost/types/channel_bookmarks';
import {ChannelBookmarkTypes} from 'mattermost-redux/action_types';
import {Client4} from 'mattermost-redux/client';
import {getChannelBookmark} from 'mattermost-redux/selectors/entities/channel_bookmarks';
import type {DispatchFunc, GetStateFunc} from 'mattermost-redux/types/actions';
import {logError} from './errors';
import {forceLogoutIfNecessary} from './helpers';
export function deleteBookmark(channelId: string, id: string, connectionId: string) {
return async (dispatch: DispatchFunc, getState: GetStateFunc) => {
const state = getState();
const bookmark = getChannelBookmark(state, channelId, id);
try {
await Client4.deleteChannelBookmark(channelId, id, connectionId);
dispatch({
type: ChannelBookmarkTypes.BOOKMARK_DELETED,
data: bookmark,
});
} catch (error) {
return {
data: false,
error,
};
}
return {data: true};
};
}
export function createBookmark(channelId: string, bookmark: ChannelBookmarkCreate, connectionId: string) {
return async (dispatch: DispatchFunc) => {
try {
const createdBookmark = await Client4.createChannelBookmark(channelId, bookmark, connectionId);
dispatch({
type: ChannelBookmarkTypes.RECEIVED_BOOKMARK,
data: createdBookmark,
});
} catch (error) {
return {
data: false,
error,
};
}
return {data: true};
};
}
export function editBookmark(channelId: string, id: string, patch: ChannelBookmarkPatch, connectionId: string) {
return async (dispatch: DispatchFunc) => {
try {
const {updated, deleted} = await Client4.updateChannelBookmark(channelId, id, patch, connectionId);
if (updated) {
dispatch({
type: ChannelBookmarkTypes.RECEIVED_BOOKMARK,
data: updated,
});
}
if (deleted) {
dispatch({
type: ChannelBookmarkTypes.BOOKMARK_DELETED,
data: deleted,
});
}
} catch (error) {
return {
data: false,
error,
};
}
return {data: true};
};
}
export function fetchChannelBookmarks(channelId: string) {
return async (dispatch: DispatchFunc, getState: GetStateFunc) => {
let bookmarks;
try {
bookmarks = await Client4.getChannelBookmarks(channelId);
dispatch({
type: ChannelBookmarkTypes.RECEIVED_BOOKMARKS,
data: {channelId, bookmarks},
});
} catch (error) {
forceLogoutIfNecessary(error, dispatch, getState);
dispatch(logError(error));
return {error};
}
return {data: bookmarks};
};
}

Просмотреть файл

@@ -0,0 +1,74 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {combineReducers, type AnyAction} from 'redux';
import type {ChannelBookmark, ChannelBookmarksState} from '@mattermost/types/channel_bookmarks';
import type {Channel} from '@mattermost/types/channels';
import type {IDMappedObjects} from '@mattermost/types/utilities';
import {ChannelBookmarkTypes, UserTypes, ChannelTypes} from 'mattermost-redux/action_types';
const toNewObj = <T extends {id: string}>(current: IDMappedObjects<T>, arr: T[]) => {
return arr.reduce((acc, x) => {
return {...acc, [x.id]: x};
}, {...current});
};
export function byChannelId(state: ChannelBookmarksState['byChannelId'] = {}, action: AnyAction) {
switch (action.type) {
case ChannelBookmarkTypes.RECEIVED_BOOKMARKS: {
const channelId: Channel['id'] = action.data.channelId;
const bookmarks: ChannelBookmark[] = action.data.bookmarks;
return {
...state,
[channelId]: toNewObj(state[channelId], bookmarks),
};
}
case ChannelBookmarkTypes.RECEIVED_BOOKMARK: {
const bookmark: ChannelBookmark = action.data;
const {id, channel_id: channelId} = bookmark;
return {
...state,
[channelId]: {
...state[channelId],
[id]: bookmark,
},
};
}
case ChannelBookmarkTypes.BOOKMARK_DELETED: {
const bookmark: ChannelBookmark = action.data;
const channelNextState = {...state[bookmark.channel_id]};
Reflect.deleteProperty(channelNextState, bookmark.id);
const nextState = {...state, [bookmark.channel_id]: channelNextState};
return nextState;
}
case ChannelTypes.LEAVE_CHANNEL: {
const channelId: string = action.data.channelId;
const nextState = {...state};
Reflect.deleteProperty(nextState, channelId);
return nextState;
}
case UserTypes.LOGOUT_SUCCESS:
return {};
default:
return state;
}
}
export default combineReducers({
byChannelId,
});

Просмотреть файл

@@ -4,10 +4,11 @@
import type {AnyAction} from 'redux';
import {combineReducers} from 'redux';
import type {ChannelBookmark} from '@mattermost/types/channel_bookmarks';
import type {FileInfo, FileSearchResultItem} from '@mattermost/types/files';
import type {Post} from '@mattermost/types/posts';
import {FileTypes, PostTypes, UserTypes} from 'mattermost-redux/action_types';
import {FileTypes, PostTypes, UserTypes, ChannelBookmarkTypes} from 'mattermost-redux/action_types';
export function files(state: Record<string, FileInfo> = {}, action: AnyAction) {
switch (action.type) {
@@ -53,6 +54,43 @@ export function files(state: Record<string, FileInfo> = {}, action: AnyAction) {
return state;
}
case ChannelBookmarkTypes.RECEIVED_BOOKMARKS: {
const bookmarks: ChannelBookmark[] = action.data.bookmarks;
const nextState = {...state};
bookmarks.forEach(({file}) => {
if (file) {
nextState[file.id] = file;
}
});
return nextState;
}
case ChannelBookmarkTypes.RECEIVED_BOOKMARK: {
const {file}: ChannelBookmark = action.data;
if (file) {
return {...state, [file.id]: file};
}
return state;
}
case ChannelBookmarkTypes.BOOKMARK_DELETED: {
const {file}: ChannelBookmark = action.data;
if (!file) {
return state;
}
const nextState = {...state};
Reflect.deleteProperty(nextState, file.id);
return nextState;
}
case UserTypes.LOGOUT_SUCCESS:
return {};
default:

Просмотреть файл

@@ -6,6 +6,7 @@ import {combineReducers} from 'redux';
import admin from './admin';
import apps from './apps';
import bots from './bots';
import channelBookmarks from './channel_bookmarks';
import channelCategories from './channel_categories';
import channels from './channels';
import cloud from './cloud';
@@ -53,4 +54,5 @@ export default combineReducers({
cloud,
usage,
hostedCustomer,
channelBookmarks,
});

Просмотреть файл

@@ -0,0 +1,21 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {ChannelBookmarksState} from '@mattermost/types/channel_bookmarks';
import type {GlobalState} from '@mattermost/types/store';
const EMPTY_BOOKMARKS = {};
export const getChannelBookmarks = (state: GlobalState, channelId: string): ChannelBookmarksState['byChannelId'][string] => {
const bookmarks = state.entities.channelBookmarks.byChannelId[channelId];
if (!bookmarks) {
return EMPTY_BOOKMARKS;
}
return bookmarks;
};
export const getChannelBookmark = (state: GlobalState, channelId: string, bookmarkId: string) => {
return getChannelBookmarks(state, channelId)[bookmarkId];
};

Просмотреть файл

@@ -65,6 +65,9 @@ const state: GlobalState = {
messageCounts: {},
channelsMemberCount: {},
},
channelBookmarks: {
byChannelId: {},
},
posts: {
posts: {},
postsReplies: {},

Просмотреть файл

@@ -459,6 +459,8 @@ export const ModalIdentifiers = {
EXPORT_USER_DATA_MODAL: 'export_user_data_modal',
UPGRADE_EXPORT_DATA_MODAL: 'upgrade_export_data_modal',
EXPORT_ERROR_MODAL: 'export_error_modal',
CHANNEL_BOOKMARK_DELETE: 'channel_bookmark_delete',
CHANNEL_BOOKMARK_CREATE: 'channel_bookmark_create',
};
export const UserStatuses = {
@@ -584,6 +586,10 @@ export const SocketEvents = {
CHANNEL_DELETED: 'channel_deleted',
CHANNEL_UNARCHIVED: 'channel_restored',
CHANNEL_UPDATED: 'channel_updated',
CHANNEL_BOOKMARK_CREATED: 'channel_bookmark_created',
CHANNEL_BOOKMARK_DELETED: 'channel_bookmark_deleted',
CHANNEL_BOOKMARK_UPDATED: 'channel_bookmark_updated',
CHANNEL_BOOKMARK_SORTED: 'channel_bookmark_sorted',
MULTIPLE_CHANNELS_VIEWED: 'multiple_channels_viewed',
CHANNEL_MEMBER_UPDATED: 'channel_member_updated',
CHANNEL_SCHEME_UPDATED: 'channel_scheme_updated',

Просмотреть файл

@@ -179,6 +179,10 @@ export function getSkin(emoji: Emoji) {
return null;
}
export function trimmedEmojiName(emojiName: string) {
return emojiName.startsWith(':') && emojiName.endsWith(':') ? emojiName.slice(1, -1) : emojiName;
}
export function emojiMatchesSkin(emoji: Emoji, skin: string) {
const emojiSkin = getSkin(emoji);
return !emojiSkin || emojiSkin === skin;

Просмотреть файл

@@ -17,8 +17,8 @@ export const FileSizes = {
};
export function canUploadFiles(config: Partial<ClientConfig>): boolean {
const enableFileAttachments = config.EnableFileAttachments === 'true';
const enableMobileFileUpload = config.EnableMobileFileUpload === 'true';
const enableFileAttachments = isFileAttachmentsEnabled(config);
const enableMobileFileUpload = isMobileFileUploadsEnabled(config);
if (!enableFileAttachments) {
return false;
@@ -35,6 +35,14 @@ export function isFileAttachmentsEnabled(config: Partial<ClientConfig>): boolean
return config.EnableFileAttachments === 'true';
}
export function isMobileFileUploadsEnabled(config: Partial<ClientConfig>): boolean {
return config.EnableMobileFileUpload === 'true';
}
export function isPublicLinksEnabled(config: Partial<ClientConfig>): boolean {
return config.EnablePublicLink === 'true';
}
export function canDownloadFiles(config: Partial<ClientConfig>): boolean {
if (UserAgent.isMobileApp()) {
return config.EnableMobileFileDownload === 'true';

Просмотреть файл

@@ -273,7 +273,7 @@ export default class Renderer extends marked.Renderer {
// Marked helper functions that should probably just be exported
function unescapeHtmlEntities(html: string) {
export function unescapeHtmlEntities(html: string) {
return html.replace(/&([#\w]+);/g, (_, m) => {
const n = m.toLowerCase();
if (n === 'colon') {

Просмотреть файл

@@ -10,6 +10,8 @@ import Constants from 'utils/constants';
import {latinise} from 'utils/latinise';
import * as TextFormatting from 'utils/text_formatting';
import {unescapeHtmlEntities} from './markdown/renderer';
type WindowObject = {
location: {
origin: string;
@@ -274,6 +276,11 @@ export function isPermalinkURL(url: string): boolean {
return isInternalURL(url, siteURL) && (regexp.test(url));
}
export function isValidUrl(url = '') {
const regex = /^https?:\/\//i;
return regex.test(url);
}
export function isStringContainingUrl(text: string): boolean {
const regex = new RegExp('(https?://|www.)');
return regex.test(text);
@@ -326,3 +333,20 @@ export function channelNameToUrl(channelName: string): UrlValidationCheck {
return {url, error: false};
}
export function parseLink(href: string) {
let outHref = href;
if (!href.startsWith('/')) {
const scheme = getScheme(href);
if (!scheme) {
outHref = `http://${outHref}`;
}
}
if (!isUrlSafe(unescapeHtmlEntities(href))) {
return undefined;
}
return outHref;
}

Просмотреть файл

@@ -10,6 +10,7 @@ import type {AppBinding, AppCallRequest, AppCallResponse} from '@mattermost/type
import type {Audit} from '@mattermost/types/audits';
import type {UserAutocomplete, AutocompleteSuggestion} from '@mattermost/types/autocomplete';
import type {Bot, BotPatch} from '@mattermost/types/bots';
import type {ChannelBookmark, ChannelBookmarkCreate, ChannelBookmarkPatch} from '@mattermost/types/channel_bookmarks';
import type {ChannelCategory, OrderedChannelCategories} from '@mattermost/types/channel_categories';
import type {
Channel,
@@ -314,6 +315,12 @@ export default class Client4 {
getChannelSchemeRoute(channelId: string) {
return `${this.getChannelRoute(channelId)}/scheme`;
}
getChannelBookmarksRoute(channelId: string) {
return `${this.getChannelRoute(channelId)}/bookmarks`;
}
getChannelBookmarkRoute(channelId: string, bookmarkId: string) {
return `${this.getChannelRoute(channelId)}/bookmarks/${bookmarkId}`;
}
getChannelCategoriesRoute(userId: string, teamId: string) {
return `${this.getBaseRoute()}/users/${userId}/teams/${teamId}/channels/categories`;
@@ -1605,7 +1612,15 @@ export default class Client4 {
includeDeleted: boolean | undefined,
excludePolicyConstrained: boolean | undefined
): Promise<ChannelsWithTotalCount>;
getAllChannels(page = 0, perPage = PER_PAGE_DEFAULT, notAssociatedToGroup = '', excludeDefaultChannels = false, includeTotalCount = false, includeDeleted = false, excludePolicyConstrained = false) {
getAllChannels(
page = 0,
perPage = PER_PAGE_DEFAULT,
notAssociatedToGroup = '',
excludeDefaultChannels = false,
includeTotalCount = false,
includeDeleted = false,
excludePolicyConstrained = false,
) {
const queryData = {
page,
per_page: perPage,
@@ -1961,7 +1976,44 @@ export default class Client4 {
);
};
// Channel Category Routes
// Channel Bookmark Routes
getChannelBookmarks = (channelId: string, bookmarksSince?: number) => {
return this.doFetch<ChannelBookmark[]>(
`${this.getChannelBookmarksRoute(channelId)}${buildQueryString({bookmarks_since: bookmarksSince})}`,
{method: 'get'},
);
};
createChannelBookmark = (channelId: string, channelBookmark: ChannelBookmarkCreate, connectionId: string) => {
return this.doFetch<ChannelBookmark>(
`${this.getChannelBookmarksRoute(channelId)}`,
{method: 'post', body: JSON.stringify(channelBookmark), headers: {'Connection-Id': connectionId}},
);
};
deleteChannelBookmark = (channelId: string, channelBookmarkId: string, connectionId: string) => {
return this.doFetch<ChannelBookmark>(
`${this.getChannelBookmarkRoute(channelId, channelBookmarkId)}`,
{method: 'delete', headers: {'Connection-Id': connectionId}},
);
};
updateChannelBookmark = (channelId: string, channelBookmarkId: string, patch: ChannelBookmarkPatch, connectionId: string) => {
return this.doFetch<{updated: ChannelBookmark; deleted: ChannelBookmark}>(
`${this.getChannelBookmarkRoute(channelId, channelBookmarkId)}`,
{method: 'PATCH', body: JSON.stringify(patch), headers: {'Connection-Id': connectionId}},
);
};
updateChannelBookmarkSortOrder = (channelId: string, channelBookmarkId: string, newOrder: number, connectionId: string) => {
return this.doFetch<ChannelBookmark[]>(
`${this.getChannelBookmarksRoute(channelId)}/${channelBookmarkId}/sort_order`,
{method: 'post', body: JSON.stringify(newOrder), headers: {'Connection-Id': connectionId}},
);
};
// Channel Category Routes
getChannelCategories = (userId: string, teamId: string) => {
return this.doFetch<OrderedChannelCategories>(
@@ -2383,7 +2435,7 @@ export default class Client4 {
return url;
}
uploadFile = (fileFormData: any) => {
uploadFile = (fileFormData: any, isBookmark?: boolean) => {
this.trackEvent('api', 'api_files_upload');
const request: any = {
method: 'post',
@@ -2391,7 +2443,7 @@ export default class Client4 {
};
return this.doFetch<FileUploadResponse>(
`${this.getFilesRoute()}`,
`${this.getFilesRoute()}${buildQueryString({bookmark: isBookmark})}`,
request,
);
};

Просмотреть файл

@@ -0,0 +1,57 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Channel} from './channels';
import type {FileInfo} from './files';
import type {IDMappedObjects} from './utilities';
type ChannelBookmarkType = 'link' | 'file';
export type ChannelBookmark = {
id: string;
create_at: number;
update_at: number;
delete_at: number;
channel_id: string;
owner_id: string;
file_id?: string;
file?: FileInfo;
display_name: string;
sort_order: number;
link_url?: string;
image_url?: string;
emoji?: string;
type: ChannelBookmarkType;
original_id?: string;
parent_id?: string;
}
export type ChannelBookmarkCreate = {
display_name: string;
image_url?: string;
emoji?: string;
type: ChannelBookmarkType;
} & ({
type: 'link';
link_url: string;
} | {
type: 'file';
file_id: string;
})
export type ChannelBookmarkPatch = {
file_id?: string;
display_name?: string;
sort_order?: number;
link_url?: string;
image_url?: string;
emoji?: string;
}
export type ChannelBookmarkWithFileInfo = ChannelBookmark & {
file: FileInfo;
}
export type ChannelBookmarksState = {
byChannelId: {[channelId: Channel['id']]: IDMappedObjects<ChannelBookmark>};
}

Просмотреть файл

@@ -20,6 +20,7 @@ export type Options = {
url?: string;
credentials?: 'omit' | 'same-origin' | 'include';
body?: any;
signal?: RequestInit['signal'];
ignoreStatus?: boolean; /** If true, status codes > 300 are ignored and don't cause an error */
};

Просмотреть файл

@@ -4,6 +4,7 @@
import type {AdminState} from './admin';
import type {AppsState} from './apps';
import type {Bot} from './bots';
import type {ChannelBookmarksState} from './channel_bookmarks';
import type {ChannelCategoriesState} from './channel_categories';
import type {ChannelsState} from './channels';
import type {CloudState, CloudUsage} from './cloud';
@@ -38,6 +39,7 @@ export type GlobalState = {
limits: LimitsState;
teams: TeamsState;
channels: ChannelsState;
channelBookmarks: ChannelBookmarksState;
posts: PostsState;
threads: ThreadsState;
bots: {