Merge branch 'master' into mattermost-pages-channel

This commit is contained in:
Catalin I. Tomai 2026-02-03 21:37:04 +01:00
commit 0426e32e52
11 changed files with 376 additions and 13 deletions

View file

@ -1,4 +1,4 @@
.PHONY: build package run stop run-client run-server run-node run-haserver stop-haserver stop-client stop-server restart restart-server restart-client restart-haserver start-docker update-docker clean-dist clean nuke check-style check-client-style check-server-style check-unit-tests test dist run-client-tests setup-run-client-tests cleanup-run-client-tests test-client build-linux build-osx build-windows package-prep package-linux package-osx package-windows internal-test-web-client vet run-server-for-web-client-tests diff-config prepackaged-plugins prepackaged-binaries test-server test-server-ee test-server-quick test-server-race test-mmctl-unit test-mmctl-e2e test-mmctl test-mmctl-coverage mmctl-build mmctl-docs new-migration migrations-extract test-public mocks-public
.PHONY: build package run stop run-client run-server run-node run-haserver stop-haserver stop-client stop-server restart restart-server restart-client restart-haserver start-docker update-docker clean-dist clean nuke check-style check-client-style check-server-style check-unit-tests test dist run-client-tests setup-run-client-tests cleanup-run-client-tests test-client build-linux build-osx build-freebsd build-windows package-prep package-linux package-osx package-windows internal-test-web-client vet run-server-for-web-client-tests diff-config prepackaged-plugins prepackaged-binaries test-server test-server-ee test-server-quick test-server-race test-mmctl-unit test-mmctl-e2e test-mmctl test-mmctl-coverage mmctl-build mmctl-docs new-migration migrations-extract test-public mocks-public
ROOT := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
@ -142,6 +142,8 @@ DIST_PATH_LIN_AMD64=$(DIST_ROOT)/linux_amd64/mattermost
DIST_PATH_LIN_ARM64=$(DIST_ROOT)/linux_arm64/mattermost
DIST_PATH_OSX_AMD64=$(DIST_ROOT)/darwin_amd64/mattermost
DIST_PATH_OSX_ARM64=$(DIST_ROOT)/darwin_arm64/mattermost
DIST_PATH_FREEBSD_AMD64=$(DIST_ROOT)/freebsd_amd64/mattermost
DIST_PATH_FREEBSD_ARM64=$(DIST_ROOT)/freebsd_arm64/mattermost
DIST_PATH_WIN=$(DIST_ROOT)/windows/mattermost
# Packages lists

View file

