diff --git a/webapp/channels/package.json b/webapp/channels/package.json index 167652c3e3..24e0ed8504 100644 --- a/webapp/channels/package.json +++ b/webapp/channels/package.json @@ -7,6 +7,7 @@ "private": true, "dependencies": { "@floating-ui/react": "0.26.6", + "@floating-ui/react-dom-interactions": "0.13.3", "@giphy/js-fetch-api": "5.1.0", "@giphy/react-components": "8.1.0", "@guyplusplus/turndown-plugin-gfm": "1.0.7", diff --git a/webapp/channels/src/actions/ai_actions.tsx b/webapp/channels/src/actions/ai_actions.tsx new file mode 100644 index 0000000000..e0a77bab43 --- /dev/null +++ b/webapp/channels/src/actions/ai_actions.tsx @@ -0,0 +1,20 @@ +import type {DispatchFunc} from 'mattermost-redux/types/actions'; + +import {ActionTypes} from 'utils/constants'; +import {generateId} from 'utils/utils'; + +import PostMenu from 'plugins/ai/components/post_menu'; + +export function registerInternalAiPlugin() { + return async (dispatch: DispatchFunc) => { + dispatch({ + type: ActionTypes.RECEIVED_PLUGIN_COMPONENT, + name: 'PostAction', + data: { + id: generateId(), + pluginId: 'ia', + component: PostMenu, + }, + }); + }; +} diff --git a/webapp/channels/src/components/hint-toast/__snapshots__/hint_toast.test.tsx.snap b/webapp/channels/src/components/hint-toast/__snapshots__/hint_toast.test.tsx.snap index 6ff158d68e..3f862c40d2 100644 --- a/webapp/channels/src/components/hint-toast/__snapshots__/hint_toast.test.tsx.snap +++ b/webapp/channels/src/components/hint-toast/__snapshots__/hint_toast.test.tsx.snap @@ -5,6 +5,7 @@ exports[`components/HintToast should match snapshot 1`] = ` className="hint-toast" data-testid="hint-toast" > +
diff --git a/webapp/channels/src/components/hint-toast/hint_toast.scss b/webapp/channels/src/components/hint-toast/hint_toast.scss index 36cdda5cd6..6033f74b4a 100644 --- a/webapp/channels/src/components/hint-toast/hint_toast.scss +++ b/webapp/channels/src/components/hint-toast/hint_toast.scss @@ -9,6 +9,11 @@ font-size: 14px; font-weight: bold; + &.thread { + display: flex; + justify-content: space-between; + } + .hint-toast__message { display: inline-block; line-height: 20px; diff --git a/webapp/channels/src/components/hint-toast/hint_toast.tsx b/webapp/channels/src/components/hint-toast/hint_toast.tsx index fc1706b240..6e5ec721e3 100644 --- a/webapp/channels/src/components/hint-toast/hint_toast.tsx +++ b/webapp/channels/src/components/hint-toast/hint_toast.tsx @@ -12,9 +12,10 @@ export const HINT_TOAST_TESTID = 'hint-toast'; type Props = { children: React.ReactNode; onDismiss: () => void; + isThreadView?: boolean; } -export const HintToast: React.FC = ({children, onDismiss}: Props) => { +export const HintToast: React.FC = ({children, onDismiss, isThreadView}: Props) => { const handleDismiss = () => { if (typeof onDismiss === 'function') { onDismiss(); @@ -24,8 +25,9 @@ export const HintToast: React.FC = ({children, onDismiss}: Props) => { return (
+
diff --git a/webapp/channels/src/components/logged_in/index.ts b/webapp/channels/src/components/logged_in/index.ts index c940fbc228..4fb918aea7 100644 --- a/webapp/channels/src/components/logged_in/index.ts +++ b/webapp/channels/src/components/logged_in/index.ts @@ -14,6 +14,7 @@ import {getCurrentTeam} from 'mattermost-redux/selectors/entities/teams'; import {getCurrentUser, shouldShowTermsOfService} from 'mattermost-redux/selectors/entities/users'; import type {ThunkActionFunc} from 'mattermost-redux/types/actions'; +import {registerInternalAiPlugin} from 'actions/ai_actions'; import {registerInternalKdrivePlugin} from 'actions/kdrive_actions'; import {declineCall, joinCall, cancelCall} from 'actions/kmeet_calls'; import {updateTeamsOrderForUser} from 'actions/team_actions'; @@ -72,6 +73,7 @@ function mapDispatchToProps(dispatch: Dispatch) { getChannelURLAction, updateApproximateViewTime, registerInternalKdrivePlugin, + registerInternalAiPlugin, setTheme, updateTeamsOrderForUser, joinCall, diff --git a/webapp/channels/src/components/logged_in/logged_in.test.tsx b/webapp/channels/src/components/logged_in/logged_in.test.tsx index df4525dbde..74e195f365 100644 --- a/webapp/channels/src/components/logged_in/logged_in.test.tsx +++ b/webapp/channels/src/components/logged_in/logged_in.test.tsx @@ -54,6 +54,7 @@ describe('components/logged_in/LoggedIn', () => { joinCall: jest.fn(), cancelCall: jest.fn(), declineCall: jest.fn(), + registerInternalAiPlugin: jest.fn(), }, isCurrentChannelManuallyUnread: false, showTermsOfService: false, diff --git a/webapp/channels/src/components/logged_in/logged_in.tsx b/webapp/channels/src/components/logged_in/logged_in.tsx index d755ce57ad..5522e1f389 100644 --- a/webapp/channels/src/components/logged_in/logged_in.tsx +++ b/webapp/channels/src/components/logged_in/logged_in.tsx @@ -51,6 +51,7 @@ export type Props = { joinCall: (channelId: string) => void; declineCall: (channelId: string) => void; cancelCall: (channelId: string) => void; + registerInternalAiPlugin: () => void; }; showTermsOfService: boolean; location: { @@ -82,6 +83,7 @@ export default class LoggedIn extends React.PureComponent { if (!UserAgent.isDesktopApp() || isServerVersionGreaterThanOrEqualTo(UserAgent.getDesktopVersion(), '2.4.0')) { this.props.actions.registerInternalKdrivePlugin(); } + this.props.actions.registerInternalAiPlugin(); this.props.actions.autoUpdateTimezone(getBrowserTimezone()); // Make sure the websockets close and reset version diff --git a/webapp/channels/src/components/post/post_options.tsx b/webapp/channels/src/components/post/post_options.tsx index 6649a2db72..eb2f98f4ea 100644 --- a/webapp/channels/src/components/post/post_options.tsx +++ b/webapp/channels/src/components/post/post_options.tsx @@ -39,6 +39,7 @@ type Props = { shouldShowActionsMenu?: boolean; oneClickReactionsEnabled?: boolean; recentEmojis: Emoji[]; + isBot: boolean; isExpanded?: boolean; hover?: boolean; isMobileView: boolean; @@ -193,6 +194,7 @@ const PostOptions = (props: Props): JSX.Element => { const Component = item.component as any; return ( diff --git a/webapp/channels/src/components/post_view/post_list/__snapshots__/post_list.test.tsx.snap b/webapp/channels/src/components/post_view/post_list/__snapshots__/post_list.test.tsx.snap index 1aa13408b3..08a30a8932 100644 --- a/webapp/channels/src/components/post_view/post_list/__snapshots__/post_list.test.tsx.snap +++ b/webapp/channels/src/components/post_view/post_list/__snapshots__/post_list.test.tsx.snap @@ -27,6 +27,7 @@ exports[`components/post_view/post_list snapshot for loading when there are no p autoRetryEnable={true} channelId="fake-id" isMobileView={false} + isThreadView={false} lastViewedAt={1532345226632} loadingNewerPosts={false} loadingOlderPosts={false} @@ -65,6 +66,7 @@ exports[`components/post_view/post_list snapshot with couple of posts 1`] = ` autoRetryEnable={true} channelId="fake-id" isMobileView={false} + isThreadView={false} lastViewedAt={1532345226632} loadingNewerPosts={false} loadingOlderPosts={false} diff --git a/webapp/channels/src/components/post_view/post_list/post_list.test.tsx b/webapp/channels/src/components/post_view/post_list/post_list.test.tsx index a2ef145a2f..a26f90f465 100644 --- a/webapp/channels/src/components/post_view/post_list/post_list.test.tsx +++ b/webapp/channels/src/components/post_view/post_list/post_list.test.tsx @@ -48,6 +48,7 @@ const baseProps = { isMobileView: false, hasInaccessiblePosts: false, shouldStartFromBottomWhenUnread: false, + isThreadView: false, }; describe('components/post_view/post_list', () => { diff --git a/webapp/channels/src/components/post_view/post_list/post_list.tsx b/webapp/channels/src/components/post_view/post_list/post_list.tsx index ee39646ea1..6c3b1d602e 100644 --- a/webapp/channels/src/components/post_view/post_list/post_list.tsx +++ b/webapp/channels/src/components/post_view/post_list/post_list.tsx @@ -109,7 +109,7 @@ export interface Props { toggleShouldStartFromBottomWhenUnread: () => void; shouldStartFromBottomWhenUnread: boolean; hasInaccessiblePosts: boolean; - + isThreadView: boolean; actions: { /* @@ -376,6 +376,7 @@ export default class PostList extends React.PureComponent { latestPostTimeStamp={this.props.latestPostTimeStamp} isMobileView={this.props.isMobileView} lastViewedAt={this.props.lastViewedAt} + isThreadView={this.props.isThreadView} />
diff --git a/webapp/channels/src/components/post_view/post_list_virtualized/post_list_virtualized.test.tsx b/webapp/channels/src/components/post_view/post_list_virtualized/post_list_virtualized.test.tsx index d602c9965d..ba7145558e 100644 --- a/webapp/channels/src/components/post_view/post_list_virtualized/post_list_virtualized.test.tsx +++ b/webapp/channels/src/components/post_view/post_list_virtualized/post_list_virtualized.test.tsx @@ -44,6 +44,7 @@ describe('PostList', () => { lastViewedAt: 0, shouldStartFromBottomWhenUnread: false, actions: baseActions, + isThreadView: false, }; const postListIdsForClassNames = [ diff --git a/webapp/channels/src/components/post_view/post_list_virtualized/post_list_virtualized.tsx b/webapp/channels/src/components/post_view/post_list_virtualized/post_list_virtualized.tsx index c0ad4b7337..3e4f4c1d70 100644 --- a/webapp/channels/src/components/post_view/post_list_virtualized/post_list_virtualized.tsx +++ b/webapp/channels/src/components/post_view/post_list_virtualized/post_list_virtualized.tsx @@ -96,7 +96,7 @@ type Props = { focusedPostId?: string; shouldStartFromBottomWhenUnread: boolean; - + isThreadView: boolean; actions: { /* @@ -674,6 +674,7 @@ export default class PostList extends React.PureComponent { showScrollToBottomToast={this.state.showScrollToBottomToast} onScrollToBottomToastDismiss={this.handleScrollToBottomToastDismiss} hideScrollToBottomToast={this.hideScrollToBottomToast} + isThreadView={this.props.isThreadView} /> ); }; diff --git a/webapp/channels/src/components/post_view/post_view.test.tsx b/webapp/channels/src/components/post_view/post_view.test.tsx index d17c61bfca..c0ccc1d572 100644 --- a/webapp/channels/src/components/post_view/post_view.test.tsx +++ b/webapp/channels/src/components/post_view/post_view.test.tsx @@ -17,6 +17,7 @@ describe('components/post_view/post_view', () => { channelId: '1234', focusedPostId: '12345', unreadScrollPosition: Preferences.UNREAD_SCROLL_POSITION_START_FROM_LEFT, + isThreadView: false, }; jest.useFakeTimers(); diff --git a/webapp/channels/src/components/post_view/post_view.tsx b/webapp/channels/src/components/post_view/post_view.tsx index 17c23c76c9..4cf71f757a 100644 --- a/webapp/channels/src/components/post_view/post_view.tsx +++ b/webapp/channels/src/components/post_view/post_view.tsx @@ -15,6 +15,7 @@ interface Props { channelId: string; focusedPostId?: string; unreadScrollPosition: string; + isThreadView: boolean; } interface State { @@ -99,6 +100,7 @@ export default class PostView extends React.PureComponent { shouldStartFromBottomWhenUnread={this.state.shouldStartFromBottomWhenUnread} toggleShouldStartFromBottomWhenUnread={this.toggleShouldStartFromBottomWhenUnread} focusedPostId={this.props.focusedPostId} + isThreadView={this.props.isThreadView} />
); diff --git a/webapp/channels/src/components/rhs_header_post/rhs_header_post.tsx b/webapp/channels/src/components/rhs_header_post/rhs_header_post.tsx index 1db69e679a..6ea75d3131 100644 --- a/webapp/channels/src/components/rhs_header_post/rhs_header_post.tsx +++ b/webapp/channels/src/components/rhs_header_post/rhs_header_post.tsx @@ -18,6 +18,8 @@ import CRTThreadsPaneTutorialTip import {getHistory} from 'utils/browser_history'; import Constants, {RHSStates} from 'utils/constants'; +import RHSHeader from 'plugins/ai/components/rhs/rhs_header'; + import type {RhsState} from 'types/store/rhs'; interface Props extends WrappedComponentProps { @@ -41,6 +43,7 @@ interface Props extends WrappedComponentProps { closeRightHandSide: (e?: React.MouseEvent) => void; toggleRhsExpanded: (e: React.MouseEvent) => void; setThreadFollow: (userId: string, teamId: string, threadId: string, newState: boolean) => void; + onChatHistoryClick?: () => void; } class RhsHeaderPost extends React.PureComponent { @@ -174,74 +177,83 @@ class RhsHeaderPost extends React.PureComponent { } return ( -
- - {back} - - {channelName && + <> +
+ + {back} + + {channelName && - } - -
- {this.props.isCollapsedThreadsEnabled ? ( - - ) : null} + } + - - - + ) : null} - - - + + + + + + +
+ {this.props.showThreadsTutorialTip && }
- {this.props.showThreadsTutorialTip && } -
+ {this.props.channel.display_name === 'kChat Bot' && ( + + )} + ); } } diff --git a/webapp/channels/src/components/rhs_thread/__snapshots__/rhs_thread.test.tsx.snap b/webapp/channels/src/components/rhs_thread/__snapshots__/rhs_thread.test.tsx.snap index 07ce992c9e..158c8db850 100644 --- a/webapp/channels/src/components/rhs_thread/__snapshots__/rhs_thread.test.tsx.snap +++ b/webapp/channels/src/components/rhs_thread/__snapshots__/rhs_thread.test.tsx.snap @@ -27,6 +27,7 @@ exports[`components/RhsThread should match snapshot 1`] = ` "update_at": 0, } } + onChatHistoryClick={[Function]} rootPostId="id" /> { + const [displayThreadList, setDisplayThreadList] = useState(false); const dispatch = useDispatch(); useEffect(() => { @@ -41,6 +43,10 @@ const RhsThread = ({ } }, [currentTeam, channel]); + const onChatHistoryClick = () => { + setDisplayThreadList((prevState) => !prevState); + }; + if (posts == null || selected == null || !channel) { return (
@@ -56,13 +62,23 @@ const RhsThread = ({ rootPostId={selected.id} channel={channel} previousRhsState={previousRhsState} + onChatHistoryClick={onChatHistoryClick} /> - + {displayThreadList ? ( + + ) : ( + + )}
); }; diff --git a/webapp/channels/src/components/root/root.tsx b/webapp/channels/src/components/root/root.tsx index 86b3a34639..bb8835280e 100644 --- a/webapp/channels/src/components/root/root.tsx +++ b/webapp/channels/src/components/root/root.tsx @@ -78,6 +78,7 @@ import RootRedirect from './root_redirect'; import {checkIKTokenExpiresSoon, checkIKTokenIsExpired, clearLocalStorageToken, getChallengeAndRedirectToLogin, isDefaultAuthServer, refreshIKToken, storeTokenResponse} from '../login/utils'; import 'plugins/export.js'; +import {LLMBotPost} from 'plugins/ai/components/llmbot_post'; const LazyErrorPage = React.lazy(() => import('components/error_page')); const LazyLogin = React.lazy(() => import('components/login/login')); @@ -754,8 +755,10 @@ export default class Root extends React.PureComponent { this.initiateMeRequests(); // See figma design on issue https://mattermost.atlassian.net/browse/MM-43649 + // this.props.actions.registerCustomPostRenderer('custom_up_notification', OpenPricingModalPost, 'upgrade_post_message_renderer'); // this.props.actions.registerCustomPostRenderer('custom_pl_notification', OpenPluginInstallPost, 'plugin_install_post_message_renderer'); + this.props.actions.registerCustomPostRenderer('custom_llmbot', LLMBotPost, 'llmbot_post_message_renderer'); if (this.themeMediaQuery.addEventListener) { this.themeMediaQuery.addEventListener('change', this.handleThemeMediaQueryChangeEvent); diff --git a/webapp/channels/src/components/toast_wrapper/toast__wrapper.scss b/webapp/channels/src/components/toast_wrapper/toast__wrapper.scss index 23bfa1ea2b..dc345fd8aa 100644 --- a/webapp/channels/src/components/toast_wrapper/toast__wrapper.scss +++ b/webapp/channels/src/components/toast_wrapper/toast__wrapper.scss @@ -7,4 +7,15 @@ justify-content: center; margin-top: 16px; gap: 7px; + +} + +.toasts-wrapper-thread { + position: absolute; + z-index: 3; + width: 100%; + align-items: center; + justify-content: center; + margin-top: 16px; + gap: 7px; } diff --git a/webapp/channels/src/components/toast_wrapper/toast_wrapper.tsx b/webapp/channels/src/components/toast_wrapper/toast_wrapper.tsx index cb31900a97..e245d135bb 100644 --- a/webapp/channels/src/components/toast_wrapper/toast_wrapper.tsx +++ b/webapp/channels/src/components/toast_wrapper/toast_wrapper.tsx @@ -61,6 +61,7 @@ export type Props = WrappedComponentProps & RouteComponentProps<{team: string}> atLatestPost?: boolean; channelId: string; intl: IntlShape; + isThreadView: boolean; actions: { updateToastStatus: (status: boolean) => void; }; @@ -509,11 +510,12 @@ export class ToastWrapperClass extends React.PureComponent { ); } - if (showSearchHintToast) { + if (showSearchHintToast && !this.props.isThreadView) { toasts.push( {this.getSearchHintToastText()} , @@ -522,7 +524,7 @@ export class ToastWrapperClass extends React.PureComponent { if (toasts.length > 0) { return ( -
+
{toasts}
); diff --git a/webapp/channels/src/plugins/ai/components/assets/buttons.tsx b/webapp/channels/src/plugins/ai/components/assets/buttons.tsx new file mode 100644 index 0000000000..d2c0d12823 --- /dev/null +++ b/webapp/channels/src/plugins/ai/components/assets/buttons.tsx @@ -0,0 +1,224 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License for license information. + +import styled from 'styled-components'; + +export const Button = styled.button` + display: inline-flex; + align-items: center; + height: 40px; + background: rgba(var(--center-channel-color-rgb), 0.08); + color: rgba(var(--center-channel-color-rgb), 0.72); + border-radius: 4px; + border: 0px; + font-weight: 600; + font-size: 14px; + padding: 0 20px; + position: relative; + justify-content: center; + + transition: all 0.15s ease-out; + + &:hover{ + background: rgba(var(--center-channel-color-rgb), 0.12); + } + + &&, &&:focus { + text-decoration: none; + } + + &&:hover:not([disabled]) { + text-decoration: none; + } + + &:disabled { + color: rgba(var(--center-channel-color-rgb), 0.32); + background: rgba(var(--center-channel-color-rgb), 0.08); + } + + i { + display: flex; + font-size: 18px; + } +`; + +export const PrimaryButton = styled(Button)` + &&, &&:focus { + background: var(--button-bg); + color: var(--button-color); + white-space: nowrap; + } + + &:active:not([disabled]) { + background: rgba(var(--button-bg-rgb), 0.8); + } + + &:before { + content: ''; + left: 0; + top: 0; + width: 100%; + height: 100%; + transition: all 0.15s ease-out; + position: absolute; + background: rgba(var(--center-channel-color-rgb), 0.16); + opacity: 0; + border-radius: 4px; + } + + &&:hover:not([disabled]) { + color: var(--button-color); + background: var(--button-bg); + &:before { + opacity: 1; + } + } + + &:disabled { + color: rgba(var(--center-channel-color-rgb), 0.32); + background: rgba(var(--center-channel-color-rgb), 0.08); + } +`; + +export const SubtlePrimaryButton = styled(Button)` + background: rgba(var(--button-bg-rgb), 0.08); + color: var(--button-bg); + &:hover, + &:active { + background: rgba(var(--button-bg-rgb), 0.12); + } +`; + +export const TertiaryButton = styled.button` + display: inline-flex; + align-items: center; + justify-content: center; + height: 40px; + border-radius: 4px; + border: 0px; + font-weight: 600; + font-size: 14px; + padding: 0 20px; + + color: var(--button-bg); + background: rgba(var(--button-bg-rgb), 0.08); + + &:disabled { + color: rgba(var(--center-channel-color-rgb), 0.32); + background: rgba(var(--center-channel-color-rgb), 0.08); + } + + &:hover:enabled { + background: rgba(var(--button-bg-rgb), 0.12); + } + + &:active:enabled { + background: rgba(var(--button-bg-rgb), 0.16); + } + + i { + display: flex; + font-size: 18px; + + &:before { + margin: 0 7px 0 0; + } + } +`; + +export const InvertedTertiaryButton = styled(Button)` + transition: all 0.15s ease-out; + + && { + color: var(--button-bg-rgb); + background-color: rgba(var(--button-color-rgb), 0.08); + } + + &&:hover:not([disabled]) { + color: var(--button-bg-rgb); + background: rgba(var(--button-bg-rgb), 0.12); + } + + &&:active:not([disabled]) { + color: var(--button-bg-rgb); + background: rgba(var(--button-bg-rgb), 0.16); + } + + &&:focus:not([disabled]) { + color: var(--button-bg-rgb); + background-color: rgba(var(--button-color-rgb), 0.08); + box-shadow: inset 0px 0px 0px 2px var(--sidebar-text-active-border-rgb); + } +`; + +export const SecondaryButton = styled(TertiaryButton)` + background: var(--button-color-rgb); + border: 1px solid var(--button-bg); + + + &:disabled { + color: rgba(var(--center-channel-color-rgb), 0.32); + background: transparent; + border: 1px solid rgba(var(--center-channel-color-rgb), 0.32); + } +`; + +export const DestructiveButton = styled.button` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + font-weight: 600; + font-size: 14px; + + padding: 0 20px; + + border-radius: 4px; + border: 0px; + + background: var(--dnd-indicator); + color: var(--button-color); + + :hover:enabled { + background: linear-gradient(0deg, rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.08)), var(--dnd-indicator); + } + + :active, :hover:active { + background: linear-gradient(0deg, rgba(0, 0, 0, 0.16), rgba(0, 0, 0, 0.16)), var(--dnd-indicator); + } + + :disabled { + background: rgba(var(--center-channel-color-rgb), 0.08); + } +`; + +export const ButtonIcon = styled.button` + width: 28px; + height: 28px; + padding: 0; + border: none; + background: transparent; + border-radius: 4px; + color: rgba(var(--center-channel-color-rgb), 0.56); + fill: rgba(var(--center-channel-color-rgb), 0.56); + font-size: 1.6rem; + + &:hover { + background: rgba(var(--center-channel-color-rgb), 0.08); + color: rgba(var(--center-channel-color-rgb), 0.72); + fill: rgba(var(--center-channel-color-rgb), 0.72); + } + + &:active, + &--active, + &--active:hover { + background: rgba(var(--button-bg-rgb), 0.08); + color: var(--button-bg); + fill: var(--button-bg); + } + + display: flex; + align-items: center; + justify-content: center; +`; diff --git a/webapp/channels/src/plugins/ai/components/assets/icon-regenerate.tsx b/webapp/channels/src/plugins/ai/components/assets/icon-regenerate.tsx new file mode 100644 index 0000000000..31a11776a6 --- /dev/null +++ b/webapp/channels/src/plugins/ai/components/assets/icon-regenerate.tsx @@ -0,0 +1,21 @@ +import React from 'react'; + +import Svg from '../svg'; + +const IconRegenerate = () => ( + + + +); + +export default IconRegenerate; diff --git a/webapp/channels/src/plugins/ai/components/assets/icon_ai.tsx b/webapp/channels/src/plugins/ai/components/assets/icon_ai.tsx new file mode 100644 index 0000000000..13c9d9f0a8 --- /dev/null +++ b/webapp/channels/src/plugins/ai/components/assets/icon_ai.tsx @@ -0,0 +1,31 @@ +import React from 'react'; + +import Svg from '../svg'; + +const IconAI = (props: {className?: string}) => ( + + + + + + +); + +export default IconAI; diff --git a/webapp/channels/src/plugins/ai/components/assets/icon_cancel.tsx b/webapp/channels/src/plugins/ai/components/assets/icon_cancel.tsx new file mode 100644 index 0000000000..83a6751eda --- /dev/null +++ b/webapp/channels/src/plugins/ai/components/assets/icon_cancel.tsx @@ -0,0 +1,21 @@ +import React from 'react'; + +import Svg from '../svg'; + +const IconCancel = () => ( + + + +); + +export default IconCancel; diff --git a/webapp/channels/src/plugins/ai/components/assets/icon_sparkle_checkmark.tsx b/webapp/channels/src/plugins/ai/components/assets/icon_sparkle_checkmark.tsx new file mode 100644 index 0000000000..37f77884c9 --- /dev/null +++ b/webapp/channels/src/plugins/ai/components/assets/icon_sparkle_checkmark.tsx @@ -0,0 +1,29 @@ +import React from 'react'; + +import Svg from '../svg'; + +const IconSparkleCheckmark = (props: {className?: string}) => ( + + + + + +); + +export default IconSparkleCheckmark; diff --git a/webapp/channels/src/plugins/ai/components/assets/icon_thread_summarization.tsx b/webapp/channels/src/plugins/ai/components/assets/icon_thread_summarization.tsx new file mode 100644 index 0000000000..0fce3dba3b --- /dev/null +++ b/webapp/channels/src/plugins/ai/components/assets/icon_thread_summarization.tsx @@ -0,0 +1,38 @@ +import React from 'react'; + +import Svg from '../svg' + +const IconThreadSummarization = () => ( + + + + + + + +); + +export default IconThreadSummarization; diff --git a/webapp/channels/src/plugins/ai/components/bot_slector.tsx b/webapp/channels/src/plugins/ai/components/bot_slector.tsx new file mode 100644 index 0000000000..256529948a --- /dev/null +++ b/webapp/channels/src/plugins/ai/components/bot_slector.tsx @@ -0,0 +1,140 @@ +import React from 'react'; +import {FormattedMessage} from 'react-intl'; + +import styled from 'styled-components'; + +import {CheckIcon, ChevronDownIcon} from '@infomaniak/compass-icons/components'; +import {Client4} from 'mattermost-redux/client'; + +import DotMenu, {DotMenuButton, DropdownMenu, DropdownMenuItem} from './dot_menu'; +import {GrayPill} from './pill'; + +interface LLMBot { + id: string; + displayName: string; + username: string; + lastIconUpdate: number; + dmChannelID: string; +} + +type DropdownBotSelectorProps = { + bots: LLMBot[] + activeBot: LLMBot | null + setActiveBot: (bot: LLMBot) => void +} + +export const DropdownBotSelector = (props: DropdownBotSelectorProps) => { + return ( + + <> + {/* + + */} + + {props.activeBot?.displayName} + + + + + ); +}; + +const BotPill = styled(GrayPill)` + font-size: 12px; + padding: 2px 6px; + gap: 0; +`; + +const BotSelectorContainer = styled.div` + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + + margin: 8px 16px; + color: rgba(var(--center-channel-color-rgb), 0.56); +`; + +type BotDropdownProps = { + bots: LLMBot[] + activeBot: LLMBot | null + setActiveBot: (bot: LLMBot) => void + container: typeof DotMenuButton + children: JSX.Element +} + +export const BotDropdown = (props: BotDropdownProps) => { + return ( + + {/* + + */} + {props.bots.map((bot) => { + const botProfileURL = Client4.getProfilePictureUrl(bot.id, bot.lastIconUpdate); + return ( + props.setActiveBot(bot)} + > + + {bot.displayName} + {props.activeBot && (props.activeBot.id === bot.id) && ( + + )} + + ); + })} + + ); +}; + +const StyledDropdownMenu = styled(DropdownMenu)` + min-width: 270px; +`; + +const StyledCheckIcon = styled(CheckIcon)` + margin-left: auto; + color: var(--button-bg); +`; + +const StyledDropdownMenuItem = styled(DropdownMenuItem)` + padding: 8px 16px; +`; + +const MenuInfoMessage = styled.div` + padding: 6px 20px; + + color: rgba(var(--center-channel-color-rgb), 0.56); + font-size: 12px; + font-weight: 600; + line-height: 16px; + letter-spacing: 0.48px; + text-transform: uppercase; +`; + +const BotIconDropdownItem = styled.img` + border-radius: 50%; + width: 24px; + height: 24px; + margin-right: 8px; +`; + +const SelectMessage = styled.div` + font-size: 12px; + font-weight: 600; + line-height: 16px; + letter-spacing: 0.24px; + text-transform: uppercase; +`; diff --git a/webapp/channels/src/plugins/ai/components/dot_menu.tsx b/webapp/channels/src/plugins/ai/components/dot_menu.tsx new file mode 100644 index 0000000000..22d7daeb20 --- /dev/null +++ b/webapp/channels/src/plugins/ai/components/dot_menu.tsx @@ -0,0 +1,222 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {ComponentProps, useState} from 'react'; +import styled, {css} from 'styled-components'; + +import {useUpdateEffect} from 'react-use'; + +import Dropdown from './dropdown'; +import {PrimaryButton} from './assets/buttons'; + +export const DotMenuButton = styled.div<{isActive: boolean}>` + display: inline-flex; + padding: 0; + border: none; + border-radius: 4px; + width: 28px; + height: 28px; + align-items: center; + justify-content: center; + fill: rgba(var(--center-channel-color-rgb), 0.56); + cursor: pointer; + + color: ${(props) => (props.isActive ? 'var(--button-bg)' : 'rgba(var(--center-channel-color-rgb), 0.56)')}; + background-color: ${(props) => (props.isActive ? 'rgba(var(--button-bg-rgb), 0.08)' : 'transparent')}; + + &:hover { + color: ${(props) => (props.isActive ? 'var(--button-bg)' : 'rgba(var(--center-channel-color-rgb), 0.56)')}; + background-color: ${(props) => (props.isActive ? 'rgba(var(--button-bg-rgb), 0.08)' : 'rgba(var(--center-channel-color-rgb), 0.08)')}; + } +`; + +export const DropdownMenu = styled.div` + display: flex; + flex-direction: column; + + width: max-content; + min-width: 16rem; + text-align: left; + list-style: none; + + padding: 10px 0; + font-family: Open Sans; + font-style: normal; + font-weight: normal; + font-size: 14px; + line-height: 20px; + color: var(--center-channel-color); + + background: var(--center-channel-bg); + border: 1px solid rgba(var(--center-channel-color-rgb), 0.16); + box-shadow: 0px 8px 24px rgba(0, 0, 0, 0.12); + border-radius: 4px; + + z-index: 12; +`; + +type DotMenuProps = { + children: React.ReactNode; + icon: JSX.Element; + dotMenuButton?: typeof DotMenuButton | typeof PrimaryButton; + dropdownMenu?: typeof DropdownMenu; + title?: string; + disabled?: boolean; + className?: string; + isActive?: boolean; + onOpenChange?: (isOpen: boolean) => void; + closeOnClick?: boolean; +}; + +type DropdownProps = Omit, 'target' | 'children' | 'isOpen'>; + +const DotMenu = ({ + children, + icon, + title, + className, + disabled, + isActive, + closeOnClick = true, + dotMenuButton: MenuButton = DotMenuButton, + dropdownMenu: Menu = DropdownMenu, + onOpenChange, + ...props +}: DotMenuProps & DropdownProps) => { + const [isOpen, setOpen] = useState(false); + const toggleOpen = () => { + setOpen(!isOpen); + }; + useUpdateEffect(() => { + onOpenChange?.(isOpen); + }, [isOpen]); + + const button = ( + + // @ts-ignore + { + e.preventDefault(); + e.stopPropagation(); + toggleOpen(); + }} + onKeyDown={(e: KeyboardEvent) => { + // Handle Enter and Space as clicking on the button + if (e.key === 'Space' || e.key === 'Enter') { + e.stopPropagation(); + toggleOpen(); + } + }} + tabIndex={0} + className={className} + role={'button'} + disabled={disabled ?? false} + data-testid={'menuButton' + (title ?? '')} + > + {icon} + + ); + + return ( + + { + e.stopPropagation(); + if (closeOnClick) { + setOpen(false); + } + }} + > + {children} + + + ); +}; + +export const DropdownMenuItemStyled = styled.a` + && { + font-family: "SuisseIntl", sans-serif; + font-style: normal; + font-weight: normal; + font-size: 14px; + color: var(--center-channel-color); + padding: 10px 20px; + text-decoration: unset; + display: inline-flex; + align-items: center; + + >.icon { + margin-right: 8px; + } + + &:hover { + background: rgba(var(--center-channel-color-rgb), 0.08); + color: var(--center-channel-color); + } + &&:focus { + text-decoration: none; + color: inherit; + } +} +`; + +export const DisabledDropdownMenuItemStyled = styled.div` + && { + cursor: default; + font-family: 'Open Sans'; + font-style: normal; + font-weight: normal; + font-size: 14px; + color: var(--center-channel-color-40); + padding: 8px 20px; + text-decoration: unset; +} +`; + +export const iconSplitStyling = css` + display: flex; + align-items: center; + gap: 8px; +`; + +export const DropdownMenuItem = (props: { children: React.ReactNode, onClick?: () => void, className?: string}) => { + return ( + + {props.children} + + ); +}; + +// Alternate dot menu button. Use `dotMenuButton={TitleButton}` for this style. +export const TitleButton = styled.div<{isActive: boolean}>` + padding: 2px 2px 2px 6px; + display: inline-flex; + border-radius: 4px; + color: ${({isActive}) => (isActive ? 'var(--button-bg)' : 'var(--center-channel-color)')}; + background: ${({isActive}) => (isActive ? 'rgba(var(--button-bg-rgb), 0.08)' : 'auto')}; + + min-width: 0; + + &:hover { + background: ${({isActive}) => (isActive ? 'rgba(var(--button-bg-rgb), 0.08)' : 'rgba(var(--center-channel-color-rgb), 0.08)')}; + } +`; + +export default DotMenu; diff --git a/webapp/channels/src/plugins/ai/components/dropdown.tsx b/webapp/channels/src/plugins/ai/components/dropdown.tsx new file mode 100644 index 0000000000..6417c62a37 --- /dev/null +++ b/webapp/channels/src/plugins/ai/components/dropdown.tsx @@ -0,0 +1,100 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {ComponentProps, cloneElement, useState} from 'react'; +import styled from 'styled-components'; + +import { + FloatingFocusManager, + FloatingPortal, + Placement, + autoUpdate, + flip, + offset, + shift, + useDismiss, + useFloating, + useInteractions, + useRole, +} from '@floating-ui/react-dom-interactions'; + +const FloatingContainer = styled.div` + min-width: 16rem; + z-index: 50; +`; + +type DropdownProps = { + target: JSX.Element; + children: React.ReactNode; + placement?: Placement; + offset?: Parameters[0]; + flip?: Parameters[0]; + shift?: Parameters[0]; + focusManager?: boolean | Omit, 'context' | 'children'>; + portal?: boolean; + isOpen: boolean; + onOpenChange?: ((open: boolean) => void); +}; + +const Dropdown = (props: DropdownProps) => { + const [isOpen, setIsOpen] = useState(props.isOpen); + + const open = props.isOpen ?? isOpen; + + const setOpen = (updatedOpen: boolean) => { + props.onOpenChange?.(updatedOpen); + setIsOpen(updatedOpen); + }; + + const {strategy, x, y, reference, floating, context} = useFloating({ + open, + onOpenChange: setOpen, + placement: props.placement ?? 'bottom-start', + middleware: [offset(props.offset ?? 2), flip(props.flip), shift(props.shift ?? {padding: 2})], + whileElementsMounted: autoUpdate, + }); + + const {getReferenceProps, getFloatingProps} = useInteractions([ + useRole(context), + useDismiss(context), + ]); + + const MaybePortal = (props.portal ?? true) ? FloatingPortal : React.Fragment; // 🤷 + + let content = ( + + {props.children} + + ); + + if (props.focusManager ?? true) { + content = ( + + {content} + + ); + } + + return ( + <> + {cloneElement(props.target, getReferenceProps({ref: reference, ...props.target.props}))} + + {open && content} + + + ); +}; + +export default Dropdown; diff --git a/webapp/channels/src/plugins/ai/components/dropdown_info.tsx b/webapp/channels/src/plugins/ai/components/dropdown_info.tsx new file mode 100644 index 0000000000..6f0f488441 --- /dev/null +++ b/webapp/channels/src/plugins/ai/components/dropdown_info.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import {FormattedMessage} from 'react-intl'; +import styled from 'styled-components'; +import {LightbulbOutlineIcon} from '@infomaniak/compass-icons/components'; + +const DropdownMenuItemInfo = styled.div` + display: flex; + align-items: flex-start; + gap: 8px; + + font-size: 12px; + font-weight: 400; + line-height: 16px; + color: rgba(var(--center-channel-color-rgb), 0.72); + + max-width: 240px; + padding: 8px 16px; +`; + +const LightbulbOutlineIconStyled = styled(LightbulbOutlineIcon)` + min-width: 22px; + min-height: 22px; + + padding: 4px; + + color: rgba(var(--center-channel-color-rgb), 0.56); + background: rgba(var(--center-channel-color-rgb), 0.08); + border-radius: 16px; +`; + +export const Divider = styled.div` + border: 1px solid rgba(var(--center-channel-color-rgb), 0.08); + margin-top: 8px; + margin-bottom: 8px; +`; + +export const DropdownInfoOnlyVisibleToYou = () => { + return ( + + + {/* */} + + ); +}; diff --git a/webapp/channels/src/plugins/ai/components/llmbot_post.tsx b/webapp/channels/src/plugins/ai/components/llmbot_post.tsx new file mode 100644 index 0000000000..1c012f4bd9 --- /dev/null +++ b/webapp/channels/src/plugins/ai/components/llmbot_post.tsx @@ -0,0 +1,338 @@ +import type {MouseEvent} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; +import {FormattedMessage} from 'react-intl'; +import {useSelector} from 'react-redux'; +import styled, {css, createGlobalStyle} from 'styled-components'; + +import {SendIcon} from '@mattermost/compass-icons/components'; +import type {Post} from '@mattermost/types/posts'; +import type {GlobalState} from '@mattermost/types/store'; + +// import {doPostbackSummary, doRegenerate, doStopGenerating} from '@/client'; + +// import IconCancel from './assets/icon_cancel'; +// import IconRegenerate from './assets/icon_regenerate'; +import {handleEvent} from 'actions/websocket_actions'; + +// import {useSelectNotAIPost, useSelectPost} from '@/hooks'; + +import WebSocketClient from 'client/web_websocket_client'; + +import IconRegenerate from './assets/icon-regenerate'; +import IconCancel from './assets/icon_cancel'; +import PostText from './post_text'; + +const PostMessagePreview = (window as any).Components.PostMessagePreview; + +const FixPostHover = createGlobalStyle<{disableHover?: string}>` + ${(props) => props.disableHover && css` + &&&& { + [data-testid="post-menu-${props.disableHover}"] { + display: none !important; + } + [data-testid="post-menu-${props.disableHover}"]:hover { + display: none !important; + } + }`} +`; + +const PostBody = styled.div<{disableHover?: boolean}>` + ${(props) => props.disableHover && css` + ::before { + content: ''; + position: absolute; + width: 110%; + height: 110%; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + }`} +`; + +// const ControlsBar = styled.div` +// display: flex; +// flex-direction: row; +// justify-content: left; +// height: 28px; +// margin-top: 8px; +// gap: 4px; +// `; + +// const GenerationButton = styled.button` +// display: flex; +// border: none; +// height: 24px; +// padding: 4px 10px; +// align-items: center; +// justify-content: center; +// gap: 6px; +// border-radius: 4px; +// background: rgba(var(--center-channel-color-rgb), 0.08); +// color: rgba(var(--center-channel-color-rgb), 0.64); + +// font-size: 12px; +// line-height: 16px; +// font-weight: 600; + +// :hover { +// background: rgba(var(--center-channel-color-rgb), 0.12); +// color: rgba(var(--center-channel-color-rgb), 0.72); +// } + +// :active { +// background: rgba(var(--button-bg-rgb), 0.08); +// } +// `; + +// const PostSummaryButton = styled(GenerationButton)` +// background: var(--button-bg); +// color: var(--button-color); + +// :hover { +// background: rgba(var(--button-bg-rgb), 0.88); +// color: var(--button-color); +// } + +// :active { +// background: rgba(var(--button-bg-rgb), 0.92); +// } +// `; + +// const StopGeneratingButton = styled.button` +// display: flex; +// padding: 5px 12px; +// align-items: center; +// justify-content: center; +// gap: 6px; +// border-radius: 4px; +// border: 1px solid rgba(var(--center-channel-color,0.12)); +// background: var(--center-channel-bg); + +// box-shadow: 0px 4px 6px 0px rgba(0, 0, 0, 0.12); + +// position: absolute; +// left: 50%; +// top: -5px; +// transform: translateX(-50%); + +// color: var(--button-bg); + +// font-size: 12px; +// font-weight: 600; +// `; + +// const PostSummaryHelpMessage = styled.div` +// font-size: 14px; +// font-style: italic; +// font-weight: 400; +// line-height: 20px; +// border-top: 1px solid rgba(var(--center-channel-color-rgb), 0.12); + +// padding-top: 8px; +// padding-bottom: 8px; +// margin-top: 16px; +// `; + +type PostUpdateWebsocketMessage = { + channel_id: string; + post_id: string; +}; + +type PostUpdateWebsocketMessageControl = PostUpdateWebsocketMessage & {control: 'start' | 'end'}; +type PostUpdateWebsocketMessageNext = PostUpdateWebsocketMessage & {next: string}; +type PostUpdateWebsocketMessageAny = PostUpdateWebsocketMessageControl | PostUpdateWebsocketMessageNext; +type PostUpdateWebsocket = { + event: string; + data: PostUpdateWebsocketMessageAny; +}; + +const isPostUpdateWebsocketMessageNext = (msg: any): msg is PostUpdateWebsocketMessageNext => + typeof msg.next === 'string'; + +interface Props { + post: Post; + + // websocketRegister: (postID: string, handler: (msg: WebSocketMessage) => void) => void; + // websocketUnregister: (postID: string) => void; +} + +export const LLMBotPost = (props: Props) => { + // const selectPost = useSelectNotAIPost(); + const [message, setMessage] = useState(props.post.message as string); + + // Generating is true while we are reciving new content from the websocket + const [generating, setGenerating] = useState(false); + + // Stopped is a flag that is used to prevent the websocket from updating the message after the user has stopped the generation + // Needs a ref because of the useEffect closure. + const [stopped, setStopped] = useState(false); + const stoppedRef = useRef(stopped); + stoppedRef.current = stopped; + + // const currentUserId = useSelector((state) => state.entities.users.currentUserId); + // const rootPost = useSelector((state) => state.entities.posts.posts[props.post.root_id]); + useEffect(() => { + function handleCustomMattermostEvent({event, data}: PostUpdateWebsocket) { + if (event === 'custom_mattermost-ai_postupdate' && data.post_id === props.post.id) { + if (isPostUpdateWebsocketMessageNext(data)) { + if (stoppedRef.current) { + return; + } + setGenerating(true); + setMessage(data.next); + } else if (data.control === 'end') { + setGenerating(false); + setStopped(false); + } else if (data.control === 'start') { + setGenerating(true); + setStopped(false); + } + } + } + WebSocketClient.addMessageListener(handleCustomMattermostEvent); + + return () => { + WebSocketClient.removeMessageListener(handleCustomMattermostEvent); + }; + }, []); + + // const regnerate = () => { + // setGenerating(true); + // setStopped(false); + // setMessage(''); + // doRegenerate(props.post.id); + // }; + + // const stopGenerating = () => { + // setStopped(true); + // setGenerating(false); + // doStopGenerating(props.post.id); + // }; + + const stopPropagationIfGenerating = (e: MouseEvent) => { + if (generating) { + e.stopPropagation(); + } + }; + + // const postSummary = async () => { + // const result = await doPostbackSummary(props.post.id); + // selectPost(result.rootid, result.channelid); + // }; + + // const requesterIsCurrentUser = (props.post.props?.llm_requester_user_id === currentUserId); + // const isThreadSummaryPost = (props.post.props?.referenced_thread && props.post.props?.referenced_thread !== ''); + // const isNoShowRegen = (props.post.props?.no_regen && props.post.props?.no_regen !== ''); + // const isTranscriptionResult = rootPost?.props?.referenced_transcript_post_id && rootPost?.props?.referenced_transcript_post_id !== ''; + + let permalinkView = null; + if (PostMessagePreview) { // Ignore permalink if version does not exporrt PostMessagePreview + const permalinkData = extractPermalinkData(props.post); + if (permalinkData !== null) { + permalinkView = ( + + ); + } + } + + // const showRegenerate = !generating && requesterIsCurrentUser && !isNoShowRegen; + const showPostbackButton = !generating; //&& requesterIsCurrentUser && isTranscriptionResult; + // const showControlsBar = (showRegenerate || showPostbackButton) && message !== ''; + + return ( + + + {/* { isThreadSummaryPost && permalinkView && */} + { permalinkView && + <> + {permalinkView} + + } + + + {/* { generating && requesterIsCurrentUser && + + + + + } */} + {/* { showPostbackButton && + + + + } */} + {/* { showControlsBar && + + {showPostbackButton && + + + + + } + { showRegenerate && + + + + + } + + } */} + + ); +}; + +type PermalinkData = { + channel_display_name: string; + channel_id: string; + post_id: string; + team_name: string; + post: { + message: string; + user_id: string; + }; +} + +function extractPermalinkData(post: any): PermalinkData | null { + for (const embed of post?.metadata?.embeds || []) { + if (embed.type === 'permalink') { + return embed.data; + } + } + return null; +} + diff --git a/webapp/channels/src/plugins/ai/components/pill.tsx b/webapp/channels/src/plugins/ai/components/pill.tsx new file mode 100644 index 0000000000..738c5ee8c6 --- /dev/null +++ b/webapp/channels/src/plugins/ai/components/pill.tsx @@ -0,0 +1,23 @@ +import styled from 'styled-components'; + +export const Pill = styled.div` + background: rgb(var(--semantic-color-info)); + color: white; + border-radius: 4px; + font-size: 10px; + font-weight: 600; + line-height: 16px; + padding: 0 4px; + display: flex; + align-items: center; + gap: 6px; +`; + +export const DangerPill = styled(Pill)` + background: rgb(var(--semantic-color-danger)); +`; + +export const GrayPill = styled(Pill)` + color: var(--center-channel-color); + background: rgba(var(--center-channel-color-rgb), 0.08); +`; diff --git a/webapp/channels/src/plugins/ai/components/post_menu.tsx b/webapp/channels/src/plugins/ai/components/post_menu.tsx new file mode 100644 index 0000000000..abcdc5a01d --- /dev/null +++ b/webapp/channels/src/plugins/ai/components/post_menu.tsx @@ -0,0 +1,108 @@ +import React from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; +import {useDispatch, useSelector} from 'react-redux'; +import styled from 'styled-components'; + +import type {Post} from '@mattermost/types/posts'; + +import {Client4} from 'mattermost-redux/client'; +import {getUser} from 'mattermost-redux/selectors/entities/users'; + +import {selectPostById} from 'actions/views/rhs'; +import {handleEvent} from 'actions/websocket_actions'; + +import type {GlobalState} from 'types/store'; + +import IconAI from './assets/icon_ai'; +import IconThreadSummarization from './assets/icon_thread_summarization'; +import {DropdownBotSelector} from './bot_slector'; +import DotMenu, {DropdownMenu, DropdownMenuItem} from './dot_menu'; +import {Divider, DropdownInfoOnlyVisibleToYou} from './dropdown_info'; +import {GrayPill} from './pill'; + +// import IconReactForMe from './assets/icon_react_for_me'; +// import {DropdownBotSelector} from './bot_slector'; +// import {useSelector} from 'react-redux'; +// import {getBotAccounts} from 'mattermost-redux/selectors/entities/bots'; +// import {getPost} from 'mattermost-redux/actions/posts'; + +// const BotPill = styled(GrayPill)` +// font-size: 12px; +// padding: 2px 6px; +// gap: 0; +// `; + +type Props = { + post: Post; + location: string; +} + +const PostMenu = (props: Props) => { + const dispatch = useDispatch(); + const intl = useIntl(); + + const post = props.post; + + const user = useSelector((state: GlobalState) => getUser(state, post.user_id)); + const isBot = Boolean(user && user.is_bot); + + // const {bots, activeBot, setActiveBot} = useBotlist(); + + // const isBasicsLicensed = useIsBasicsLicensed(); + + const summarizePost = async (postId: string) => { + try { + const result = await Client4.doSummarize(postId, 'kchat.bot'); + dispatch(selectPostById(result.postid)); + Client4.viewMyChannel(result.channelid); + } catch (error) { + console.error('Error summarizing post:', error); + } + }; + + // if (!isBasicsLicensed) { + // return null; + // } + + // Unconfigured state + // if (bots && botsArray.length === 0) { + // return null; + // } + + if (isBot || props.location === 'RHS_ROOT') { + return null; + } + + return ( + } + title={intl.formatMessage({id: 'ai.actions', defaultMessage: 'AI Actions'})} + dropdownMenu={StyledDropdownMenu} + > + {/* + */} + summarizePost(post.id)}> + +
+ +
+ +
+ {/* + */} +
+ ); +}; + +const StyledDropdownMenu = styled(DropdownMenu)` + min-width: 240px; +`; + +export default PostMenu; diff --git a/webapp/channels/src/plugins/ai/components/post_text.tsx b/webapp/channels/src/plugins/ai/components/post_text.tsx new file mode 100644 index 0000000000..bb20b44dd7 --- /dev/null +++ b/webapp/channels/src/plugins/ai/components/post_text.tsx @@ -0,0 +1,106 @@ +import React, {useEffect} from 'react'; +import {useSelector} from 'react-redux'; +import styled, {keyframes, css} from 'styled-components'; + +import type {Channel} from '@mattermost/types/channels'; +import type {GlobalState} from '@mattermost/types/store'; +import type {Team} from '@mattermost/types/teams'; + +import Markdown from 'components/markdown'; + +export type ChannelNamesMap = { + [name: string]: { + display_name: string; + team_name?: string; + } | Channel; +}; + +interface Props { + message: string; + channelID: string; + postID: string; + showCursor?: boolean; +} + +const blinkKeyframes = keyframes` + 0% { opacity: 0.48; } + 20% { opacity: 0.48; } + 100% { opacity: 0.12; } +`; + +const TextContainer = styled.div<{showCursor?: boolean}>` + ${(props) => props.showCursor && css` + >ul:last-child>li:last-child>span:not(:has(li))::after, + >ol:last-child>li:last-child>span:not(:has(li))::after, + >ul:last-child>li:last-child>span>ul>li:last-child>span:not(:has(li))::after, + >ol:last-child>li:last-child>span>ul>li:last-child>span:not(:has(li))::after, + >ul:last-child>li:last-child>span>ol>li:last-child>span:not(:has(li))::after, + >ol:last-child>li:last-child>span>ol>li:last-child>span:not(:has(li))::after, + >h1:last-child::after, + >h2:last-child::after, + >h3:last-child::after, + >h4:last-child::after, + >h5:last-child::after, + >h6:last-child::after, + >blockquote:last-child>p::after, + >p:last-child::after { + content: ''; + width: 7px; + height: 16px; + background: rgba(var(--center-channel-color-rgb), 0.48); + display: inline-block; + margin-left: 3px; + + animation: ${blinkKeyframes} 500ms ease-in-out infinite; + } + `} +`; + +const PostText = (props: Props) => { + const channel = useSelector((state) => state.entities.channels.channels[props.channelID]); + const team = useSelector((state) => state.entities.teams.teams[channel?.team_id]); + const siteURL = useSelector((state) => state.entities.general.config.SiteURL); + + // @ts-ignore + const {formatText, messageHtmlToComponent} = window.PostUtils; + + const markdownOptions = { + singleline: false, + mentionHighlight: true, + atMentions: true, + team, + unsafeLinks: true, + minimumHashtagLength: 1000000000, + siteURL, + }; + + const messageHtmlToComponentOptions = { + hasPluginTooltips: true, + latex: false, + inlinelatex: false, + postId: props.postID, + }; + + const text = messageHtmlToComponent( + formatText(props.message, markdownOptions), + messageHtmlToComponentOptions, + ); + + if (!text) { + return {

}; + } + + return ( + + + {/* {text} */} + + ); +}; + +export default PostText; diff --git a/webapp/channels/src/plugins/ai/components/rhs/common.tsx b/webapp/channels/src/plugins/ai/components/rhs/common.tsx new file mode 100644 index 0000000000..9e723f8fda --- /dev/null +++ b/webapp/channels/src/plugins/ai/components/rhs/common.tsx @@ -0,0 +1,52 @@ +import styled from 'styled-components'; + +export const Button = styled.button` + border-radius: 4px; + padding: 8px 16px; + display: flex; + align-items: center; + font-weight: 600; + font-size: 12px; + background-color: rgb(var(--center-channel-bg-rgb)); + border: 0; + + &:hover { + background-color: rgba(var(--button-bg-rgb), 0.08); + color: rgb(var(--link-color-rgb)); + svg { + fill: rgb(var(--link-color-rgb)) + } + } + + svg { + fill: rgb(var(--center-channel-color)); + margin-right: 6px; + } + + i { + display: flex; + font-size: 14px; + margin-right: 2px; + } +`; + +export const RHSTitle = styled.div` + font-family: Metropolis; + font-weight: 600; + font-size: 22px; + line-height: 28px; +`; + +export const RHSText = styled.div` + font-weight: 500; + font-size: 14px; + line-height: 20px; +`; + +export const RHSPaddingContainer = styled.div` + margin: 0 24px; + margin-top: 16px; + display: flex; + flex-direction: column; + gap: 8px; +`; diff --git a/webapp/channels/src/plugins/ai/components/rhs/rhs.tsx b/webapp/channels/src/plugins/ai/components/rhs/rhs.tsx new file mode 100644 index 0000000000..7747633e81 --- /dev/null +++ b/webapp/channels/src/plugins/ai/components/rhs/rhs.tsx @@ -0,0 +1,179 @@ +import React, {useState, useEffect, useCallback} from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; +import {useDispatch, useSelector} from 'react-redux'; +import styled from 'styled-components'; + +import {GlobalState} from '@mattermost/types/store'; + +import manifest from '@/manifest'; + +import {getAIThreads, updateRead} from '@/client'; + +import {useBotlist} from '@/bots'; + +import RHSImage from '../assets/rhs_image'; + +import ThreadItem from './thread_item'; +import RHSHeader from './rhs_header'; +import RHSNewTab from './rhs_new_tab'; +import {RHSPaddingContainer, RHSText, RHSTitle} from './common'; + +const ThreadViewer = (window as any).Components.ThreadViewer && styled((window as any).Components.ThreadViewer)` + height: 100%; +`; + +const ThreadsList = styled.div` + overflow-y: scroll; +`; + +const RhsContainer = styled.div` + height: 100%; + display: flex; + flex-direction: column; +`; + +const RHSDivider = styled.div` + border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.12); + margin-top: 12px; + margin-bottom: 12px; +`; + +const RHSSubtitle = styled(RHSText)` + font-weight: 600; +`; + +const RHSBullet = styled.li` + margin-bottom: 8px; +`; + +export interface AIThread { + ID: string; + Message: string; + ChannelID: string; + Title: string; + ReplyCount: number; + UpdateAt: number; +} + +const twentyFourHoursInMS = 24 * 60 * 60 * 1000; + +export default function RHS() { + const dispatch = useDispatch(); + const intl = useIntl(); + const [currentTab, setCurrentTab] = useState('new'); + const selectedPostId = useSelector((state: any) => state['plugins-' + manifest.id].selectedPostId); + const currentUserId = useSelector((state) => state.entities.users.currentUserId); + const currentTeamId = useSelector((state) => state.entities.teams.currentTeamId); + + const [threads, setThreads] = useState(null); + + useEffect(() => { + const fetchThreads = async () => { + setThreads(await getAIThreads()); + }; + if (currentTab === 'threads') { + fetchThreads(); + } else if (currentTab === 'thread' && Boolean(selectedPostId)) { + // Update read for the thread to tommorow. We don't really want the unreads thing to show up. + updateRead(currentUserId, currentTeamId, selectedPostId, Date.now() + twentyFourHoursInMS); + } + return () => { + // Somtimes we are too fast for the server, so try again on unmount/switch. + if (selectedPostId) { + updateRead(currentUserId, currentTeamId, selectedPostId, Date.now() + twentyFourHoursInMS); + } + }; + }, [currentTab, selectedPostId]); + + const selectPost = useCallback((postId: string) => { + dispatch({type: 'SELECT_AI_POST', postId}); + }, [dispatch]); + + const {bots, activeBot, setActiveBot} = useBotlist(); + + // Unconfigured state + if (bots && bots.length === 0) { + return ( + + + + + + + + + +

    + + + +
+ + + + + ); + } + + let content = null; + if (selectedPostId) { + if (currentTab !== 'thread') { + setCurrentTab('thread'); + } + content = ( + + ); + } else if (currentTab === 'threads') { + if (threads && bots) { + content = ( + + {threads.map((p) => ( + bot.dmChannelID === p.ChannelID)?.displayName ?? ''} + onClick={() => { + setCurrentTab('thread'); + selectPost(p.ID); + }} + />))} + + ); + } else { + content = null; + } + } else if (currentTab === 'new') { + content = ( + + ); + } + return ( + + + {content} + + ); +} diff --git a/webapp/channels/src/plugins/ai/components/rhs/rhs_header.tsx b/webapp/channels/src/plugins/ai/components/rhs/rhs_header.tsx new file mode 100644 index 0000000000..4da9613e99 --- /dev/null +++ b/webapp/channels/src/plugins/ai/components/rhs/rhs_header.tsx @@ -0,0 +1,163 @@ + +import React from 'react'; +import {FormattedMessage} from 'react-intl'; +import {useDispatch, useSelector} from 'react-redux'; +import {Link, useLocation, useRouteMatch} from 'react-router-dom'; +import styled from 'styled-components'; + +import {ChevronDownIcon} from '@mattermost/compass-icons/components'; + +import {getChannelByName} from 'mattermost-redux/selectors/entities/channels'; + +import {switchToChannel} from 'actions/views/channel'; +import {closeRightHandSide} from 'actions/views/rhs'; + +import type {GlobalState} from 'types/store'; + +import {Button} from './common'; + +import {BotDropdown} from '../bot_slector'; +import {DotMenuButton} from '../dot_menu'; + +import type {LLMBot} from '@/bots'; + +type Props = { + currentTab?: string; + bots?: LLMBot[] | null; + activeBot?: LLMBot | null; + setCurrentTab?: (tab: string) => void; + selectPost?: (postId: string) => void; + setActiveBot?: (bot: LLMBot) => void; + channelName: string; + onChatHistoryClick?: () => void; +} + +const RHSHeader = (props: Props) => { + const dispatch = useDispatch(); + const kChatBotChannel = useSelector((state: GlobalState) => getChannelByName(state, props.channelName)); + + let historyButton = null; + if (props.currentTab === 'threads') { + historyButton = ( + + + + + ); + } else { + historyButton = ( + + + + + ); + } + const currentBotName = props.activeBot?.displayName ?? ''; + return ( +
+ {historyButton} + {props.currentTab !== 'new' && kChatBotChannel && ( + { + dispatch(closeRightHandSide()); + dispatch(switchToChannel(kChatBotChannel)); + }} + + // onClick={() => { + // props.setCurrentTab('new'); + // props.selectPost(''); + // }} + > + + + + )} + {(props.currentTab === 'new' && props.bots) && ( + + <> + {currentBotName} + + + + )} +
+ ); +}; + +const HistoryButton = styled(Button)` + padding: 8px 12px; + color: rgba(var(--center-channel-color-rgb), 0.64); +`; + +const ButtonDisabled = styled(Button)` + &:hover { + background: transparent; + color: rgb(var(--center-channel-color)); + cursor: unset; + } +`; + +const NewChatButton = styled(Button)` + color: rgb(var(--link-color-rgb)); + &:hover { + color: rgb(var(--link-color-rgb)); + background-color: rgba(var(--button-bg-rgb), 0.08); + } + + &:active { + background-color: rgba(var(--button-bg-rgb), 0.12); + } +`; + +const Header = styled.div` + display: flex; + padding 8px 12px; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.12); + flex-wrap: wrap; +`; + +const SelectorDropdown = styled(DotMenuButton)<{isActive: boolean}>` + display: flex; + align-items: center; + padding: 2px 4px 2px 6px; + border-radius: 4px; + height: 20px; + width: auto; + max-width: 145px; + overflow: ellipsis; + + font-size: 11px; + font-weight: 600; + line-height: 16px; + + color: ${(props) => (props.isActive ? 'var(--button-bg)' : 'var(--center-channel-color-rgb)')}; + background-color: ${(props) => (props.isActive ? 'rgba(var(--button-bg-rgb), 0.16)' : 'rgba(var(--center-channel-color-rgb), 0.08)')}; + + &:hover { + color: ${(props) => (props.isActive ? 'var(--button-bg)' : 'var(--center-channel-color-rgb)')}; + background-color: ${(props) => (props.isActive ? 'rgba(var(--button-bg-rgb), 0.16)' : 'rgba(var(--center-channel-color-rgb), 0.16)')}; + } +`; + +export default React.memo(RHSHeader); diff --git a/webapp/channels/src/plugins/ai/components/rhs/thread_item.tsx b/webapp/channels/src/plugins/ai/components/rhs/thread_item.tsx new file mode 100644 index 0000000000..8ae59b0055 --- /dev/null +++ b/webapp/channels/src/plugins/ai/components/rhs/thread_item.tsx @@ -0,0 +1,101 @@ +import React from 'react'; +import styled from 'styled-components'; + +import {GrayPill} from '../pill'; + +const ThreadItemContainer = styled.div` + padding: 16px; + cursor: pointer; + border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.12) +`; + +const Timestamp = (window as any).Components.Timestamp; + +const Title = styled.div` + color: var(--center-channel-color); + display: flex; + align-items: center; + margin-bottom: 4px; + justify-content: space-between; +`; + +const TitleText = styled.div` + font-size: 14px; + font-weight: 600; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +`; + +const Preview = styled.div` + overflow: hidden; + color: var(--center-channel-color); + text-overflow: ellipsis; + whitespace: nowrap; + margin-bottom: 12px; + height: 40px; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +`; + +const RepliesCount = styled.div` + color: rgba(var(--center-channel-color-rgb), 0.64); + font-weight: 600; +`; + +const LastActivityDate = styled.div` + color: rgba(var(--center-channel-color-rgb), 0.64); + font-size: 12px; + font-weight: 400; + white-space: nowrap; + margin-left: 13px; +`; + +const Label = styled(GrayPill)` + padding: 0 4px; + font-size: 10px; + font-weight: 600; + line-height: 16px; +`; + +const Footer = styled.div` + display: flex; + flex-direction: row; + gap: 10px; +`; + +type Props = { + postTitle: string; + postMessage: string; + repliesCount: number; + lastActivityDate: number; + label: string; + onClick: () => void; +} + +const DefaultTitle = 'Conversation with Copilot'; + +export default function ThreadItem(props: Props) { + const repliesText = props.repliesCount === 1 ? '1 reply' : `${props.repliesCount} replies`; + return ( + + + <TitleText>{props.postTitle || DefaultTitle}</TitleText> + <LastActivityDate> + <Timestamp // Matches the timestap format in the threads view + value={props.lastActivityDate} + units={['now', 'minute', 'hour', 'day', 'week']} + useTime={false} + day={'numeric'} + /> + </LastActivityDate> + + {props.postMessage} +
+ + {repliesText} +
+
+ ); +} diff --git a/webapp/channels/src/plugins/ai/components/svg.tsx b/webapp/channels/src/plugins/ai/components/svg.tsx new file mode 100644 index 0000000000..3f4c97c489 --- /dev/null +++ b/webapp/channels/src/plugins/ai/components/svg.tsx @@ -0,0 +1,10 @@ +import styled from 'styled-components'; + +// Hat-tip: https://www.pinkdroids.com/blog/svg-react-styled-components/ +const Svg = styled.svg.attrs({ + version: '1.1', + xmlns: 'http://www.w3.org/2000/svg', + xmlnsXlink: 'http://www.w3.org/1999/xlink', +})``; + +export default Svg; diff --git a/webapp/channels/src/plugins/ai/components/thread_summarize.tsx b/webapp/channels/src/plugins/ai/components/thread_summarize.tsx new file mode 100644 index 0000000000..c36c2f7b66 --- /dev/null +++ b/webapp/channels/src/plugins/ai/components/thread_summarize.tsx @@ -0,0 +1,14 @@ +import React from 'react' +import {FormattedMessage} from 'react-intl'; +import IconThreadSummarization from './assets/icon_thread_summarization'; + +const ThreadSummarizeMenuItem = ( + <> + + + + + +); + +export default ThreadSummarizeMenuItem; diff --git a/webapp/platform/client/src/client4.ts b/webapp/platform/client/src/client4.ts index 1bbcede87c..e8854eda08 100644 --- a/webapp/platform/client/src/client4.ts +++ b/webapp/platform/client/src/client4.ts @@ -53,7 +53,7 @@ import { ServerChannel, PendingGuests, } from '@mattermost/types/channels'; -import {Options, StatusOK, ClientResponse, LogLevel, FetchPaginatedThreadOptions} from '@mattermost/types/client4'; +import {Options, StatusOK, ClientResponse, LogLevel, FetchPaginatedThreadOptions, SummarizeResult} from '@mattermost/types/client4'; import {Compliance} from '@mattermost/types/compliance'; import { ClientConfig, @@ -4865,6 +4865,33 @@ export default class Client4 { })} ) } + + // + // PLUGIN AI + // + async doSummarize(postId: string, botUsername: string): Promise { + const url = `${this.getPostRoute(postId)}/summarize?botUsername=${botUsername}`; + + return this.doFetch(url, {method: 'post'}); + } + + async doStopGenerating(postId: string) { + const url = `${this.getPostRoute(postId)}/stop`; + + return this.doFetch(url, {method: 'post'}); + } + + async doRegenerate(postId: string) { + const url = `${this.getPostRoute(postId)}/regenerate`; + + return this.doFetch(url, {method: 'post'}); + } + + async doPostbackSummary(postId: string) { + const url = `${this.getPostRoute(postId)}/postback_summary`; + + return this.doFetch(url, {method: 'post'}); + } } export function parseAndMergeNestedHeaders(originalHeaders: any) { diff --git a/webapp/platform/types/src/client4.ts b/webapp/platform/types/src/client4.ts index cbe7587a77..4ebf8e81e0 100644 --- a/webapp/platform/types/src/client4.ts +++ b/webapp/platform/types/src/client4.ts @@ -36,3 +36,8 @@ export type FetchPaginatedThreadOptions = { fromCreateAt?: number; fromPost?: string; } + +export type SummarizeResult ={ + postid: string; + channelid: string; +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index b556f7e643..b52eb34c99 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2638,6 +2638,25 @@ __metadata: languageName: node linkType: hard +"@floating-ui/core@npm:^1.6.0": + version: 1.6.4 + resolution: "@floating-ui/core@npm:1.6.4" + dependencies: + "@floating-ui/utils": "npm:^0.2.4" + checksum: 10/589430cbff4bac90b9b891e2c94c57dc113d39ac163552f547d9e4c7d21f09997b9d33e82ec717759caee678c47f845f14a3f28df6f029fcfcf3ad803ba4eb7c + languageName: node + linkType: hard + +"@floating-ui/dom@npm:^1.2.1": + version: 1.6.7 + resolution: "@floating-ui/dom@npm:1.6.7" + dependencies: + "@floating-ui/core": "npm:^1.6.0" + "@floating-ui/utils": "npm:^0.2.4" + checksum: 10/a6a42bfd243c311f6040043808a6549c1db45fa36138b81cb1e615170d61fd2daf4f37accc1df3e0189405d97e3d71b12de39879c9d58ccf181c982b69cf6cf9 + languageName: node + linkType: hard + "@floating-ui/dom@npm:^1.6.1": version: 1.6.3 resolution: "@floating-ui/dom@npm:1.6.3" @@ -2648,6 +2667,32 @@ __metadata: languageName: node linkType: hard +"@floating-ui/react-dom-interactions@npm:0.13.3": + version: 0.13.3 + resolution: "@floating-ui/react-dom-interactions@npm:0.13.3" + dependencies: + "@floating-ui/react-dom": "npm:^1.0.1" + aria-hidden: "npm:^1.1.3" + tabbable: "npm:^6.0.1" + peerDependencies: + react: ">=16.8.0" + react-dom: ">=16.8.0" + checksum: 10/839e682bfd2ea694729ced70011526150b84677f3ba6b2c13b60ea0068095e7326dff6b3f9fc51871fe0ff5b2ce12ad4eef62b30c5e688c9e3e5ba00a86aa8aa + languageName: node + linkType: hard + +"@floating-ui/react-dom@npm:^1.0.1": + version: 1.3.0 + resolution: "@floating-ui/react-dom@npm:1.3.0" + dependencies: + "@floating-ui/dom": "npm:^1.2.1" + peerDependencies: + react: ">=16.8.0" + react-dom: ">=16.8.0" + checksum: 10/0f9ad9513167a302a844614e2a2a4f8a4d79c7faed3f98a82f84bb28dcdc296faa5462ba54e7afbf643f61fa893778d0aade1eb23e3daebe5351034f202db277 + languageName: node + linkType: hard + "@floating-ui/react-dom@npm:^2.0.6": version: 2.0.8 resolution: "@floating-ui/react-dom@npm:2.0.8" @@ -2681,6 +2726,13 @@ __metadata: languageName: node linkType: hard +"@floating-ui/utils@npm:^0.2.4": + version: 0.2.4 + resolution: "@floating-ui/utils@npm:0.2.4" + checksum: 10/7662d7a4ae39c0287e026f666297a3d28c80e588251c8c59ff66938a0aead47d380bbb9018629bd63a98f399c3919ec689d5448a5c48ffc176d545ddef705df1 + languageName: node + linkType: hard + "@formatjs/ecma402-abstract@npm:1.14.3": version: 1.14.3 resolution: "@formatjs/ecma402-abstract@npm:1.14.3" @@ -7557,6 +7609,15 @@ __metadata: languageName: node linkType: hard +"aria-hidden@npm:^1.1.3": + version: 1.2.4 + resolution: "aria-hidden@npm:1.2.4" + dependencies: + tslib: "npm:^2.0.0" + checksum: 10/df4bc15423aaaba3729a7d40abcbf6d3fffa5b8fd5eb33d3ac8b7da0110c47552fca60d97f2e1edfbb68a27cae1da499f1c3896966efb3e26aac4e3b57e3cc8b + languageName: node + linkType: hard + "aria-query@npm:5.1.3": version: 5.1.3 resolution: "aria-query@npm:5.1.3" @@ -17008,6 +17069,7 @@ __metadata: "@babel/preset-typescript": "npm:7.21.5" "@deanwhillier/jest-matchmedia-mock": "npm:1.2.0" "@floating-ui/react": "npm:0.26.6" + "@floating-ui/react-dom-interactions": "npm:0.13.3" "@giphy/js-fetch-api": "npm:5.1.0" "@giphy/react-components": "npm:8.1.0" "@guyplusplus/turndown-plugin-gfm": "npm:1.0.7" @@ -23422,6 +23484,13 @@ __metadata: languageName: node linkType: hard +"tslib@npm:^2.0.0": + version: 2.6.3 + resolution: "tslib@npm:2.6.3" + checksum: 10/52109bb681f8133a2e58142f11a50e05476de4f075ca906d13b596ae5f7f12d30c482feb0bff167ae01cfc84c5803e575a307d47938999246f5a49d174fc558c + languageName: node + linkType: hard + "tslib@npm:^2.0.3, tslib@npm:^2.1.0, tslib@npm:^2.3.1, tslib@npm:^2.4.0, tslib@npm:^2.5.0, tslib@npm:^2.6.2": version: 2.6.2 resolution: "tslib@npm:2.6.2"