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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function uploadFile({file, name, type, rootId, channelId, clientId, onProgress, onSuccess, onError}: UploadFile): ThunkActionFunc<XMLHttpRequest> {
|
||||
export function uploadFile({file, name, type, rootId, channelId, clientId, onProgress, onSuccess, onError}: UploadFile, isBookmark?: boolean): ThunkActionFunc<XMLHttpRequest> {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({type: FileTypes.UPLOAD_FILES_REQUEST});
|
||||
|
||||
let url = Client4.getFilesRoute();
|
||||
if (isBookmark) {
|
||||
url += '?bookmark=true';
|
||||
}
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
xhr.open('POST', Client4.getFilesRoute(), true);
|
||||
xhr.open('POST', url, true);
|
||||
|
||||
const client4Headers = Client4.getOptions({method: 'POST'}).headers;
|
||||
Object.keys(client4Headers).forEach((client4Header) => {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
AppsTypes,
|
||||
CloudTypes,
|
||||
HostedCustomerTypes,
|
||||
ChannelBookmarkTypes,
|
||||
} from 'mattermost-redux/action_types';
|
||||
import {getStandardAnalytics} from 'mattermost-redux/actions/admin';
|
||||
import {fetchAppBindings, fetchRHSAppsBindings} from 'mattermost-redux/actions/apps';
|
||||
@@ -432,6 +433,22 @@ export function handleEvent(msg) {
|
||||
handleChannelMemberUpdatedEvent(msg);
|
||||
break;
|
||||
|
||||
case SocketEvents.CHANNEL_BOOKMARK_CREATED:
|
||||
dispatch(handleChannelBookmarkCreated(msg));
|
||||
break;
|
||||
|
||||
case SocketEvents.CHANNEL_BOOKMARK_UPDATED:
|
||||
dispatch(handleChannelBookmarkUpdated(msg));
|
||||
break;
|
||||
|
||||
case SocketEvents.CHANNEL_BOOKMARK_DELETED:
|
||||
dispatch(handleChannelBookmarkDeleted(msg));
|
||||
break;
|
||||
|
||||
case SocketEvents.CHANNEL_BOOKMARK_SORTED:
|
||||
dispatch(handleChannelBookmarkSorted(msg));
|
||||
break;
|
||||
|
||||
case SocketEvents.DIRECT_ADDED:
|
||||
dispatch(handleDirectAddedEvent(msg));
|
||||
break;
|
||||
@@ -1755,3 +1772,50 @@ function handleHostedCustomerSignupProgressUpdated(msg) {
|
||||
data: msg.data.progress,
|
||||
};
|
||||
}
|
||||
|
||||
function handleChannelBookmarkCreated(msg) {
|
||||
const bookmark = JSON.parse(msg.data.bookmark);
|
||||
|
||||
return {
|
||||
type: ChannelBookmarkTypes.RECEIVED_BOOKMARK,
|
||||
data: bookmark,
|
||||
};
|
||||
}
|
||||
|
||||
function handleChannelBookmarkUpdated(msg) {
|
||||
return async (doDispatch) => {
|
||||
const {updated, deleted} = JSON.parse(msg.data.bookmarks);
|
||||
|
||||
if (updated) {
|
||||
doDispatch({
|
||||
type: ChannelBookmarkTypes.RECEIVED_BOOKMARK,
|
||||
data: updated,
|
||||
});
|
||||
}
|
||||
|
||||
if (deleted) {
|
||||
doDispatch({
|
||||
type: ChannelBookmarkTypes.BOOKMARK_DELETED,
|
||||
data: deleted,
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function handleChannelBookmarkDeleted(msg) {
|
||||
const bookmark = JSON.parse(msg.data.bookmark);
|
||||
|
||||
return {
|
||||
type: ChannelBookmarkTypes.BOOKMARK_DELETED,
|
||||
data: bookmark,
|
||||
};
|
||||
}
|
||||
|
||||
function handleChannelBookmarkSorted(msg) {
|
||||
const bookmarks = JSON.parse(msg.data.bookmarks);
|
||||
|
||||
return {
|
||||
type: ChannelBookmarkTypes.RECEIVED_BOOKMARKS,
|
||||
data: {channelId: msg.broadcast.channel_id, bookmarks},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -63,21 +63,21 @@ exports[`components/admin_console/license_settings/LicenseSettings load screen a
|
||||
className="terms-and-policy"
|
||||
>
|
||||
See also
|
||||
<ExternalLink
|
||||
<ForwardRef
|
||||
href="https://mattermost.com/pl/terms-of-use/"
|
||||
id="privacyLink"
|
||||
location="license_settings"
|
||||
>
|
||||
Enterprise Edition Terms of Use
|
||||
</ExternalLink>
|
||||
</ForwardRef>
|
||||
and
|
||||
<ExternalLink
|
||||
<ForwardRef
|
||||
href="https://mattermost.com/pl/privacy-policy/"
|
||||
id="privacyLink"
|
||||
location="license_settings"
|
||||
>
|
||||
Privacy Policy
|
||||
</ExternalLink>
|
||||
</ForwardRef>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -167,21 +167,21 @@ exports[`components/admin_console/license_settings/LicenseSettings load screen w
|
||||
className="terms-and-policy"
|
||||
>
|
||||
See also
|
||||
<ExternalLink
|
||||
<ForwardRef
|
||||
href="https://mattermost.com/pl/terms-of-use/"
|
||||
id="privacyLink"
|
||||
location="license_settings"
|
||||
>
|
||||
Enterprise Edition Terms of Use
|
||||
</ExternalLink>
|
||||
</ForwardRef>
|
||||
and
|
||||
<ExternalLink
|
||||
<ForwardRef
|
||||
href="https://mattermost.com/pl/privacy-policy/"
|
||||
id="privacyLink"
|
||||
location="license_settings"
|
||||
>
|
||||
Privacy Policy
|
||||
</ExternalLink>
|
||||
</ForwardRef>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -285,21 +285,21 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
|
||||
className="terms-and-policy"
|
||||
>
|
||||
See also
|
||||
<ExternalLink
|
||||
<ForwardRef
|
||||
href="https://mattermost.com/pl/terms-of-use/"
|
||||
id="privacyLink"
|
||||
location="license_settings"
|
||||
>
|
||||
Enterprise Edition Terms of Use
|
||||
</ExternalLink>
|
||||
</ForwardRef>
|
||||
and
|
||||
<ExternalLink
|
||||
<ForwardRef
|
||||
href="https://mattermost.com/pl/privacy-policy/"
|
||||
id="privacyLink"
|
||||
location="license_settings"
|
||||
>
|
||||
Privacy Policy
|
||||
</ExternalLink>
|
||||
</ForwardRef>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -328,13 +328,13 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
|
||||
className="compare-plans-text"
|
||||
>
|
||||
Curious about upgrading?
|
||||
<ExternalLink
|
||||
<ForwardRef
|
||||
href="https://mattermost.com/pl/pricing/"
|
||||
id="privacyLink"
|
||||
location="license_settings"
|
||||
>
|
||||
Compare Plans
|
||||
</ExternalLink>
|
||||
</ForwardRef>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -420,21 +420,21 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
|
||||
className="terms-and-policy"
|
||||
>
|
||||
See also
|
||||
<ExternalLink
|
||||
<ForwardRef
|
||||
href="https://mattermost.com/pl/terms-of-use/"
|
||||
id="privacyLink"
|
||||
location="license_settings"
|
||||
>
|
||||
Enterprise Edition Terms of Use
|
||||
</ExternalLink>
|
||||
</ForwardRef>
|
||||
and
|
||||
<ExternalLink
|
||||
<ForwardRef
|
||||
href="https://mattermost.com/pl/privacy-policy/"
|
||||
id="privacyLink"
|
||||
location="license_settings"
|
||||
>
|
||||
Privacy Policy
|
||||
</ExternalLink>
|
||||
</ForwardRef>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -543,21 +543,21 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
|
||||
className="terms-and-policy"
|
||||
>
|
||||
See also
|
||||
<ExternalLink
|
||||
<ForwardRef
|
||||
href="https://mattermost.com/pl/terms-of-use/"
|
||||
id="privacyLink"
|
||||
location="license_settings"
|
||||
>
|
||||
Enterprise Edition Terms of Use
|
||||
</ExternalLink>
|
||||
</ForwardRef>
|
||||
and
|
||||
<ExternalLink
|
||||
<ForwardRef
|
||||
href="https://mattermost.com/pl/privacy-policy/"
|
||||
id="privacyLink"
|
||||
location="license_settings"
|
||||
>
|
||||
Privacy Policy
|
||||
</ExternalLink>
|
||||
</ForwardRef>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -666,21 +666,21 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
|
||||
className="terms-and-policy"
|
||||
>
|
||||
See also
|
||||
<ExternalLink
|
||||
<ForwardRef
|
||||
href="https://mattermost.com/pl/terms-of-use/"
|
||||
id="privacyLink"
|
||||
location="license_settings"
|
||||
>
|
||||
Enterprise Edition Terms of Use
|
||||
</ExternalLink>
|
||||
</ForwardRef>
|
||||
and
|
||||
<ExternalLink
|
||||
<ForwardRef
|
||||
href="https://mattermost.com/pl/privacy-policy/"
|
||||
id="privacyLink"
|
||||
location="license_settings"
|
||||
>
|
||||
Privacy Policy
|
||||
</ExternalLink>
|
||||
</ForwardRef>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -789,21 +789,21 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
|
||||
className="terms-and-policy"
|
||||
>
|
||||
See also
|
||||
<ExternalLink
|
||||
<ForwardRef
|
||||
href="https://mattermost.com/pl/terms-of-use/"
|
||||
id="privacyLink"
|
||||
location="license_settings"
|
||||
>
|
||||
Enterprise Edition Terms of Use
|
||||
</ExternalLink>
|
||||
</ForwardRef>
|
||||
and
|
||||
<ExternalLink
|
||||
<ForwardRef
|
||||
href="https://mattermost.com/pl/privacy-policy/"
|
||||
id="privacyLink"
|
||||
location="license_settings"
|
||||
>
|
||||
Privacy Policy
|
||||
</ExternalLink>
|
||||
</ForwardRef>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -912,21 +912,21 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
|
||||
className="terms-and-policy"
|
||||
>
|
||||
See also
|
||||
<ExternalLink
|
||||
<ForwardRef
|
||||
href="https://mattermost.com/pl/terms-of-use/"
|
||||
id="privacyLink"
|
||||
location="license_settings"
|
||||
>
|
||||
Enterprise Edition Terms of Use
|
||||
</ExternalLink>
|
||||
</ForwardRef>
|
||||
and
|
||||
<ExternalLink
|
||||
<ForwardRef
|
||||
href="https://mattermost.com/pl/privacy-policy/"
|
||||
id="privacyLink"
|
||||
location="license_settings"
|
||||
>
|
||||
Privacy Policy
|
||||
</ExternalLink>
|
||||
</ForwardRef>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -1044,13 +1044,13 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
|
||||
className="compare-plans-text"
|
||||
>
|
||||
Curious about upgrading?
|
||||
<ExternalLink
|
||||
<ForwardRef
|
||||
href="https://mattermost.com/pl/pricing/"
|
||||
id="privacyLink"
|
||||
location="license_settings"
|
||||
>
|
||||
Compare Plans
|
||||
</ExternalLink>
|
||||
</ForwardRef>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1127,21 +1127,21 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
|
||||
className="terms-and-policy"
|
||||
>
|
||||
See also
|
||||
<ExternalLink
|
||||
<ForwardRef
|
||||
href="https://mattermost.com/pl/terms-of-use/"
|
||||
id="privacyLink"
|
||||
location="license_settings"
|
||||
>
|
||||
Enterprise Edition Terms of Use
|
||||
</ExternalLink>
|
||||
</ForwardRef>
|
||||
and
|
||||
<ExternalLink
|
||||
<ForwardRef
|
||||
href="https://mattermost.com/pl/privacy-policy/"
|
||||
id="privacyLink"
|
||||
location="license_settings"
|
||||
>
|
||||
Privacy Policy
|
||||
</ExternalLink>
|
||||
</ForwardRef>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -1156,13 +1156,13 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
|
||||
className="compare-plans-text"
|
||||
>
|
||||
Curious about upgrading?
|
||||
<ExternalLink
|
||||
<ForwardRef
|
||||
href="https://mattermost.com/pl/pricing/"
|
||||
id="privacyLink"
|
||||
location="license_settings"
|
||||
>
|
||||
Compare Plans
|
||||
</ExternalLink>
|
||||
</ForwardRef>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1239,21 +1239,21 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
|
||||
className="terms-and-policy"
|
||||
>
|
||||
See also
|
||||
<ExternalLink
|
||||
<ForwardRef
|
||||
href="https://mattermost.com/pl/terms-of-use/"
|
||||
id="privacyLink"
|
||||
location="license_settings"
|
||||
>
|
||||
Enterprise Edition Terms of Use
|
||||
</ExternalLink>
|
||||
</ForwardRef>
|
||||
and
|
||||
<ExternalLink
|
||||
<ForwardRef
|
||||
href="https://mattermost.com/pl/privacy-policy/"
|
||||
id="privacyLink"
|
||||
location="license_settings"
|
||||
>
|
||||
Privacy Policy
|
||||
</ExternalLink>
|
||||
</ForwardRef>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -1268,13 +1268,13 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
|
||||
className="compare-plans-text"
|
||||
>
|
||||
Curious about upgrading?
|
||||
<ExternalLink
|
||||
<ForwardRef
|
||||
href="https://mattermost.com/pl/pricing/"
|
||||
id="privacyLink"
|
||||
location="license_settings"
|
||||
>
|
||||
Compare Plans
|
||||
</ExternalLink>
|
||||
</ForwardRef>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1346,21 +1346,21 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
|
||||
className="terms-and-policy"
|
||||
>
|
||||
See also
|
||||
<ExternalLink
|
||||
<ForwardRef
|
||||
href="https://mattermost.com/pl/terms-of-use/"
|
||||
id="privacyLink"
|
||||
location="license_settings"
|
||||
>
|
||||
Enterprise Edition Terms of Use
|
||||
</ExternalLink>
|
||||
</ForwardRef>
|
||||
and
|
||||
<ExternalLink
|
||||
<ForwardRef
|
||||
href="https://mattermost.com/pl/privacy-policy/"
|
||||
id="privacyLink"
|
||||
location="license_settings"
|
||||
>
|
||||
Privacy Policy
|
||||
</ExternalLink>
|
||||
</ForwardRef>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -1448,21 +1448,21 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
|
||||
className="terms-and-policy"
|
||||
>
|
||||
See also
|
||||
<ExternalLink
|
||||
<ForwardRef
|
||||
href="https://mattermost.com/pl/terms-of-use/"
|
||||
id="privacyLink"
|
||||
location="license_settings"
|
||||
>
|
||||
Enterprise Edition Terms of Use
|
||||
</ExternalLink>
|
||||
</ForwardRef>
|
||||
and
|
||||
<ExternalLink
|
||||
<ForwardRef
|
||||
href="https://mattermost.com/pl/privacy-policy/"
|
||||
id="privacyLink"
|
||||
location="license_settings"
|
||||
>
|
||||
Privacy Policy
|
||||
</ExternalLink>
|
||||
</ForwardRef>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -1486,13 +1486,13 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
|
||||
className="compare-plans-text"
|
||||
>
|
||||
Curious about upgrading?
|
||||
<ExternalLink
|
||||
<ForwardRef
|
||||
href="https://mattermost.com/pl/pricing/"
|
||||
id="privacyLink"
|
||||
location="license_settings"
|
||||
>
|
||||
Compare Plans
|
||||
</ExternalLink>
|
||||
</ForwardRef>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1554,21 +1554,21 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
|
||||
className="terms-and-policy"
|
||||
>
|
||||
See also
|
||||
<ExternalLink
|
||||
<ForwardRef
|
||||
href="https://mattermost.com/pl/terms-of-use/"
|
||||
id="privacyLink"
|
||||
location="license_settings"
|
||||
>
|
||||
Enterprise Edition Terms of Use
|
||||
</ExternalLink>
|
||||
</ForwardRef>
|
||||
and
|
||||
<ExternalLink
|
||||
<ForwardRef
|
||||
href="https://mattermost.com/pl/privacy-policy/"
|
||||
id="privacyLink"
|
||||
location="license_settings"
|
||||
>
|
||||
Privacy Policy
|
||||
</ExternalLink>
|
||||
</ForwardRef>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -1583,13 +1583,13 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
|
||||
className="compare-plans-text"
|
||||
>
|
||||
Curious about upgrading?
|
||||
<ExternalLink
|
||||
<ForwardRef
|
||||
href="https://mattermost.com/pl/pricing/"
|
||||
id="privacyLink"
|
||||
location="license_settings"
|
||||
>
|
||||
Compare Plans
|
||||
</ExternalLink>
|
||||
</ForwardRef>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1659,21 +1659,21 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
|
||||
className="terms-and-policy"
|
||||
>
|
||||
See also
|
||||
<ExternalLink
|
||||
<ForwardRef
|
||||
href="https://mattermost.com/pl/terms-of-use/"
|
||||
id="privacyLink"
|
||||
location="license_settings"
|
||||
>
|
||||
Enterprise Edition Terms of Use
|
||||
</ExternalLink>
|
||||
</ForwardRef>
|
||||
and
|
||||
<ExternalLink
|
||||
<ForwardRef
|
||||
href="https://mattermost.com/pl/privacy-policy/"
|
||||
id="privacyLink"
|
||||
location="license_settings"
|
||||
>
|
||||
Privacy Policy
|
||||
</ExternalLink>
|
||||
</ForwardRef>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -1783,21 +1783,21 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
|
||||
className="terms-and-policy"
|
||||
>
|
||||
See also
|
||||
<ExternalLink
|
||||
<ForwardRef
|
||||
href="https://mattermost.com/pl/terms-of-use/"
|
||||
id="privacyLink"
|
||||
location="license_settings"
|
||||
>
|
||||
Enterprise Edition Terms of Use
|
||||
</ExternalLink>
|
||||
</ForwardRef>
|
||||
and
|
||||
<ExternalLink
|
||||
<ForwardRef
|
||||
href="https://mattermost.com/pl/privacy-policy/"
|
||||
id="privacyLink"
|
||||
location="license_settings"
|
||||
>
|
||||
Privacy Policy
|
||||
</ExternalLink>
|
||||
</ForwardRef>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -35,7 +35,7 @@ exports[`components/OpenIdConvert should match snapshot 1`] = `
|
||||
id="admin.openIdConvert.text"
|
||||
/>
|
||||
</button>
|
||||
<ExternalLink
|
||||
<ForwardRef
|
||||
className="btn-secondary"
|
||||
data-testid="openIdLearnMore"
|
||||
href="https://www.mattermost.com/default-openid-docs"
|
||||
@@ -45,7 +45,7 @@ exports[`components/OpenIdConvert should match snapshot 1`] = `
|
||||
defaultMessage="Learn more"
|
||||
id="admin.openIdConvert.help"
|
||||
/>
|
||||
</ExternalLink>
|
||||
</ForwardRef>
|
||||
<div
|
||||
className="error-message"
|
||||
data-testid="errorMessage"
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
.app__body .modal .GenericModal.channel-bookmarks-create-modal .modal-body .form-control {
|
||||
height: 40px;
|
||||
border: unset;
|
||||
}
|
||||
|
||||
.channel-bookmarks-create-modal {
|
||||
.linkInput {
|
||||
margin-bottom: 3rem;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.72);
|
||||
}
|
||||
|
||||
.Input___customMessage {
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
.LoadingSpinner {
|
||||
padding: 10px 12px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {FormattedMessage, useIntl} from 'react-intl';
|
||||
|
||||
import {GenericModal} from '@mattermost/components';
|
||||
|
||||
type Props = {
|
||||
displayName: string;
|
||||
onConfirm: () => void;
|
||||
onCancel?: () => void;
|
||||
onExited: () => void;
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
function BookmarkDeleteModal({
|
||||
displayName,
|
||||
onExited,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: Props) {
|
||||
const {formatMessage} = useIntl();
|
||||
|
||||
const title = formatMessage({
|
||||
id: 'channel_bookmarks.confirm.delete.title',
|
||||
defaultMessage: 'Delete bookmark',
|
||||
});
|
||||
|
||||
const confirmButtonText = formatMessage({
|
||||
id: 'channel_bookmarks.confirm.delete.button',
|
||||
defaultMessage: 'Yes, delete',
|
||||
});
|
||||
|
||||
const message = (
|
||||
<FormattedMessage
|
||||
id={'channel_bookmarks.confirm.delete.text'}
|
||||
defaultMessage={'Are you sure you want to delete the bookmark <strong>{displayName}</strong>?'}
|
||||
values={{
|
||||
strong: (chunk: string) => <strong>{chunk}</strong>,
|
||||
displayName,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<GenericModal
|
||||
confirmButtonText={confirmButtonText}
|
||||
handleCancel={onCancel ?? noop}
|
||||
handleConfirm={onConfirm}
|
||||
modalHeaderText={title}
|
||||
onExited={onExited}
|
||||
compassDesign={true}
|
||||
isDeleteModal={true}
|
||||
>
|
||||
{message}
|
||||
</GenericModal>
|
||||
);
|
||||
}
|
||||
|
||||
export default BookmarkDeleteModal;
|
||||
@@ -0,0 +1,176 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {useDispatch} from 'react-redux';
|
||||
|
||||
import {
|
||||
DotsHorizontalIcon,
|
||||
PencilOutlineIcon,
|
||||
LinkVariantIcon,
|
||||
TrashCanOutlineIcon,
|
||||
ArrowExpandIcon,
|
||||
OpenInNewIcon,
|
||||
BookOutlineIcon,
|
||||
} from '@mattermost/compass-icons/components';
|
||||
import type {ChannelBookmark, ChannelBookmarkPatch} from '@mattermost/types/channel_bookmarks';
|
||||
|
||||
import type {ActionResult} from 'mattermost-redux/types/actions';
|
||||
import {getFileDownloadUrl} from 'mattermost-redux/utils/file_utils';
|
||||
|
||||
import {editBookmark, deleteBookmark} from 'actions/channel_bookmarks';
|
||||
import {openModal} from 'actions/views/modals';
|
||||
|
||||
import GetPublicModal from 'components/get_public_link_modal';
|
||||
import * as Menu from 'components/menu';
|
||||
|
||||
import {ModalIdentifiers} from 'utils/constants';
|
||||
import {getSiteURL, shouldOpenInNewTab} from 'utils/url';
|
||||
import {copyToClipboard} from 'utils/utils';
|
||||
|
||||
import BookmarkDeleteModal from './bookmark_delete_modal';
|
||||
import ChannelBookmarksCreateModal from './channel_bookmarks_create_modal';
|
||||
import {useCanGetPublicLink, useChannelBookmarkPermission} from './utils';
|
||||
|
||||
type Props = {bookmark: ChannelBookmark; open: () => void};
|
||||
const BookmarkItemDotMenu = ({
|
||||
bookmark,
|
||||
open,
|
||||
}: Props) => {
|
||||
const {formatMessage} = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const siteURL = getSiteURL();
|
||||
const openInNewTab = bookmark.type === 'link' && bookmark.link_url && shouldOpenInNewTab(bookmark.link_url, siteURL);
|
||||
|
||||
let openIcon;
|
||||
if (bookmark.type === 'file') {
|
||||
openIcon = <ArrowExpandIcon size={18}/>;
|
||||
} else if (bookmark.link_url) {
|
||||
openIcon = openInNewTab ? <OpenInNewIcon size={18}/> : <BookOutlineIcon size={18}/>;
|
||||
}
|
||||
|
||||
const canEdit = useChannelBookmarkPermission(bookmark.channel_id, 'edit');
|
||||
const canDelete = useChannelBookmarkPermission(bookmark.channel_id, 'delete');
|
||||
const canGetPublicLink = useCanGetPublicLink();
|
||||
|
||||
const editLabel = formatMessage({id: 'channel_bookmarks.edit', defaultMessage: 'Edit'});
|
||||
const openLabel = formatMessage({id: 'channel_bookmarks.open', defaultMessage: 'Open'});
|
||||
const copyLinkLabel = formatMessage({id: 'channel_bookmarks.copy', defaultMessage: 'Copy link'});
|
||||
const copyFileLabel = formatMessage({id: 'channel_bookmarks.copyFilePublicLink', defaultMessage: 'Get a public link'});
|
||||
const deleteLabel = formatMessage({id: 'channel_bookmarks.delete', defaultMessage: 'Delete'});
|
||||
|
||||
const handleEdit = useCallback(() => {
|
||||
dispatch(openModal({
|
||||
modalId: ModalIdentifiers.CHANNEL_BOOKMARK_CREATE,
|
||||
dialogType: ChannelBookmarksCreateModal,
|
||||
dialogProps: {
|
||||
bookmark,
|
||||
channelId: bookmark.channel_id,
|
||||
onConfirm: async (data: ChannelBookmarkPatch) => dispatch(editBookmark(bookmark.channel_id, bookmark.id, data)) as ActionResult<boolean>,
|
||||
},
|
||||
}));
|
||||
}, [editBookmark, dispatch, bookmark]);
|
||||
|
||||
const copyLink = useCallback(() => {
|
||||
if (bookmark.type === 'link' && bookmark.link_url) {
|
||||
copyToClipboard(bookmark.link_url);
|
||||
} else if (bookmark.type === 'file' && bookmark.file_id) {
|
||||
copyToClipboard(getFileDownloadUrl(bookmark.file_id));
|
||||
}
|
||||
}, [bookmark.type, bookmark.link_url, bookmark.file_id]);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
dispatch(openModal({
|
||||
modalId: ModalIdentifiers.CHANNEL_BOOKMARK_DELETE,
|
||||
dialogType: BookmarkDeleteModal,
|
||||
dialogProps: {
|
||||
displayName: bookmark.display_name,
|
||||
onConfirm: () => dispatch(deleteBookmark(bookmark.channel_id, bookmark.id)),
|
||||
},
|
||||
}));
|
||||
}, [deleteBookmark, dispatch, bookmark]);
|
||||
|
||||
const handleGetPublicLink = useCallback(() => {
|
||||
if (!bookmark.file_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(openModal({
|
||||
modalId: ModalIdentifiers.GET_PUBLIC_LINK_MODAL,
|
||||
dialogType: GetPublicModal,
|
||||
dialogProps: {
|
||||
fileId: bookmark.file_id,
|
||||
},
|
||||
}));
|
||||
}, [bookmark.file_id, dispatch]);
|
||||
|
||||
return (
|
||||
<Menu.Container
|
||||
anchorOrigin={{vertical: 'bottom', horizontal: 'right'}}
|
||||
transformOrigin={{vertical: 'top', horizontal: 'right'}}
|
||||
menuButton={{
|
||||
id: `channelBookmarksDotMenuButton-${bookmark.id}`,
|
||||
class: 'channelBookmarksDotMenuButton',
|
||||
children: <DotsHorizontalIcon size={18}/>,
|
||||
'aria-label': formatMessage({id: 'channel_bookmarks.editBookmarkLabel', defaultMessage: 'Bookmark menu'}),
|
||||
}}
|
||||
menu={{
|
||||
id: 'channelBookmarksDotMenuDropdown',
|
||||
}}
|
||||
>
|
||||
<Menu.Item
|
||||
key='channelBookmarksOpen'
|
||||
id='channelBookmarksOpen'
|
||||
onClick={open}
|
||||
leadingElement={openIcon}
|
||||
labels={<span>{openLabel}</span>}
|
||||
aria-label={openLabel}
|
||||
/>
|
||||
{canEdit && (
|
||||
<Menu.Item
|
||||
key='channelBookmarksEdit'
|
||||
id='channelBookmarksEdit'
|
||||
onClick={handleEdit}
|
||||
leadingElement={<PencilOutlineIcon size={18}/>}
|
||||
labels={<span>{editLabel}</span>}
|
||||
aria-label={editLabel}
|
||||
/>
|
||||
)}
|
||||
{bookmark.type === 'link' && (
|
||||
<Menu.Item
|
||||
key='channelBookmarksLinkCopy'
|
||||
id='channelBookmarksLinkCopy'
|
||||
onClick={copyLink}
|
||||
leadingElement={<LinkVariantIcon size={18}/>}
|
||||
labels={<span>{copyLinkLabel}</span>}
|
||||
aria-label={copyLinkLabel}
|
||||
/>
|
||||
)}
|
||||
{bookmark.type === 'file' && canGetPublicLink && (
|
||||
<Menu.Item
|
||||
key='channelBookmarksFileCopy'
|
||||
id='channelBookmarksFileCopy'
|
||||
onClick={handleGetPublicLink}
|
||||
leadingElement={<LinkVariantIcon size={18}/>}
|
||||
labels={<span>{copyFileLabel}</span>}
|
||||
aria-label={copyFileLabel}
|
||||
/>
|
||||
)}
|
||||
{canDelete && (
|
||||
<Menu.Item
|
||||
key='channelBookmarksDelete'
|
||||
id='channelBookmarksDelete'
|
||||
onClick={handleDelete}
|
||||
leadingElement={<TrashCanOutlineIcon size={18}/>}
|
||||
labels={<span>{deleteLabel}</span>}
|
||||
aria-label={deleteLabel}
|
||||
isDestructive={true}
|
||||
/>
|
||||
)}
|
||||
</Menu.Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookmarkItemDotMenu;
|
||||
@@ -0,0 +1,92 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {useSelector} from 'react-redux';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import {FileGenericOutlineIcon, BookOutlineIcon} from '@mattermost/compass-icons/components';
|
||||
import type {ChannelBookmark} from '@mattermost/types/channel_bookmarks';
|
||||
import type {FileInfo} from '@mattermost/types/files';
|
||||
|
||||
import {getConfig} from 'mattermost-redux/selectors/entities/general';
|
||||
|
||||
import RenderEmoji from 'components/emoji/render_emoji';
|
||||
import FileThumbnail from 'components/file_attachment/file_thumbnail';
|
||||
import type {FilePreviewInfo} from 'components/file_preview/file_preview';
|
||||
|
||||
import {trimmedEmojiName} from 'utils/emoji_utils';
|
||||
import {getImageSrc} from 'utils/post_utils';
|
||||
|
||||
import type {GlobalState} from 'types/store';
|
||||
|
||||
type Props = {
|
||||
type: ChannelBookmark['type'];
|
||||
emoji?: string;
|
||||
imageUrl?: string;
|
||||
fileInfo?: FileInfo | FilePreviewInfo;
|
||||
size?: 16 | 24;
|
||||
}
|
||||
|
||||
const BookmarkIcon = ({
|
||||
type,
|
||||
emoji,
|
||||
imageUrl,
|
||||
fileInfo,
|
||||
size = 16,
|
||||
}: Props) => {
|
||||
let icon = type === 'link' ? <BookOutlineIcon size={size}/> : <FileGenericOutlineIcon size={size}/>;
|
||||
const emojiName = emoji && trimmedEmojiName(emoji);
|
||||
const hasImageProxy = useSelector((state: GlobalState) => getConfig(state).HasImageProxy === 'true');
|
||||
|
||||
if (emojiName) {
|
||||
icon = (
|
||||
<RenderEmoji
|
||||
emojiName={emojiName}
|
||||
size={size}
|
||||
/>
|
||||
);
|
||||
} else if (imageUrl) {
|
||||
icon = (
|
||||
<BookmarkIconImg
|
||||
src={getImageSrc(imageUrl, hasImageProxy)}
|
||||
size={size}
|
||||
/>
|
||||
);
|
||||
} else if (fileInfo) {
|
||||
icon = (
|
||||
<FileThumbnail
|
||||
fileInfo={fileInfo}
|
||||
disablePreview={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Icon $size={size}>
|
||||
{icon}
|
||||
</Icon>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookmarkIcon;
|
||||
|
||||
const Icon = styled.div<{$size: number}>`
|
||||
padding: 3px 1px 3px 2px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.file-icon {
|
||||
width: ${({$size: size}) => size}px;
|
||||
height: ${({$size: size}) => size}px;
|
||||
background-size: ${({$size: size}) => size * 0.8}px ${({$size: size}) => size}px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
`;
|
||||
|
||||
const BookmarkIconImg = styled.img<{size: number}>`
|
||||
width: ${({size}) => size}px;
|
||||
height: ${({size}) => size}px;
|
||||
`;
|
||||
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"
|
||||
viewArchivedChannels={false}
|
||||
/>
|
||||
<ChannelBookmarks
|
||||
channelId="channelId"
|
||||
/>
|
||||
<DeferredRenderWrapper
|
||||
channelId="channelId"
|
||||
/>
|
||||
@@ -85,6 +88,9 @@ exports[`components/channel_view Should match snapshot if channel is deactivated
|
||||
teamUrl="/team"
|
||||
viewArchivedChannels={false}
|
||||
/>
|
||||
<ChannelBookmarks
|
||||
channelId="channelId"
|
||||
/>
|
||||
<DeferredRenderWrapper
|
||||
channelId="channelId"
|
||||
/>
|
||||
@@ -141,6 +147,9 @@ exports[`components/channel_view Should match snapshot with base props 1`] = `
|
||||
teamUrl="/team"
|
||||
viewArchivedChannels={false}
|
||||
/>
|
||||
<ChannelBookmarks
|
||||
channelId="channelId"
|
||||
/>
|
||||
<DeferredRenderWrapper
|
||||
channelId="channelId"
|
||||
/>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {FormattedMessage} from 'react-intl';
|
||||
import type {RouteComponentProps} from 'react-router-dom';
|
||||
|
||||
import AdvancedCreatePost from 'components/advanced_create_post';
|
||||
import ChannelBookmarks from 'components/channel_bookmarks';
|
||||
import ChannelHeader from 'components/channel_header';
|
||||
import deferComponentRender from 'components/deferComponentRender';
|
||||
import FileUploadOverlay from 'components/file_upload_overlay';
|
||||
@@ -173,9 +174,8 @@ export default class ChannelView extends React.PureComponent<Props, State> {
|
||||
className='app__content'
|
||||
>
|
||||
<FileUploadOverlay overlayType='center'/>
|
||||
<ChannelHeader
|
||||
{...this.props}
|
||||
/>
|
||||
<ChannelHeader {...this.props}/>
|
||||
<ChannelBookmarks channelId={this.props.channelId}/>
|
||||
<DeferredPostView
|
||||
channelId={this.props.channelId}
|
||||
focusedPostId={this.state.focusedPostId}
|
||||
|
||||
@@ -12,10 +12,10 @@ import AnyTeamPermissionGate from 'components/permissions_gates/any_team_permiss
|
||||
interface Props {
|
||||
customEmojisEnabled: boolean;
|
||||
currentTeamName: string;
|
||||
handleEmojiPickerClose: () => void;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function EmojiPickerCustomEmojiButton({customEmojisEnabled, currentTeamName, handleEmojiPickerClose}: Props) {
|
||||
function EmojiPickerCustomEmojiButton({customEmojisEnabled, currentTeamName, onClick}: Props) {
|
||||
if (!customEmojisEnabled) {
|
||||
return null;
|
||||
}
|
||||
@@ -30,7 +30,7 @@ function EmojiPickerCustomEmojiButton({customEmojisEnabled, currentTeamName, han
|
||||
<Link
|
||||
className='btn btn-tertiary'
|
||||
to={`/${currentTeamName}/emoji`}
|
||||
onClick={handleEmojiPickerClose}
|
||||
onClick={onClick}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='emoji_picker.custom_emoji'
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface Props extends PropsFromRedux {
|
||||
onEmojiClick: (emoji: Emoji) => void;
|
||||
handleFilterChange: (filter: string) => void;
|
||||
handleEmojiPickerClose: () => void;
|
||||
onAddCustomEmojiClick?: () => void;
|
||||
}
|
||||
|
||||
const EmojiPicker = ({
|
||||
@@ -46,6 +47,7 @@ const EmojiPicker = ({
|
||||
onEmojiClick,
|
||||
handleFilterChange,
|
||||
handleEmojiPickerClose,
|
||||
onAddCustomEmojiClick,
|
||||
customEmojisEnabled = false,
|
||||
customEmojiPage = 0,
|
||||
emojiMap,
|
||||
@@ -204,6 +206,11 @@ const EmojiPicker = ({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onAddCustomEmojiClickInner = useCallback(() => {
|
||||
handleEmojiPickerClose();
|
||||
onAddCustomEmojiClick?.();
|
||||
}, []);
|
||||
|
||||
const [cursorCategory, cursorCategoryIndex, cursorEmojiIndex] = getCursorProperties(cursor.rowIndex, cursor.emojiId, categoryOrEmojisRows as EmojiRow[]);
|
||||
|
||||
function calculateNewCursorForUpArrow(cursorCategory: string, emojiPositions: EmojiPosition[], currentCursorsPositionIndex: number, categories: Categories, focusOnSearchInput: () => void) {
|
||||
@@ -429,13 +436,11 @@ const EmojiPicker = ({
|
||||
/>
|
||||
)}
|
||||
<div className='emoji-picker__footer'>
|
||||
{areSearchResultsEmpty ? (<div/>) :
|
||||
(<EmojiPickerPreview emoji={cursor.emoji}/>)
|
||||
}
|
||||
{areSearchResultsEmpty ? <div/> : <EmojiPickerPreview emoji={cursor.emoji}/>}
|
||||
<EmojiPickerCustomEmojiButton
|
||||
currentTeamName={currentTeamName}
|
||||
customEmojisEnabled={customEmojisEnabled}
|
||||
handleEmojiPickerClose={handleEmojiPickerClose}
|
||||
onClick={onAddCustomEmojiClickInner}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import memoize from 'memoize-one';
|
||||
import React from 'react';
|
||||
import type {ReactNode} from 'react';
|
||||
import type {ComponentProps, ReactNode} from 'react';
|
||||
import {Overlay} from 'react-bootstrap';
|
||||
|
||||
import type {Emoji} from '@mattermost/types/emojis';
|
||||
@@ -20,9 +20,11 @@ export interface Props extends PropsFromRedux {
|
||||
target: () => ReactNode;
|
||||
onEmojiClick: (emoji: Emoji) => void;
|
||||
onGifClick?: (gif: string) => void;
|
||||
onAddCustomEmojiClick?: () => void;
|
||||
onHide: () => void;
|
||||
onExited?: () => void;
|
||||
show: boolean;
|
||||
placement?: ComponentProps<typeof Overlay>['placement'];
|
||||
topOffset?: number;
|
||||
rightOffset?: number;
|
||||
leftOffset?: number;
|
||||
@@ -89,7 +91,7 @@ export default class EmojiPickerOverlay extends React.PureComponent<Props> {
|
||||
return (
|
||||
<Overlay
|
||||
show={show}
|
||||
placement={placement}
|
||||
placement={this.props.placement ?? placement}
|
||||
rootClose={!isMobileView}
|
||||
container={this.props.container}
|
||||
onHide={this.props.onHide}
|
||||
@@ -105,6 +107,7 @@ export default class EmojiPickerOverlay extends React.PureComponent<Props> {
|
||||
rightOffset={calculatedRightOffset}
|
||||
topOffset={this.props.topOffset}
|
||||
leftOffset={this.props.leftOffset}
|
||||
onAddCustomEmojiClick={this.props.onAddCustomEmojiClick}
|
||||
/>
|
||||
</Overlay>
|
||||
);
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface Props {
|
||||
onEmojiClose: () => void;
|
||||
onEmojiClick: (emoji: Emoji) => void;
|
||||
onGifClick?: (gif: string) => void;
|
||||
onAddCustomEmojiClick?: () => void;
|
||||
enableGifPicker?: boolean;
|
||||
}
|
||||
|
||||
@@ -176,6 +177,7 @@ export default class EmojiPickerTabs extends PureComponent<Props, State> {
|
||||
onEmojiClick={this.props.onEmojiClick}
|
||||
handleFilterChange={this.handleFilterChange}
|
||||
handleEmojiPickerClose={this.handleEmojiPickerClose}
|
||||
onAddCustomEmojiClick={this.props.onAddCustomEmojiClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`components/error_page/ErrorLink should match snapshot 1`] = `
|
||||
<ExternalLink
|
||||
<ForwardRef
|
||||
href="https://docs.mattermost.com/deployment/sso-gitlab.html"
|
||||
location="error_link"
|
||||
>
|
||||
@@ -9,5 +9,5 @@ exports[`components/error_page/ErrorLink should match snapshot 1`] = `
|
||||
defaultMessage="GitLab"
|
||||
id="error.oauth_missing_code.gitlab.link"
|
||||
/>
|
||||
</ExternalLink>
|
||||
</ForwardRef>
|
||||
`;
|
||||
|
||||
@@ -13,7 +13,7 @@ exports[`components/external_link should match snapshot 1`] = `
|
||||
}
|
||||
}
|
||||
>
|
||||
<ExternalLink
|
||||
<ForwardRef
|
||||
href="https://mattermost.com"
|
||||
location="test"
|
||||
>
|
||||
@@ -26,6 +26,6 @@ exports[`components/external_link should match snapshot 1`] = `
|
||||
>
|
||||
Click Me
|
||||
</a>
|
||||
</ExternalLink>
|
||||
</ForwardRef>
|
||||
</Provider>
|
||||
`;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
/* eslint-disable @mattermost/use-external-link */
|
||||
|
||||
import React from 'react';
|
||||
import React, {forwardRef} from 'react';
|
||||
import {useSelector} from 'react-redux';
|
||||
|
||||
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/common';
|
||||
@@ -29,7 +29,7 @@ type Props = React.AnchorHTMLAttributes<HTMLAnchorElement> & {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export default function ExternalLink(props: Props) {
|
||||
const ExternalLink = forwardRef<HTMLAnchorElement, Props>((props, ref) => {
|
||||
const userId = useSelector(getCurrentUserId);
|
||||
const config = useSelector(getConfig);
|
||||
const license = useSelector(getLicense);
|
||||
@@ -70,6 +70,7 @@ export default function ExternalLink(props: Props) {
|
||||
return (
|
||||
<a
|
||||
{...props}
|
||||
ref={ref}
|
||||
target={props.target || '_blank'}
|
||||
rel={props.rel || 'noopener noreferrer'}
|
||||
onClick={handleClick}
|
||||
@@ -78,4 +79,6 @@ export default function ExternalLink(props: Props) {
|
||||
{props.children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default ExternalLink;
|
||||
|
||||
@@ -37,14 +37,14 @@ exports[`components/file_attachment/FilenameOverlay should match snapshot, stand
|
||||
placement="top"
|
||||
title="Download"
|
||||
>
|
||||
<ExternalLink
|
||||
<ForwardRef
|
||||
aria-label="download"
|
||||
download="test_filename"
|
||||
href="/api/v4/files/thumbnail_id?download=1"
|
||||
location="filename_overlay"
|
||||
>
|
||||
test_filename
|
||||
</ExternalLink>
|
||||
</ForwardRef>
|
||||
</WithTooltip>
|
||||
</div>
|
||||
`;
|
||||
@@ -58,14 +58,14 @@ exports[`components/file_attachment/FilenameOverlay should match snapshot, with
|
||||
placement="top"
|
||||
title="Download"
|
||||
>
|
||||
<ExternalLink
|
||||
<ForwardRef
|
||||
aria-label="download"
|
||||
download="test_filename"
|
||||
href="/api/v4/files/thumbnail_id?download=1"
|
||||
location="filename_overlay"
|
||||
>
|
||||
<AttachmentIcon />
|
||||
</ExternalLink>
|
||||
</ForwardRef>
|
||||
</WithTooltip>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -52,6 +52,7 @@ interface Props extends PropsFromRedux {
|
||||
* Display in compact format
|
||||
*/
|
||||
compactDisplay?: boolean;
|
||||
disablePreview?: boolean;
|
||||
handleFileDropdownOpened?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
@@ -190,6 +191,7 @@ export default function FileAttachment(props: Props) {
|
||||
defaultItems.push(
|
||||
<Menu.ItemAction
|
||||
data-title='Public Image'
|
||||
key={fileInfo.id + '_publiclinkmenuitem'}
|
||||
onClick={handleGetPublicLink}
|
||||
ariaLabel={formatMessage({id: 'view_image_popover.publicLink', defaultMessage: 'Get a public link'})}
|
||||
text={formatMessage({id: 'view_image_popover.publicLink', defaultMessage: 'Get a public link'})}
|
||||
@@ -284,7 +286,10 @@ export default function FileAttachment(props: Props) {
|
||||
onClick={onAttachmentClick}
|
||||
>
|
||||
{loaded ? (
|
||||
<FileThumbnail fileInfo={fileInfo}/>
|
||||
<FileThumbnail
|
||||
fileInfo={fileInfo}
|
||||
disablePreview={props.disablePreview}
|
||||
/>
|
||||
) : (
|
||||
<div className='post-image__load'/>
|
||||
)}
|
||||
|
||||
@@ -7,55 +7,72 @@ import type {FileInfo} from '@mattermost/types/files';
|
||||
|
||||
import {getFileThumbnailUrl, getFileUrl} from 'mattermost-redux/utils/file_utils';
|
||||
|
||||
import type {FilePreviewInfo} from 'components/file_preview/file_preview';
|
||||
|
||||
import Constants, {FileTypes} from 'utils/constants';
|
||||
import {getFileTypeFromMime} from 'utils/file_utils';
|
||||
import {
|
||||
getFileType,
|
||||
getIconClassName,
|
||||
isGIFImage,
|
||||
} from 'utils/utils';
|
||||
|
||||
type FilePreviewInfoLimited = Pick<FilePreviewInfo, 'clientId' | 'name' | 'percent' | 'type'>;
|
||||
|
||||
type Props = {
|
||||
enableSVGs: boolean;
|
||||
fileInfo: FileInfo;
|
||||
}
|
||||
fileInfo: FileInfo | FilePreviewInfo | FilePreviewInfoLimited;
|
||||
disablePreview?: boolean;
|
||||
};
|
||||
|
||||
const FileThumbnail = ({
|
||||
fileInfo,
|
||||
enableSVGs,
|
||||
disablePreview,
|
||||
}: Props) => {
|
||||
const type = getFileType(fileInfo.extension);
|
||||
const {id, extension, has_preview_image: hasPreviewImage, width = 0, height = 0} = (fileInfo as FileInfo);
|
||||
const mimeType = (fileInfo as FileInfo).mime_type || (fileInfo as FilePreviewInfo | FilePreviewInfoLimited).type;
|
||||
|
||||
if (type === FileTypes.IMAGE) {
|
||||
let className = 'post-image';
|
||||
let type = FileTypes.OTHER;
|
||||
if (extension) {
|
||||
type = getFileType(extension);
|
||||
} else if (mimeType) {
|
||||
type = getFileTypeFromMime(mimeType);
|
||||
}
|
||||
|
||||
if (fileInfo.width < Constants.THUMBNAIL_WIDTH && fileInfo.height < Constants.THUMBNAIL_HEIGHT) {
|
||||
className += ' small';
|
||||
} else {
|
||||
className += ' normal';
|
||||
if (id && !disablePreview) {
|
||||
if (type === FileTypes.IMAGE) {
|
||||
let className = 'post-image';
|
||||
|
||||
if (width < Constants.THUMBNAIL_WIDTH && height < Constants.THUMBNAIL_HEIGHT) {
|
||||
className += ' small';
|
||||
} else {
|
||||
className += ' normal';
|
||||
}
|
||||
|
||||
let thumbnailUrl = getFileThumbnailUrl(id);
|
||||
if (extension && isGIFImage(extension) && !hasPreviewImage) {
|
||||
thumbnailUrl = getFileUrl(id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
backgroundImage: `url(${thumbnailUrl})`,
|
||||
backgroundSize: 'cover',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else if (extension === FileTypes.SVG && enableSVGs) {
|
||||
return (
|
||||
<img
|
||||
alt={'file thumbnail image'}
|
||||
className='post-image normal'
|
||||
src={getFileUrl(id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let thumbnailUrl = getFileThumbnailUrl(fileInfo.id);
|
||||
if (isGIFImage(fileInfo.extension) && !fileInfo.has_preview_image) {
|
||||
thumbnailUrl = getFileUrl(fileInfo.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
backgroundImage: `url(${thumbnailUrl})`,
|
||||
backgroundSize: 'cover',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else if (fileInfo.extension === FileTypes.SVG && enableSVGs) {
|
||||
return (
|
||||
<img
|
||||
alt={'file thumbnail image'}
|
||||
className='post-image normal'
|
||||
src={getFileUrl(fileInfo.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className={'file-icon ' + getIconClassName(type)}/>;
|
||||
|
||||
@@ -75,57 +75,6 @@ exports[`components/file_search_result/FileSearchResultItem should match snapsho
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<OverlayTrigger
|
||||
defaultOverlayShown={false}
|
||||
delayShow={1000}
|
||||
overlay={
|
||||
<Tooltip
|
||||
id="file-name__tooltip"
|
||||
>
|
||||
More Actions
|
||||
</Tooltip>
|
||||
}
|
||||
placement="top"
|
||||
trigger={
|
||||
Array [
|
||||
"hover",
|
||||
"focus",
|
||||
]
|
||||
}
|
||||
>
|
||||
<MenuWrapper
|
||||
animationComponent={[Function]}
|
||||
className=""
|
||||
onToggle={[Function]}
|
||||
stopPropagationOnToggle={true}
|
||||
>
|
||||
<a
|
||||
className="action-icon dots-icon"
|
||||
href="#"
|
||||
>
|
||||
<i
|
||||
className="icon icon-dots-vertical"
|
||||
/>
|
||||
</a>
|
||||
<Menu
|
||||
ariaLabel="file menu"
|
||||
openLeft={true}
|
||||
>
|
||||
<MenuItemAction
|
||||
ariaLabel="Open in channel"
|
||||
onClick={[Function]}
|
||||
show={true}
|
||||
text="Open in channel"
|
||||
/>
|
||||
<MenuItemAction
|
||||
ariaLabel="Copy link"
|
||||
onClick={[Function]}
|
||||
show={true}
|
||||
text="Copy link"
|
||||
/>
|
||||
</Menu>
|
||||
</MenuWrapper>
|
||||
</OverlayTrigger>
|
||||
<OverlayTrigger
|
||||
defaultOverlayShown={false}
|
||||
delayShow={1000}
|
||||
@@ -242,57 +191,6 @@ exports[`components/file_search_result/FileSearchResultItem should match snapsho
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<OverlayTrigger
|
||||
defaultOverlayShown={false}
|
||||
delayShow={1000}
|
||||
overlay={
|
||||
<Tooltip
|
||||
id="file-name__tooltip"
|
||||
>
|
||||
More Actions
|
||||
</Tooltip>
|
||||
}
|
||||
placement="top"
|
||||
trigger={
|
||||
Array [
|
||||
"hover",
|
||||
"focus",
|
||||
]
|
||||
}
|
||||
>
|
||||
<MenuWrapper
|
||||
animationComponent={[Function]}
|
||||
className=""
|
||||
onToggle={[Function]}
|
||||
stopPropagationOnToggle={true}
|
||||
>
|
||||
<a
|
||||
className="action-icon dots-icon"
|
||||
href="#"
|
||||
>
|
||||
<i
|
||||
className="icon icon-dots-vertical"
|
||||
/>
|
||||
</a>
|
||||
<Menu
|
||||
ariaLabel="file menu"
|
||||
openLeft={true}
|
||||
>
|
||||
<MenuItemAction
|
||||
ariaLabel="Open in channel"
|
||||
onClick={[Function]}
|
||||
show={true}
|
||||
text="Open in channel"
|
||||
/>
|
||||
<MenuItemAction
|
||||
ariaLabel="Copy link"
|
||||
onClick={[Function]}
|
||||
show={true}
|
||||
text="Copy link"
|
||||
/>
|
||||
</Menu>
|
||||
</MenuWrapper>
|
||||
</OverlayTrigger>
|
||||
<OverlayTrigger
|
||||
defaultOverlayShown={false}
|
||||
delayShow={1000}
|
||||
@@ -409,57 +307,6 @@ exports[`components/file_search_result/FileSearchResultItem should match snapsho
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<OverlayTrigger
|
||||
defaultOverlayShown={false}
|
||||
delayShow={1000}
|
||||
overlay={
|
||||
<Tooltip
|
||||
id="file-name__tooltip"
|
||||
>
|
||||
More Actions
|
||||
</Tooltip>
|
||||
}
|
||||
placement="top"
|
||||
trigger={
|
||||
Array [
|
||||
"hover",
|
||||
"focus",
|
||||
]
|
||||
}
|
||||
>
|
||||
<MenuWrapper
|
||||
animationComponent={[Function]}
|
||||
className=""
|
||||
onToggle={[Function]}
|
||||
stopPropagationOnToggle={true}
|
||||
>
|
||||
<a
|
||||
className="action-icon dots-icon"
|
||||
href="#"
|
||||
>
|
||||
<i
|
||||
className="icon icon-dots-vertical"
|
||||
/>
|
||||
</a>
|
||||
<Menu
|
||||
ariaLabel="file menu"
|
||||
openLeft={true}
|
||||
>
|
||||
<MenuItemAction
|
||||
ariaLabel="Open in channel"
|
||||
onClick={[Function]}
|
||||
show={true}
|
||||
text="Open in channel"
|
||||
/>
|
||||
<MenuItemAction
|
||||
ariaLabel="Copy link"
|
||||
onClick={[Function]}
|
||||
show={true}
|
||||
text="Copy link"
|
||||
/>
|
||||
</Menu>
|
||||
</MenuWrapper>
|
||||
</OverlayTrigger>
|
||||
<OverlayTrigger
|
||||
defaultOverlayShown={false}
|
||||
delayShow={1000}
|
||||
@@ -571,57 +418,6 @@ exports[`components/file_search_result/FileSearchResultItem should match snapsho
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<OverlayTrigger
|
||||
defaultOverlayShown={false}
|
||||
delayShow={1000}
|
||||
overlay={
|
||||
<Tooltip
|
||||
id="file-name__tooltip"
|
||||
>
|
||||
More Actions
|
||||
</Tooltip>
|
||||
}
|
||||
placement="top"
|
||||
trigger={
|
||||
Array [
|
||||
"hover",
|
||||
"focus",
|
||||
]
|
||||
}
|
||||
>
|
||||
<MenuWrapper
|
||||
animationComponent={[Function]}
|
||||
className=""
|
||||
onToggle={[Function]}
|
||||
stopPropagationOnToggle={true}
|
||||
>
|
||||
<a
|
||||
className="action-icon dots-icon"
|
||||
href="#"
|
||||
>
|
||||
<i
|
||||
className="icon icon-dots-vertical"
|
||||
/>
|
||||
</a>
|
||||
<Menu
|
||||
ariaLabel="file menu"
|
||||
openLeft={true}
|
||||
>
|
||||
<MenuItemAction
|
||||
ariaLabel="Open in channel"
|
||||
onClick={[Function]}
|
||||
show={true}
|
||||
text="Open in channel"
|
||||
/>
|
||||
<MenuItemAction
|
||||
ariaLabel="Copy link"
|
||||
onClick={[Function]}
|
||||
show={true}
|
||||
text="Copy link"
|
||||
/>
|
||||
</Menu>
|
||||
</MenuWrapper>
|
||||
</OverlayTrigger>
|
||||
<OverlayTrigger
|
||||
defaultOverlayShown={false}
|
||||
delayShow={1000}
|
||||
|
||||
@@ -145,43 +145,45 @@ export default class FileSearchResultItem extends React.PureComponent<Props, Sta
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<OverlayTrigger
|
||||
delayShow={1000}
|
||||
placement='top'
|
||||
overlay={
|
||||
<Tooltip id='file-name__tooltip'>
|
||||
{localizeMessage('file_search_result_item.more_actions', 'More Actions')}
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<MenuWrapper
|
||||
onToggle={this.keepOpen}
|
||||
stopPropagationOnToggle={true}
|
||||
{this.props.fileInfo.post_id && (
|
||||
<OverlayTrigger
|
||||
delayShow={1000}
|
||||
placement='top'
|
||||
overlay={
|
||||
<Tooltip id='file-name__tooltip'>
|
||||
{localizeMessage('file_search_result_item.more_actions', 'More Actions')}
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<a
|
||||
href='#'
|
||||
className='action-icon dots-icon'
|
||||
<MenuWrapper
|
||||
onToggle={this.keepOpen}
|
||||
stopPropagationOnToggle={true}
|
||||
>
|
||||
<i className='icon icon-dots-vertical'/>
|
||||
</a>
|
||||
<Menu
|
||||
ariaLabel={'file menu'}
|
||||
openLeft={true}
|
||||
>
|
||||
<Menu.ItemAction
|
||||
onClick={this.jumpToConv}
|
||||
ariaLabel={localizeMessage('file_search_result_item.open_in_channel', 'Open in channel')}
|
||||
text={localizeMessage('file_search_result_item.open_in_channel', 'Open in channel')}
|
||||
/>
|
||||
<Menu.ItemAction
|
||||
onClick={this.copyLink}
|
||||
ariaLabel={localizeMessage('file_search_result_item.copy_link', 'Copy link')}
|
||||
text={localizeMessage('file_search_result_item.copy_link', 'Copy link')}
|
||||
/>
|
||||
{this.renderPluginItems()}
|
||||
</Menu>
|
||||
</MenuWrapper>
|
||||
</OverlayTrigger>
|
||||
<a
|
||||
href='#'
|
||||
className='action-icon dots-icon'
|
||||
>
|
||||
<i className='icon icon-dots-vertical'/>
|
||||
</a>
|
||||
<Menu
|
||||
ariaLabel={'file menu'}
|
||||
openLeft={true}
|
||||
>
|
||||
<Menu.ItemAction
|
||||
onClick={this.jumpToConv}
|
||||
ariaLabel={localizeMessage('file_search_result_item.open_in_channel', 'Open in channel')}
|
||||
text={localizeMessage('file_search_result_item.open_in_channel', 'Open in channel')}
|
||||
/>
|
||||
<Menu.ItemAction
|
||||
onClick={this.copyLink}
|
||||
ariaLabel={localizeMessage('file_search_result_item.copy_link', 'Copy link')}
|
||||
text={localizeMessage('file_search_result_item.copy_link', 'Copy link')}
|
||||
/>
|
||||
{this.renderPluginItems()}
|
||||
</Menu>
|
||||
</MenuWrapper>
|
||||
</OverlayTrigger>
|
||||
)}
|
||||
<OverlayTrigger
|
||||
delayShow={1000}
|
||||
placement='top'
|
||||
|
||||
@@ -139,7 +139,7 @@ exports[`components/integrations/AbstractCommand should match snapshot 1`] = `
|
||||
id="add_command.trigger.helpReserved"
|
||||
values={
|
||||
Object {
|
||||
"link": <ExternalLink
|
||||
"link": <ForwardRef
|
||||
href="https://mattermost.com/pl/custom-slash-commands"
|
||||
location="abstract_command"
|
||||
>
|
||||
@@ -147,7 +147,7 @@ exports[`components/integrations/AbstractCommand should match snapshot 1`] = `
|
||||
defaultMessage="See built-in slash commands"
|
||||
id="add_command.trigger.helpReservedLinkText"
|
||||
/>
|
||||
</ExternalLink>,
|
||||
</ForwardRef>,
|
||||
}
|
||||
}
|
||||
/>
|
||||
@@ -588,7 +588,7 @@ exports[`components/integrations/AbstractCommand should match snapshot when head
|
||||
id="add_command.trigger.helpReserved"
|
||||
values={
|
||||
Object {
|
||||
"link": <ExternalLink
|
||||
"link": <ForwardRef
|
||||
href="https://mattermost.com/pl/custom-slash-commands"
|
||||
location="abstract_command"
|
||||
>
|
||||
@@ -596,7 +596,7 @@ exports[`components/integrations/AbstractCommand should match snapshot when head
|
||||
defaultMessage="See built-in slash commands"
|
||||
id="add_command.trigger.helpReservedLinkText"
|
||||
/>
|
||||
</ExternalLink>,
|
||||
</ForwardRef>,
|
||||
}
|
||||
}
|
||||
/>
|
||||
@@ -1037,7 +1037,7 @@ exports[`components/integrations/AbstractCommand should match snapshot, displays
|
||||
id="add_command.trigger.helpReserved"
|
||||
values={
|
||||
Object {
|
||||
"link": <ExternalLink
|
||||
"link": <ForwardRef
|
||||
href="https://mattermost.com/pl/custom-slash-commands"
|
||||
location="abstract_command"
|
||||
>
|
||||
@@ -1045,7 +1045,7 @@ exports[`components/integrations/AbstractCommand should match snapshot, displays
|
||||
defaultMessage="See built-in slash commands"
|
||||
id="add_command.trigger.helpReservedLinkText"
|
||||
/>
|
||||
</ExternalLink>,
|
||||
</ForwardRef>,
|
||||
}
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -291,7 +291,7 @@ callbackUrl2.com
|
||||
id="add_outgoing_webhook.callbackUrls.help"
|
||||
values={
|
||||
Object {
|
||||
"link": <ExternalLink
|
||||
"link": <ForwardRef
|
||||
href="https://mattermost.com/pl/default-allow-untrusted-internal-connections"
|
||||
location="abstract_outgoing_webhook"
|
||||
>
|
||||
@@ -299,7 +299,7 @@ callbackUrl2.com
|
||||
defaultMessage="trusted internal connection"
|
||||
id="add_outgoing_webhook.callbackUrls.helpLinkText"
|
||||
/>
|
||||
</ExternalLink>,
|
||||
</ForwardRef>,
|
||||
}
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -29,7 +29,7 @@ exports[`components/integrations/InstalledOAuthApps should match snapshot 1`] =
|
||||
id="installed_oauth_apps.help"
|
||||
values={
|
||||
Object {
|
||||
"appDirectory": <ExternalLink
|
||||
"appDirectory": <ForwardRef
|
||||
href="https://mattermost.com/marketplace/"
|
||||
location="installed_oauth_apps"
|
||||
>
|
||||
@@ -37,8 +37,8 @@ exports[`components/integrations/InstalledOAuthApps should match snapshot 1`] =
|
||||
defaultMessage="App Directory"
|
||||
id="installed_oauth_apps.help.appDirectory"
|
||||
/>
|
||||
</ExternalLink>,
|
||||
"oauthApplications": <ExternalLink
|
||||
</ForwardRef>,
|
||||
"oauthApplications": <ForwardRef
|
||||
href="https://mattermost.com/pl/setup-oauth-2.0"
|
||||
location="installed_oauth_apps"
|
||||
>
|
||||
@@ -46,7 +46,7 @@ exports[`components/integrations/InstalledOAuthApps should match snapshot 1`] =
|
||||
defaultMessage="OAuth 2.0 applications"
|
||||
id="installed_oauth_apps.help.oauthApplications"
|
||||
/>
|
||||
</ExternalLink>,
|
||||
</ForwardRef>,
|
||||
}
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -257,7 +257,7 @@ exports[`components/integrations/InstalledOutgoingWebhooks should match snapshot
|
||||
id="installed_outgoing_webhooks.help"
|
||||
values={
|
||||
Object {
|
||||
"appDirectory": <ExternalLink
|
||||
"appDirectory": <ForwardRef
|
||||
href="https://mattermost.com/marketplace"
|
||||
location="installed_outgoing_webhooks"
|
||||
>
|
||||
@@ -265,8 +265,8 @@ exports[`components/integrations/InstalledOutgoingWebhooks should match snapshot
|
||||
defaultMessage="App Directory"
|
||||
id="installed_outgoing_webhooks.help.appDirectory"
|
||||
/>
|
||||
</ExternalLink>,
|
||||
"buildYourOwn": <ExternalLink
|
||||
</ForwardRef>,
|
||||
"buildYourOwn": <ForwardRef
|
||||
href="https://mattermost.com/pl/setup-outgoing-webhooks"
|
||||
location="installed_outgoing_webhooks"
|
||||
>
|
||||
@@ -274,7 +274,7 @@ exports[`components/integrations/InstalledOutgoingWebhooks should match snapshot
|
||||
defaultMessage="Build your own"
|
||||
id="installed_outgoing_webhooks.help.buildYourOwn"
|
||||
/>
|
||||
</ExternalLink>,
|
||||
</ForwardRef>,
|
||||
}
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -85,7 +85,7 @@ exports[`components/integrations/InstalledOutgoingOAuthConnections should match
|
||||
id="installed_outgoing_oauth_connections.help"
|
||||
values={
|
||||
Object {
|
||||
"outgoingOauthConnections": <ExternalLink
|
||||
"outgoingOauthConnections": <ForwardRef
|
||||
href="https://mattermost.com/pl/setup-oauth-2.0"
|
||||
location="installed_outgoing_oauth_connections"
|
||||
>
|
||||
@@ -93,7 +93,7 @@ exports[`components/integrations/InstalledOutgoingOAuthConnections should match
|
||||
defaultMessage="Outgoing OAuth Connections"
|
||||
id="installed_outgoing_oauth_connections.help.outgoingOauthConnections"
|
||||
/>
|
||||
</ExternalLink>,
|
||||
</ForwardRef>,
|
||||
}
|
||||
}
|
||||
/>
|
||||
@@ -180,7 +180,7 @@ exports[`components/integrations/InstalledOutgoingOAuthConnections should match
|
||||
id="installed_outgoing_oauth_connections.help"
|
||||
values={
|
||||
Object {
|
||||
"outgoingOauthConnections": <ExternalLink
|
||||
"outgoingOauthConnections": <ForwardRef
|
||||
href="https://mattermost.com/pl/setup-oauth-2.0"
|
||||
location="installed_outgoing_oauth_connections"
|
||||
>
|
||||
@@ -188,13 +188,13 @@ exports[`components/integrations/InstalledOutgoingOAuthConnections should match
|
||||
defaultMessage="Outgoing OAuth Connections"
|
||||
id="installed_outgoing_oauth_connections.help.outgoingOauthConnections"
|
||||
/>
|
||||
</ExternalLink>,
|
||||
</ForwardRef>,
|
||||
}
|
||||
}
|
||||
>
|
||||
<span>
|
||||
Create
|
||||
<ExternalLink
|
||||
<ForwardRef
|
||||
href="https://mattermost.com/pl/setup-oauth-2.0"
|
||||
key=".$.1"
|
||||
location="installed_outgoing_oauth_connections"
|
||||
@@ -215,7 +215,7 @@ exports[`components/integrations/InstalledOutgoingOAuthConnections should match
|
||||
</span>
|
||||
</FormattedMessage>
|
||||
</a>
|
||||
</ExternalLink>
|
||||
</ForwardRef>
|
||||
to securely integrate bots and third-party apps with Mattermost.
|
||||
</span>
|
||||
</FormattedMessage>
|
||||
|
||||
@@ -64,11 +64,25 @@ type MenuProps = {
|
||||
width?: string;
|
||||
}
|
||||
|
||||
const defaultAnchorOrigin = {vertical: 'bottom', horizontal: 'left'};
|
||||
const defaultTransformOrigin = {vertical: 'top', horizontal: 'left'};
|
||||
|
||||
type VerticalOrigin = 'top' | 'center' | 'bottom';
|
||||
type HorizontalOrigin = 'left' | 'center' | 'right';
|
||||
|
||||
interface Props {
|
||||
menuButton: MenuButtonProps;
|
||||
menuButtonTooltip?: MenuButtonTooltipProps;
|
||||
menu: MenuProps;
|
||||
children: ReactNode[];
|
||||
anchorOrigin?: {
|
||||
vertical: VerticalOrigin;
|
||||
horizontal: HorizontalOrigin;
|
||||
};
|
||||
transformOrigin?: {
|
||||
vertical: VerticalOrigin;
|
||||
horizontal: HorizontalOrigin;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -236,6 +250,8 @@ export function Menu(props: Props) {
|
||||
onKeyDown={handleMenuKeyDown}
|
||||
className={A11yClassNames.POPUP}
|
||||
width={props.menu.width}
|
||||
anchorOrigin={props.anchorOrigin || defaultAnchorOrigin}
|
||||
transformOrigin={props.transformOrigin || defaultTransformOrigin}
|
||||
disableAutoFocusItem={disableAutoFocusItem} // This is not anti-pattern, see handleMenuButtonMouseDown
|
||||
MenuListProps={{
|
||||
id: props.menu.id,
|
||||
|
||||
@@ -1006,7 +1006,7 @@ exports[`components/MarketplaceItemPlugin UpdateDetails should render with relea
|
||||
releaseNotesUrl="http://example.com/release"
|
||||
version="0.0.2"
|
||||
>
|
||||
<ExternalLink
|
||||
<ForwardRef
|
||||
href="http://example.com/release"
|
||||
location="marketplace_item_plugin"
|
||||
>
|
||||
@@ -1019,7 +1019,7 @@ exports[`components/MarketplaceItemPlugin UpdateDetails should render with relea
|
||||
>
|
||||
0.0.2
|
||||
</a>
|
||||
</ExternalLink>
|
||||
</ForwardRef>
|
||||
</UpdateVersion>
|
||||
-
|
||||
<b>
|
||||
|
||||
@@ -24,7 +24,7 @@ exports[`components/post_view/MessageAttachment should call actions.doPostAction
|
||||
}
|
||||
}
|
||||
>
|
||||
<ExternalLink
|
||||
<ForwardRef
|
||||
href="author_link"
|
||||
key="attachment__author-name"
|
||||
location="message_attachment"
|
||||
@@ -41,17 +41,17 @@ exports[`components/post_view/MessageAttachment should call actions.doPostAction
|
||||
>
|
||||
author_name
|
||||
</span>
|
||||
</ExternalLink>
|
||||
</ForwardRef>
|
||||
<h1
|
||||
className="attachment__title"
|
||||
>
|
||||
<ExternalLink
|
||||
<ForwardRef
|
||||
className="attachment__title-link"
|
||||
href="title_link"
|
||||
location="message_attachment"
|
||||
>
|
||||
title
|
||||
</ExternalLink>
|
||||
</ForwardRef>
|
||||
</h1>
|
||||
<div>
|
||||
<div
|
||||
@@ -170,7 +170,7 @@ exports[`components/post_view/MessageAttachment should match snapshot 1`] = `
|
||||
}
|
||||
}
|
||||
>
|
||||
<ExternalLink
|
||||
<ForwardRef
|
||||
href="author_link"
|
||||
key="attachment__author-name"
|
||||
location="message_attachment"
|
||||
@@ -187,17 +187,17 @@ exports[`components/post_view/MessageAttachment should match snapshot 1`] = `
|
||||
>
|
||||
author_name
|
||||
</span>
|
||||
</ExternalLink>
|
||||
</ForwardRef>
|
||||
<h1
|
||||
className="attachment__title"
|
||||
>
|
||||
<ExternalLink
|
||||
<ForwardRef
|
||||
className="attachment__title-link"
|
||||
href="title_link"
|
||||
location="message_attachment"
|
||||
>
|
||||
title
|
||||
</ExternalLink>
|
||||
</ForwardRef>
|
||||
</h1>
|
||||
<div>
|
||||
<div
|
||||
@@ -312,7 +312,7 @@ exports[`components/post_view/MessageAttachment should match snapshot when no fo
|
||||
}
|
||||
}
|
||||
>
|
||||
<ExternalLink
|
||||
<ForwardRef
|
||||
href="author_link"
|
||||
key="attachment__author-name"
|
||||
location="message_attachment"
|
||||
@@ -329,17 +329,17 @@ exports[`components/post_view/MessageAttachment should match snapshot when no fo
|
||||
>
|
||||
author_name
|
||||
</span>
|
||||
</ExternalLink>
|
||||
</ForwardRef>
|
||||
<h1
|
||||
className="attachment__title"
|
||||
>
|
||||
<ExternalLink
|
||||
<ForwardRef
|
||||
className="attachment__title-link"
|
||||
href="title_link"
|
||||
location="message_attachment"
|
||||
>
|
||||
title
|
||||
</ExternalLink>
|
||||
</ForwardRef>
|
||||
</h1>
|
||||
<div>
|
||||
<div
|
||||
|
||||
@@ -85,7 +85,7 @@ exports[`ProductNoticesModal Match snapshot for user notice 1`] = `
|
||||
message="descr"
|
||||
/>
|
||||
</span>
|
||||
<ExternalLink
|
||||
<ForwardRef
|
||||
className="GenericModal__button actionButton"
|
||||
href="http://download.com/path"
|
||||
id="actionButton"
|
||||
@@ -93,7 +93,7 @@ exports[`ProductNoticesModal Match snapshot for user notice 1`] = `
|
||||
onClick={[Function]}
|
||||
>
|
||||
Download
|
||||
</ExternalLink>
|
||||
</ForwardRef>
|
||||
<div
|
||||
className="productNotices__imageDiv"
|
||||
/>
|
||||
@@ -157,7 +157,7 @@ exports[`ProductNoticesModal Should match snapshot for system admin notice 1`] =
|
||||
message="your eyes only! [test](https://test.com)"
|
||||
/>
|
||||
</span>
|
||||
<ExternalLink
|
||||
<ForwardRef
|
||||
className="GenericModal__button actionButton"
|
||||
href="http://download.com/path"
|
||||
id="actionButton"
|
||||
@@ -165,7 +165,7 @@ exports[`ProductNoticesModal Should match snapshot for system admin notice 1`] =
|
||||
onClick={[Function]}
|
||||
>
|
||||
Download
|
||||
</ExternalLink>
|
||||
</ForwardRef>
|
||||
<div
|
||||
className="productNotices__imageDiv"
|
||||
>
|
||||
|
||||
@@ -105,13 +105,13 @@ exports[`components/signup/Signup should match snapshot for all signup options e
|
||||
className="link"
|
||||
>
|
||||
Sign up at
|
||||
<ExternalLink
|
||||
<ForwardRef
|
||||
href="https://mattermost.com/security-updates/"
|
||||
key=".1"
|
||||
location="signup"
|
||||
>
|
||||
https://mattermost.com/security-updates/
|
||||
</ExternalLink>
|
||||
</ForwardRef>
|
||||
.
|
||||
</span>
|
||||
</div>
|
||||
@@ -279,13 +279,13 @@ exports[`components/signup/Signup should match snapshot for all signup options e
|
||||
className="link"
|
||||
>
|
||||
Sign up at
|
||||
<ExternalLink
|
||||
<ForwardRef
|
||||
href="https://mattermost.com/security-updates/"
|
||||
key=".1"
|
||||
location="signup"
|
||||
>
|
||||
https://mattermost.com/security-updates/
|
||||
</ExternalLink>
|
||||
</ForwardRef>
|
||||
.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,7 @@ export enum SIZE {
|
||||
LARGE = 'large',
|
||||
}
|
||||
|
||||
export type CustomMessageInputType = {type: 'info' | 'error' | 'warning' | 'success'; value: React.ReactNode} | null;
|
||||
export type CustomMessageInputType = {type?: 'info' | 'error' | 'warning' | 'success'; value: React.ReactNode} | null;
|
||||
|
||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement> {
|
||||
required?: boolean;
|
||||
@@ -225,14 +225,15 @@ const Input = React.forwardRef((
|
||||
</fieldset>
|
||||
{customInputLabel && (
|
||||
<div className={`Input___customMessage Input___${customInputLabel.type}`}>
|
||||
<i
|
||||
className={classNames(`icon ${customInputLabel.type}`, {
|
||||
'icon-alert-outline': customInputLabel.type === ItemStatus.WARNING,
|
||||
'icon-alert-circle-outline': customInputLabel.type === ItemStatus.ERROR,
|
||||
'icon-information-outline': customInputLabel.type === ItemStatus.INFO,
|
||||
'icon-check': customInputLabel.type === ItemStatus.SUCCESS,
|
||||
})}
|
||||
/>
|
||||
{customInputLabel.type && (
|
||||
<i
|
||||
className={classNames(`icon ${customInputLabel.type}`, {
|
||||
'icon-alert-outline': customInputLabel.type === ItemStatus.WARNING,
|
||||
'icon-alert-circle-outline': customInputLabel.type === ItemStatus.ERROR,
|
||||
'icon-information-outline': customInputLabel.type === ItemStatus.INFO,
|
||||
'icon-check': customInputLabel.type === ItemStatus.SUCCESS,
|
||||
})}
|
||||
/>)}
|
||||
<span>{customInputLabel.value}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -16,7 +16,7 @@ describe('components/MenuItemExternalLink', () => {
|
||||
);
|
||||
|
||||
expect(wrapper).toMatchInlineSnapshot(`
|
||||
<ExternalLink
|
||||
<ForwardRef
|
||||
href="http://test.com"
|
||||
location="menu_item_external_link"
|
||||
>
|
||||
@@ -25,7 +25,7 @@ describe('components/MenuItemExternalLink', () => {
|
||||
>
|
||||
Whatever
|
||||
</span>
|
||||
</ExternalLink>
|
||||
</ForwardRef>
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,7 +44,7 @@ exports[`YoutubeVideo should match init snapshot 1`] = `
|
||||
<span
|
||||
className="video-title"
|
||||
>
|
||||
<ExternalLink
|
||||
<ForwardRef
|
||||
href="https://www.youtube.com/watch?v=xqCoNej8Zxo"
|
||||
location="youtube_video"
|
||||
>
|
||||
@@ -57,7 +57,7 @@ exports[`YoutubeVideo should match init snapshot 1`] = `
|
||||
>
|
||||
Youtube title
|
||||
</a>
|
||||
</ExternalLink>
|
||||
</ForwardRef>
|
||||
</span>
|
||||
</h4>
|
||||
<div
|
||||
@@ -118,12 +118,12 @@ exports[`YoutubeVideo should match snapshot for playing state 1`] = `
|
||||
<span
|
||||
className="video-title"
|
||||
>
|
||||
<ExternalLink
|
||||
<ForwardRef
|
||||
href="https://www.youtube.com/watch?v=xqCoNej8Zxo"
|
||||
location="youtube_video"
|
||||
>
|
||||
Youtube title
|
||||
</ExternalLink>
|
||||
</ForwardRef>
|
||||
</span>
|
||||
</h4>
|
||||
<div
|
||||
|
||||
@@ -3035,6 +3035,31 @@
|
||||
"change_url.shorter": "URLs must have maximum 64 characters.",
|
||||
"change_url.startAndEndWithLetter": "URLs must start and end with a lowercase letter or number.",
|
||||
"change_url.startWithLetter": "URLs must start with a lowercase letter or number.",
|
||||
"channel_bookmarks.addBookmark": "Add a bookmark",
|
||||
"channel_bookmarks.addBookmarkLimitReached": "Cannot add more than {limit} bookmarks",
|
||||
"channel_bookmarks.addLink": "Add a link",
|
||||
"channel_bookmarks.attachFile": "Attach a file",
|
||||
"channel_bookmarks.confirm.delete.button": "Yes, delete",
|
||||
"channel_bookmarks.confirm.delete.text": "Are you sure you want to delete the bookmark <strong>{displayName}</strong>?",
|
||||
"channel_bookmarks.confirm.delete.title": "Delete bookmark",
|
||||
"channel_bookmarks.copy": "Copy link",
|
||||
"channel_bookmarks.copyFilePublicLink": "Get a public link",
|
||||
"channel_bookmarks.create.confirm_add.button": "Add bookmark",
|
||||
"channel_bookmarks.create.confirm_save.button": "Save bookmark",
|
||||
"channel_bookmarks.create.edit.title": "Edit bookmark",
|
||||
"channel_bookmarks.create.error.generic_save": "There was an error trying to save the bookmark.",
|
||||
"channel_bookmarks.create.error.invalid_url": "Please enter a valid link",
|
||||
"channel_bookmarks.create.file_input.edit": "Edit",
|
||||
"channel_bookmarks.create.file_input.label": "Attachment",
|
||||
"channel_bookmarks.create.link_info": "Add a link to any post, file, or any external link",
|
||||
"channel_bookmarks.create.link_placeholder": "Link",
|
||||
"channel_bookmarks.create.title": "Add a bookmark",
|
||||
"channel_bookmarks.create.title_input.clear_emoji": "Remove emoji",
|
||||
"channel_bookmarks.create.title_input.label": "Title",
|
||||
"channel_bookmarks.delete": "Delete",
|
||||
"channel_bookmarks.edit": "Edit",
|
||||
"channel_bookmarks.editBookmarkLabel": "Bookmark menu",
|
||||
"channel_bookmarks.open": "Open",
|
||||
"channel_groups": "{channel} Groups",
|
||||
"channel_header.addChannelHeader": "Add a channel header",
|
||||
"channel_header.channelFiles": "Channel files",
|
||||
@@ -3617,6 +3642,7 @@
|
||||
"file_upload.fileAbove": "File above {max}MB could not be uploaded: {filename}",
|
||||
"file_upload.filesAbove": "Files above {max}MB could not be uploaded: {filenames}",
|
||||
"file_upload.generic_error": "There was a problem uploading your files.",
|
||||
"file_upload.generic_error_file": "There was a problem uploading your file.",
|
||||
"file_upload.limited": "Uploads limited to {count, number} files maximum. Please use additional posts for more files.",
|
||||
"file_upload.menuAriaLabel": "Upload type selector",
|
||||
"file_upload.pasted": "Image Pasted at ",
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import keyMirror from 'mattermost-redux/utils/key_mirror';
|
||||
|
||||
export default keyMirror({
|
||||
RECEIVED_BOOKMARK: null,
|
||||
RECEIVED_BOOKMARKS: null,
|
||||
|
||||
BOOKMARK_DELETED: null,
|
||||
});
|
||||
@@ -4,6 +4,7 @@
|
||||
import AdminTypes from './admin';
|
||||
import AppsTypes from './apps';
|
||||
import BotTypes from './bots';
|
||||
import ChannelBookmarkTypes from './channel_bookmarks';
|
||||
import ChannelCategoryTypes from './channel_categories';
|
||||
import ChannelTypes from './channels';
|
||||
import CloudTypes from './cloud';
|
||||
@@ -55,4 +56,5 @@ export {
|
||||
HostedCustomerTypes,
|
||||
DraftTypes,
|
||||
PlaybookType,
|
||||
ChannelBookmarkTypes,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {ChannelBookmarkCreate, ChannelBookmarkPatch} from '@mattermost/types/channel_bookmarks';
|
||||
|
||||
import {ChannelBookmarkTypes} from 'mattermost-redux/action_types';
|
||||
import {Client4} from 'mattermost-redux/client';
|
||||
import {getChannelBookmark} from 'mattermost-redux/selectors/entities/channel_bookmarks';
|
||||
import type {DispatchFunc, GetStateFunc} from 'mattermost-redux/types/actions';
|
||||
|
||||
import {logError} from './errors';
|
||||
import {forceLogoutIfNecessary} from './helpers';
|
||||
|
||||
export function deleteBookmark(channelId: string, id: string, connectionId: string) {
|
||||
return async (dispatch: DispatchFunc, getState: GetStateFunc) => {
|
||||
const state = getState();
|
||||
const bookmark = getChannelBookmark(state, channelId, id);
|
||||
|
||||
try {
|
||||
await Client4.deleteChannelBookmark(channelId, id, connectionId);
|
||||
|
||||
dispatch({
|
||||
type: ChannelBookmarkTypes.BOOKMARK_DELETED,
|
||||
data: bookmark,
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
data: false,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
return {data: true};
|
||||
};
|
||||
}
|
||||
|
||||
export function createBookmark(channelId: string, bookmark: ChannelBookmarkCreate, connectionId: string) {
|
||||
return async (dispatch: DispatchFunc) => {
|
||||
try {
|
||||
const createdBookmark = await Client4.createChannelBookmark(channelId, bookmark, connectionId);
|
||||
|
||||
dispatch({
|
||||
type: ChannelBookmarkTypes.RECEIVED_BOOKMARK,
|
||||
data: createdBookmark,
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
data: false,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
return {data: true};
|
||||
};
|
||||
}
|
||||
|
||||
export function editBookmark(channelId: string, id: string, patch: ChannelBookmarkPatch, connectionId: string) {
|
||||
return async (dispatch: DispatchFunc) => {
|
||||
try {
|
||||
const {updated, deleted} = await Client4.updateChannelBookmark(channelId, id, patch, connectionId);
|
||||
|
||||
if (updated) {
|
||||
dispatch({
|
||||
type: ChannelBookmarkTypes.RECEIVED_BOOKMARK,
|
||||
data: updated,
|
||||
});
|
||||
}
|
||||
|
||||
if (deleted) {
|
||||
dispatch({
|
||||
type: ChannelBookmarkTypes.BOOKMARK_DELETED,
|
||||
data: deleted,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
data: false,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
return {data: true};
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchChannelBookmarks(channelId: string) {
|
||||
return async (dispatch: DispatchFunc, getState: GetStateFunc) => {
|
||||
let bookmarks;
|
||||
try {
|
||||
bookmarks = await Client4.getChannelBookmarks(channelId);
|
||||
|
||||
dispatch({
|
||||
type: ChannelBookmarkTypes.RECEIVED_BOOKMARKS,
|
||||
data: {channelId, bookmarks},
|
||||
});
|
||||
} catch (error) {
|
||||
forceLogoutIfNecessary(error, dispatch, getState);
|
||||
dispatch(logError(error));
|
||||
return {error};
|
||||
}
|
||||
|
||||
return {data: bookmarks};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {combineReducers, type AnyAction} from 'redux';
|
||||
|
||||
import type {ChannelBookmark, ChannelBookmarksState} from '@mattermost/types/channel_bookmarks';
|
||||
import type {Channel} from '@mattermost/types/channels';
|
||||
import type {IDMappedObjects} from '@mattermost/types/utilities';
|
||||
|
||||
import {ChannelBookmarkTypes, UserTypes, ChannelTypes} from 'mattermost-redux/action_types';
|
||||
|
||||
const toNewObj = <T extends {id: string}>(current: IDMappedObjects<T>, arr: T[]) => {
|
||||
return arr.reduce((acc, x) => {
|
||||
return {...acc, [x.id]: x};
|
||||
}, {...current});
|
||||
};
|
||||
|
||||
export function byChannelId(state: ChannelBookmarksState['byChannelId'] = {}, action: AnyAction) {
|
||||
switch (action.type) {
|
||||
case ChannelBookmarkTypes.RECEIVED_BOOKMARKS: {
|
||||
const channelId: Channel['id'] = action.data.channelId;
|
||||
const bookmarks: ChannelBookmark[] = action.data.bookmarks;
|
||||
|
||||
return {
|
||||
...state,
|
||||
[channelId]: toNewObj(state[channelId], bookmarks),
|
||||
};
|
||||
}
|
||||
|
||||
case ChannelBookmarkTypes.RECEIVED_BOOKMARK: {
|
||||
const bookmark: ChannelBookmark = action.data;
|
||||
const {id, channel_id: channelId} = bookmark;
|
||||
|
||||
return {
|
||||
...state,
|
||||
[channelId]: {
|
||||
...state[channelId],
|
||||
[id]: bookmark,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case ChannelBookmarkTypes.BOOKMARK_DELETED: {
|
||||
const bookmark: ChannelBookmark = action.data;
|
||||
|
||||
const channelNextState = {...state[bookmark.channel_id]};
|
||||
|
||||
Reflect.deleteProperty(channelNextState, bookmark.id);
|
||||
|
||||
const nextState = {...state, [bookmark.channel_id]: channelNextState};
|
||||
|
||||
return nextState;
|
||||
}
|
||||
|
||||
case ChannelTypes.LEAVE_CHANNEL: {
|
||||
const channelId: string = action.data.channelId;
|
||||
|
||||
const nextState = {...state};
|
||||
|
||||
Reflect.deleteProperty(nextState, channelId);
|
||||
|
||||
return nextState;
|
||||
}
|
||||
|
||||
case UserTypes.LOGOUT_SUCCESS:
|
||||
return {};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export default combineReducers({
|
||||
byChannelId,
|
||||
});
|
||||
@@ -4,10 +4,11 @@
|
||||
import type {AnyAction} from 'redux';
|
||||
import {combineReducers} from 'redux';
|
||||
|
||||
import type {ChannelBookmark} from '@mattermost/types/channel_bookmarks';
|
||||
import type {FileInfo, FileSearchResultItem} from '@mattermost/types/files';
|
||||
import type {Post} from '@mattermost/types/posts';
|
||||
|
||||
import {FileTypes, PostTypes, UserTypes} from 'mattermost-redux/action_types';
|
||||
import {FileTypes, PostTypes, UserTypes, ChannelBookmarkTypes} from 'mattermost-redux/action_types';
|
||||
|
||||
export function files(state: Record<string, FileInfo> = {}, action: AnyAction) {
|
||||
switch (action.type) {
|
||||
@@ -53,6 +54,43 @@ export function files(state: Record<string, FileInfo> = {}, action: AnyAction) {
|
||||
return state;
|
||||
}
|
||||
|
||||
case ChannelBookmarkTypes.RECEIVED_BOOKMARKS: {
|
||||
const bookmarks: ChannelBookmark[] = action.data.bookmarks;
|
||||
|
||||
const nextState = {...state};
|
||||
|
||||
bookmarks.forEach(({file}) => {
|
||||
if (file) {
|
||||
nextState[file.id] = file;
|
||||
}
|
||||
});
|
||||
|
||||
return nextState;
|
||||
}
|
||||
|
||||
case ChannelBookmarkTypes.RECEIVED_BOOKMARK: {
|
||||
const {file}: ChannelBookmark = action.data;
|
||||
|
||||
if (file) {
|
||||
return {...state, [file.id]: file};
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
case ChannelBookmarkTypes.BOOKMARK_DELETED: {
|
||||
const {file}: ChannelBookmark = action.data;
|
||||
|
||||
if (!file) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const nextState = {...state};
|
||||
Reflect.deleteProperty(nextState, file.id);
|
||||
|
||||
return nextState;
|
||||
}
|
||||
|
||||
case UserTypes.LOGOUT_SUCCESS:
|
||||
return {};
|
||||
default:
|
||||
|
||||
@@ -6,6 +6,7 @@ import {combineReducers} from 'redux';
|
||||
import admin from './admin';
|
||||
import apps from './apps';
|
||||
import bots from './bots';
|
||||
import channelBookmarks from './channel_bookmarks';
|
||||
import channelCategories from './channel_categories';
|
||||
import channels from './channels';
|
||||
import cloud from './cloud';
|
||||
@@ -53,4 +54,5 @@ export default combineReducers({
|
||||
cloud,
|
||||
usage,
|
||||
hostedCustomer,
|
||||
channelBookmarks,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {ChannelBookmarksState} from '@mattermost/types/channel_bookmarks';
|
||||
import type {GlobalState} from '@mattermost/types/store';
|
||||
|
||||
const EMPTY_BOOKMARKS = {};
|
||||
|
||||
export const getChannelBookmarks = (state: GlobalState, channelId: string): ChannelBookmarksState['byChannelId'][string] => {
|
||||
const bookmarks = state.entities.channelBookmarks.byChannelId[channelId];
|
||||
|
||||
if (!bookmarks) {
|
||||
return EMPTY_BOOKMARKS;
|
||||
}
|
||||
|
||||
return bookmarks;
|
||||
};
|
||||
|
||||
export const getChannelBookmark = (state: GlobalState, channelId: string, bookmarkId: string) => {
|
||||
return getChannelBookmarks(state, channelId)[bookmarkId];
|
||||
};
|
||||
@@ -65,6 +65,9 @@ const state: GlobalState = {
|
||||
messageCounts: {},
|
||||
channelsMemberCount: {},
|
||||
},
|
||||
channelBookmarks: {
|
||||
byChannelId: {},
|
||||
},
|
||||
posts: {
|
||||
posts: {},
|
||||
postsReplies: {},
|
||||
|
||||
@@ -459,6 +459,8 @@ export const ModalIdentifiers = {
|
||||
EXPORT_USER_DATA_MODAL: 'export_user_data_modal',
|
||||
UPGRADE_EXPORT_DATA_MODAL: 'upgrade_export_data_modal',
|
||||
EXPORT_ERROR_MODAL: 'export_error_modal',
|
||||
CHANNEL_BOOKMARK_DELETE: 'channel_bookmark_delete',
|
||||
CHANNEL_BOOKMARK_CREATE: 'channel_bookmark_create',
|
||||
};
|
||||
|
||||
export const UserStatuses = {
|
||||
@@ -584,6 +586,10 @@ export const SocketEvents = {
|
||||
CHANNEL_DELETED: 'channel_deleted',
|
||||
CHANNEL_UNARCHIVED: 'channel_restored',
|
||||
CHANNEL_UPDATED: 'channel_updated',
|
||||
CHANNEL_BOOKMARK_CREATED: 'channel_bookmark_created',
|
||||
CHANNEL_BOOKMARK_DELETED: 'channel_bookmark_deleted',
|
||||
CHANNEL_BOOKMARK_UPDATED: 'channel_bookmark_updated',
|
||||
CHANNEL_BOOKMARK_SORTED: 'channel_bookmark_sorted',
|
||||
MULTIPLE_CHANNELS_VIEWED: 'multiple_channels_viewed',
|
||||
CHANNEL_MEMBER_UPDATED: 'channel_member_updated',
|
||||
CHANNEL_SCHEME_UPDATED: 'channel_scheme_updated',
|
||||
|
||||
@@ -179,6 +179,10 @@ export function getSkin(emoji: Emoji) {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function trimmedEmojiName(emojiName: string) {
|
||||
return emojiName.startsWith(':') && emojiName.endsWith(':') ? emojiName.slice(1, -1) : emojiName;
|
||||
}
|
||||
|
||||
export function emojiMatchesSkin(emoji: Emoji, skin: string) {
|
||||
const emojiSkin = getSkin(emoji);
|
||||
return !emojiSkin || emojiSkin === skin;
|
||||
|
||||
@@ -17,8 +17,8 @@ export const FileSizes = {
|
||||
};
|
||||
|
||||
export function canUploadFiles(config: Partial<ClientConfig>): boolean {
|
||||
const enableFileAttachments = config.EnableFileAttachments === 'true';
|
||||
const enableMobileFileUpload = config.EnableMobileFileUpload === 'true';
|
||||
const enableFileAttachments = isFileAttachmentsEnabled(config);
|
||||
const enableMobileFileUpload = isMobileFileUploadsEnabled(config);
|
||||
|
||||
if (!enableFileAttachments) {
|
||||
return false;
|
||||
@@ -35,6 +35,14 @@ export function isFileAttachmentsEnabled(config: Partial<ClientConfig>): boolean
|
||||
return config.EnableFileAttachments === 'true';
|
||||
}
|
||||
|
||||
export function isMobileFileUploadsEnabled(config: Partial<ClientConfig>): boolean {
|
||||
return config.EnableMobileFileUpload === 'true';
|
||||
}
|
||||
|
||||
export function isPublicLinksEnabled(config: Partial<ClientConfig>): boolean {
|
||||
return config.EnablePublicLink === 'true';
|
||||
}
|
||||
|
||||
export function canDownloadFiles(config: Partial<ClientConfig>): boolean {
|
||||
if (UserAgent.isMobileApp()) {
|
||||
return config.EnableMobileFileDownload === 'true';
|
||||
|
||||
@@ -273,7 +273,7 @@ export default class Renderer extends marked.Renderer {
|
||||
|
||||
// Marked helper functions that should probably just be exported
|
||||
|
||||
function unescapeHtmlEntities(html: string) {
|
||||
export function unescapeHtmlEntities(html: string) {
|
||||
return html.replace(/&([#\w]+);/g, (_, m) => {
|
||||
const n = m.toLowerCase();
|
||||
if (n === 'colon') {
|
||||
|
||||
@@ -10,6 +10,8 @@ import Constants from 'utils/constants';
|
||||
import {latinise} from 'utils/latinise';
|
||||
import * as TextFormatting from 'utils/text_formatting';
|
||||
|
||||
import {unescapeHtmlEntities} from './markdown/renderer';
|
||||
|
||||
type WindowObject = {
|
||||
location: {
|
||||
origin: string;
|
||||
@@ -274,6 +276,11 @@ export function isPermalinkURL(url: string): boolean {
|
||||
return isInternalURL(url, siteURL) && (regexp.test(url));
|
||||
}
|
||||
|
||||
export function isValidUrl(url = '') {
|
||||
const regex = /^https?:\/\//i;
|
||||
return regex.test(url);
|
||||
}
|
||||
|
||||
export function isStringContainingUrl(text: string): boolean {
|
||||
const regex = new RegExp('(https?://|www.)');
|
||||
return regex.test(text);
|
||||
@@ -326,3 +333,20 @@ export function channelNameToUrl(channelName: string): UrlValidationCheck {
|
||||
|
||||
return {url, error: false};
|
||||
}
|
||||
|
||||
export function parseLink(href: string) {
|
||||
let outHref = href;
|
||||
|
||||
if (!href.startsWith('/')) {
|
||||
const scheme = getScheme(href);
|
||||
if (!scheme) {
|
||||
outHref = `http://${outHref}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isUrlSafe(unescapeHtmlEntities(href))) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return outHref;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {AppBinding, AppCallRequest, AppCallResponse} from '@mattermost/type
|
||||
import type {Audit} from '@mattermost/types/audits';
|
||||
import type {UserAutocomplete, AutocompleteSuggestion} from '@mattermost/types/autocomplete';
|
||||
import type {Bot, BotPatch} from '@mattermost/types/bots';
|
||||
import type {ChannelBookmark, ChannelBookmarkCreate, ChannelBookmarkPatch} from '@mattermost/types/channel_bookmarks';
|
||||
import type {ChannelCategory, OrderedChannelCategories} from '@mattermost/types/channel_categories';
|
||||
import type {
|
||||
Channel,
|
||||
@@ -314,6 +315,12 @@ export default class Client4 {
|
||||
getChannelSchemeRoute(channelId: string) {
|
||||
return `${this.getChannelRoute(channelId)}/scheme`;
|
||||
}
|
||||
getChannelBookmarksRoute(channelId: string) {
|
||||
return `${this.getChannelRoute(channelId)}/bookmarks`;
|
||||
}
|
||||
getChannelBookmarkRoute(channelId: string, bookmarkId: string) {
|
||||
return `${this.getChannelRoute(channelId)}/bookmarks/${bookmarkId}`;
|
||||
}
|
||||
|
||||
getChannelCategoriesRoute(userId: string, teamId: string) {
|
||||
return `${this.getBaseRoute()}/users/${userId}/teams/${teamId}/channels/categories`;
|
||||
@@ -1605,7 +1612,15 @@ export default class Client4 {
|
||||
includeDeleted: boolean | undefined,
|
||||
excludePolicyConstrained: boolean | undefined
|
||||
): Promise<ChannelsWithTotalCount>;
|
||||
getAllChannels(page = 0, perPage = PER_PAGE_DEFAULT, notAssociatedToGroup = '', excludeDefaultChannels = false, includeTotalCount = false, includeDeleted = false, excludePolicyConstrained = false) {
|
||||
getAllChannels(
|
||||
page = 0,
|
||||
perPage = PER_PAGE_DEFAULT,
|
||||
notAssociatedToGroup = '',
|
||||
excludeDefaultChannels = false,
|
||||
includeTotalCount = false,
|
||||
includeDeleted = false,
|
||||
excludePolicyConstrained = false,
|
||||
) {
|
||||
const queryData = {
|
||||
page,
|
||||
per_page: perPage,
|
||||
@@ -1961,7 +1976,44 @@ export default class Client4 {
|
||||
);
|
||||
};
|
||||
|
||||
// Channel Category Routes
|
||||
// Channel Bookmark Routes
|
||||
|
||||
getChannelBookmarks = (channelId: string, bookmarksSince?: number) => {
|
||||
return this.doFetch<ChannelBookmark[]>(
|
||||
`${this.getChannelBookmarksRoute(channelId)}${buildQueryString({bookmarks_since: bookmarksSince})}`,
|
||||
{method: 'get'},
|
||||
);
|
||||
};
|
||||
|
||||
createChannelBookmark = (channelId: string, channelBookmark: ChannelBookmarkCreate, connectionId: string) => {
|
||||
return this.doFetch<ChannelBookmark>(
|
||||
`${this.getChannelBookmarksRoute(channelId)}`,
|
||||
{method: 'post', body: JSON.stringify(channelBookmark), headers: {'Connection-Id': connectionId}},
|
||||
);
|
||||
};
|
||||
|
||||
deleteChannelBookmark = (channelId: string, channelBookmarkId: string, connectionId: string) => {
|
||||
return this.doFetch<ChannelBookmark>(
|
||||
`${this.getChannelBookmarkRoute(channelId, channelBookmarkId)}`,
|
||||
{method: 'delete', headers: {'Connection-Id': connectionId}},
|
||||
);
|
||||
};
|
||||
|
||||
updateChannelBookmark = (channelId: string, channelBookmarkId: string, patch: ChannelBookmarkPatch, connectionId: string) => {
|
||||
return this.doFetch<{updated: ChannelBookmark; deleted: ChannelBookmark}>(
|
||||
`${this.getChannelBookmarkRoute(channelId, channelBookmarkId)}`,
|
||||
{method: 'PATCH', body: JSON.stringify(patch), headers: {'Connection-Id': connectionId}},
|
||||
);
|
||||
};
|
||||
|
||||
updateChannelBookmarkSortOrder = (channelId: string, channelBookmarkId: string, newOrder: number, connectionId: string) => {
|
||||
return this.doFetch<ChannelBookmark[]>(
|
||||
`${this.getChannelBookmarksRoute(channelId)}/${channelBookmarkId}/sort_order`,
|
||||
{method: 'post', body: JSON.stringify(newOrder), headers: {'Connection-Id': connectionId}},
|
||||
);
|
||||
};
|
||||
|
||||
// Channel Category Routes
|
||||
|
||||
getChannelCategories = (userId: string, teamId: string) => {
|
||||
return this.doFetch<OrderedChannelCategories>(
|
||||
@@ -2383,7 +2435,7 @@ export default class Client4 {
|
||||
return url;
|
||||
}
|
||||
|
||||
uploadFile = (fileFormData: any) => {
|
||||
uploadFile = (fileFormData: any, isBookmark?: boolean) => {
|
||||
this.trackEvent('api', 'api_files_upload');
|
||||
const request: any = {
|
||||
method: 'post',
|
||||
@@ -2391,7 +2443,7 @@ export default class Client4 {
|
||||
};
|
||||
|
||||
return this.doFetch<FileUploadResponse>(
|
||||
`${this.getFilesRoute()}`,
|
||||
`${this.getFilesRoute()}${buildQueryString({bookmark: isBookmark})}`,
|
||||
request,
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
credentials?: 'omit' | 'same-origin' | 'include';
|
||||
body?: any;
|
||||
signal?: RequestInit['signal'];
|
||||
ignoreStatus?: boolean; /** If true, status codes > 300 are ignored and don't cause an error */
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import type {AdminState} from './admin';
|
||||
import type {AppsState} from './apps';
|
||||
import type {Bot} from './bots';
|
||||
import type {ChannelBookmarksState} from './channel_bookmarks';
|
||||
import type {ChannelCategoriesState} from './channel_categories';
|
||||
import type {ChannelsState} from './channels';
|
||||
import type {CloudState, CloudUsage} from './cloud';
|
||||
@@ -38,6 +39,7 @@ export type GlobalState = {
|
||||
limits: LimitsState;
|
||||
teams: TeamsState;
|
||||
channels: ChannelsState;
|
||||
channelBookmarks: ChannelBookmarksState;
|
||||
posts: PostsState;
|
||||
threads: ThreadsState;
|
||||
bots: {
|
||||
|
||||
Ссылка в новой задаче
Block a user