@ -49,6 +49,26 @@ else
env GOOS=darwin GOARCH=arm64 $(GO) build -o $(GOBIN)/darwin_arm64 $(GOFLAGS) -trimpath -tags '$(BUILD_TAGS) production' -ldflags '$(LDFLAGS)' ./...
endif
build-freebsd: build-freebsd-amd64 build-freebsd-arm64
build-freebsd-amd64:
@echo Build FreeBSD amd64
ifeq ($(BUILDER_GOOS_GOARCH),"freebsd_amd64")
env GOOS=freebsd GOARCH=amd64 $(GO) build -o $(GOBIN) $(GOFLAGS) -trimpath -tags production -ldflags '$(LDFLAGS)' ./...
else
mkdir -p $(GOBIN)/freebsd_amd64
env GOOS=freebsd GOARCH=amd64 $(GO) build -o $(GOBIN)/freebsd_amd64 $(GOFLAGS) -trimpath -tags production -ldflags '$(LDFLAGS)' ./...
endif
build-freebsd-arm64:
@echo Build FreeBSD arm64
ifeq ($(BUILDER_GOOS_GOARCH),"freebsd_arm64")
env GOOS=freebsd GOARCH=arm64 $(GO) build -o $(GOBIN) $(GOFLAGS) -trimpath -tags production -ldflags '$(LDFLAGS)' ./...
else
mkdir -p $(GOBIN)/freebsd_arm64
env GOOS=freebsd GOARCH=arm64 $(GO) build -o $(GOBIN)/freebsd_arm64 $(GOFLAGS) -trimpath -tags production -ldflags '$(LDFLAGS)' ./...
endif
build-windows:
@echo Build Windows amd64
ifeq ($(BUILDER_GOOS_GOARCH),"windows_amd64")
@ -103,6 +123,22 @@ else
env GOOS=darwin GOARCH=arm64 $(GO) build -o $(GOBIN)/darwin_arm64 $(GOFLAGS) -trimpath -tags '$(BUILD_TAGS) production' -ldflags '$(LDFLAGS)' ./cmd/...
endif
build-cmd-freebsd:
@echo Build CMD FreeBSD amd64
ifeq ($(BUILDER_GOOS_GOARCH),"freebsd_amd64")
env GOOS=freebsd GOARCH=amd64 $(GO) build -o $(GOBIN) $(GOFLAGS) -trimpath -tags production -ldflags '$(LDFLAGS)' ./cmd/...
else
mkdir -p $(GOBIN)/freebsd_amd64
env GOOS=freebsd GOARCH=amd64 $(GO) build -o $(GOBIN)/freebsd_amd64 $(GOFLAGS) -trimpath -tags production -ldflags '$(LDFLAGS)' ./cmd/...
endif
@echo Build CMD FreeBSD arm64
ifeq ($(BUILDER_GOOS_GOARCH),"freebsd_arm64")
env GOOS=freebsd GOARCH=arm64 $(GO) build -o $(GOBIN) $(GOFLAGS) -trimpath -tags production -ldflags '$(LDFLAGS)' ./cmd/...
else
mkdir -p $(GOBIN)/freebsd_arm64
env GOOS=freebsd GOARCH=arm64 $(GO) build -o $(GOBIN)/freebsd_arm64 $(GOFLAGS) -trimpath -tags production -ldflags '$(LDFLAGS)' ./cmd/...
endif
build-cmd-windows:
@echo Build CMD Windows amd64
ifeq ($(BUILDER_GOOS_GOARCH),"windows_amd64")
@ -223,6 +259,22 @@ package-osx-arm64: package-prep
package-osx: package-osx-amd64 package-osx-arm64
package-freebsd-amd64: package-prep
DIST_PATH_GENERIC=$(DIST_PATH_FREEBSD_AMD64) CURRENT_PACKAGE_ARCH=freebsd_amd64 MM_BIN_NAME=mattermost MMCTL_BIN_NAME=mmctl $(MAKE) package-general
@# Package
tar -C $(DIST_PATH_FREEBSD_AMD64)/.. -czf $(DIST_PATH)-$(BUILD_TYPE_NAME)-freebsd-amd64.tar.gz mattermost ../mattermost
@# Cleanup
rm -rf $(DIST_ROOT)/freebsd_amd64
package-freebsd-arm64: package-prep
DIST_PATH_GENERIC=$(DIST_PATH_FREEBSD_ARM64) CURRENT_PACKAGE_ARCH=freebsd_arm64 MM_BIN_NAME=mattermost MMCTL_BIN_NAME=mmctl $(MAKE) package-general
@# Package
tar -C $(DIST_PATH_FREEBSD_ARM64)/.. -czf $(DIST_PATH)-$(BUILD_TYPE_NAME)-freebsd-arm64.tar.gz mattermost ../mattermost
@# Cleanup
rm -rf $(DIST_ROOT)/freebsd_arm64
package-freebsd: package-freebsd-amd64 package-freebsd-arm64
package-linux-amd64: package-prep
DIST_PATH_GENERIC=$(DIST_PATH_LIN_AMD64) PLUGIN_ARCH=linux-amd64 $(MAKE) package-plugins
DIST_PATH_GENERIC=$(DIST_PATH_LIN_AMD64) CURRENT_PACKAGE_ARCH=linux_amd64 MM_BIN_NAME=mattermost MMCTL_BIN_NAME=mmctl $(MAKE) package-general

View file

