From c5bead3a4bfa5e59c280460422f3b169968b1c4f Mon Sep 17 00:00:00 2001 From: Christopher Poile Date: Mon, 15 Jun 2026 08:40:49 -0400 Subject: [PATCH] [MM-68781] MBE Phase 8d: add hooks ChannelComposerBanner & ChannelIntro (#36581) * phase 8d * review: key PluggableErrorBoundary by channel + add missing-channel intro test Co-Authored-By: Claude Sonnet 4.6 (1M context) * simplify: remove redundant channelId from call-site keys PluggableErrorBoundary now carries key={registration.id:channel.id} internally, so the outer ChannelDecoratorRenderer no longer needs a channel-scoped key to reset the boundary on navigation. Co-Authored-By: Claude Sonnet 4.6 (1M context) * merge conflicts * more conflicts * remove unregisterChannelDecorator + REMOVED_PLUGIN_COMPONENT_BY_ID The unregister method dispatched an action whose reducer case was removed during Phase 8b's cleanup, making the call a silent no-op (surfaced by a failing CI test). Restore is not warranted: plugin lifecycle already sweeps all registrations on uninstall and bundle-path-changed reload via REMOVED_WEBAPP_PLUGIN, and the matcher contract handles dynamic on/off without re-registration. Drop the unregister method, the action constant, and the failing scoped-removal tests; move clearLoggedDecoratorErrors into register so the log-once tracker still resets on re-registration. Replace the deleted tests with a REMOVED_WEBAPP_PLUGIN sweep test mirroring the 8b pattern. Co-Authored-By: Claude Opus 4.7 (1M context) * remove mount_overlay, left_of_channel_name; add after_channel_name * simplifications: remove slots, no after name decorator, intro fixed --------- Co-authored-by: Claude Sonnet 4.6 (1M context) --- .../channel_intro_renderer.test.tsx | 70 ++++++ .../channel_intro_renderer.tsx | 25 ++ .../channel_icon_override.ts | 21 +- .../channel_composer_banner.test.tsx | 99 ++++++++ .../channel_view/channel_composer_banner.tsx | 24 ++ .../components/channel_view/channel_view.tsx | 2 + .../channel_intro_message.test.tsx | 99 ++++++++ .../channel_intro_message.tsx | 56 +++-- .../post_view/post_list_row/post_list_row.tsx | 2 +- .../create_comment.test.tsx | 29 +++ .../create_comment.tsx | 2 + webapp/channels/src/plugins/registry.test.ts | 80 ++++++ webapp/channels/src/plugins/registry.ts | 30 +++ webapp/channels/src/reducers/plugins/index.ts | 2 + .../src/selectors/channel_intro.test.ts | 228 ++++++++++++++++++ .../channels/src/selectors/channel_intro.ts | 36 +++ webapp/channels/src/types/store/plugins.ts | 11 + .../src/utils/matcher_error_log.test.ts | 182 ++++++++++++++ .../channels/src/utils/matcher_error_log.ts | 42 ++++ 19 files changed, 1007 insertions(+), 33 deletions(-) create mode 100644 webapp/channels/src/components/channel_intro_renderer/channel_intro_renderer.test.tsx create mode 100644 webapp/channels/src/components/channel_intro_renderer/channel_intro_renderer.tsx create mode 100644 webapp/channels/src/components/channel_view/channel_composer_banner.test.tsx create mode 100644 webapp/channels/src/components/channel_view/channel_composer_banner.tsx create mode 100644 webapp/channels/src/selectors/channel_intro.test.ts create mode 100644 webapp/channels/src/selectors/channel_intro.ts create mode 100644 webapp/channels/src/utils/matcher_error_log.test.ts create mode 100644 webapp/channels/src/utils/matcher_error_log.ts diff --git a/webapp/channels/src/components/channel_intro_renderer/channel_intro_renderer.test.tsx b/webapp/channels/src/components/channel_intro_renderer/channel_intro_renderer.test.tsx new file mode 100644 index 00000000000..d90444e93d5 --- /dev/null +++ b/webapp/channels/src/components/channel_intro_renderer/channel_intro_renderer.test.tsx @@ -0,0 +1,70 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import {renderWithContext, screen} from 'tests/react_testing_utils'; +import {TestHelper} from 'utils/test_helper'; + +import type {ChannelIntroRegistration} from 'types/store/plugins'; + +import ChannelIntroRenderer from './channel_intro_renderer'; + +const mockChannel = TestHelper.getChannelMock({id: 'channel-1'}); + +const makeRegistration = (component: React.ComponentType): ChannelIntroRegistration => ({ + id: 'reg-1', + pluginId: 'test-plugin', + matcher: () => true, + component, +}); + +const minimalState = { + entities: { + general: {config: {}}, + preferences: {myPreferences: {}}, + users: {currentUserId: 'user1', profiles: {}}, + }, +} as any; + +describe('components/channel_intro_renderer/ChannelIntroRenderer', () => { + it('renders the plugin component with channel prop', () => { + const PluginComponent = () =>
; + const reg = makeRegistration(PluginComponent); + + renderWithContext( + , + minimalState, + ); + + expect(screen.getByTestId('plugin-component')).toBeInTheDocument(); + }); + + it('error boundary catches a crashing component and host area stays mounted', () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + const Crasher = (): null => { + throw new Error('test crash'); + }; + const reg = makeRegistration(Crasher); + + const {container} = renderWithContext( + , + minimalState, + ); + + expect(screen.getByText(/An error occurred/i)).toBeInTheDocument(); + expect(screen.getByText(/Refresh/i)).toBeInTheDocument(); + + // Container is still mounted — the crash did not tear down the host DOM + expect(container).toBeInTheDocument(); + + consoleSpy.mockRestore(); + }); +}); diff --git a/webapp/channels/src/components/channel_intro_renderer/channel_intro_renderer.tsx b/webapp/channels/src/components/channel_intro_renderer/channel_intro_renderer.tsx new file mode 100644 index 00000000000..bb489ea5db0 --- /dev/null +++ b/webapp/channels/src/components/channel_intro_renderer/channel_intro_renderer.tsx @@ -0,0 +1,25 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import type {Channel} from '@mattermost/types/channels'; + +import PluggableErrorBoundary from 'plugins/pluggable/error_boundary'; + +import type {ChannelIntroRegistration} from 'types/store/plugins'; + +export default function ChannelIntroRenderer({registration, channel}: { + registration: ChannelIntroRegistration; + channel: Channel; +}) { + const Component = registration.component; + return ( + + + + ); +} diff --git a/webapp/channels/src/components/channel_type_icon/channel_icon_override.ts b/webapp/channels/src/components/channel_type_icon/channel_icon_override.ts index 332b428fe89..f950dcbf8b6 100644 --- a/webapp/channels/src/components/channel_type_icon/channel_icon_override.ts +++ b/webapp/channels/src/components/channel_type_icon/channel_icon_override.ts @@ -5,24 +5,18 @@ import type {IconGlyphTypes} from '@mattermost/compass-icons/IconGlyphs'; import type {Channel} from '@mattermost/types/channels'; import {getChannelIconClassName} from 'utils/channel_utils'; +import {createMatcherErrorLog} from 'utils/matcher_error_log'; import type {GlobalState} from 'types/store'; -// Tracks plugin ids that have already logged a matcher error to avoid spamming the console. -const loggedMatcherErrors = new Set(); +const matcherErrorLog = createMatcherErrorLog('ChannelIconOverride'); /** * Clears the per-pluginId log-once tracker for matcher errors. * No-arg form clears all entries; with-arg form clears one plugin's entry. * Called on each new registration so that re-registering a plugin starts fresh. */ -export function clearLoggedMatcherErrors(pluginId?: string): void { - if (pluginId === undefined) { - loggedMatcherErrors.clear(); - } else { - loggedMatcherErrors.delete(pluginId); - } -} +export const clearLoggedMatcherErrors = matcherErrorLog.clear; /** * Returns the IconGlyphTypes name of the first matching plugin override, or null. @@ -46,14 +40,7 @@ export function getChannelIconOverrideForChannel( return entry.iconName; } } catch (err) { - if (!loggedMatcherErrors.has(entry.pluginId)) { - loggedMatcherErrors.add(entry.pluginId); - // eslint-disable-next-line no-console - console.error( - `ChannelIconOverride: matcher for plugin '${entry.pluginId}' threw — treating as no-match.`, - err, - ); - } + matcherErrorLog.logOnce(entry.pluginId, err); } } return null; diff --git a/webapp/channels/src/components/channel_view/channel_composer_banner.test.tsx b/webapp/channels/src/components/channel_view/channel_composer_banner.test.tsx new file mode 100644 index 00000000000..d4d67d93cc3 --- /dev/null +++ b/webapp/channels/src/components/channel_view/channel_composer_banner.test.tsx @@ -0,0 +1,99 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import {renderWithContext, screen} from 'tests/react_testing_utils'; +import {TestHelper} from 'utils/test_helper'; + +import {ChannelComposerBanner} from './channel_composer_banner'; + +const makeState = (channelId: string, components: any[] = []) => ({ + entities: { + channels: { + channels: { + [channelId]: TestHelper.getChannelMock({id: channelId}), + }, + }, + }, + plugins: { + components: { + ChannelComposerBanner: components, + }, + }, +} as any); + +describe('components/channel_view/ChannelComposerBanner', () => { + const channelId = 'channel_id'; + + test('no registered component — renders nothing', () => { + const {container} = renderWithContext( + , + makeState(channelId, []), + ); + + expect(container.firstChild).toBeNull(); + }); + + test('registered component renders above the composer', () => { + const state = makeState(channelId, [{ + id: 'banner-1', + pluginId: 'test-plugin', + component: () =>
, + }]); + + renderWithContext( + , + state, + ); + + expect(screen.getByTestId('composer-banner-content')).toBeInTheDocument(); + }); + + test('missing channel — renders nothing', () => { + const state = makeState(channelId, [{ + id: 'banner-1', + pluginId: 'test-plugin', + component: () =>
, + }]); + + // Remove the channel to simulate missing channel entity + delete state.entities.channels.channels[channelId]; + + const {container} = renderWithContext( + , + state, + ); + + expect(container.firstChild).toBeNull(); + expect(screen.queryByTestId('composer-banner-content')).not.toBeInTheDocument(); + }); + + test('multiple registered components — all render in order', () => { + const state = makeState(channelId, [ + { + id: 'banner-1', + pluginId: 'test-plugin', + component: () =>
, + }, + { + id: 'banner-2', + pluginId: 'test-plugin', + component: () =>
, + }, + ]); + + renderWithContext( + , + state, + ); + + const first = screen.getByTestId('composer-banner-content-1'); + const second = screen.getByTestId('composer-banner-content-2'); + expect(first).toBeInTheDocument(); + expect(second).toBeInTheDocument(); + + // First component precedes second in DOM order + expect(first.compareDocumentPosition(second)).toBe(Node.DOCUMENT_POSITION_FOLLOWING); + }); +}); diff --git a/webapp/channels/src/components/channel_view/channel_composer_banner.tsx b/webapp/channels/src/components/channel_view/channel_composer_banner.tsx new file mode 100644 index 00000000000..94c7e3375b9 --- /dev/null +++ b/webapp/channels/src/components/channel_view/channel_composer_banner.tsx @@ -0,0 +1,24 @@ +// 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 {getChannel} from 'mattermost-redux/selectors/entities/channels'; + +import Pluggable from 'plugins/pluggable'; + +import type {GlobalState} from 'types/store'; + +export const ChannelComposerBanner = ({channelId}: {channelId: string}) => { + const channel = useSelector((s: GlobalState) => getChannel(s, channelId)); + if (!channel) { + return null; + } + return ( + + ); +}; diff --git a/webapp/channels/src/components/channel_view/channel_view.tsx b/webapp/channels/src/components/channel_view/channel_view.tsx index 3d0e0a4aed1..c4192aebee8 100644 --- a/webapp/channels/src/components/channel_view/channel_view.tsx +++ b/webapp/channels/src/components/channel_view/channel_view.tsx @@ -14,6 +14,7 @@ import PostView from 'components/post_view'; import WebSocketClient from 'client/web_websocket_client'; +import {ChannelComposerBanner} from './channel_composer_banner'; import InputLoading from './input_loading'; import type {PropsFromRedux} from './index'; @@ -206,6 +207,7 @@ export default class ChannelView extends React.PureComponent { data-testid='post-create' className='post-create__container AdvancedTextEditor__ctr' > +
); diff --git a/webapp/channels/src/components/post_view/channel_intro_message/channel_intro_message.test.tsx b/webapp/channels/src/components/post_view/channel_intro_message/channel_intro_message.test.tsx index e5ba9c49c2b..baaada15e4b 100644 --- a/webapp/channels/src/components/post_view/channel_intro_message/channel_intro_message.test.tsx +++ b/webapp/channels/src/components/post_view/channel_intro_message/channel_intro_message.test.tsx @@ -294,6 +294,105 @@ describe('components/post_view/ChannelIntroMessages', () => { }); }); + describe('ChannelIntro body override', () => { + const privateChannel = { + ...channel, + type: Constants.PRIVATE_CHANNEL as ChannelType, + }; + + const makeStateWithIntroReg = (regs: any[]) => ({ + ...initialState, + entities: { + ...initialState.entities, + channels: { + ...initialState.entities.channels, + channels: {channel_id: privateChannel}, + }, + }, + plugins: { + components: { + ChannelIntro: regs, + }, + }, + } as any); + + test('matching ChannelIntro registration — override body renders, action buttons still render', () => { + const state = makeStateWithIntroReg([{ + id: 'intro-reg-1', + pluginId: 'test-plugin', + matcher: () => true, + component: () =>
, + }]); + + renderWithContext( + , + state, + ); + + // Override body is present + expect(screen.getByTestId('intro-override-body')).toBeInTheDocument(); + + // Default body title is absent (it was replaced) + expect(screen.queryByText('test channel')).not.toBeInTheDocument(); + + // Action buttons row is still rendered by the server — Favorite button is always shown + expect(screen.getByLabelText('Favorite')).toBeInTheDocument(); + }); + + test('no matching registration — default body renders', () => { + const state = makeStateWithIntroReg([{ + id: 'intro-reg-1', + pluginId: 'test-plugin', + matcher: () => false, + component: () =>
, + }]); + + renderWithContext( + , + state, + ); + + // Default body title is present + expect(screen.getByText('test channel')).toBeInTheDocument(); + + // Override body is absent + expect(screen.queryByTestId('intro-override-body')).not.toBeInTheDocument(); + }); + + test('DM channel — ChannelIntro registration does not affect DM intro (non-standard channel)', () => { + const dmChannel = { + ...channel, + type: Constants.DM_CHANNEL as ChannelType, + }; + const state = makeStateWithIntroReg([{ + id: 'intro-reg-1', + pluginId: 'test-plugin', + matcher: () => true, + component: () =>
, + }]); + + renderWithContext( + , + state, + ); + + // DM intro renders its own layout — override body should not appear + expect(screen.queryByTestId('intro-override-body')).not.toBeInTheDocument(); + + // DM text is present + expect(screen.getByText('This is the start of your direct message history with this teammate.', {exact: false})).toBeInTheDocument(); + }); + }); + describe('plugin channel icon override', () => { const mockedCompassIconForName = jest.mocked(compassIconForName); diff --git a/webapp/channels/src/components/post_view/channel_intro_message/channel_intro_message.tsx b/webapp/channels/src/components/post_view/channel_intro_message/channel_intro_message.tsx index aad7c54c622..a3868ba1c45 100644 --- a/webapp/channels/src/components/post_view/channel_intro_message/channel_intro_message.tsx +++ b/webapp/channels/src/components/post_view/channel_intro_message/channel_intro_message.tsx @@ -3,6 +3,7 @@ import React from 'react'; import {FormattedDate, FormattedMessage, defineMessages} from 'react-intl'; +import {useSelector} from 'react-redux'; import {BellRingOutlineIcon, PencilOutlineIcon, StarOutlineIcon, StarIcon} from '@mattermost/compass-icons/components'; import {WithTooltip} from '@mattermost/shared/components/tooltip'; @@ -13,7 +14,10 @@ import {Permissions} from 'mattermost-redux/constants'; import {NotificationLevel} from 'mattermost-redux/constants/channels'; import {isChannelMuted} from 'mattermost-redux/utils/channel_utils'; +import {getChannelIntroOverride} from 'selectors/channel_intro'; + import AddGroupsToTeamModal from 'components/add_groups_to_team_modal'; +import ChannelIntroRenderer from 'components/channel_intro_renderer/channel_intro_renderer'; import ChannelNotificationsModal from 'components/channel_notifications_modal'; import {ChannelIcon} from 'components/channel_type_icon'; import ChannelIntroPrivateSvg from 'components/common/svg_images_components/channel_intro_private_svg'; @@ -30,6 +34,8 @@ import {Constants, ModalIdentifiers} from 'utils/constants'; import {getMonthLong} from 'utils/i18n'; import * as Utils from 'utils/utils'; +import type {GlobalState} from 'types/store'; + import AddMembersButton from './add_members_button'; import PluggableIntroButtons from './pluggable_intro_buttons'; @@ -524,6 +530,24 @@ function createDefaultIntroMessage( ); } +/** + * Renders either a plugin-supplied intro body for channels the plugin's matcher selects, or the + * default children. Action buttons (favorite, add members, etc.) are rendered outside this + * component so the server controls them regardless of any override. + */ +const ChannelIntroBody = ({channel, children}: {channel: Channel; children: React.ReactNode}) => { + const override = useSelector((state: GlobalState) => getChannelIntroOverride(state, channel.id)); + if (override) { + return ( + + ); + } + return <>{children}; +}; + function createStandardIntroMessage( channel: Channel, centeredIntro: string, @@ -684,21 +708,23 @@ function createStandardIntroMessage( id='channelIntro' className={'channel-intro ' + centeredIntro} > - {isPrivate ? : } -

