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>
Этот коммит содержится в:
коммит произвёл
GitHub
родитель
a5d263f26c
Коммит
f12eb75d25
@@ -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,
|
mark_unread: NotificationLevels.MENTION,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
39
webapp/channels/src/actions/channel_bookmarks.ts
Обычный файл
39
webapp/channels/src/actions/channel_bookmarks.ts
Обычный файл
@@ -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;
|
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) => {
|
return (dispatch, getState) => {
|
||||||
dispatch({type: FileTypes.UPLOAD_FILES_REQUEST});
|
dispatch({type: FileTypes.UPLOAD_FILES_REQUEST});
|
||||||
|
|
||||||
|
let url = Client4.getFilesRoute();
|
||||||
|
if (isBookmark) {
|
||||||
|
url += '?bookmark=true';
|
||||||
|
}
|
||||||
|
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
|
|
||||||
xhr.open('POST', Client4.getFilesRoute(), true);
|
xhr.open('POST', url, true);
|
||||||
|
|
||||||
const client4Headers = Client4.getOptions({method: 'POST'}).headers;
|
const client4Headers = Client4.getOptions({method: 'POST'}).headers;
|
||||||
Object.keys(client4Headers).forEach((client4Header) => {
|
Object.keys(client4Headers).forEach((client4Header) => {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
AppsTypes,
|
AppsTypes,
|
||||||
CloudTypes,
|
CloudTypes,
|
||||||
HostedCustomerTypes,
|
HostedCustomerTypes,
|
||||||
|
ChannelBookmarkTypes,
|
||||||
} from 'mattermost-redux/action_types';
|
} from 'mattermost-redux/action_types';
|
||||||
import {getStandardAnalytics} from 'mattermost-redux/actions/admin';
|
import {getStandardAnalytics} from 'mattermost-redux/actions/admin';
|
||||||
import {fetchAppBindings, fetchRHSAppsBindings} from 'mattermost-redux/actions/apps';
|
import {fetchAppBindings, fetchRHSAppsBindings} from 'mattermost-redux/actions/apps';
|
||||||
@@ -432,6 +433,22 @@ export function handleEvent(msg) {
|
|||||||
handleChannelMemberUpdatedEvent(msg);
|
handleChannelMemberUpdatedEvent(msg);
|
||||||
break;
|
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:
|
case SocketEvents.DIRECT_ADDED:
|
||||||
dispatch(handleDirectAddedEvent(msg));
|
dispatch(handleDirectAddedEvent(msg));
|
||||||
break;
|
break;
|
||||||
@@ -1755,3 +1772,50 @@ function handleHostedCustomerSignupProgressUpdated(msg) {
|
|||||||
data: msg.data.progress,
|
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"
|
className="terms-and-policy"
|
||||||
>
|
>
|
||||||
See also
|
See also
|
||||||
<ExternalLink
|
<ForwardRef
|
||||||
href="https://mattermost.com/pl/terms-of-use/"
|
href="https://mattermost.com/pl/terms-of-use/"
|
||||||
id="privacyLink"
|
id="privacyLink"
|
||||||
location="license_settings"
|
location="license_settings"
|
||||||
>
|
>
|
||||||
Enterprise Edition Terms of Use
|
Enterprise Edition Terms of Use
|
||||||
</ExternalLink>
|
</ForwardRef>
|
||||||
and
|
and
|
||||||
<ExternalLink
|
<ForwardRef
|
||||||
href="https://mattermost.com/pl/privacy-policy/"
|
href="https://mattermost.com/pl/privacy-policy/"
|
||||||
id="privacyLink"
|
id="privacyLink"
|
||||||
location="license_settings"
|
location="license_settings"
|
||||||
>
|
>
|
||||||
Privacy Policy
|
Privacy Policy
|
||||||
</ExternalLink>
|
</ForwardRef>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -167,21 +167,21 @@ exports[`components/admin_console/license_settings/LicenseSettings load screen w
|
|||||||
className="terms-and-policy"
|
className="terms-and-policy"
|
||||||
>
|
>
|
||||||
See also
|
See also
|
||||||
<ExternalLink
|
<ForwardRef
|
||||||
href="https://mattermost.com/pl/terms-of-use/"
|
href="https://mattermost.com/pl/terms-of-use/"
|
||||||
id="privacyLink"
|
id="privacyLink"
|
||||||
location="license_settings"
|
location="license_settings"
|
||||||
>
|
>
|
||||||
Enterprise Edition Terms of Use
|
Enterprise Edition Terms of Use
|
||||||
</ExternalLink>
|
</ForwardRef>
|
||||||
and
|
and
|
||||||
<ExternalLink
|
<ForwardRef
|
||||||
href="https://mattermost.com/pl/privacy-policy/"
|
href="https://mattermost.com/pl/privacy-policy/"
|
||||||
id="privacyLink"
|
id="privacyLink"
|
||||||
location="license_settings"
|
location="license_settings"
|
||||||
>
|
>
|
||||||
Privacy Policy
|
Privacy Policy
|
||||||
</ExternalLink>
|
</ForwardRef>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -285,21 +285,21 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
|
|||||||
className="terms-and-policy"
|
className="terms-and-policy"
|
||||||
>
|
>
|
||||||
See also
|
See also
|
||||||
<ExternalLink
|
<ForwardRef
|
||||||
href="https://mattermost.com/pl/terms-of-use/"
|
href="https://mattermost.com/pl/terms-of-use/"
|
||||||
id="privacyLink"
|
id="privacyLink"
|
||||||
location="license_settings"
|
location="license_settings"
|
||||||
>
|
>
|
||||||
Enterprise Edition Terms of Use
|
Enterprise Edition Terms of Use
|
||||||
</ExternalLink>
|
</ForwardRef>
|
||||||
and
|
and
|
||||||
<ExternalLink
|
<ForwardRef
|
||||||
href="https://mattermost.com/pl/privacy-policy/"
|
href="https://mattermost.com/pl/privacy-policy/"
|
||||||
id="privacyLink"
|
id="privacyLink"
|
||||||
location="license_settings"
|
location="license_settings"
|
||||||
>
|
>
|
||||||
Privacy Policy
|
Privacy Policy
|
||||||
</ExternalLink>
|
</ForwardRef>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -328,13 +328,13 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
|
|||||||
className="compare-plans-text"
|
className="compare-plans-text"
|
||||||
>
|
>
|
||||||
Curious about upgrading?
|
Curious about upgrading?
|
||||||
<ExternalLink
|
<ForwardRef
|
||||||
href="https://mattermost.com/pl/pricing/"
|
href="https://mattermost.com/pl/pricing/"
|
||||||
id="privacyLink"
|
id="privacyLink"
|
||||||
location="license_settings"
|
location="license_settings"
|
||||||
>
|
>
|
||||||
Compare Plans
|
Compare Plans
|
||||||
</ExternalLink>
|
</ForwardRef>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -420,21 +420,21 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
|
|||||||
className="terms-and-policy"
|
className="terms-and-policy"
|
||||||
>
|
>
|
||||||
See also
|
See also
|
||||||
<ExternalLink
|
<ForwardRef
|
||||||
href="https://mattermost.com/pl/terms-of-use/"
|
href="https://mattermost.com/pl/terms-of-use/"
|
||||||
id="privacyLink"
|
id="privacyLink"
|
||||||
location="license_settings"
|
location="license_settings"
|
||||||
>
|
>
|
||||||
Enterprise Edition Terms of Use
|
Enterprise Edition Terms of Use
|
||||||
</ExternalLink>
|
</ForwardRef>
|
||||||
and
|
and
|
||||||
<ExternalLink
|
<ForwardRef
|
||||||
href="https://mattermost.com/pl/privacy-policy/"
|
href="https://mattermost.com/pl/privacy-policy/"
|
||||||
id="privacyLink"
|
id="privacyLink"
|
||||||
location="license_settings"
|
location="license_settings"
|
||||||
>
|
>
|
||||||
Privacy Policy
|
Privacy Policy
|
||||||
</ExternalLink>
|
</ForwardRef>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -543,21 +543,21 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
|
|||||||
className="terms-and-policy"
|
className="terms-and-policy"
|
||||||
>
|
>
|
||||||
See also
|
See also
|
||||||
<ExternalLink
|
<ForwardRef
|
||||||
href="https://mattermost.com/pl/terms-of-use/"
|
href="https://mattermost.com/pl/terms-of-use/"
|
||||||
id="privacyLink"
|
id="privacyLink"
|
||||||
location="license_settings"
|
location="license_settings"
|
||||||
>
|
>
|
||||||
Enterprise Edition Terms of Use
|
Enterprise Edition Terms of Use
|
||||||
</ExternalLink>
|
</ForwardRef>
|
||||||
and
|
and
|
||||||
<ExternalLink
|
<ForwardRef
|
||||||
href="https://mattermost.com/pl/privacy-policy/"
|
href="https://mattermost.com/pl/privacy-policy/"
|
||||||
id="privacyLink"
|
id="privacyLink"
|
||||||
location="license_settings"
|
location="license_settings"
|
||||||
>
|
>
|
||||||
Privacy Policy
|
Privacy Policy
|
||||||
</ExternalLink>
|
</ForwardRef>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -666,21 +666,21 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
|
|||||||
className="terms-and-policy"
|
className="terms-and-policy"
|
||||||
>
|
>
|
||||||
See also
|
See also
|
||||||
<ExternalLink
|
<ForwardRef
|
||||||
href="https://mattermost.com/pl/terms-of-use/"
|
href="https://mattermost.com/pl/terms-of-use/"
|
||||||
id="privacyLink"
|
id="privacyLink"
|
||||||
location="license_settings"
|
location="license_settings"
|
||||||
>
|
>
|
||||||
Enterprise Edition Terms of Use
|
Enterprise Edition Terms of Use
|
||||||
</ExternalLink>
|
</ForwardRef>
|
||||||
and
|
and
|
||||||
<ExternalLink
|
<ForwardRef
|
||||||
href="https://mattermost.com/pl/privacy-policy/"
|
href="https://mattermost.com/pl/privacy-policy/"
|
||||||
id="privacyLink"
|
id="privacyLink"
|
||||||
location="license_settings"
|
location="license_settings"
|
||||||
>
|
>
|
||||||
Privacy Policy
|
Privacy Policy
|
||||||
</ExternalLink>
|
</ForwardRef>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -789,21 +789,21 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
|
|||||||
className="terms-and-policy"
|
className="terms-and-policy"
|
||||||
>
|
>
|
||||||
See also
|
See also
|
||||||
<ExternalLink
|
<ForwardRef
|
||||||
href="https://mattermost.com/pl/terms-of-use/"
|
href="https://mattermost.com/pl/terms-of-use/"
|
||||||
id="privacyLink"
|
id="privacyLink"
|
||||||
location="license_settings"
|
location="license_settings"
|
||||||
>
|
>
|
||||||
Enterprise Edition Terms of Use
|
Enterprise Edition Terms of Use
|
||||||
</ExternalLink>
|
</ForwardRef>
|
||||||
and
|
and
|
||||||
<ExternalLink
|
<ForwardRef
|
||||||
href="https://mattermost.com/pl/privacy-policy/"
|
href="https://mattermost.com/pl/privacy-policy/"
|
||||||
id="privacyLink"
|
id="privacyLink"
|
||||||
location="license_settings"
|
location="license_settings"
|
||||||
>
|
>
|
||||||
Privacy Policy
|
Privacy Policy
|
||||||
</ExternalLink>
|
</ForwardRef>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -912,21 +912,21 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
|
|||||||
className="terms-and-policy"
|
className="terms-and-policy"
|
||||||
>
|
>
|
||||||
See also
|
See also
|
||||||
<ExternalLink
|
<ForwardRef
|
||||||
href="https://mattermost.com/pl/terms-of-use/"
|
href="https://mattermost.com/pl/terms-of-use/"
|
||||||
id="privacyLink"
|
id="privacyLink"
|
||||||
location="license_settings"
|
location="license_settings"
|
||||||
>
|
>
|
||||||
Enterprise Edition Terms of Use
|
Enterprise Edition Terms of Use
|
||||||
</ExternalLink>
|
</ForwardRef>
|
||||||
and
|
and
|
||||||
<ExternalLink
|
<ForwardRef
|
||||||
href="https://mattermost.com/pl/privacy-policy/"
|
href="https://mattermost.com/pl/privacy-policy/"
|
||||||
id="privacyLink"
|
id="privacyLink"
|
||||||
location="license_settings"
|
location="license_settings"
|
||||||
>
|
>
|
||||||
Privacy Policy
|
Privacy Policy
|
||||||
</ExternalLink>
|
</ForwardRef>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -1044,13 +1044,13 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
|
|||||||
className="compare-plans-text"
|
className="compare-plans-text"
|
||||||
>
|
>
|
||||||
Curious about upgrading?
|
Curious about upgrading?
|
||||||
<ExternalLink
|
<ForwardRef
|
||||||
href="https://mattermost.com/pl/pricing/"
|
href="https://mattermost.com/pl/pricing/"
|
||||||
id="privacyLink"
|
id="privacyLink"
|
||||||
location="license_settings"
|
location="license_settings"
|
||||||
>
|
>
|
||||||
Compare Plans
|
Compare Plans
|
||||||
</ExternalLink>
|
</ForwardRef>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1127,21 +1127,21 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
|
|||||||
className="terms-and-policy"
|
className="terms-and-policy"
|
||||||
>
|
>
|
||||||
See also
|
See also
|
||||||
<ExternalLink
|
<ForwardRef
|
||||||
href="https://mattermost.com/pl/terms-of-use/"
|
href="https://mattermost.com/pl/terms-of-use/"
|
||||||
id="privacyLink"
|
id="privacyLink"
|
||||||
location="license_settings"
|
location="license_settings"
|
||||||
>
|
>
|
||||||
Enterprise Edition Terms of Use
|
Enterprise Edition Terms of Use
|
||||||
</ExternalLink>
|
</ForwardRef>
|
||||||
and
|
and
|
||||||
<ExternalLink
|
<ForwardRef
|
||||||
href="https://mattermost.com/pl/privacy-policy/"
|
href="https://mattermost.com/pl/privacy-policy/"
|
||||||
id="privacyLink"
|
id="privacyLink"
|
||||||
location="license_settings"
|
location="license_settings"
|
||||||
>
|
>
|
||||||
Privacy Policy
|
Privacy Policy
|
||||||
</ExternalLink>
|
</ForwardRef>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -1156,13 +1156,13 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
|
|||||||
className="compare-plans-text"
|
className="compare-plans-text"
|
||||||
>
|
>
|
||||||
Curious about upgrading?
|
Curious about upgrading?
|
||||||
<ExternalLink
|
<ForwardRef
|
||||||
href="https://mattermost.com/pl/pricing/"
|
href="https://mattermost.com/pl/pricing/"
|
||||||
id="privacyLink"
|
id="privacyLink"
|
||||||
location="license_settings"
|
location="license_settings"
|
||||||
>
|
>
|
||||||
Compare Plans
|
Compare Plans
|
||||||
</ExternalLink>
|
</ForwardRef>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1239,21 +1239,21 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
|
|||||||
className="terms-and-policy"
|
className="terms-and-policy"
|
||||||
>
|
>
|
||||||
See also
|
See also
|
||||||
<ExternalLink
|
<ForwardRef
|
||||||
href="https://mattermost.com/pl/terms-of-use/"
|
href="https://mattermost.com/pl/terms-of-use/"
|
||||||
id="privacyLink"
|
id="privacyLink"
|
||||||
location="license_settings"
|
location="license_settings"
|
||||||
>
|
>
|
||||||
Enterprise Edition Terms of Use
|
Enterprise Edition Terms of Use
|
||||||
</ExternalLink>
|
</ForwardRef>
|
||||||
and
|
and
|
||||||
<ExternalLink
|
<ForwardRef
|
||||||
href="https://mattermost.com/pl/privacy-policy/"
|
href="https://mattermost.com/pl/privacy-policy/"
|
||||||
id="privacyLink"
|
id="privacyLink"
|
||||||
location="license_settings"
|
location="license_settings"
|
||||||
>
|
>
|
||||||
Privacy Policy
|
Privacy Policy
|
||||||
</ExternalLink>
|
</ForwardRef>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -1268,13 +1268,13 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
|
|||||||
className="compare-plans-text"
|
className="compare-plans-text"
|
||||||
>
|
>
|
||||||
Curious about upgrading?
|
Curious about upgrading?
|
||||||
<ExternalLink
|
<ForwardRef
|
||||||
href="https://mattermost.com/pl/pricing/"
|
href="https://mattermost.com/pl/pricing/"
|
||||||
id="privacyLink"
|
id="privacyLink"
|
||||||
location="license_settings"
|
location="license_settings"
|
||||||
>
|
>
|
||||||
Compare Plans
|
Compare Plans
|
||||||
</ExternalLink>
|
</ForwardRef>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1346,21 +1346,21 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
|
|||||||
className="terms-and-policy"
|
className="terms-and-policy"
|
||||||
>
|
>
|
||||||
See also
|
See also
|
||||||
<ExternalLink
|
<ForwardRef
|
||||||
href="https://mattermost.com/pl/terms-of-use/"
|
href="https://mattermost.com/pl/terms-of-use/"
|
||||||
id="privacyLink"
|
id="privacyLink"
|
||||||
location="license_settings"
|
location="license_settings"
|
||||||
>
|
>
|
||||||
Enterprise Edition Terms of Use
|
Enterprise Edition Terms of Use
|
||||||
</ExternalLink>
|
</ForwardRef>
|
||||||
and
|
and
|
||||||
<ExternalLink
|
<ForwardRef
|
||||||
href="https://mattermost.com/pl/privacy-policy/"
|
href="https://mattermost.com/pl/privacy-policy/"
|
||||||
id="privacyLink"
|
id="privacyLink"
|
||||||
location="license_settings"
|
location="license_settings"
|
||||||
>
|
>
|
||||||
Privacy Policy
|
Privacy Policy
|
||||||
</ExternalLink>
|
</ForwardRef>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -1448,21 +1448,21 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
|
|||||||
className="terms-and-policy"
|
className="terms-and-policy"
|
||||||
>
|
>
|
||||||
See also
|
See also
|
||||||
<ExternalLink
|
<ForwardRef
|
||||||
href="https://mattermost.com/pl/terms-of-use/"
|
href="https://mattermost.com/pl/terms-of-use/"
|
||||||
id="privacyLink"
|
id="privacyLink"
|
||||||
location="license_settings"
|
location="license_settings"
|
||||||
>
|
>
|
||||||
Enterprise Edition Terms of Use
|
Enterprise Edition Terms of Use
|
||||||
</ExternalLink>
|
</ForwardRef>
|
||||||
and
|
and
|
||||||
<ExternalLink
|
<ForwardRef
|
||||||
href="https://mattermost.com/pl/privacy-policy/"
|
href="https://mattermost.com/pl/privacy-policy/"
|
||||||
id="privacyLink"
|
id="privacyLink"
|
||||||
location="license_settings"
|
location="license_settings"
|
||||||
>
|
>
|
||||||
Privacy Policy
|
Privacy Policy
|
||||||
</ExternalLink>
|
</ForwardRef>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -1486,13 +1486,13 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
|
|||||||
className="compare-plans-text"
|
className="compare-plans-text"
|
||||||
>
|
>
|
||||||
Curious about upgrading?
|
Curious about upgrading?
|
||||||
<ExternalLink
|
<ForwardRef
|
||||||
href="https://mattermost.com/pl/pricing/"
|
href="https://mattermost.com/pl/pricing/"
|
||||||
id="privacyLink"
|
id="privacyLink"
|
||||||
location="license_settings"
|
location="license_settings"
|
||||||
>
|
>
|
||||||
Compare Plans
|
Compare Plans
|
||||||
</ExternalLink>
|
</ForwardRef>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1554,21 +1554,21 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
|
|||||||
className="terms-and-policy"
|
className="terms-and-policy"
|
||||||
>
|
>
|
||||||
See also
|
See also
|
||||||
<ExternalLink
|
<ForwardRef
|
||||||
href="https://mattermost.com/pl/terms-of-use/"
|
href="https://mattermost.com/pl/terms-of-use/"
|
||||||
id="privacyLink"
|
id="privacyLink"
|
||||||
location="license_settings"
|
location="license_settings"
|
||||||
>
|
>
|
||||||
Enterprise Edition Terms of Use
|
Enterprise Edition Terms of Use
|
||||||
</ExternalLink>
|
</ForwardRef>
|
||||||
and
|
and
|
||||||
<ExternalLink
|
<ForwardRef
|
||||||
href="https://mattermost.com/pl/privacy-policy/"
|
href="https://mattermost.com/pl/privacy-policy/"
|
||||||
id="privacyLink"
|
id="privacyLink"
|
||||||
location="license_settings"
|
location="license_settings"
|
||||||
>
|
>
|
||||||
Privacy Policy
|
Privacy Policy
|
||||||
</ExternalLink>
|
</ForwardRef>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -1583,13 +1583,13 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
|
|||||||
className="compare-plans-text"
|
className="compare-plans-text"
|
||||||
>
|
>
|
||||||
Curious about upgrading?
|
Curious about upgrading?
|
||||||
<ExternalLink
|
<ForwardRef
|
||||||
href="https://mattermost.com/pl/pricing/"
|
href="https://mattermost.com/pl/pricing/"
|
||||||
id="privacyLink"
|
id="privacyLink"
|
||||||
location="license_settings"
|
location="license_settings"
|
||||||
>
|
>
|
||||||
Compare Plans
|
Compare Plans
|
||||||
</ExternalLink>
|
</ForwardRef>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1659,21 +1659,21 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
|
|||||||
className="terms-and-policy"
|
className="terms-and-policy"
|
||||||
>
|
>
|
||||||
See also
|
See also
|
||||||
<ExternalLink
|
<ForwardRef
|
||||||
href="https://mattermost.com/pl/terms-of-use/"
|
href="https://mattermost.com/pl/terms-of-use/"
|
||||||
id="privacyLink"
|
id="privacyLink"
|
||||||
location="license_settings"
|
location="license_settings"
|
||||||
>
|
>
|
||||||
Enterprise Edition Terms of Use
|
Enterprise Edition Terms of Use
|
||||||
</ExternalLink>
|
</ForwardRef>
|
||||||
and
|
and
|
||||||
<ExternalLink
|
<ForwardRef
|
||||||
href="https://mattermost.com/pl/privacy-policy/"
|
href="https://mattermost.com/pl/privacy-policy/"
|
||||||
id="privacyLink"
|
id="privacyLink"
|
||||||
location="license_settings"
|
location="license_settings"
|
||||||
>
|
>
|
||||||
Privacy Policy
|
Privacy Policy
|
||||||
</ExternalLink>
|
</ForwardRef>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -1783,21 +1783,21 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
|
|||||||
className="terms-and-policy"
|
className="terms-and-policy"
|
||||||
>
|
>
|
||||||
See also
|
See also
|
||||||
<ExternalLink
|
<ForwardRef
|
||||||
href="https://mattermost.com/pl/terms-of-use/"
|
href="https://mattermost.com/pl/terms-of-use/"
|
||||||
id="privacyLink"
|
id="privacyLink"
|
||||||
location="license_settings"
|
location="license_settings"
|
||||||
>
|
>
|
||||||
Enterprise Edition Terms of Use
|
Enterprise Edition Terms of Use
|
||||||
</ExternalLink>
|
</ForwardRef>
|
||||||
and
|
and
|
||||||
<ExternalLink
|
<ForwardRef
|
||||||
href="https://mattermost.com/pl/privacy-policy/"
|
href="https://mattermost.com/pl/privacy-policy/"
|
||||||
id="privacyLink"
|
id="privacyLink"
|
||||||
location="license_settings"
|
location="license_settings"
|
||||||
>
|
>
|
||||||
Privacy Policy
|
Privacy Policy
|
||||||
</ExternalLink>
|
</ForwardRef>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ exports[`components/OpenIdConvert should match snapshot 1`] = `
|
|||||||
id="admin.openIdConvert.text"
|
id="admin.openIdConvert.text"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<ExternalLink
|
<ForwardRef
|
||||||
className="btn-secondary"
|
className="btn-secondary"
|
||||||
data-testid="openIdLearnMore"
|
data-testid="openIdLearnMore"
|
||||||
href="https://www.mattermost.com/default-openid-docs"
|
href="https://www.mattermost.com/default-openid-docs"
|
||||||
@@ -45,7 +45,7 @@ exports[`components/OpenIdConvert should match snapshot 1`] = `
|
|||||||
defaultMessage="Learn more"
|
defaultMessage="Learn more"
|
||||||
id="admin.openIdConvert.help"
|
id="admin.openIdConvert.help"
|
||||||
/>
|
/>
|
||||||
</ExternalLink>
|
</ForwardRef>
|
||||||
<div
|
<div
|
||||||
className="error-message"
|
className="error-message"
|
||||||
data-testid="errorMessage"
|
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;
|
||||||
|
`;
|
||||||
245
webapp/channels/src/components/channel_bookmarks/bookmark_item.tsx
Обычный файл
245
webapp/channels/src/components/channel_bookmarks/bookmark_item.tsx
Обычный файл
@@ -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;
|
||||||
4
webapp/channels/src/components/channel_bookmarks/index.ts
Обычный файл
4
webapp/channels/src/components/channel_bookmarks/index.ts
Обычный файл
@@ -0,0 +1,4 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
export {default} from './channel_bookmarks';
|
||||||
135
webapp/channels/src/components/channel_bookmarks/utils.ts
Обычный файл
135
webapp/channels/src/components/channel_bookmarks/utils.ts
Обычный файл
@@ -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"
|
teamUrl="/team"
|
||||||
viewArchivedChannels={false}
|
viewArchivedChannels={false}
|
||||||
/>
|
/>
|
||||||
|
<ChannelBookmarks
|
||||||
|
channelId="channelId"
|
||||||
|
/>
|
||||||
<DeferredRenderWrapper
|
<DeferredRenderWrapper
|
||||||
channelId="channelId"
|
channelId="channelId"
|
||||||
/>
|
/>
|
||||||
@@ -85,6 +88,9 @@ exports[`components/channel_view Should match snapshot if channel is deactivated
|
|||||||
teamUrl="/team"
|
teamUrl="/team"
|
||||||
viewArchivedChannels={false}
|
viewArchivedChannels={false}
|
||||||
/>
|
/>
|
||||||
|
<ChannelBookmarks
|
||||||
|
channelId="channelId"
|
||||||
|
/>
|
||||||
<DeferredRenderWrapper
|
<DeferredRenderWrapper
|
||||||
channelId="channelId"
|
channelId="channelId"
|
||||||
/>
|
/>
|
||||||
@@ -141,6 +147,9 @@ exports[`components/channel_view Should match snapshot with base props 1`] = `
|
|||||||
teamUrl="/team"
|
teamUrl="/team"
|
||||||
viewArchivedChannels={false}
|
viewArchivedChannels={false}
|
||||||
/>
|
/>
|
||||||
|
<ChannelBookmarks
|
||||||
|
channelId="channelId"
|
||||||
|
/>
|
||||||
<DeferredRenderWrapper
|
<DeferredRenderWrapper
|
||||||
channelId="channelId"
|
channelId="channelId"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {FormattedMessage} from 'react-intl';
|
|||||||
import type {RouteComponentProps} from 'react-router-dom';
|
import type {RouteComponentProps} from 'react-router-dom';
|
||||||
|
|
||||||
import AdvancedCreatePost from 'components/advanced_create_post';
|
import AdvancedCreatePost from 'components/advanced_create_post';
|
||||||
|
import ChannelBookmarks from 'components/channel_bookmarks';
|
||||||
import ChannelHeader from 'components/channel_header';
|
import ChannelHeader from 'components/channel_header';
|
||||||
import deferComponentRender from 'components/deferComponentRender';
|
import deferComponentRender from 'components/deferComponentRender';
|
||||||
import FileUploadOverlay from 'components/file_upload_overlay';
|
import FileUploadOverlay from 'components/file_upload_overlay';
|
||||||
@@ -173,9 +174,8 @@ export default class ChannelView extends React.PureComponent<Props, State> {
|
|||||||
className='app__content'
|
className='app__content'
|
||||||
>
|
>
|
||||||
<FileUploadOverlay overlayType='center'/>
|
<FileUploadOverlay overlayType='center'/>
|
||||||
<ChannelHeader
|
<ChannelHeader {...this.props}/>
|
||||||
{...this.props}
|
<ChannelBookmarks channelId={this.props.channelId}/>
|
||||||
/>
|
|
||||||
<DeferredPostView
|
<DeferredPostView
|
||||||
channelId={this.props.channelId}
|
channelId={this.props.channelId}
|
||||||
focusedPostId={this.state.focusedPostId}
|
focusedPostId={this.state.focusedPostId}
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ import AnyTeamPermissionGate from 'components/permissions_gates/any_team_permiss
|
|||||||
interface Props {
|
interface Props {
|
||||||
customEmojisEnabled: boolean;
|
customEmojisEnabled: boolean;
|
||||||
currentTeamName: string;
|
currentTeamName: string;
|
||||||
handleEmojiPickerClose: () => void;
|
onClick: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function EmojiPickerCustomEmojiButton({customEmojisEnabled, currentTeamName, handleEmojiPickerClose}: Props) {
|
function EmojiPickerCustomEmojiButton({customEmojisEnabled, currentTeamName, onClick}: Props) {
|
||||||
if (!customEmojisEnabled) {
|
if (!customEmojisEnabled) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -30,7 +30,7 @@ function EmojiPickerCustomEmojiButton({customEmojisEnabled, currentTeamName, han
|
|||||||
<Link
|
<Link
|
||||||
className='btn btn-tertiary'
|
className='btn btn-tertiary'
|
||||||
to={`/${currentTeamName}/emoji`}
|
to={`/${currentTeamName}/emoji`}
|
||||||
onClick={handleEmojiPickerClose}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='emoji_picker.custom_emoji'
|
id='emoji_picker.custom_emoji'
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export interface Props extends PropsFromRedux {
|
|||||||
onEmojiClick: (emoji: Emoji) => void;
|
onEmojiClick: (emoji: Emoji) => void;
|
||||||
handleFilterChange: (filter: string) => void;
|
handleFilterChange: (filter: string) => void;
|
||||||
handleEmojiPickerClose: () => void;
|
handleEmojiPickerClose: () => void;
|
||||||
|
onAddCustomEmojiClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EmojiPicker = ({
|
const EmojiPicker = ({
|
||||||
@@ -46,6 +47,7 @@ const EmojiPicker = ({
|
|||||||
onEmojiClick,
|
onEmojiClick,
|
||||||
handleFilterChange,
|
handleFilterChange,
|
||||||
handleEmojiPickerClose,
|
handleEmojiPickerClose,
|
||||||
|
onAddCustomEmojiClick,
|
||||||
customEmojisEnabled = false,
|
customEmojisEnabled = false,
|
||||||
customEmojiPage = 0,
|
customEmojiPage = 0,
|
||||||
emojiMap,
|
emojiMap,
|
||||||
@@ -204,6 +206,11 @@ const EmojiPicker = ({
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const onAddCustomEmojiClickInner = useCallback(() => {
|
||||||
|
handleEmojiPickerClose();
|
||||||
|
onAddCustomEmojiClick?.();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [cursorCategory, cursorCategoryIndex, cursorEmojiIndex] = getCursorProperties(cursor.rowIndex, cursor.emojiId, categoryOrEmojisRows as EmojiRow[]);
|
const [cursorCategory, cursorCategoryIndex, cursorEmojiIndex] = getCursorProperties(cursor.rowIndex, cursor.emojiId, categoryOrEmojisRows as EmojiRow[]);
|
||||||
|
|
||||||
function calculateNewCursorForUpArrow(cursorCategory: string, emojiPositions: EmojiPosition[], currentCursorsPositionIndex: number, categories: Categories, focusOnSearchInput: () => void) {
|
function calculateNewCursorForUpArrow(cursorCategory: string, emojiPositions: EmojiPosition[], currentCursorsPositionIndex: number, categories: Categories, focusOnSearchInput: () => void) {
|
||||||
@@ -429,13 +436,11 @@ const EmojiPicker = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className='emoji-picker__footer'>
|
<div className='emoji-picker__footer'>
|
||||||
{areSearchResultsEmpty ? (<div/>) :
|
{areSearchResultsEmpty ? <div/> : <EmojiPickerPreview emoji={cursor.emoji}/>}
|
||||||
(<EmojiPickerPreview emoji={cursor.emoji}/>)
|
|
||||||
}
|
|
||||||
<EmojiPickerCustomEmojiButton
|
<EmojiPickerCustomEmojiButton
|
||||||
currentTeamName={currentTeamName}
|
currentTeamName={currentTeamName}
|
||||||
customEmojisEnabled={customEmojisEnabled}
|
customEmojisEnabled={customEmojisEnabled}
|
||||||
handleEmojiPickerClose={handleEmojiPickerClose}
|
onClick={onAddCustomEmojiClickInner}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
import memoize from 'memoize-one';
|
import memoize from 'memoize-one';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type {ReactNode} from 'react';
|
import type {ComponentProps, ReactNode} from 'react';
|
||||||
import {Overlay} from 'react-bootstrap';
|
import {Overlay} from 'react-bootstrap';
|
||||||
|
|
||||||
import type {Emoji} from '@mattermost/types/emojis';
|
import type {Emoji} from '@mattermost/types/emojis';
|
||||||
@@ -20,9 +20,11 @@ export interface Props extends PropsFromRedux {
|
|||||||
target: () => ReactNode;
|
target: () => ReactNode;
|
||||||
onEmojiClick: (emoji: Emoji) => void;
|
onEmojiClick: (emoji: Emoji) => void;
|
||||||
onGifClick?: (gif: string) => void;
|
onGifClick?: (gif: string) => void;
|
||||||
|
onAddCustomEmojiClick?: () => void;
|
||||||
onHide: () => void;
|
onHide: () => void;
|
||||||
onExited?: () => void;
|
onExited?: () => void;
|
||||||
show: boolean;
|
show: boolean;
|
||||||
|
placement?: ComponentProps<typeof Overlay>['placement'];
|
||||||
topOffset?: number;
|
topOffset?: number;
|
||||||
rightOffset?: number;
|
rightOffset?: number;
|
||||||
leftOffset?: number;
|
leftOffset?: number;
|
||||||
@@ -89,7 +91,7 @@ export default class EmojiPickerOverlay extends React.PureComponent<Props> {
|
|||||||
return (
|
return (
|
||||||
<Overlay
|
<Overlay
|
||||||
show={show}
|
show={show}
|
||||||
placement={placement}
|
placement={this.props.placement ?? placement}
|
||||||
rootClose={!isMobileView}
|
rootClose={!isMobileView}
|
||||||
container={this.props.container}
|
container={this.props.container}
|
||||||
onHide={this.props.onHide}
|
onHide={this.props.onHide}
|
||||||
@@ -105,6 +107,7 @@ export default class EmojiPickerOverlay extends React.PureComponent<Props> {
|
|||||||
rightOffset={calculatedRightOffset}
|
rightOffset={calculatedRightOffset}
|
||||||
topOffset={this.props.topOffset}
|
topOffset={this.props.topOffset}
|
||||||
leftOffset={this.props.leftOffset}
|
leftOffset={this.props.leftOffset}
|
||||||
|
onAddCustomEmojiClick={this.props.onAddCustomEmojiClick}
|
||||||
/>
|
/>
|
||||||
</Overlay>
|
</Overlay>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export interface Props {
|
|||||||
onEmojiClose: () => void;
|
onEmojiClose: () => void;
|
||||||
onEmojiClick: (emoji: Emoji) => void;
|
onEmojiClick: (emoji: Emoji) => void;
|
||||||
onGifClick?: (gif: string) => void;
|
onGifClick?: (gif: string) => void;
|
||||||
|
onAddCustomEmojiClick?: () => void;
|
||||||
enableGifPicker?: boolean;
|
enableGifPicker?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,6 +177,7 @@ export default class EmojiPickerTabs extends PureComponent<Props, State> {
|
|||||||
onEmojiClick={this.props.onEmojiClick}
|
onEmojiClick={this.props.onEmojiClick}
|
||||||
handleFilterChange={this.handleFilterChange}
|
handleFilterChange={this.handleFilterChange}
|
||||||
handleEmojiPickerClose={this.handleEmojiPickerClose}
|
handleEmojiPickerClose={this.handleEmojiPickerClose}
|
||||||
|
onAddCustomEmojiClick={this.props.onAddCustomEmojiClick}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
exports[`components/error_page/ErrorLink should match snapshot 1`] = `
|
exports[`components/error_page/ErrorLink should match snapshot 1`] = `
|
||||||
<ExternalLink
|
<ForwardRef
|
||||||
href="https://docs.mattermost.com/deployment/sso-gitlab.html"
|
href="https://docs.mattermost.com/deployment/sso-gitlab.html"
|
||||||
location="error_link"
|
location="error_link"
|
||||||
>
|
>
|
||||||
@@ -9,5 +9,5 @@ exports[`components/error_page/ErrorLink should match snapshot 1`] = `
|
|||||||
defaultMessage="GitLab"
|
defaultMessage="GitLab"
|
||||||
id="error.oauth_missing_code.gitlab.link"
|
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"
|
href="https://mattermost.com"
|
||||||
location="test"
|
location="test"
|
||||||
>
|
>
|
||||||
@@ -26,6 +26,6 @@ exports[`components/external_link should match snapshot 1`] = `
|
|||||||
>
|
>
|
||||||
Click Me
|
Click Me
|
||||||
</a>
|
</a>
|
||||||
</ExternalLink>
|
</ForwardRef>
|
||||||
</Provider>
|
</Provider>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
/* eslint-disable @mattermost/use-external-link */
|
/* eslint-disable @mattermost/use-external-link */
|
||||||
|
|
||||||
import React from 'react';
|
import React, {forwardRef} from 'react';
|
||||||
import {useSelector} from 'react-redux';
|
import {useSelector} from 'react-redux';
|
||||||
|
|
||||||
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/common';
|
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/common';
|
||||||
@@ -29,7 +29,7 @@ type Props = React.AnchorHTMLAttributes<HTMLAnchorElement> & {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ExternalLink(props: Props) {
|
const ExternalLink = forwardRef<HTMLAnchorElement, Props>((props, ref) => {
|
||||||
const userId = useSelector(getCurrentUserId);
|
const userId = useSelector(getCurrentUserId);
|
||||||
const config = useSelector(getConfig);
|
const config = useSelector(getConfig);
|
||||||
const license = useSelector(getLicense);
|
const license = useSelector(getLicense);
|
||||||
@@ -70,6 +70,7 @@ export default function ExternalLink(props: Props) {
|
|||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
{...props}
|
{...props}
|
||||||
|
ref={ref}
|
||||||
target={props.target || '_blank'}
|
target={props.target || '_blank'}
|
||||||
rel={props.rel || 'noopener noreferrer'}
|
rel={props.rel || 'noopener noreferrer'}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
@@ -78,4 +79,6 @@ export default function ExternalLink(props: Props) {
|
|||||||
{props.children}
|
{props.children}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
export default ExternalLink;
|
||||||
|
|||||||
@@ -37,14 +37,14 @@ exports[`components/file_attachment/FilenameOverlay should match snapshot, stand
|
|||||||
placement="top"
|
placement="top"
|
||||||
title="Download"
|
title="Download"
|
||||||
>
|
>
|
||||||
<ExternalLink
|
<ForwardRef
|
||||||
aria-label="download"
|
aria-label="download"
|
||||||
download="test_filename"
|
download="test_filename"
|
||||||
href="/api/v4/files/thumbnail_id?download=1"
|
href="/api/v4/files/thumbnail_id?download=1"
|
||||||
location="filename_overlay"
|
location="filename_overlay"
|
||||||
>
|
>
|
||||||
test_filename
|
test_filename
|
||||||
</ExternalLink>
|
</ForwardRef>
|
||||||
</WithTooltip>
|
</WithTooltip>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -58,14 +58,14 @@ exports[`components/file_attachment/FilenameOverlay should match snapshot, with
|
|||||||
placement="top"
|
placement="top"
|
||||||
title="Download"
|
title="Download"
|
||||||
>
|
>
|
||||||
<ExternalLink
|
<ForwardRef
|
||||||
aria-label="download"
|
aria-label="download"
|
||||||
download="test_filename"
|
download="test_filename"
|
||||||
href="/api/v4/files/thumbnail_id?download=1"
|
href="/api/v4/files/thumbnail_id?download=1"
|
||||||
location="filename_overlay"
|
location="filename_overlay"
|
||||||
>
|
>
|
||||||
<AttachmentIcon />
|
<AttachmentIcon />
|
||||||
</ExternalLink>
|
</ForwardRef>
|
||||||
</WithTooltip>
|
</WithTooltip>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ interface Props extends PropsFromRedux {
|
|||||||
* Display in compact format
|
* Display in compact format
|
||||||
*/
|
*/
|
||||||
compactDisplay?: boolean;
|
compactDisplay?: boolean;
|
||||||
|
disablePreview?: boolean;
|
||||||
handleFileDropdownOpened?: (open: boolean) => void;
|
handleFileDropdownOpened?: (open: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,6 +191,7 @@ export default function FileAttachment(props: Props) {
|
|||||||
defaultItems.push(
|
defaultItems.push(
|
||||||
<Menu.ItemAction
|
<Menu.ItemAction
|
||||||
data-title='Public Image'
|
data-title='Public Image'
|
||||||
|
key={fileInfo.id + '_publiclinkmenuitem'}
|
||||||
onClick={handleGetPublicLink}
|
onClick={handleGetPublicLink}
|
||||||
ariaLabel={formatMessage({id: 'view_image_popover.publicLink', defaultMessage: 'Get a public link'})}
|
ariaLabel={formatMessage({id: 'view_image_popover.publicLink', defaultMessage: 'Get a public link'})}
|
||||||
text={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}
|
onClick={onAttachmentClick}
|
||||||
>
|
>
|
||||||
{loaded ? (
|
{loaded ? (
|
||||||
<FileThumbnail fileInfo={fileInfo}/>
|
<FileThumbnail
|
||||||
|
fileInfo={fileInfo}
|
||||||
|
disablePreview={props.disablePreview}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className='post-image__load'/>
|
<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 {getFileThumbnailUrl, getFileUrl} from 'mattermost-redux/utils/file_utils';
|
||||||
|
|
||||||
|
import type {FilePreviewInfo} from 'components/file_preview/file_preview';
|
||||||
|
|
||||||
import Constants, {FileTypes} from 'utils/constants';
|
import Constants, {FileTypes} from 'utils/constants';
|
||||||
|
import {getFileTypeFromMime} from 'utils/file_utils';
|
||||||
import {
|
import {
|
||||||
getFileType,
|
getFileType,
|
||||||
getIconClassName,
|
getIconClassName,
|
||||||
isGIFImage,
|
isGIFImage,
|
||||||
} from 'utils/utils';
|
} from 'utils/utils';
|
||||||
|
|
||||||
|
type FilePreviewInfoLimited = Pick<FilePreviewInfo, 'clientId' | 'name' | 'percent' | 'type'>;
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
enableSVGs: boolean;
|
enableSVGs: boolean;
|
||||||
fileInfo: FileInfo;
|
fileInfo: FileInfo | FilePreviewInfo | FilePreviewInfoLimited;
|
||||||
}
|
disablePreview?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
const FileThumbnail = ({
|
const FileThumbnail = ({
|
||||||
fileInfo,
|
fileInfo,
|
||||||
enableSVGs,
|
enableSVGs,
|
||||||
|
disablePreview,
|
||||||
}: Props) => {
|
}: 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 type = FileTypes.OTHER;
|
||||||
let className = 'post-image';
|
if (extension) {
|
||||||
|
type = getFileType(extension);
|
||||||
|
} else if (mimeType) {
|
||||||
|
type = getFileTypeFromMime(mimeType);
|
||||||
|
}
|
||||||
|
|
||||||
if (fileInfo.width < Constants.THUMBNAIL_WIDTH && fileInfo.height < Constants.THUMBNAIL_HEIGHT) {
|
if (id && !disablePreview) {
|
||||||
className += ' small';
|
if (type === FileTypes.IMAGE) {
|
||||||
} else {
|
let className = 'post-image';
|
||||||
className += ' normal';
|
|
||||||
|
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)}/>;
|
return <div className={'file-icon ' + getIconClassName(type)}/>;
|
||||||
|
|||||||
@@ -75,57 +75,6 @@ exports[`components/file_search_result/FileSearchResultItem should match snapsho
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<OverlayTrigger
|
||||||
defaultOverlayShown={false}
|
defaultOverlayShown={false}
|
||||||
delayShow={1000}
|
delayShow={1000}
|
||||||
@@ -242,57 +191,6 @@ exports[`components/file_search_result/FileSearchResultItem should match snapsho
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<OverlayTrigger
|
||||||
defaultOverlayShown={false}
|
defaultOverlayShown={false}
|
||||||
delayShow={1000}
|
delayShow={1000}
|
||||||
@@ -409,57 +307,6 @@ exports[`components/file_search_result/FileSearchResultItem should match snapsho
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<OverlayTrigger
|
||||||
defaultOverlayShown={false}
|
defaultOverlayShown={false}
|
||||||
delayShow={1000}
|
delayShow={1000}
|
||||||
@@ -571,57 +418,6 @@ exports[`components/file_search_result/FileSearchResultItem should match snapsho
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<OverlayTrigger
|
||||||
defaultOverlayShown={false}
|
defaultOverlayShown={false}
|
||||||
delayShow={1000}
|
delayShow={1000}
|
||||||
|
|||||||
@@ -145,43 +145,45 @@ export default class FileSearchResultItem extends React.PureComponent<Props, Sta
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<OverlayTrigger
|
{this.props.fileInfo.post_id && (
|
||||||
delayShow={1000}
|
<OverlayTrigger
|
||||||
placement='top'
|
delayShow={1000}
|
||||||
overlay={
|
placement='top'
|
||||||
<Tooltip id='file-name__tooltip'>
|
overlay={
|
||||||
{localizeMessage('file_search_result_item.more_actions', 'More Actions')}
|
<Tooltip id='file-name__tooltip'>
|
||||||
</Tooltip>
|
{localizeMessage('file_search_result_item.more_actions', 'More Actions')}
|
||||||
}
|
</Tooltip>
|
||||||
>
|
}
|
||||||
<MenuWrapper
|
|
||||||
onToggle={this.keepOpen}
|
|
||||||
stopPropagationOnToggle={true}
|
|
||||||
>
|
>
|
||||||
<a
|
<MenuWrapper
|
||||||
href='#'
|
onToggle={this.keepOpen}
|
||||||
className='action-icon dots-icon'
|
stopPropagationOnToggle={true}
|
||||||
>
|
>
|
||||||
<i className='icon icon-dots-vertical'/>
|
<a
|
||||||
</a>
|
href='#'
|
||||||
<Menu
|
className='action-icon dots-icon'
|
||||||
ariaLabel={'file menu'}
|
>
|
||||||
openLeft={true}
|
<i className='icon icon-dots-vertical'/>
|
||||||
>
|
</a>
|
||||||
<Menu.ItemAction
|
<Menu
|
||||||
onClick={this.jumpToConv}
|
ariaLabel={'file menu'}
|
||||||
ariaLabel={localizeMessage('file_search_result_item.open_in_channel', 'Open in channel')}
|
openLeft={true}
|
||||||
text={localizeMessage('file_search_result_item.open_in_channel', 'Open in channel')}
|
>
|
||||||
/>
|
<Menu.ItemAction
|
||||||
<Menu.ItemAction
|
onClick={this.jumpToConv}
|
||||||
onClick={this.copyLink}
|
ariaLabel={localizeMessage('file_search_result_item.open_in_channel', 'Open in channel')}
|
||||||
ariaLabel={localizeMessage('file_search_result_item.copy_link', 'Copy link')}
|
text={localizeMessage('file_search_result_item.open_in_channel', 'Open in channel')}
|
||||||
text={localizeMessage('file_search_result_item.copy_link', 'Copy link')}
|
/>
|
||||||
/>
|
<Menu.ItemAction
|
||||||
{this.renderPluginItems()}
|
onClick={this.copyLink}
|
||||||
</Menu>
|
ariaLabel={localizeMessage('file_search_result_item.copy_link', 'Copy link')}
|
||||||
</MenuWrapper>
|
text={localizeMessage('file_search_result_item.copy_link', 'Copy link')}
|
||||||
</OverlayTrigger>
|
/>
|
||||||
|
{this.renderPluginItems()}
|
||||||
|
</Menu>
|
||||||
|
</MenuWrapper>
|
||||||
|
</OverlayTrigger>
|
||||||
|
)}
|
||||||
<OverlayTrigger
|
<OverlayTrigger
|
||||||
delayShow={1000}
|
delayShow={1000}
|
||||||
placement='top'
|
placement='top'
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ exports[`components/integrations/AbstractCommand should match snapshot 1`] = `
|
|||||||
id="add_command.trigger.helpReserved"
|
id="add_command.trigger.helpReserved"
|
||||||
values={
|
values={
|
||||||
Object {
|
Object {
|
||||||
"link": <ExternalLink
|
"link": <ForwardRef
|
||||||
href="https://mattermost.com/pl/custom-slash-commands"
|
href="https://mattermost.com/pl/custom-slash-commands"
|
||||||
location="abstract_command"
|
location="abstract_command"
|
||||||
>
|
>
|
||||||
@@ -147,7 +147,7 @@ exports[`components/integrations/AbstractCommand should match snapshot 1`] = `
|
|||||||
defaultMessage="See built-in slash commands"
|
defaultMessage="See built-in slash commands"
|
||||||
id="add_command.trigger.helpReservedLinkText"
|
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"
|
id="add_command.trigger.helpReserved"
|
||||||
values={
|
values={
|
||||||
Object {
|
Object {
|
||||||
"link": <ExternalLink
|
"link": <ForwardRef
|
||||||
href="https://mattermost.com/pl/custom-slash-commands"
|
href="https://mattermost.com/pl/custom-slash-commands"
|
||||||
location="abstract_command"
|
location="abstract_command"
|
||||||
>
|
>
|
||||||
@@ -596,7 +596,7 @@ exports[`components/integrations/AbstractCommand should match snapshot when head
|
|||||||
defaultMessage="See built-in slash commands"
|
defaultMessage="See built-in slash commands"
|
||||||
id="add_command.trigger.helpReservedLinkText"
|
id="add_command.trigger.helpReservedLinkText"
|
||||||
/>
|
/>
|
||||||
</ExternalLink>,
|
</ForwardRef>,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -1037,7 +1037,7 @@ exports[`components/integrations/AbstractCommand should match snapshot, displays
|
|||||||
id="add_command.trigger.helpReserved"
|
id="add_command.trigger.helpReserved"
|
||||||
values={
|
values={
|
||||||
Object {
|
Object {
|
||||||
"link": <ExternalLink
|
"link": <ForwardRef
|
||||||
href="https://mattermost.com/pl/custom-slash-commands"
|
href="https://mattermost.com/pl/custom-slash-commands"
|
||||||
location="abstract_command"
|
location="abstract_command"
|
||||||
>
|
>
|
||||||
@@ -1045,7 +1045,7 @@ exports[`components/integrations/AbstractCommand should match snapshot, displays
|
|||||||
defaultMessage="See built-in slash commands"
|
defaultMessage="See built-in slash commands"
|
||||||
id="add_command.trigger.helpReservedLinkText"
|
id="add_command.trigger.helpReservedLinkText"
|
||||||
/>
|
/>
|
||||||
</ExternalLink>,
|
</ForwardRef>,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -291,7 +291,7 @@ callbackUrl2.com
|
|||||||
id="add_outgoing_webhook.callbackUrls.help"
|
id="add_outgoing_webhook.callbackUrls.help"
|
||||||
values={
|
values={
|
||||||
Object {
|
Object {
|
||||||
"link": <ExternalLink
|
"link": <ForwardRef
|
||||||
href="https://mattermost.com/pl/default-allow-untrusted-internal-connections"
|
href="https://mattermost.com/pl/default-allow-untrusted-internal-connections"
|
||||||
location="abstract_outgoing_webhook"
|
location="abstract_outgoing_webhook"
|
||||||
>
|
>
|
||||||
@@ -299,7 +299,7 @@ callbackUrl2.com
|
|||||||
defaultMessage="trusted internal connection"
|
defaultMessage="trusted internal connection"
|
||||||
id="add_outgoing_webhook.callbackUrls.helpLinkText"
|
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"
|
id="installed_oauth_apps.help"
|
||||||
values={
|
values={
|
||||||
Object {
|
Object {
|
||||||
"appDirectory": <ExternalLink
|
"appDirectory": <ForwardRef
|
||||||
href="https://mattermost.com/marketplace/"
|
href="https://mattermost.com/marketplace/"
|
||||||
location="installed_oauth_apps"
|
location="installed_oauth_apps"
|
||||||
>
|
>
|
||||||
@@ -37,8 +37,8 @@ exports[`components/integrations/InstalledOAuthApps should match snapshot 1`] =
|
|||||||
defaultMessage="App Directory"
|
defaultMessage="App Directory"
|
||||||
id="installed_oauth_apps.help.appDirectory"
|
id="installed_oauth_apps.help.appDirectory"
|
||||||
/>
|
/>
|
||||||
</ExternalLink>,
|
</ForwardRef>,
|
||||||
"oauthApplications": <ExternalLink
|
"oauthApplications": <ForwardRef
|
||||||
href="https://mattermost.com/pl/setup-oauth-2.0"
|
href="https://mattermost.com/pl/setup-oauth-2.0"
|
||||||
location="installed_oauth_apps"
|
location="installed_oauth_apps"
|
||||||
>
|
>
|
||||||
@@ -46,7 +46,7 @@ exports[`components/integrations/InstalledOAuthApps should match snapshot 1`] =
|
|||||||
defaultMessage="OAuth 2.0 applications"
|
defaultMessage="OAuth 2.0 applications"
|
||||||
id="installed_oauth_apps.help.oauthApplications"
|
id="installed_oauth_apps.help.oauthApplications"
|
||||||
/>
|
/>
|
||||||
</ExternalLink>,
|
</ForwardRef>,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -257,7 +257,7 @@ exports[`components/integrations/InstalledOutgoingWebhooks should match snapshot
|
|||||||
id="installed_outgoing_webhooks.help"
|
id="installed_outgoing_webhooks.help"
|
||||||
values={
|
values={
|
||||||
Object {
|
Object {
|
||||||
"appDirectory": <ExternalLink
|
"appDirectory": <ForwardRef
|
||||||
href="https://mattermost.com/marketplace"
|
href="https://mattermost.com/marketplace"
|
||||||
location="installed_outgoing_webhooks"
|
location="installed_outgoing_webhooks"
|
||||||
>
|
>
|
||||||
@@ -265,8 +265,8 @@ exports[`components/integrations/InstalledOutgoingWebhooks should match snapshot
|
|||||||
defaultMessage="App Directory"
|
defaultMessage="App Directory"
|
||||||
id="installed_outgoing_webhooks.help.appDirectory"
|
id="installed_outgoing_webhooks.help.appDirectory"
|
||||||
/>
|
/>
|
||||||
</ExternalLink>,
|
</ForwardRef>,
|
||||||
"buildYourOwn": <ExternalLink
|
"buildYourOwn": <ForwardRef
|
||||||
href="https://mattermost.com/pl/setup-outgoing-webhooks"
|
href="https://mattermost.com/pl/setup-outgoing-webhooks"
|
||||||
location="installed_outgoing_webhooks"
|
location="installed_outgoing_webhooks"
|
||||||
>
|
>
|
||||||
@@ -274,7 +274,7 @@ exports[`components/integrations/InstalledOutgoingWebhooks should match snapshot
|
|||||||
defaultMessage="Build your own"
|
defaultMessage="Build your own"
|
||||||
id="installed_outgoing_webhooks.help.buildYourOwn"
|
id="installed_outgoing_webhooks.help.buildYourOwn"
|
||||||
/>
|
/>
|
||||||
</ExternalLink>,
|
</ForwardRef>,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ exports[`components/integrations/InstalledOutgoingOAuthConnections should match
|
|||||||
id="installed_outgoing_oauth_connections.help"
|
id="installed_outgoing_oauth_connections.help"
|
||||||
values={
|
values={
|
||||||
Object {
|
Object {
|
||||||
"outgoingOauthConnections": <ExternalLink
|
"outgoingOauthConnections": <ForwardRef
|
||||||
href="https://mattermost.com/pl/setup-oauth-2.0"
|
href="https://mattermost.com/pl/setup-oauth-2.0"
|
||||||
location="installed_outgoing_oauth_connections"
|
location="installed_outgoing_oauth_connections"
|
||||||
>
|
>
|
||||||
@@ -93,7 +93,7 @@ exports[`components/integrations/InstalledOutgoingOAuthConnections should match
|
|||||||
defaultMessage="Outgoing OAuth Connections"
|
defaultMessage="Outgoing OAuth Connections"
|
||||||
id="installed_outgoing_oauth_connections.help.outgoingOauthConnections"
|
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"
|
id="installed_outgoing_oauth_connections.help"
|
||||||
values={
|
values={
|
||||||
Object {
|
Object {
|
||||||
"outgoingOauthConnections": <ExternalLink
|
"outgoingOauthConnections": <ForwardRef
|
||||||
href="https://mattermost.com/pl/setup-oauth-2.0"
|
href="https://mattermost.com/pl/setup-oauth-2.0"
|
||||||
location="installed_outgoing_oauth_connections"
|
location="installed_outgoing_oauth_connections"
|
||||||
>
|
>
|
||||||
@@ -188,13 +188,13 @@ exports[`components/integrations/InstalledOutgoingOAuthConnections should match
|
|||||||
defaultMessage="Outgoing OAuth Connections"
|
defaultMessage="Outgoing OAuth Connections"
|
||||||
id="installed_outgoing_oauth_connections.help.outgoingOauthConnections"
|
id="installed_outgoing_oauth_connections.help.outgoingOauthConnections"
|
||||||
/>
|
/>
|
||||||
</ExternalLink>,
|
</ForwardRef>,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
Create
|
Create
|
||||||
<ExternalLink
|
<ForwardRef
|
||||||
href="https://mattermost.com/pl/setup-oauth-2.0"
|
href="https://mattermost.com/pl/setup-oauth-2.0"
|
||||||
key=".$.1"
|
key=".$.1"
|
||||||
location="installed_outgoing_oauth_connections"
|
location="installed_outgoing_oauth_connections"
|
||||||
@@ -215,7 +215,7 @@ exports[`components/integrations/InstalledOutgoingOAuthConnections should match
|
|||||||
</span>
|
</span>
|
||||||
</FormattedMessage>
|
</FormattedMessage>
|
||||||
</a>
|
</a>
|
||||||
</ExternalLink>
|
</ForwardRef>
|
||||||
to securely integrate bots and third-party apps with Mattermost.
|
to securely integrate bots and third-party apps with Mattermost.
|
||||||
</span>
|
</span>
|
||||||
</FormattedMessage>
|
</FormattedMessage>
|
||||||
|
|||||||
@@ -64,11 +64,25 @@ type MenuProps = {
|
|||||||
width?: string;
|
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 {
|
interface Props {
|
||||||
menuButton: MenuButtonProps;
|
menuButton: MenuButtonProps;
|
||||||
menuButtonTooltip?: MenuButtonTooltipProps;
|
menuButtonTooltip?: MenuButtonTooltipProps;
|
||||||
menu: MenuProps;
|
menu: MenuProps;
|
||||||
children: ReactNode[];
|
children: ReactNode[];
|
||||||
|
anchorOrigin?: {
|
||||||
|
vertical: VerticalOrigin;
|
||||||
|
horizontal: HorizontalOrigin;
|
||||||
|
};
|
||||||
|
transformOrigin?: {
|
||||||
|
vertical: VerticalOrigin;
|
||||||
|
horizontal: HorizontalOrigin;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -236,6 +250,8 @@ export function Menu(props: Props) {
|
|||||||
onKeyDown={handleMenuKeyDown}
|
onKeyDown={handleMenuKeyDown}
|
||||||
className={A11yClassNames.POPUP}
|
className={A11yClassNames.POPUP}
|
||||||
width={props.menu.width}
|
width={props.menu.width}
|
||||||
|
anchorOrigin={props.anchorOrigin || defaultAnchorOrigin}
|
||||||
|
transformOrigin={props.transformOrigin || defaultTransformOrigin}
|
||||||
disableAutoFocusItem={disableAutoFocusItem} // This is not anti-pattern, see handleMenuButtonMouseDown
|
disableAutoFocusItem={disableAutoFocusItem} // This is not anti-pattern, see handleMenuButtonMouseDown
|
||||||
MenuListProps={{
|
MenuListProps={{
|
||||||
id: props.menu.id,
|
id: props.menu.id,
|
||||||
|
|||||||
@@ -1006,7 +1006,7 @@ exports[`components/MarketplaceItemPlugin UpdateDetails should render with relea
|
|||||||
releaseNotesUrl="http://example.com/release"
|
releaseNotesUrl="http://example.com/release"
|
||||||
version="0.0.2"
|
version="0.0.2"
|
||||||
>
|
>
|
||||||
<ExternalLink
|
<ForwardRef
|
||||||
href="http://example.com/release"
|
href="http://example.com/release"
|
||||||
location="marketplace_item_plugin"
|
location="marketplace_item_plugin"
|
||||||
>
|
>
|
||||||
@@ -1019,7 +1019,7 @@ exports[`components/MarketplaceItemPlugin UpdateDetails should render with relea
|
|||||||
>
|
>
|
||||||
0.0.2
|
0.0.2
|
||||||
</a>
|
</a>
|
||||||
</ExternalLink>
|
</ForwardRef>
|
||||||
</UpdateVersion>
|
</UpdateVersion>
|
||||||
-
|
-
|
||||||
<b>
|
<b>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ exports[`components/post_view/MessageAttachment should call actions.doPostAction
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ExternalLink
|
<ForwardRef
|
||||||
href="author_link"
|
href="author_link"
|
||||||
key="attachment__author-name"
|
key="attachment__author-name"
|
||||||
location="message_attachment"
|
location="message_attachment"
|
||||||
@@ -41,17 +41,17 @@ exports[`components/post_view/MessageAttachment should call actions.doPostAction
|
|||||||
>
|
>
|
||||||
author_name
|
author_name
|
||||||
</span>
|
</span>
|
||||||
</ExternalLink>
|
</ForwardRef>
|
||||||
<h1
|
<h1
|
||||||
className="attachment__title"
|
className="attachment__title"
|
||||||
>
|
>
|
||||||
<ExternalLink
|
<ForwardRef
|
||||||
className="attachment__title-link"
|
className="attachment__title-link"
|
||||||
href="title_link"
|
href="title_link"
|
||||||
location="message_attachment"
|
location="message_attachment"
|
||||||
>
|
>
|
||||||
title
|
title
|
||||||
</ExternalLink>
|
</ForwardRef>
|
||||||
</h1>
|
</h1>
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
@@ -170,7 +170,7 @@ exports[`components/post_view/MessageAttachment should match snapshot 1`] = `
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ExternalLink
|
<ForwardRef
|
||||||
href="author_link"
|
href="author_link"
|
||||||
key="attachment__author-name"
|
key="attachment__author-name"
|
||||||
location="message_attachment"
|
location="message_attachment"
|
||||||
@@ -187,17 +187,17 @@ exports[`components/post_view/MessageAttachment should match snapshot 1`] = `
|
|||||||
>
|
>
|
||||||
author_name
|
author_name
|
||||||
</span>
|
</span>
|
||||||
</ExternalLink>
|
</ForwardRef>
|
||||||
<h1
|
<h1
|
||||||
className="attachment__title"
|
className="attachment__title"
|
||||||
>
|
>
|
||||||
<ExternalLink
|
<ForwardRef
|
||||||
className="attachment__title-link"
|
className="attachment__title-link"
|
||||||
href="title_link"
|
href="title_link"
|
||||||
location="message_attachment"
|
location="message_attachment"
|
||||||
>
|
>
|
||||||
title
|
title
|
||||||
</ExternalLink>
|
</ForwardRef>
|
||||||
</h1>
|
</h1>
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
@@ -312,7 +312,7 @@ exports[`components/post_view/MessageAttachment should match snapshot when no fo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ExternalLink
|
<ForwardRef
|
||||||
href="author_link"
|
href="author_link"
|
||||||
key="attachment__author-name"
|
key="attachment__author-name"
|
||||||
location="message_attachment"
|
location="message_attachment"
|
||||||
@@ -329,17 +329,17 @@ exports[`components/post_view/MessageAttachment should match snapshot when no fo
|
|||||||
>
|
>
|
||||||
author_name
|
author_name
|
||||||
</span>
|
</span>
|
||||||
</ExternalLink>
|
</ForwardRef>
|
||||||
<h1
|
<h1
|
||||||
className="attachment__title"
|
className="attachment__title"
|
||||||
>
|
>
|
||||||
<ExternalLink
|
<ForwardRef
|
||||||
className="attachment__title-link"
|
className="attachment__title-link"
|
||||||
href="title_link"
|
href="title_link"
|
||||||
location="message_attachment"
|
location="message_attachment"
|
||||||
>
|
>
|
||||||
title
|
title
|
||||||
</ExternalLink>
|
</ForwardRef>
|
||||||
</h1>
|
</h1>
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ exports[`ProductNoticesModal Match snapshot for user notice 1`] = `
|
|||||||
message="descr"
|
message="descr"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<ExternalLink
|
<ForwardRef
|
||||||
className="GenericModal__button actionButton"
|
className="GenericModal__button actionButton"
|
||||||
href="http://download.com/path"
|
href="http://download.com/path"
|
||||||
id="actionButton"
|
id="actionButton"
|
||||||
@@ -93,7 +93,7 @@ exports[`ProductNoticesModal Match snapshot for user notice 1`] = `
|
|||||||
onClick={[Function]}
|
onClick={[Function]}
|
||||||
>
|
>
|
||||||
Download
|
Download
|
||||||
</ExternalLink>
|
</ForwardRef>
|
||||||
<div
|
<div
|
||||||
className="productNotices__imageDiv"
|
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)"
|
message="your eyes only! [test](https://test.com)"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<ExternalLink
|
<ForwardRef
|
||||||
className="GenericModal__button actionButton"
|
className="GenericModal__button actionButton"
|
||||||
href="http://download.com/path"
|
href="http://download.com/path"
|
||||||
id="actionButton"
|
id="actionButton"
|
||||||
@@ -165,7 +165,7 @@ exports[`ProductNoticesModal Should match snapshot for system admin notice 1`] =
|
|||||||
onClick={[Function]}
|
onClick={[Function]}
|
||||||
>
|
>
|
||||||
Download
|
Download
|
||||||
</ExternalLink>
|
</ForwardRef>
|
||||||
<div
|
<div
|
||||||
className="productNotices__imageDiv"
|
className="productNotices__imageDiv"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -105,13 +105,13 @@ exports[`components/signup/Signup should match snapshot for all signup options e
|
|||||||
className="link"
|
className="link"
|
||||||
>
|
>
|
||||||
Sign up at
|
Sign up at
|
||||||
<ExternalLink
|
<ForwardRef
|
||||||
href="https://mattermost.com/security-updates/"
|
href="https://mattermost.com/security-updates/"
|
||||||
key=".1"
|
key=".1"
|
||||||
location="signup"
|
location="signup"
|
||||||
>
|
>
|
||||||
https://mattermost.com/security-updates/
|
https://mattermost.com/security-updates/
|
||||||
</ExternalLink>
|
</ForwardRef>
|
||||||
.
|
.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -279,13 +279,13 @@ exports[`components/signup/Signup should match snapshot for all signup options e
|
|||||||
className="link"
|
className="link"
|
||||||
>
|
>
|
||||||
Sign up at
|
Sign up at
|
||||||
<ExternalLink
|
<ForwardRef
|
||||||
href="https://mattermost.com/security-updates/"
|
href="https://mattermost.com/security-updates/"
|
||||||
key=".1"
|
key=".1"
|
||||||
location="signup"
|
location="signup"
|
||||||
>
|
>
|
||||||
https://mattermost.com/security-updates/
|
https://mattermost.com/security-updates/
|
||||||
</ExternalLink>
|
</ForwardRef>
|
||||||
.
|
.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export enum SIZE {
|
|||||||
LARGE = 'large',
|
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> {
|
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement> {
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
@@ -225,14 +225,15 @@ const Input = React.forwardRef((
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
{customInputLabel && (
|
{customInputLabel && (
|
||||||
<div className={`Input___customMessage Input___${customInputLabel.type}`}>
|
<div className={`Input___customMessage Input___${customInputLabel.type}`}>
|
||||||
<i
|
{customInputLabel.type && (
|
||||||
className={classNames(`icon ${customInputLabel.type}`, {
|
<i
|
||||||
'icon-alert-outline': customInputLabel.type === ItemStatus.WARNING,
|
className={classNames(`icon ${customInputLabel.type}`, {
|
||||||
'icon-alert-circle-outline': customInputLabel.type === ItemStatus.ERROR,
|
'icon-alert-outline': customInputLabel.type === ItemStatus.WARNING,
|
||||||
'icon-information-outline': customInputLabel.type === ItemStatus.INFO,
|
'icon-alert-circle-outline': customInputLabel.type === ItemStatus.ERROR,
|
||||||
'icon-check': customInputLabel.type === ItemStatus.SUCCESS,
|
'icon-information-outline': customInputLabel.type === ItemStatus.INFO,
|
||||||
})}
|
'icon-check': customInputLabel.type === ItemStatus.SUCCESS,
|
||||||
/>
|
})}
|
||||||
|
/>)}
|
||||||
<span>{customInputLabel.value}</span>
|
<span>{customInputLabel.value}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ describe('components/MenuItemExternalLink', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(wrapper).toMatchInlineSnapshot(`
|
expect(wrapper).toMatchInlineSnapshot(`
|
||||||
<ExternalLink
|
<ForwardRef
|
||||||
href="http://test.com"
|
href="http://test.com"
|
||||||
location="menu_item_external_link"
|
location="menu_item_external_link"
|
||||||
>
|
>
|
||||||
@@ -25,7 +25,7 @@ describe('components/MenuItemExternalLink', () => {
|
|||||||
>
|
>
|
||||||
Whatever
|
Whatever
|
||||||
</span>
|
</span>
|
||||||
</ExternalLink>
|
</ForwardRef>
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ exports[`YoutubeVideo should match init snapshot 1`] = `
|
|||||||
<span
|
<span
|
||||||
className="video-title"
|
className="video-title"
|
||||||
>
|
>
|
||||||
<ExternalLink
|
<ForwardRef
|
||||||
href="https://www.youtube.com/watch?v=xqCoNej8Zxo"
|
href="https://www.youtube.com/watch?v=xqCoNej8Zxo"
|
||||||
location="youtube_video"
|
location="youtube_video"
|
||||||
>
|
>
|
||||||
@@ -57,7 +57,7 @@ exports[`YoutubeVideo should match init snapshot 1`] = `
|
|||||||
>
|
>
|
||||||
Youtube title
|
Youtube title
|
||||||
</a>
|
</a>
|
||||||
</ExternalLink>
|
</ForwardRef>
|
||||||
</span>
|
</span>
|
||||||
</h4>
|
</h4>
|
||||||
<div
|
<div
|
||||||
@@ -118,12 +118,12 @@ exports[`YoutubeVideo should match snapshot for playing state 1`] = `
|
|||||||
<span
|
<span
|
||||||
className="video-title"
|
className="video-title"
|
||||||
>
|
>
|
||||||
<ExternalLink
|
<ForwardRef
|
||||||
href="https://www.youtube.com/watch?v=xqCoNej8Zxo"
|
href="https://www.youtube.com/watch?v=xqCoNej8Zxo"
|
||||||
location="youtube_video"
|
location="youtube_video"
|
||||||
>
|
>
|
||||||
Youtube title
|
Youtube title
|
||||||
</ExternalLink>
|
</ForwardRef>
|
||||||
</span>
|
</span>
|
||||||
</h4>
|
</h4>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -3035,6 +3035,31 @@
|
|||||||
"change_url.shorter": "URLs must have maximum 64 characters.",
|
"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.startAndEndWithLetter": "URLs must start and end with a lowercase letter or number.",
|
||||||
"change_url.startWithLetter": "URLs must start 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_groups": "{channel} Groups",
|
||||||
"channel_header.addChannelHeader": "Add a channel header",
|
"channel_header.addChannelHeader": "Add a channel header",
|
||||||
"channel_header.channelFiles": "Channel files",
|
"channel_header.channelFiles": "Channel files",
|
||||||
@@ -3617,6 +3642,7 @@
|
|||||||
"file_upload.fileAbove": "File above {max}MB could not be uploaded: {filename}",
|
"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.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": "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.limited": "Uploads limited to {count, number} files maximum. Please use additional posts for more files.",
|
||||||
"file_upload.menuAriaLabel": "Upload type selector",
|
"file_upload.menuAriaLabel": "Upload type selector",
|
||||||
"file_upload.pasted": "Image Pasted at ",
|
"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 AdminTypes from './admin';
|
||||||
import AppsTypes from './apps';
|
import AppsTypes from './apps';
|
||||||
import BotTypes from './bots';
|
import BotTypes from './bots';
|
||||||
|
import ChannelBookmarkTypes from './channel_bookmarks';
|
||||||
import ChannelCategoryTypes from './channel_categories';
|
import ChannelCategoryTypes from './channel_categories';
|
||||||
import ChannelTypes from './channels';
|
import ChannelTypes from './channels';
|
||||||
import CloudTypes from './cloud';
|
import CloudTypes from './cloud';
|
||||||
@@ -55,4 +56,5 @@ export {
|
|||||||
HostedCustomerTypes,
|
HostedCustomerTypes,
|
||||||
DraftTypes,
|
DraftTypes,
|
||||||
PlaybookType,
|
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 type {AnyAction} from 'redux';
|
||||||
import {combineReducers} from 'redux';
|
import {combineReducers} from 'redux';
|
||||||
|
|
||||||
|
import type {ChannelBookmark} from '@mattermost/types/channel_bookmarks';
|
||||||
import type {FileInfo, FileSearchResultItem} from '@mattermost/types/files';
|
import type {FileInfo, FileSearchResultItem} from '@mattermost/types/files';
|
||||||
import type {Post} from '@mattermost/types/posts';
|
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) {
|
export function files(state: Record<string, FileInfo> = {}, action: AnyAction) {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
@@ -53,6 +54,43 @@ export function files(state: Record<string, FileInfo> = {}, action: AnyAction) {
|
|||||||
return state;
|
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:
|
case UserTypes.LOGOUT_SUCCESS:
|
||||||
return {};
|
return {};
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {combineReducers} from 'redux';
|
|||||||
import admin from './admin';
|
import admin from './admin';
|
||||||
import apps from './apps';
|
import apps from './apps';
|
||||||
import bots from './bots';
|
import bots from './bots';
|
||||||
|
import channelBookmarks from './channel_bookmarks';
|
||||||
import channelCategories from './channel_categories';
|
import channelCategories from './channel_categories';
|
||||||
import channels from './channels';
|
import channels from './channels';
|
||||||
import cloud from './cloud';
|
import cloud from './cloud';
|
||||||
@@ -53,4 +54,5 @@ export default combineReducers({
|
|||||||
cloud,
|
cloud,
|
||||||
usage,
|
usage,
|
||||||
hostedCustomer,
|
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: {},
|
messageCounts: {},
|
||||||
channelsMemberCount: {},
|
channelsMemberCount: {},
|
||||||
},
|
},
|
||||||
|
channelBookmarks: {
|
||||||
|
byChannelId: {},
|
||||||
|
},
|
||||||
posts: {
|
posts: {
|
||||||
posts: {},
|
posts: {},
|
||||||
postsReplies: {},
|
postsReplies: {},
|
||||||
|
|||||||
@@ -459,6 +459,8 @@ export const ModalIdentifiers = {
|
|||||||
EXPORT_USER_DATA_MODAL: 'export_user_data_modal',
|
EXPORT_USER_DATA_MODAL: 'export_user_data_modal',
|
||||||
UPGRADE_EXPORT_DATA_MODAL: 'upgrade_export_data_modal',
|
UPGRADE_EXPORT_DATA_MODAL: 'upgrade_export_data_modal',
|
||||||
EXPORT_ERROR_MODAL: 'export_error_modal',
|
EXPORT_ERROR_MODAL: 'export_error_modal',
|
||||||
|
CHANNEL_BOOKMARK_DELETE: 'channel_bookmark_delete',
|
||||||
|
CHANNEL_BOOKMARK_CREATE: 'channel_bookmark_create',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const UserStatuses = {
|
export const UserStatuses = {
|
||||||
@@ -584,6 +586,10 @@ export const SocketEvents = {
|
|||||||
CHANNEL_DELETED: 'channel_deleted',
|
CHANNEL_DELETED: 'channel_deleted',
|
||||||
CHANNEL_UNARCHIVED: 'channel_restored',
|
CHANNEL_UNARCHIVED: 'channel_restored',
|
||||||
CHANNEL_UPDATED: 'channel_updated',
|
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',
|
MULTIPLE_CHANNELS_VIEWED: 'multiple_channels_viewed',
|
||||||
CHANNEL_MEMBER_UPDATED: 'channel_member_updated',
|
CHANNEL_MEMBER_UPDATED: 'channel_member_updated',
|
||||||
CHANNEL_SCHEME_UPDATED: 'channel_scheme_updated',
|
CHANNEL_SCHEME_UPDATED: 'channel_scheme_updated',
|
||||||
|
|||||||
@@ -179,6 +179,10 @@ export function getSkin(emoji: Emoji) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function trimmedEmojiName(emojiName: string) {
|
||||||
|
return emojiName.startsWith(':') && emojiName.endsWith(':') ? emojiName.slice(1, -1) : emojiName;
|
||||||
|
}
|
||||||
|
|
||||||
export function emojiMatchesSkin(emoji: Emoji, skin: string) {
|
export function emojiMatchesSkin(emoji: Emoji, skin: string) {
|
||||||
const emojiSkin = getSkin(emoji);
|
const emojiSkin = getSkin(emoji);
|
||||||
return !emojiSkin || emojiSkin === skin;
|
return !emojiSkin || emojiSkin === skin;
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ export const FileSizes = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function canUploadFiles(config: Partial<ClientConfig>): boolean {
|
export function canUploadFiles(config: Partial<ClientConfig>): boolean {
|
||||||
const enableFileAttachments = config.EnableFileAttachments === 'true';
|
const enableFileAttachments = isFileAttachmentsEnabled(config);
|
||||||
const enableMobileFileUpload = config.EnableMobileFileUpload === 'true';
|
const enableMobileFileUpload = isMobileFileUploadsEnabled(config);
|
||||||
|
|
||||||
if (!enableFileAttachments) {
|
if (!enableFileAttachments) {
|
||||||
return false;
|
return false;
|
||||||
@@ -35,6 +35,14 @@ export function isFileAttachmentsEnabled(config: Partial<ClientConfig>): boolean
|
|||||||
return config.EnableFileAttachments === 'true';
|
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 {
|
export function canDownloadFiles(config: Partial<ClientConfig>): boolean {
|
||||||
if (UserAgent.isMobileApp()) {
|
if (UserAgent.isMobileApp()) {
|
||||||
return config.EnableMobileFileDownload === 'true';
|
return config.EnableMobileFileDownload === 'true';
|
||||||
|
|||||||
@@ -273,7 +273,7 @@ export default class Renderer extends marked.Renderer {
|
|||||||
|
|
||||||
// Marked helper functions that should probably just be exported
|
// Marked helper functions that should probably just be exported
|
||||||
|
|
||||||
function unescapeHtmlEntities(html: string) {
|
export function unescapeHtmlEntities(html: string) {
|
||||||
return html.replace(/&([#\w]+);/g, (_, m) => {
|
return html.replace(/&([#\w]+);/g, (_, m) => {
|
||||||
const n = m.toLowerCase();
|
const n = m.toLowerCase();
|
||||||
if (n === 'colon') {
|
if (n === 'colon') {
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import Constants from 'utils/constants';
|
|||||||
import {latinise} from 'utils/latinise';
|
import {latinise} from 'utils/latinise';
|
||||||
import * as TextFormatting from 'utils/text_formatting';
|
import * as TextFormatting from 'utils/text_formatting';
|
||||||
|
|
||||||
|
import {unescapeHtmlEntities} from './markdown/renderer';
|
||||||
|
|
||||||
type WindowObject = {
|
type WindowObject = {
|
||||||
location: {
|
location: {
|
||||||
origin: string;
|
origin: string;
|
||||||
@@ -274,6 +276,11 @@ export function isPermalinkURL(url: string): boolean {
|
|||||||
return isInternalURL(url, siteURL) && (regexp.test(url));
|
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 {
|
export function isStringContainingUrl(text: string): boolean {
|
||||||
const regex = new RegExp('(https?://|www.)');
|
const regex = new RegExp('(https?://|www.)');
|
||||||
return regex.test(text);
|
return regex.test(text);
|
||||||
@@ -326,3 +333,20 @@ export function channelNameToUrl(channelName: string): UrlValidationCheck {
|
|||||||
|
|
||||||
return {url, error: false};
|
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 {Audit} from '@mattermost/types/audits';
|
||||||
import type {UserAutocomplete, AutocompleteSuggestion} from '@mattermost/types/autocomplete';
|
import type {UserAutocomplete, AutocompleteSuggestion} from '@mattermost/types/autocomplete';
|
||||||
import type {Bot, BotPatch} from '@mattermost/types/bots';
|
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 {ChannelCategory, OrderedChannelCategories} from '@mattermost/types/channel_categories';
|
||||||
import type {
|
import type {
|
||||||
Channel,
|
Channel,
|
||||||
@@ -314,6 +315,12 @@ export default class Client4 {
|
|||||||
getChannelSchemeRoute(channelId: string) {
|
getChannelSchemeRoute(channelId: string) {
|
||||||
return `${this.getChannelRoute(channelId)}/scheme`;
|
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) {
|
getChannelCategoriesRoute(userId: string, teamId: string) {
|
||||||
return `${this.getBaseRoute()}/users/${userId}/teams/${teamId}/channels/categories`;
|
return `${this.getBaseRoute()}/users/${userId}/teams/${teamId}/channels/categories`;
|
||||||
@@ -1605,7 +1612,15 @@ export default class Client4 {
|
|||||||
includeDeleted: boolean | undefined,
|
includeDeleted: boolean | undefined,
|
||||||
excludePolicyConstrained: boolean | undefined
|
excludePolicyConstrained: boolean | undefined
|
||||||
): Promise<ChannelsWithTotalCount>;
|
): 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 = {
|
const queryData = {
|
||||||
page,
|
page,
|
||||||
per_page: perPage,
|
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) => {
|
getChannelCategories = (userId: string, teamId: string) => {
|
||||||
return this.doFetch<OrderedChannelCategories>(
|
return this.doFetch<OrderedChannelCategories>(
|
||||||
@@ -2383,7 +2435,7 @@ export default class Client4 {
|
|||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
uploadFile = (fileFormData: any) => {
|
uploadFile = (fileFormData: any, isBookmark?: boolean) => {
|
||||||
this.trackEvent('api', 'api_files_upload');
|
this.trackEvent('api', 'api_files_upload');
|
||||||
const request: any = {
|
const request: any = {
|
||||||
method: 'post',
|
method: 'post',
|
||||||
@@ -2391,7 +2443,7 @@ export default class Client4 {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return this.doFetch<FileUploadResponse>(
|
return this.doFetch<FileUploadResponse>(
|
||||||
`${this.getFilesRoute()}`,
|
`${this.getFilesRoute()}${buildQueryString({bookmark: isBookmark})}`,
|
||||||
request,
|
request,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
57
webapp/platform/types/src/channel_bookmarks.ts
Обычный файл
57
webapp/platform/types/src/channel_bookmarks.ts
Обычный файл
@@ -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;
|
url?: string;
|
||||||
credentials?: 'omit' | 'same-origin' | 'include';
|
credentials?: 'omit' | 'same-origin' | 'include';
|
||||||
body?: any;
|
body?: any;
|
||||||
|
signal?: RequestInit['signal'];
|
||||||
ignoreStatus?: boolean; /** If true, status codes > 300 are ignored and don't cause an error */
|
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 {AdminState} from './admin';
|
||||||
import type {AppsState} from './apps';
|
import type {AppsState} from './apps';
|
||||||
import type {Bot} from './bots';
|
import type {Bot} from './bots';
|
||||||
|
import type {ChannelBookmarksState} from './channel_bookmarks';
|
||||||
import type {ChannelCategoriesState} from './channel_categories';
|
import type {ChannelCategoriesState} from './channel_categories';
|
||||||
import type {ChannelsState} from './channels';
|
import type {ChannelsState} from './channels';
|
||||||
import type {CloudState, CloudUsage} from './cloud';
|
import type {CloudState, CloudUsage} from './cloud';
|
||||||
@@ -38,6 +39,7 @@ export type GlobalState = {
|
|||||||
limits: LimitsState;
|
limits: LimitsState;
|
||||||
teams: TeamsState;
|
teams: TeamsState;
|
||||||
channels: ChannelsState;
|
channels: ChannelsState;
|
||||||
|
channelBookmarks: ChannelBookmarksState;
|
||||||
posts: PostsState;
|
posts: PostsState;
|
||||||
threads: ThreadsState;
|
threads: ThreadsState;
|
||||||
bots: {
|
bots: {
|
||||||
|
|||||||
Ссылка в новой задаче
Block a user