@ -3177,11 +3177,23 @@ func (a *App) RewriteMessage(
return nil, model.NewAppError("RewriteMessage", "app.post.rewrite.invalid_action", nil, fmt.Sprintf("invalid action: %s", action), 400)
}
userLocale := ""
if session := rctx.Session(); session != nil && session.UserId != "" {
user, appErr := a.GetUser(session.UserId)
if appErr == nil {
userLocale = user.Locale
} else {
rctx.Logger().Warn("Failed to get user for rewrite locale", mlog.Err(appErr), mlog.String("user_id", session.UserId))
}
}
systemPrompt := buildRewriteSystemPrompt(userLocale)
// Prepare completion request in the format expected by the client
client := a.GetBridgeClient(rctx.Session().UserId)
completionRequest := agentclient.CompletionRequest{
Posts: []agentclient.Post{
{Role: "system", Message: model.RewriteSystemPrompt},
{Role: "system", Message: systemPrompt},
{Role: "user", Message: userPrompt},
},
}
@ -3379,6 +3391,17 @@ func getRewritePromptForAction(action model.RewriteAction, message string, custo
return actionPrompt
}
func buildRewriteSystemPrompt(userLocale string) string {
locale := strings.TrimSpace(userLocale)
if locale == "" {
return model.RewriteSystemPrompt
}
return fmt.Sprintf(`%s
User locale: %s. Preserve locale-specific spelling, grammar, and formatting. Keep locale identifiers (like %s) unchanged. Do not translate between locales.`, model.RewriteSystemPrompt, locale, locale)
}
// RevealPost reveals a burn-on-read post for a specific user, creating a read receipt
// if this is the first time the user is revealing it. Returns the revealed post content
// with expiration metadata.

View file

@ -0,0 +1,28 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
func TestBuildRewriteSystemPrompt(t *testing.T) {
basePrompt := model.RewriteSystemPrompt
t.Run("uses_user_locale", func(t *testing.T) {
prompt := buildRewriteSystemPrompt("en_CA")
require.True(t, strings.HasPrefix(prompt, basePrompt))
require.Contains(t, prompt, "User locale: en_CA.")
})
t.Run("returns_base_prompt_when_no_locale", func(t *testing.T) {
prompt := buildRewriteSystemPrompt("")
require.Equal(t, basePrompt, prompt)
})
}

View file