- {channel.display_name} -

-
- - {createMessage} -
-

- {memberMessage} - {purposeMessage} -

+ + {isPrivate ? : } +

+ {channel.display_name} +

+
+ + {createMessage} +
+

+ {memberMessage} + {purposeMessage} +

+
{actionButtons}
); diff --git a/webapp/channels/src/components/post_view/post_list_row/post_list_row.tsx b/webapp/channels/src/components/post_view/post_list_row/post_list_row.tsx index c9a1a28cc11..bdd3f55c5b0 100644 --- a/webapp/channels/src/components/post_view/post_list_row/post_list_row.tsx +++ b/webapp/channels/src/components/post_view/post_list_row/post_list_row.tsx @@ -14,7 +14,7 @@ import type {emitShortcutReactToLastPostFrom} from 'actions/post_actions'; import CenterMessageLock from 'components/center_message_lock'; import PostComponent from 'components/post'; -import ChannelIntroMessage from 'components/post_view/channel_intro_message/'; +import ChannelIntroMessage from 'components/post_view/channel_intro_message'; import CombinedUserActivityPost from 'components/post_view/combined_user_activity_post'; import DateSeparator from 'components/post_view/date_separator'; import NewMessageSeparator from 'components/post_view/new_message_separator/new_message_separator'; diff --git a/webapp/channels/src/components/threading/virtualized_thread_viewer/create_comment.test.tsx b/webapp/channels/src/components/threading/virtualized_thread_viewer/create_comment.test.tsx index 52c6cb1e000..1ed6a2059f4 100644 --- a/webapp/channels/src/components/threading/virtualized_thread_viewer/create_comment.test.tsx +++ b/webapp/channels/src/components/threading/virtualized_thread_viewer/create_comment.test.tsx @@ -139,4 +139,33 @@ describe('components/threading/CreateComment', () => { expect(screen.getByTestId('advanced-create-comment')).toBeInTheDocument(); }); + + it('renders ChannelComposerBanner component above the thread composer', () => { + const channel = TestHelper.getChannelMock({ + id: 'ch-1', + type: 'O', + delete_at: 0, + }); + + const state = { + ...makeState(channel, threadId), + plugins: { + components: { + ChannelIconOverride: [], + ChannelComposerBanner: [{ + id: 'banner-1', + pluginId: 'test-plugin', + component: () =>
, + }], + }, + }, + } as any; + + renderWithContext( + , + state, + ); + + expect(screen.getByTestId('composer-banner-content')).toBeInTheDocument(); + }); }); diff --git a/webapp/channels/src/components/threading/virtualized_thread_viewer/create_comment.tsx b/webapp/channels/src/components/threading/virtualized_thread_viewer/create_comment.tsx index 5aaf1a9b13d..84eebb4fab0 100644 --- a/webapp/channels/src/components/threading/virtualized_thread_viewer/create_comment.tsx +++ b/webapp/channels/src/components/threading/virtualized_thread_viewer/create_comment.tsx @@ -12,6 +12,7 @@ import {getPost, getLimitedViews} from 'mattermost-redux/selectors/entities/post import AdvancedCreateComment from 'components/advanced_create_comment'; import {compassIconForName, useChannelIconOverrideName} from 'components/channel_type_icon'; +import {ChannelComposerBanner} from 'components/channel_view/channel_composer_banner'; import BasicSeparator from 'components/widgets/separator/basic-separator'; import {getArchiveIconComponent} from 'utils/channel_utils'; @@ -104,6 +105,7 @@ const CreateComment = forwardRef(({ ref={ref} data-testid='comment-create' > + { consoleSpy.mockRestore(); }); }); + +describe('PluginRegistry — registerChannelComposerBannerComponent', () => { + const PLUGIN_ID = 'test_plugin'; + + beforeEach(() => { + mockCurrentStore = createStore(pluginsReducer); + }); + + function getBanners() { + return mockCurrentStore.getState().components.ChannelComposerBanner; + } + + it('adds an entry to ChannelComposerBanner with the plugin id', () => { + const registry = new PluginRegistry(PLUGIN_ID); + registry.registerChannelComposerBannerComponent({component: () => null}); + + const entries = getBanners(); + expect(entries).toHaveLength(1); + expect(entries[0].pluginId).toBe(PLUGIN_ID); + }); + + it('REMOVED_WEBAPP_PLUGIN sweeps entries for that plugin and leaves others intact', () => { + const registry = new PluginRegistry(PLUGIN_ID); + const otherRegistry = new PluginRegistry('other_plugin'); + + registry.registerChannelComposerBannerComponent({component: () => null}); + otherRegistry.registerChannelComposerBannerComponent({component: () => null}); + + mockCurrentStore.dispatch({ + type: ActionTypes.REMOVED_WEBAPP_PLUGIN, + data: {id: PLUGIN_ID}, + }); + + const entries = getBanners(); + expect(entries).toHaveLength(1); + expect(entries[0].pluginId).toBe('other_plugin'); + }); +}); + +describe('PluginRegistry — registerChannelIntro', () => { + const PLUGIN_ID = 'test_plugin'; + + beforeEach(() => { + mockCurrentStore = createStore(pluginsReducer); + }); + + function getIntroRegs() { + return mockCurrentStore.getState().components.ChannelIntro; + } + + it('adds an entry to ChannelIntro with the plugin id, matcher, and component', () => { + const registry = new PluginRegistry(PLUGIN_ID); + const matcher = () => true; + const component = () => null; + registry.registerChannelIntro({matcher, component}); + + const entries = getIntroRegs(); + expect(entries).toHaveLength(1); + expect(entries[0].pluginId).toBe(PLUGIN_ID); + expect(entries[0].matcher).toBe(matcher); + expect(entries[0].component).toBe(component); + }); + + it('REMOVED_WEBAPP_PLUGIN sweeps entries for that plugin and leaves others intact', () => { + const registry = new PluginRegistry(PLUGIN_ID); + const otherRegistry = new PluginRegistry('other_plugin'); + + registry.registerChannelIntro({matcher: () => true, component: () => null}); + otherRegistry.registerChannelIntro({matcher: () => false, component: () => null}); + + mockCurrentStore.dispatch({ + type: ActionTypes.REMOVED_WEBAPP_PLUGIN, + data: {id: PLUGIN_ID}, + }); + + const entries = getIntroRegs(); + expect(entries).toHaveLength(1); + expect(entries[0].pluginId).toBe('other_plugin'); + }); +}); diff --git a/webapp/channels/src/plugins/registry.ts b/webapp/channels/src/plugins/registry.ts index d9b5e7bd771..17e26bf4035 100644 --- a/webapp/channels/src/plugins/registry.ts +++ b/webapp/channels/src/plugins/registry.ts @@ -27,6 +27,7 @@ import { registerPluginReconnectHandler, unregisterPluginReconnectHandler, } from 'actions/websocket_actions'; +import {clearLoggedChannelIntroErrors} from 'selectors/channel_intro'; import store from 'stores/redux_store'; import {compassIconForName} from 'components/channel_type_icon'; @@ -73,6 +74,7 @@ import type { AIActionMenuItemComponent, ChannelTypeOptionComponent, ChannelIconOverrideRegistration, + ChannelIntroRegistration, } from 'types/store/plugins'; const defaultShouldRender = () => true; @@ -1397,6 +1399,34 @@ export default class PluginRegistry { return id; }); + /** + * Register a component rendered above the message input in both the center-channel composer + * and the thread/RHS composer. Receives {channel}; return null when nothing should show. + * Multiple registrations stack. Cleaned up automatically when the plugin is removed. + */ + registerChannelComposerBannerComponent = reArg(['component'], ({component}: DPluginComponentProp) => { + return dispatchPluginComponentAction('ChannelComposerBanner', this.id, component); + }); + + /** + * Register a component that replaces the descriptive body (icon, title, creation info, and + * description) of a standard public/private channel's intro for channels the matcher selects. + * The channel's action buttons (favorite, add members, set header, notification preferences, + * and plugin intro buttons) remain rendered by the server. The matcher receives the full Redux + * state so it can read plugin-owned slices (e.g. state['plugins-']). First registration + * whose matcher returns true wins (alphabetical pluginId, then insertion order); the rest are + * ignored for that channel. Cleaned up automatically when the plugin is removed. + */ + registerChannelIntro = reArg(['matcher', 'component'], ({matcher, component}: { + matcher: ChannelIntroRegistration['matcher']; + component: ChannelIntroRegistration['component']; + }) => { + clearLoggedChannelIntroErrors(this.id); + const id = generateId(); + dispatchPluginComponentWithData('ChannelIntro', {id, pluginId: this.id, matcher, component}); + return id; + }); + /** * INTERNAL: Subject to change without notice. * Register a component to render in channel's center view, in place of a channel toast. diff --git a/webapp/channels/src/reducers/plugins/index.ts b/webapp/channels/src/reducers/plugins/index.ts index 39b10cffcbb..11d1a4bf725 100644 --- a/webapp/channels/src/reducers/plugins/index.ts +++ b/webapp/channels/src/reducers/plugins/index.ts @@ -224,6 +224,8 @@ const initialComponents: PluginsState['components'] = { SidebarBrowseOrAddChannelMenu: [], ChannelTypeOption: [], ChannelIconOverride: [], + ChannelComposerBanner: [], + ChannelIntro: [], MessageWillBePosted: [], MessageWillBeUpdated: [], SlashCommandWillBePosted: [], diff --git a/webapp/channels/src/selectors/channel_intro.test.ts b/webapp/channels/src/selectors/channel_intro.test.ts new file mode 100644 index 00000000000..37a9e489c24 --- /dev/null +++ b/webapp/channels/src/selectors/channel_intro.test.ts @@ -0,0 +1,228 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {Channel} from '@mattermost/types/channels'; + +import type {GlobalState} from 'types/store'; +import type {ChannelIntroRegistration} from 'types/store/plugins'; + +import {clearLoggedChannelIntroErrors, getChannelIntroOverride} from './channel_intro'; + +function makeChannel(partial: Partial = {}): Channel { + return { + id: 'channel-1', + type: 'O', + delete_at: 0, + ...partial, + } as Channel; +} + +function makeState( + regs: ChannelIntroRegistration[] = [], + channels: Record = {}, +): GlobalState { + return { + plugins: { + components: { + ChannelIntro: regs, + }, + }, + entities: { + channels: { + channels, + }, + }, + } as unknown as GlobalState; +} + +function makeRegistration(partial: Partial = {}): ChannelIntroRegistration { + return { + id: 'reg-1', + pluginId: 'test-plugin', + matcher: () => true, + component: () => null, + ...partial, + } as ChannelIntroRegistration; +} + +describe('selectors/getChannelIntroOverride', () => { + beforeEach(() => { + clearLoggedChannelIntroErrors(); + }); + + describe('empty / missing data', () => { + it('returns null when ChannelIntro list is empty', () => { + const channel = makeChannel(); + const state = makeState([], {[channel.id]: channel}); + expect(getChannelIntroOverride(state, channel.id)).toBeNull(); + }); + + it('returns null when channelId is empty string', () => { + const channel = makeChannel(); + const state = makeState([makeRegistration()], {[channel.id]: channel}); + expect(getChannelIntroOverride(state, '')).toBeNull(); + }); + + it('returns null when channel is not in state', () => { + const state = makeState([makeRegistration()], {}); + expect(getChannelIntroOverride(state, 'nonexistent')).toBeNull(); + }); + }); + + describe('matcher semantics — strict boolean true', () => { + it('returns the registration when matcher returns exactly true', () => { + const channel = makeChannel(); + const reg = makeRegistration({matcher: () => true}); + const state = makeState([reg], {[channel.id]: channel}); + expect(getChannelIntroOverride(state, channel.id)).toBe(reg); + }); + + it('returns null when matcher returns false', () => { + const channel = makeChannel(); + const reg = makeRegistration({matcher: () => false}); + const state = makeState([reg], {[channel.id]: channel}); + expect(getChannelIntroOverride(state, channel.id)).toBeNull(); + }); + + it('returns null when matcher returns truthy non-boolean (channel object)', () => { + const channel = makeChannel(); + const reg = makeRegistration({matcher: (_state, ch) => ch as unknown as boolean}); + const state = makeState([reg], {[channel.id]: channel}); + expect(getChannelIntroOverride(state, channel.id)).toBeNull(); + }); + + it('returns null when matcher returns 1 (truthy non-boolean)', () => { + const channel = makeChannel(); + const reg = makeRegistration({matcher: () => 1 as unknown as boolean}); + const state = makeState([reg], {[channel.id]: channel}); + expect(getChannelIntroOverride(state, channel.id)).toBeNull(); + }); + }); + + describe('first-match-wins semantics', () => { + it('returns the first matching registration when multiple registrations match', () => { + const channel = makeChannel(); + const reg1 = makeRegistration({id: 'r1', pluginId: 'alpha'}); + const reg2 = makeRegistration({id: 'r2', pluginId: 'beta'}); + const state = makeState([reg1, reg2], {[channel.id]: channel}); + const result = getChannelIntroOverride(state, channel.id); + expect(result).toBe(reg1); + }); + + it('returns the first alphabetical pluginId when reducer inserts them in order', () => { + const channel = makeChannel(); + const regAlpha = makeRegistration({id: 'r1', pluginId: 'alpha'}); + const regBeta = makeRegistration({id: 'r2', pluginId: 'beta'}); + + // Reducer inserts in alphabetical pluginId order, so alpha comes first + const state = makeState([regAlpha, regBeta], {[channel.id]: channel}); + const result = getChannelIntroOverride(state, channel.id); + expect(result?.pluginId).toBe('alpha'); + }); + + it('returns null when no registrations match', () => { + const channel = makeChannel(); + const reg = makeRegistration({matcher: () => false}); + const state = makeState([reg], {[channel.id]: channel}); + expect(getChannelIntroOverride(state, channel.id)).toBeNull(); + }); + }); + + describe('matcher error handling', () => { + it('treats a throwing matcher as no-match', () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const channel = makeChannel(); + const reg = makeRegistration({ + pluginId: 'bad-plugin', + matcher: () => { + throw new Error('boom'); + }, + }); + const state = makeState([reg], {[channel.id]: channel}); + expect(getChannelIntroOverride(state, channel.id)).toBeNull(); + consoleSpy.mockRestore(); + }); + + it('logs the error exactly once for a given pluginId', () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const channel = makeChannel(); + const reg = makeRegistration({ + pluginId: 'bad-plugin', + matcher: () => { + throw new Error('boom'); + }, + }); + const state = makeState([reg], {[channel.id]: channel}); + + getChannelIntroOverride(state, channel.id); + getChannelIntroOverride(state, channel.id); + getChannelIntroOverride(state, channel.id); + + expect(consoleSpy).toHaveBeenCalledTimes(1); + consoleSpy.mockRestore(); + }); + }); + + describe('clearLoggedChannelIntroErrors', () => { + it('resets the error log so errors are logged again after clearing', () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const channel = makeChannel(); + const reg = makeRegistration({ + pluginId: 'bad-plugin', + matcher: () => { + throw new Error('boom'); + }, + }); + const state = makeState([reg], {[channel.id]: channel}); + + getChannelIntroOverride(state, channel.id); + expect(consoleSpy).toHaveBeenCalledTimes(1); + + clearLoggedChannelIntroErrors('bad-plugin'); + + getChannelIntroOverride(state, channel.id); + expect(consoleSpy).toHaveBeenCalledTimes(2); + + consoleSpy.mockRestore(); + }); + + it('clears all entries when called with no argument', () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const channel = makeChannel(); + const reg = makeRegistration({ + pluginId: 'bad-plugin', + matcher: () => { + throw new Error('boom'); + }, + }); + const state = makeState([reg], {[channel.id]: channel}); + + getChannelIntroOverride(state, channel.id); + expect(consoleSpy).toHaveBeenCalledTimes(1); + + clearLoggedChannelIntroErrors(); + + getChannelIntroOverride(state, channel.id); + expect(consoleSpy).toHaveBeenCalledTimes(2); + + consoleSpy.mockRestore(); + }); + }); + + describe('matcher receives full state', () => { + it('passes full state to matcher so plugins can read their own slice', () => { + const channel = makeChannel(); + const capturedStates: GlobalState[] = []; + const reg = makeRegistration({ + matcher: (st) => { + capturedStates.push(st); + return true; + }, + }); + const state = makeState([reg], {[channel.id]: channel}); + getChannelIntroOverride(state, channel.id); + expect(capturedStates).toHaveLength(1); + expect(capturedStates[0]).toBe(state); + }); + }); +}); diff --git a/webapp/channels/src/selectors/channel_intro.ts b/webapp/channels/src/selectors/channel_intro.ts new file mode 100644 index 00000000000..083705d96c1 --- /dev/null +++ b/webapp/channels/src/selectors/channel_intro.ts @@ -0,0 +1,36 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {createMatcherErrorLog} from 'utils/matcher_error_log'; + +import type {GlobalState} from 'types/store'; +import type {ChannelIntroRegistration} from 'types/store/plugins'; + +const matcherErrorLog = createMatcherErrorLog('ChannelIntro'); + +export const clearLoggedChannelIntroErrors = matcherErrorLog.clear; + +/** First registration whose matcher returns === true for this channel, or null. */ +export function getChannelIntroOverride( + state: GlobalState, + channelId: string, +): ChannelIntroRegistration | null { + const regs = state.plugins.components.ChannelIntro; + if (!channelId || !regs?.length) { + return null; + } + const channel = state.entities?.channels?.channels?.[channelId]; + if (!channel) { + return null; + } + for (const reg of regs) { + try { + if (reg.matcher(state, channel) === true) { + return reg; + } + } catch (err) { + matcherErrorLog.logOnce(reg.pluginId, err, 'intro'); + } + } + return null; +} diff --git a/webapp/channels/src/types/store/plugins.ts b/webapp/channels/src/types/store/plugins.ts index f5314f193cb..5a58471b3b3 100644 --- a/webapp/channels/src/types/store/plugins.ts +++ b/webapp/channels/src/types/store/plugins.ts @@ -72,6 +72,8 @@ export type PluginsState = { SidebarBrowseOrAddChannelMenu: SidebarBrowseOrAddChannelMenuAction[]; ChannelTypeOption: ChannelTypeOptionComponent[]; ChannelIconOverride: ChannelIconOverrideRegistration[]; + ChannelComposerBanner: ChannelComposerBannerComponent[]; + ChannelIntro: ChannelIntroRegistration[]; FilesWillUploadHook: FilesWillUploadHook[]; DesktopNotificationHooks: DesktopNotificationHook[]; MessageWillFormat: MessageWillFormatHook[]; @@ -427,6 +429,15 @@ export type ChannelIconOverrideRegistration = PluginComponent & { iconName: IconGlyphTypes; }; +export type ChannelComposerBannerComponent = PluginComponent & { + component: React.ComponentType<{channel: Channel}>; +}; + +export type ChannelIntroRegistration = PluginComponent & { + matcher: (state: GlobalState, channel: Channel) => boolean; + component: React.ComponentType<{channel: Channel}>; +}; + export type ChannelTypeOptionComponent = PluginComponent & { label: PluggableText; description: PluggableText; diff --git a/webapp/channels/src/utils/matcher_error_log.test.ts b/webapp/channels/src/utils/matcher_error_log.test.ts new file mode 100644 index 00000000000..0e171b8e395 --- /dev/null +++ b/webapp/channels/src/utils/matcher_error_log.test.ts @@ -0,0 +1,182 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {createMatcherErrorLog} from './matcher_error_log'; + +describe('utils/createMatcherErrorLog', () => { + let consoleSpy: jest.SpyInstance; + + beforeEach(() => { + consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + }); + + describe('logOnce — no slot (icon-override keying scheme)', () => { + it('logs on first call for a pluginId', () => { + const {logOnce} = createMatcherErrorLog('TestLabel'); + const err = new Error('boom'); + + logOnce('plugin-a', err); + + expect(consoleSpy).toHaveBeenCalledTimes(1); + expect(consoleSpy.mock.calls[0][0]).toBe( + "TestLabel: matcher for plugin 'plugin-a' threw — treating as no-match.", + ); + expect(consoleSpy.mock.calls[0][1]).toBe(err); + }); + + it('does not log on subsequent calls for the same pluginId', () => { + const {logOnce} = createMatcherErrorLog('TestLabel'); + const err = new Error('boom'); + + logOnce('plugin-a', err); + logOnce('plugin-a', err); + logOnce('plugin-a', err); + + expect(consoleSpy).toHaveBeenCalledTimes(1); + }); + + it('message has no " at slot" fragment when slot is absent', () => { + const {logOnce} = createMatcherErrorLog('TestLabel'); + + logOnce('plugin-a', new Error('boom')); + + const msg: string = consoleSpy.mock.calls[0][0]; + expect(msg).not.toContain('at slot'); + }); + + it('logs once per distinct pluginId', () => { + const {logOnce} = createMatcherErrorLog('TestLabel'); + + logOnce('plugin-a', new Error('a')); + logOnce('plugin-b', new Error('b')); + logOnce('plugin-a', new Error('a2')); + + expect(consoleSpy).toHaveBeenCalledTimes(2); + }); + }); + + describe('logOnce — with slot (decorator keying scheme)', () => { + it('logs on first call for a pluginId+slot pair', () => { + const {logOnce} = createMatcherErrorLog('TestLabel'); + const err = new Error('boom'); + + logOnce('plugin-a', err, 'intro'); + + expect(consoleSpy).toHaveBeenCalledTimes(1); + expect(consoleSpy.mock.calls[0][0]).toBe( + "TestLabel: matcher for plugin 'plugin-a' at slot 'intro' threw — treating as no-match.", + ); + }); + + it('does not log on subsequent calls for the same pluginId+slot', () => { + const {logOnce} = createMatcherErrorLog('TestLabel'); + + logOnce('plugin-a', new Error('boom'), 'intro'); + logOnce('plugin-a', new Error('boom'), 'intro'); + + expect(consoleSpy).toHaveBeenCalledTimes(1); + }); + + it('same pluginId but different slots produce distinct keys and each log once', () => { + const {logOnce} = createMatcherErrorLog('TestLabel'); + + logOnce('plugin-a', new Error('boom'), 'intro'); + logOnce('plugin-a', new Error('boom'), 'other-slot'); + + expect(consoleSpy).toHaveBeenCalledTimes(2); + }); + + it('slot is included in the error message text', () => { + const {logOnce} = createMatcherErrorLog('TestLabel'); + + logOnce('plugin-a', new Error('boom'), 'some-slot'); + + const msg: string = consoleSpy.mock.calls[0][0]; + expect(msg).toContain("at slot 'some-slot'"); + }); + }); + + describe('clear — with pluginId argument', () => { + it('clears the exact-pluginId key (icon scheme) so errors log again', () => { + const {logOnce, clear} = createMatcherErrorLog('TestLabel'); + + logOnce('plugin-a', new Error('boom')); + expect(consoleSpy).toHaveBeenCalledTimes(1); + + clear('plugin-a'); + + logOnce('plugin-a', new Error('boom')); + expect(consoleSpy).toHaveBeenCalledTimes(2); + }); + + it('clears pluginId:-prefixed keys (decorator scheme) so errors log again', () => { + const {logOnce, clear} = createMatcherErrorLog('TestLabel'); + + logOnce('plugin-a', new Error('boom'), 'intro'); + logOnce('plugin-a', new Error('boom'), 'above_composer'); + expect(consoleSpy).toHaveBeenCalledTimes(2); + + clear('plugin-a'); + + logOnce('plugin-a', new Error('boom'), 'intro'); + logOnce('plugin-a', new Error('boom'), 'above_composer'); + expect(consoleSpy).toHaveBeenCalledTimes(4); + }); + + it('does not clear keys belonging to other plugins', () => { + const {logOnce, clear} = createMatcherErrorLog('TestLabel'); + + logOnce('plugin-a', new Error('boom'), 'intro'); + logOnce('plugin-b', new Error('boom'), 'intro'); + expect(consoleSpy).toHaveBeenCalledTimes(2); + + clear('plugin-a'); + + // plugin-b is still silenced + logOnce('plugin-b', new Error('boom'), 'intro'); + expect(consoleSpy).toHaveBeenCalledTimes(2); + + // plugin-a logs again + logOnce('plugin-a', new Error('boom'), 'intro'); + expect(consoleSpy).toHaveBeenCalledTimes(3); + }); + + it('guards against prefix collisions (e.g. "foo" vs "foobar")', () => { + const {logOnce, clear} = createMatcherErrorLog('TestLabel'); + + // Both 'foo' and 'foobar' log under their own slots + logOnce('foo', new Error('boom'), 'intro'); + logOnce('foobar', new Error('boom'), 'intro'); + expect(consoleSpy).toHaveBeenCalledTimes(2); + + // Clearing 'foo' must NOT clear 'foobar' + clear('foo'); + + logOnce('foobar', new Error('boom'), 'intro'); + expect(consoleSpy).toHaveBeenCalledTimes(2); // foobar still silenced + + logOnce('foo', new Error('boom'), 'intro'); + expect(consoleSpy).toHaveBeenCalledTimes(3); // foo logs again + }); + }); + + describe('clear — no argument', () => { + it('clears all entries so every key logs again', () => { + const {logOnce, clear} = createMatcherErrorLog('TestLabel'); + + logOnce('plugin-a', new Error('boom')); + logOnce('plugin-b', new Error('boom'), 'intro'); + expect(consoleSpy).toHaveBeenCalledTimes(2); + + clear(); + + logOnce('plugin-a', new Error('boom')); + logOnce('plugin-b', new Error('boom'), 'intro'); + expect(consoleSpy).toHaveBeenCalledTimes(4); + }); + }); +}); diff --git a/webapp/channels/src/utils/matcher_error_log.ts b/webapp/channels/src/utils/matcher_error_log.ts new file mode 100644 index 00000000000..33e0efd22c1 --- /dev/null +++ b/webapp/channels/src/utils/matcher_error_log.ts @@ -0,0 +1,42 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +// Plugin-registered matchers run on the render path. A throwing matcher is treated as a no-match, +// but the first occurrence per key is logged so a broken plugin is diagnosable without spamming the +// console on every render. Callers key by pluginId alone, or by pluginId+slot when one label hosts +// several matcher slots — the optional `slot` selects the keying scheme. +export function createMatcherErrorLog(label: string) { + const logged = new Set(); + + const logOnce = (pluginId: string, err: unknown, slot?: string): void => { + const key = slot === undefined ? pluginId : `${pluginId}:${slot}`; + if (logged.has(key)) { + return; + } + logged.add(key); + const where = slot === undefined ? '' : ` at slot '${slot}'`; + + // eslint-disable-next-line no-console + console.error( + `${label}: matcher for plugin '${pluginId}'${where} threw — treating as no-match.`, + err, + ); + }; + + // Clears all entries, or just those for one plugin: the exact-pluginId key plus any + // `${pluginId}:`-prefixed (slot-keyed) entries. The colon guards against prefix collisions + // between plugin ids (e.g. 'foo' vs 'foobar'). + const clear = (pluginId?: string): void => { + if (pluginId === undefined) { + logged.clear(); + return; + } + for (const key of logged) { + if (key === pluginId || key.startsWith(`${pluginId}:`)) { + logged.delete(key); + } + } + }; + + return {logOnce, clear}; +}