@ -1,5 +1,47 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`components/ProductBranding should fallback to ProductChannelsIcon when string icon name is not found in glyphMap 1`] = `
<ProductBrandingContainer
tabIndex={-1}
>
<ProductChannelsIcon
size={24}
/>
<h1
className="sr-only"
>
InvalidProduct
</h1>
<ProductBrandingHeading>
InvalidProduct
</ProductBrandingHeading>
</ProductBrandingContainer>
`;
exports[`components/ProductBranding should render a React element icon when switcherIcon is a React node 1`] = `
<ProductBrandingContainer
tabIndex={-1}
>
<svg
data-testid="custom-icon"
>
<circle
cx="12"
cy="12"
r="10"
/>
</svg>
<h1
className="sr-only"
>
CustomProduct
</h1>
<ProductBrandingHeading>
CustomProduct
</ProductBrandingHeading>
</ProductBrandingContainer>
`;
exports[`components/ProductBranding should show correct icon glyph when we are on Boards 1`] = `
<ProductBrandingContainer
tabIndex={-1}

View file

@ -8,6 +8,8 @@ import {TopLevelProducts} from 'utils/constants';
import * as productUtils from 'utils/products';
import {TestHelper} from 'utils/test_helper';
import type {ProductComponent} from 'types/store/plugins';
import ProductBranding from './product_branding';
describe('components/ProductBranding', () => {
@ -42,4 +44,45 @@ describe('components/ProductBranding', () => {
expect(wrapper).toMatchSnapshot();
});
test('should render a React element icon when switcherIcon is a React node', () => {
const currentProductSpy = jest.spyOn(productUtils, 'useCurrentProduct');
const CustomIcon = (
<svg data-testid='custom-icon'>
<circle
cx='12'
cy='12'
r='10'
/>
</svg>
);
const productWithReactIcon: ProductComponent = {
...TestHelper.makeProduct('CustomProduct'),
switcherIcon: CustomIcon,
};
currentProductSpy.mockReturnValue(productWithReactIcon);
const wrapper = shallow(
<ProductBranding/>,
);
expect(wrapper).toMatchSnapshot();
expect(wrapper.find('[data-testid="custom-icon"]').exists()).toBe(true);
});
test('should fallback to ProductChannelsIcon when string icon name is not found in glyphMap', () => {
const currentProductSpy = jest.spyOn(productUtils, 'useCurrentProduct');
const productWithInvalidIcon: ProductComponent = {
...TestHelper.makeProduct('InvalidProduct'),
switcherIcon: 'non-existent-icon-name' as ProductComponent['switcherIcon'],
};
currentProductSpy.mockReturnValue(productWithInvalidIcon);
const wrapper = shallow(
<ProductBranding/>,
);
expect(wrapper).toMatchSnapshot();
expect(wrapper.find('ProductChannelsIcon').exists()).toBe(true);
});
});

View file

@ -5,6 +5,7 @@ import React from 'react';
import styled from 'styled-components';
import glyphMap, {ProductChannelsIcon} from '@mattermost/compass-icons/components';
import type {IconGlyphTypes} from '@mattermost/compass-icons/IconGlyphs';
import {useCurrentProduct} from 'utils/products';
@ -27,11 +28,29 @@ const ProductBrandingHeading = styled.span`
const ProductBranding = (): JSX.Element => {
const currentProduct = useCurrentProduct();
const Icon = currentProduct?.switcherIcon ? glyphMap[currentProduct.switcherIcon] : ProductChannelsIcon;
// Handle both string icon names and React elements
const renderIcon = () => {
if (!currentProduct?.switcherIcon) {
return <ProductChannelsIcon size={24}/>;
}
if (typeof currentProduct.switcherIcon === 'string') {
const Icon = glyphMap[currentProduct.switcherIcon as IconGlyphTypes];
if (Icon) {
return <Icon size={24}/>;
}
// Fallback if icon name not found in glyphMap
return <ProductChannelsIcon size={24}/>;
}
// React element - render directly
return <>{currentProduct.switcherIcon}</>;
};
return (
<ProductBrandingContainer tabIndex={-1}>
<Icon size={24}/>
{renderIcon()}
<h1 className='sr-only'>
{currentProduct ? currentProduct.switcherText : 'Channels'}
</h1>

View file

@ -0,0 +1,147 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {renderWithContext, screen, userEvent} from 'tests/react_testing_utils';
import ProductMenuItem from './product_menu_item';
import type {ProductMenuItemProps} from './product_menu_item';
describe('components/ProductMenuItem', () => {
const defaultProps: ProductMenuItemProps = {
destination: '/test-destination',
icon: 'product-channels',
text: 'Test Product',
active: false,
onClick: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
});
test('should render menu item with correct text', () => {
renderWithContext(<ProductMenuItem {...defaultProps}/>);
expect(screen.getByRole('menuitem')).toBeInTheDocument();
expect(screen.getByText('Test Product')).toBeInTheDocument();
});
test('should render with string icon from glyphMap', () => {
renderWithContext(<ProductMenuItem {...defaultProps}/>);
// When icon is a string, the component looks up the glyph from glyphMap
// The icon should be rendered with proper styling
const menuItem = screen.getByRole('menuitem');
expect(menuItem).toBeInTheDocument();
// The ProductChannelsIcon should be rendered (via glyphMap lookup)
// We can verify the menu item contains an svg element
expect(menuItem.querySelector('svg')).toBeInTheDocument();
});
test('should render with React element icon', () => {
const CustomIcon = (
<svg data-testid='custom-svg-icon'>
<rect
width='24'
height='24'
/>
</svg>
);
const props: ProductMenuItemProps = {
...defaultProps,
icon: CustomIcon,
};
renderWithContext(<ProductMenuItem {...props}/>);
expect(screen.getByTestId('custom-svg-icon')).toBeInTheDocument();
});
test('should show check icon when active is true', () => {
const props: ProductMenuItemProps = {
...defaultProps,
active: true,
};
renderWithContext(<ProductMenuItem {...props}/>);
const menuItem = screen.getByRole('menuitem');
// When active, there should be two SVG elements: the product icon and the check icon
const svgElements = menuItem.querySelectorAll('svg');
expect(svgElements.length).toBe(2);
});
test('should not show check icon when active is false', () => {
renderWithContext(<ProductMenuItem {...defaultProps}/>);
const menuItem = screen.getByRole('menuitem');
// When not active, there should only be one SVG element: the product icon
const svgElements = menuItem.querySelectorAll('svg');
expect(svgElements.length).toBe(1);
});
test('should call onClick when clicked', async () => {
const onClick = jest.fn();
const props: ProductMenuItemProps = {
...defaultProps,
onClick,
};
renderWithContext(<ProductMenuItem {...props}/>);
await userEvent.click(screen.getByRole('menuitem'));
expect(onClick).toHaveBeenCalledTimes(1);
});
test('should render tour tip when provided', () => {
const tourTipContent = 'Tour tip content';
const TourTip = <div data-testid='tour-tip'>{tourTipContent}</div>;
const props: ProductMenuItemProps = {
...defaultProps,
tourTip: TourTip,
};
renderWithContext(<ProductMenuItem {...props}/>);
expect(screen.getByTestId('tour-tip')).toBeInTheDocument();
expect(screen.getByText(tourTipContent)).toBeInTheDocument();
});
test('should pass correct id to menu item', () => {
const props: ProductMenuItemProps = {
...defaultProps,
id: 'test-menu-item-id',
};
renderWithContext(<ProductMenuItem {...props}/>);
expect(screen.getByRole('menuitem')).toHaveAttribute('id', 'test-menu-item-id');
});
test('should render with correct destination link', () => {
renderWithContext(<ProductMenuItem {...defaultProps}/>);
const menuItem = screen.getByRole('menuitem');
expect(menuItem).toHaveAttribute('href', '/test-destination');
});
test('should render custom React component as icon', () => {
const customIconText = 'Custom Icon';
const CustomIconComponent = () => <span data-testid='custom-component-icon'>{customIconText}</span>;
const props: ProductMenuItemProps = {
...defaultProps,
icon: <CustomIconComponent/>,
};
renderWithContext(<ProductMenuItem {...props}/>);
expect(screen.getByTestId('custom-component-icon')).toBeInTheDocument();
expect(screen.getByText(customIconText)).toBeInTheDocument();
});
});

View file

@ -10,7 +10,7 @@ import type {IconGlyphTypes} from '@mattermost/compass-icons/IconGlyphs';
export interface ProductMenuItemProps {
destination: string;
icon: IconGlyphTypes;
icon: IconGlyphTypes | React.ReactNode;
text: React.ReactNode;
active: boolean;
onClick: () => void;
@ -54,7 +54,7 @@ const MenuItemTextContainer = styled.div`
`;
const ProductMenuItem = ({icon, destination, text, active, onClick, tourTip, id}: ProductMenuItemProps): JSX.Element => {
const ProductIcon = glyphMap[icon];
const ProductIcon = typeof icon === 'string' ? glyphMap[icon as IconGlyphTypes] : null;
return (
<MenuItem
@ -63,10 +63,14 @@ const ProductMenuItem = ({icon, destination, text, active, onClick, tourTip, id}
id={id}
role='menuitem'
>
<ProductIcon
size={24}
color={'var(--button-bg)'}
/>
{ProductIcon ? (
<ProductIcon
size={24}
color={'var(--button-bg)'}
/>
) : (
icon
)}
<MenuItemTextContainer>
{text}
</MenuItemTextContainer>

View file

@ -1183,7 +1183,7 @@ export default class PluginRegistry {
dispatchPluginComponentWithData('Product', {
id,
pluginId: this.id,
switcherIcon,
switcherIcon: resolveReactElement(switcherIcon),
switcherText: resolveReactElement(switcherText),
baseURL: '/' + standardizeRoute(baseURL),
switcherLinkURL: '/' + standardizeRoute(switcherLinkURL),

View file

@ -244,9 +244,12 @@ export type ProductSubComponentNames = 'mainComponent' | 'publicComponent' | 'he
export type ProductComponent = PluginComponent & {
/**
* A compass-icon glyph to display as the icon in the product switcher
* A compass-icon glyph name or React element to display as the icon in the product switcher.
* Accepts either:
* - IconGlyphTypes: A string name from the Compass Icons library (e.g., 'product-channels')
* - React.ReactNode: A custom React element to render as the icon
*/
switcherIcon: IconGlyphTypes;
switcherIcon: IconGlyphTypes | React.ReactNode;
/**
* A string or React element to display in the product switcher