diff --git a/.gitignore b/.gitignore
index feb8d45a1ef..ecb3772cb70 100644
--- a/.gitignore
+++ b/.gitignore
@@ -45,3 +45,4 @@ lib/
storybook-static/
plans/
+output
diff --git a/apps/webapp/src/i18n/en-US.json b/apps/webapp/src/i18n/en-US.json
index d555ddea10a..a99e1a38bee 100644
--- a/apps/webapp/src/i18n/en-US.json
+++ b/apps/webapp/src/i18n/en-US.json
@@ -678,6 +678,11 @@
"conversationCreatedYou": "You started a conversation with {users}",
"conversationCreatedYouMore": "You started a conversation with {users}, and [showmore]{count} more[/showmore]",
"conversationDeleteTimestamp": "Deleted: {date}",
+ "conversationDescriptionAddedYou": " added a group description",
+ "conversationDescriptionEditedYou": " edited the group description",
+ "conversationDescriptionLabel": "Description:",
+ "conversationDescriptionOptionalLabel": "Description (optional)",
+ "conversationDescriptionPlaceholder": "Enter a description",
"conversationDetails1to1ReceiptsFirst": "If both sides turn on read receipts, you can see when messages are read.",
"conversationDetails1to1ReceiptsHeadDisabled": "You have disabled read receipts",
"conversationDetails1to1ReceiptsHeadEnabled": "You have enabled read receipts",
@@ -700,6 +705,8 @@
"conversationDetailsActionTimedMessagesDisabled": "Self-deleting messages are off",
"conversationDetailsActionUnblock": "Unblock",
"conversationDetailsCloseLabel": "Close image details view",
+ "conversationDetailsDescription": "Description (optional)",
+ "conversationDetailsDescriptionPlaceholder": "Enter a description (maximum 200 characters)",
"conversationDetailsGroupAdmin": "Group Admin",
"conversationDetailsGroupAdminInfo": "When this is on, the admin can add or remove people and apps, update group settings, and change a participant’s role.",
"conversationDetailsOff": "Off",
diff --git a/apps/webapp/src/i18n/pt-BR.json b/apps/webapp/src/i18n/pt-BR.json
index 926a8e32759..3a3388ba2f4 100644
--- a/apps/webapp/src/i18n/pt-BR.json
+++ b/apps/webapp/src/i18n/pt-BR.json
@@ -662,6 +662,7 @@
"conversationContextMenuReply": "Responder",
"conversationContextMenuUnlike": "Descurtir",
"conversationCreateReceiptsEnabled": "As confirmações de leitura estão ligadas",
+ "conversationDescriptionLabel": "Descrição:",
"conversationCreateTeam": "com [showmore]todos os membros[/showmore]",
"conversationCreateTeamGuest": "com [showmore]todos os membros e um convidado[/showmore]",
"conversationCreateTeamGuests": "com [showmore]todos os membros e {count} convidados[/showmore]",
@@ -699,6 +700,8 @@
"conversationDetailsCloseLabel": "Fechar visualização de detalhes da imagem",
"conversationDetailsGroupAdmin": "Administrador do Grupo",
"conversationDetailsGroupAdminInfo": "When this is on, the admin can add or remove people and apps, update group settings, and change a participant’s role.",
+ "conversationDescriptionAddedYou": " adicionou uma descrição ao grupo",
+ "conversationDescriptionEditedYou": " editou a descrição do grupo",
"conversationDetailsOff": "Desligado",
"conversationDetailsOn": "Ligado",
"conversationDetailsOptions": "Opções",
diff --git a/apps/webapp/src/script/components/Conversation/Conversation.tsx b/apps/webapp/src/script/components/Conversation/Conversation.tsx
index fa5a4bb127b..f8dca354470 100644
--- a/apps/webapp/src/script/components/Conversation/Conversation.tsx
+++ b/apps/webapp/src/script/components/Conversation/Conversation.tsx
@@ -154,6 +154,15 @@ export const Conversation = ({
}
}, [activeConversation, isVirtualizedMessagesListEnabled]);
+ useEffect(() => {
+ if (!activeConversation) {
+ return;
+ }
+
+ // TODO: Remove when description is part of the API Conversation payload
+ void conversationRepository.loadConversationDescription(activeConversation);
+ }, [activeConversation, conversationRepository]);
+
const uploadImages = useCallback(
(images: File[]) => {
if (!activeConversation || isHittingUploadLimit(images, repositories.asset, translate)) {
diff --git a/apps/webapp/src/script/components/MessagesList/Message/MemberMessage.test.tsx b/apps/webapp/src/script/components/MessagesList/Message/MemberMessage.test.tsx
index 4693dcfed56..f1a0a23af6f 100644
--- a/apps/webapp/src/script/components/MessagesList/Message/MemberMessage.test.tsx
+++ b/apps/webapp/src/script/components/MessagesList/Message/MemberMessage.test.tsx
@@ -100,6 +100,66 @@ describe('MemberMessage', () => {
expect(getByTestId('element-connected-message')).not.toBeNull();
});
+ it('shows conversation description when group is created and description exists', () => {
+ const message = createMemberMessage({systemType: SystemMessageType.CONVERSATION_CREATE}, [generateUser()]);
+ const props = {
+ ...baseProps,
+ message,
+ conversationDescription: 'A useful group description',
+ };
+
+ const {getByTestId, getByText} = render(withTheme( ));
+ expect(getByText('Description:')).toBeInTheDocument();
+ expect(getByText('A useful group description')).toBeInTheDocument();
+ expect(getByTestId('group-creation-description-label').nextElementSibling).toBe(
+ getByTestId('group-creation-description-text'),
+ );
+ });
+
+ it('renders conversation description headings, line breaks, and links like chat messages', () => {
+ const message = createMemberMessage({systemType: SystemMessageType.CONVERSATION_CREATE}, [generateUser()]);
+ const props = {
+ ...baseProps,
+ message,
+ conversationDescription: '# First line\nhttps://wire.com',
+ };
+
+ const {getByRole, getByTestId} = render(withTheme( ));
+
+ const link = getByRole('link', {name: 'https://wire.com'});
+ const description = getByTestId('group-creation-description-text');
+ expect(link).toHaveAttribute('href', 'https://wire.com');
+ expect(link).toHaveAttribute('target', '_blank');
+ expect(description.querySelector('.md-heading--1')).toHaveTextContent('First line');
+ expect(description.innerHTML).toContain(' ');
+ });
+
+ it('escapes html in the conversation description shown in chat', () => {
+ const message = createMemberMessage({systemType: SystemMessageType.CONVERSATION_CREATE}, [generateUser()]);
+ const props = {
+ ...baseProps,
+ message,
+ conversationDescription: ' \nhttps://wire.com',
+ };
+
+ const {getByTestId, getByText} = render(withTheme( ));
+
+ expect(getByTestId('group-creation-description-text').querySelector('img')).toBeNull();
+ expect(getByText(' ')).toBeInTheDocument();
+ });
+
+ it('does not show conversation description when group is created without description', () => {
+ const message = createMemberMessage({systemType: SystemMessageType.CONVERSATION_CREATE}, [generateUser()]);
+ const props = {
+ ...baseProps,
+ message,
+ conversationDescription: '',
+ };
+
+ const {queryByText} = render(withTheme( ));
+ expect(queryByText('Description:')).not.toBeInTheDocument();
+ });
+
it('shows self-deleting messages off banner when group is created and self-deleting messages are disabled', () => {
const message = createMemberMessage({systemType: SystemMessageType.CONVERSATION_CREATE}, [generateUser()]);
const props = {
diff --git a/apps/webapp/src/script/components/MessagesList/Message/MemberMessage.tsx b/apps/webapp/src/script/components/MessagesList/Message/MemberMessage.tsx
index 00050a42f05..f05643204cb 100644
--- a/apps/webapp/src/script/components/MessagesList/Message/MemberMessage.tsx
+++ b/apps/webapp/src/script/components/MessagesList/Message/MemberMessage.tsx
@@ -25,12 +25,19 @@ import {User} from 'Repositories/entity/User';
import {SystemMessageType} from 'src/script/message/SystemMessageType';
import {useApplicationContext} from 'src/script/page/RootProvider';
import {useKoSubscribableChildren} from 'Util/componentUtil';
+import {renderMessage} from 'Util/messageRenderer';
import {E2eEncryptionMessage} from './E2eEncryptionMessage/E2eEncryptionMessage';
import {ConnectedMessage} from './MemberMessage/ConnectedMessage';
import {MessageContent} from './MemberMessage/MessageContent';
import {MessageTime} from './MessageTime';
+const PinIcon = () => (
+
+
+
+);
+
interface MemberMessageProps {
classifiedDomains?: string[];
hasReadReceiptsTurnedOn: boolean;
@@ -42,6 +49,7 @@ interface MemberMessageProps {
onClickParticipants: (participants: User[]) => void;
shouldShowInvitePeople: boolean;
conversationName: string;
+ conversationDescription?: string;
isCellsConversation: boolean;
}
@@ -56,6 +64,7 @@ export const MemberMessage = ({
onClickCancelRequest,
classifiedDomains,
conversationName,
+ conversationDescription,
isCellsConversation,
}: MemberMessageProps) => {
const {translate} = useApplicationContext();
@@ -73,6 +82,9 @@ export const MemberMessage = ({
const cellsConversationLabel = translate('conversationCellsConversationEnabled');
const receiptsEnabledLabel = translate('conversationCreateReceiptsEnabled');
const timedMessagesDisabledLabel = translate('conversationDetailsActionTimedMessagesDisabled');
+ const hasConversationDescription = conversationDescription !== undefined && conversationDescription.length > 0;
+ const shouldShowDescription = isGroupCreation && hasConversationDescription;
+ const renderedConversationDescription = renderMessage(conversationDescription ?? '');
const isConnectedMessage = [SystemMessageType.CONNECTION_ACCEPTED, SystemMessageType.CONNECTION_REQUEST].includes(
message.memberMessageType,
@@ -103,6 +115,31 @@ export const MemberMessage = ({
)}
+ {shouldShowDescription && (
+
diff --git a/apps/webapp/src/script/components/MessagesList/Message/MessageWrapper.tsx b/apps/webapp/src/script/components/MessagesList/Message/MessageWrapper.tsx
index 1c143932252..a87e9bd2229 100644
--- a/apps/webapp/src/script/components/MessagesList/Message/MessageWrapper.tsx
+++ b/apps/webapp/src/script/components/MessagesList/Message/MessageWrapper.tsx
@@ -123,10 +123,11 @@ export const MessageWrapper = ({
await messageRepository.retryUploadFile(conversation, file, firstAsset.isImage(), message.id);
}
};
- const {display_name: displayName, hasGlobalMessageTimer} = useKoSubscribableChildren(conversation, [
- 'display_name',
- 'hasGlobalMessageTimer',
- ]);
+ const {
+ display_name: displayName,
+ description: conversationDescription,
+ hasGlobalMessageTimer,
+ } = useKoSubscribableChildren(conversation, ['display_name', 'description', 'hasGlobalMessageTimer']);
const isFileShareRestricted = !teamState.isFileSharingReceivingEnabled();
const isCellsConversation =
@@ -262,6 +263,7 @@ export const MessageWrapper = ({
({
EditIcon: () => {
return ;
@@ -40,6 +44,7 @@ jest.mock('Components/icon', () => ({
__esModule: true,
}));
+
describe('SystemMessage', () => {
it('shows edit icon for RenameMessage', async () => {
const message = new RenameMessage('new name', undefined, undefined, translateForTest);
@@ -50,6 +55,26 @@ describe('SystemMessage', () => {
expect(screen.queryByTestId('editicon')).not.toBeNull();
});
+ it('shows added description update without description body', async () => {
+ const message = new DescriptionUpdateMessage('Updated description', 'add', translate);
+
+ render( );
+
+ expect(screen.queryByTestId('element-message-system')).not.toBeNull();
+ expect(screen.queryByTestId('editicon')).not.toBeNull();
+ expect(screen.queryByText('Updated description')).not.toBeInTheDocument();
+ expect(screen.getByText(/added a group description/)).toBeInTheDocument();
+ });
+
+ it('shows edited description update without description body', async () => {
+ const message = new DescriptionUpdateMessage('Updated description', 'edit', translate);
+
+ render( );
+
+ expect(screen.queryByText('Updated description')).not.toBeInTheDocument();
+ expect(screen.getByText(/edited the group description/)).toBeInTheDocument();
+ });
+
it('shows timer icon for MessageTimerUpdateMessage', async () => {
const message = new MessageTimerUpdateMessage(0, translateForTest);
diff --git a/apps/webapp/src/script/components/MessagesList/Message/SystemMessage/SystemMessage.tsx b/apps/webapp/src/script/components/MessagesList/Message/SystemMessage/SystemMessage.tsx
index 4111f3b7513..a8fc7a8d288 100644
--- a/apps/webapp/src/script/components/MessagesList/Message/SystemMessage/SystemMessage.tsx
+++ b/apps/webapp/src/script/components/MessagesList/Message/SystemMessage/SystemMessage.tsx
@@ -20,6 +20,7 @@
import {MLSVerified} from '@wireapp/react-ui-kit';
import * as Icon from 'Components/icon';
+import {DescriptionUpdateMessage} from 'Repositories/entity/message/DescriptionUpdateMessage';
import {E2EIVerificationMessage} from 'Repositories/entity/message/E2EIVerificationMessage';
import {JoinedAfterMLSMigrationFinalisationMessage} from 'Repositories/entity/message/JoinedAfterMLSMigrationFinalisationMessage';
import {MessageTimerUpdateMessage} from 'Repositories/entity/message/MessageTimerUpdateMessage';
@@ -52,6 +53,10 @@ export const SystemMessage = ({message}: SystemMessageProps) => {
);
}
+ if (message instanceof DescriptionUpdateMessage) {
+ return } />;
+ }
+
if (message instanceof MessageTimerUpdateMessage) {
return } />;
}
diff --git a/apps/webapp/src/script/components/Modals/CreateConversation/CreateConversationSteps/ConversationDetails/ConversationDetails.styles.ts b/apps/webapp/src/script/components/Modals/CreateConversation/CreateConversationSteps/ConversationDetails/ConversationDetails.styles.ts
index 8890928fbd0..c1e45c5e23b 100644
--- a/apps/webapp/src/script/components/Modals/CreateConversation/CreateConversationSteps/ConversationDetails/ConversationDetails.styles.ts
+++ b/apps/webapp/src/script/components/Modals/CreateConversation/CreateConversationSteps/ConversationDetails/ConversationDetails.styles.ts
@@ -89,3 +89,47 @@ export const groupsNotAllowedSectionCss: CSSObject = {
marginTop: '35%',
textAlign: 'center',
};
+
+export const conversationDescriptionInputWrapperCss: CSSObject = {
+ '&:focus-within label': {
+ color: 'var(--accent-color-500)',
+ },
+ marginTop: '4px',
+ paddingBottom: '1rem',
+ width: '100%',
+};
+
+export const conversationDescriptionLabelCss: CSSObject = {
+ color: 'var(--text-input-color)',
+ display: 'block',
+ fontWeight: 'var(--font-weight-semibold)',
+ marginBottom: 2,
+};
+
+export const conversationDescriptionInputCss: CSSObject = {
+ '&::placeholder': {
+ color: 'var(--text-input-placeholder)',
+ opacity: 1,
+ },
+ '&:hover': {
+ borderColor: 'var(--text-input-border-hover)',
+ },
+ '&:focus, &:focus-visible, &:active': {
+ borderColor: 'var(--accent-color-500)',
+ },
+ background: 'var(--text-input-background)',
+ boxSizing: 'border-box',
+ border: '1px solid var(--text-input-border)',
+ borderRadius: 12,
+ color: 'var(--text-input-color)',
+ fontSize: 'var(--font-size-base)',
+ fontWeight: 'var(--font-weight-regular)',
+ lineHeight: 'var(--line-height-lg)',
+ marginLeft: 0,
+ marginRight: 0,
+ minHeight: 96,
+ outline: 'none',
+ padding: '12px 16px',
+ resize: 'vertical',
+ width: '100%',
+};
diff --git a/apps/webapp/src/script/components/Modals/CreateConversation/CreateConversationSteps/ConversationDetails/ConversationDetails.tsx b/apps/webapp/src/script/components/Modals/CreateConversation/CreateConversationSteps/ConversationDetails/ConversationDetails.tsx
index 54add7b3d4a..9c9fe92d113 100644
--- a/apps/webapp/src/script/components/Modals/CreateConversation/CreateConversationSteps/ConversationDetails/ConversationDetails.tsx
+++ b/apps/webapp/src/script/components/Modals/CreateConversation/CreateConversationSteps/ConversationDetails/ConversationDetails.tsx
@@ -23,7 +23,12 @@ import {UserState} from 'Repositories/user/userState';
import {useApplicationContext} from 'src/script/page/RootProvider';
import {ChannelSettings} from './ChannelSettings';
-import {groupsNotAllowedSectionCss} from './ConversationDetails.styles';
+import {
+ conversationDescriptionInputCss,
+ conversationDescriptionInputWrapperCss,
+ conversationDescriptionLabelCss,
+ groupsNotAllowedSectionCss,
+} from './ConversationDetails.styles';
import {ConversationNameInput} from './ConversationNameInput';
import {useCreateConversationModal} from '../../hooks/useCreateConversationModal';
@@ -32,7 +37,7 @@ import {Preference} from '../Preference';
export const ConversationDetails = () => {
const {translate} = useApplicationContext();
- const {conversationType} = useCreateConversationModal();
+ const {conversationType, conversationDescription, setConversationDescription} = useCreateConversationModal();
const userState = container.resolve(UserState);
const selfUser = userState.self();
@@ -45,6 +50,23 @@ export const ConversationDetails = () => {
) : (
<>
+ {conversationType === ConversationType.Group && (
+
+
+ {translate('conversationDescriptionOptionalLabel')}
+
+
+ )}
{conversationType === ConversationType.Group ? : }
>
);
diff --git a/apps/webapp/src/script/components/Modals/CreateConversation/hooks/useCreateConversation.ts b/apps/webapp/src/script/components/Modals/CreateConversation/hooks/useCreateConversation.ts
index 7b2f88bf191..10f9b79f27d 100644
--- a/apps/webapp/src/script/components/Modals/CreateConversation/hooks/useCreateConversation.ts
+++ b/apps/webapp/src/script/components/Modals/CreateConversation/hooks/useCreateConversation.ts
@@ -55,6 +55,7 @@ export const useCreateConversation = (nonFederatingParticipantsModalCopy: NonFed
const {mainViewModel, translate} = useApplicationContext();
const {
conversationName,
+ conversationDescription,
hideModal,
showModal,
selectedContacts,
@@ -169,6 +170,11 @@ export const useCreateConversation = (nonFederatingParticipantsModalCopy: NonFed
},
);
+ const trimmedDescription = conversationDescription.trim();
+ if (conversationType === ConversationType.Group && trimmedDescription.length > 0) {
+ await conversationRepository.updateConversationDescription(conversation, trimmedDescription);
+ }
+
setCurrentSidebarTab(SidebarTabs.RECENT);
if (isKeyboardEvent(event)) {
diff --git a/apps/webapp/src/script/components/Modals/CreateConversation/hooks/useCreateConversationModal.ts b/apps/webapp/src/script/components/Modals/CreateConversation/hooks/useCreateConversationModal.ts
index 0de0a224545..8bf729d894d 100644
--- a/apps/webapp/src/script/components/Modals/CreateConversation/hooks/useCreateConversationModal.ts
+++ b/apps/webapp/src/script/components/Modals/CreateConversation/hooks/useCreateConversationModal.ts
@@ -36,6 +36,7 @@ import {
type CreateConversationModalState = {
isOpen: boolean;
conversationName: string;
+ conversationDescription: string;
access: ConversationAccess;
moderator: ADD_PERMISSION;
chatHistory: ChatHistory;
@@ -58,6 +59,7 @@ type CreateConversationModalState = {
showModal: () => void;
hideModal: () => void;
setConversationName: (name: string) => void;
+ setConversationDescription: (description: string) => void;
setAccess: (access: ConversationAccess) => void;
setModerator: (access: ADD_PERMISSION) => void;
setChatHistory: (history: ChatHistory) => void;
@@ -88,6 +90,7 @@ type CreateConversationModalState = {
const initialState = {
isOpen: false,
conversationName: '',
+ conversationDescription: '',
access: ConversationAccess.Private,
moderator: ADD_PERMISSION.EVERYONE,
chatHistory: ChatHistory.Off,
@@ -115,6 +118,7 @@ export const useCreateConversationModal = create(s
showModal: () => set(state => ({...state, isOpen: true})),
hideModal: () => set({...initialState}),
setConversationName: (name: string) => set({conversationName: name}),
+ setConversationDescription: (description: string) => set({conversationDescription: description}),
setAccess: (access: ConversationAccess) =>
set(state => ({
access,
diff --git a/apps/webapp/src/script/components/Modals/GroupCreation/GroupCreationModal.tsx b/apps/webapp/src/script/components/Modals/GroupCreation/GroupCreationModal.tsx
index 1e7397fafaa..ae7bbfb3b1e 100644
--- a/apps/webapp/src/script/components/Modals/GroupCreation/GroupCreationModal.tsx
+++ b/apps/webapp/src/script/components/Modals/GroupCreation/GroupCreationModal.tsx
@@ -139,6 +139,7 @@ const GroupCreationModal = ({
);
const [nameError, setNameError] = useState('');
const [groupName, setGroupName] = useState('');
+ const [groupDescription, setGroupDescription] = useState('');
const [participantsInput, setParticipantsInput] = useState('');
const [groupCreationState, setGroupCreationState] = useState(
GroupCreationModalState.DEFAULT,
@@ -235,6 +236,7 @@ const GroupCreationModal = ({
setIsCreatingConversation(false);
setNameError('');
setGroupName('');
+ setGroupDescription('');
setParticipantsInput('');
setSelectedContacts([]);
setGroupCreationState(GroupCreationModalState.DEFAULT);
@@ -259,6 +261,11 @@ const GroupCreationModal = ({
},
);
+ const trimmedDescription = groupDescription.trim();
+ if (trimmedDescription.length > 0) {
+ await conversationRepository.updateConversationDescription(conversation, trimmedDescription);
+ }
+
setCurrentSidebarTab(SidebarTabs.RECENT);
if (isKeyboardEvent(event)) {
@@ -269,6 +276,7 @@ const GroupCreationModal = ({
} catch (error: unknown) {
if (isNonFederatingBackendsError(error)) {
const tempName = groupName;
+ const tempDescription = groupDescription;
setIsShown(false);
const backendString = error.backends.join(', and ');
@@ -285,6 +293,7 @@ const GroupCreationModal = ({
text: translate('groupCreationPreferencesNonFederatingEditList'),
action: () => {
setGroupName(tempName);
+ setGroupDescription(tempDescription);
setIsShown(true);
setIsCreatingConversation(false);
setGroupCreationState(GroupCreationModalState.PARTICIPANTS);
@@ -511,6 +520,22 @@ const GroupCreationModal = ({
/>
+
+
+ {translate('conversationDescriptionOptionalLabel')}
+
+
+
{isTeam && (
<>
{
+ const onDescriptionChange = jest.fn();
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('filled state', () => {
+ it('renders the description heading and text', () => {
+ const description = 'This is the channel description';
+
+ render( );
+
+ expect(screen.getByText('conversationDetailsDescription')).not.toBeNull();
+ expect(screen.getByText(description)).not.toBeNull();
+ });
+ });
+
+ describe('empty state', () => {
+ it('renders the placeholder when description is empty', () => {
+ render( );
+
+ expect(screen.getByTestId('conversation-details-description')).not.toBeNull();
+ expect(screen.getByText('conversationDetailsDescriptionPlaceholder')).not.toBeNull();
+ });
+
+ it('renders the placeholder when description is undefined', () => {
+ render( );
+
+ expect(screen.getByText('conversationDetailsDescriptionPlaceholder')).not.toBeNull();
+ });
+ });
+
+ describe('hover state', () => {
+ it('shows the edit icon on hover when description exists and editing is allowed', async () => {
+ const description = 'Some description';
+ render( );
+
+ const section = screen.getByTestId('conversation-details-description');
+
+ expect(screen.queryByTestId('description-edit-icon')).toBeNull();
+
+ fireEvent.mouseEnter(section);
+
+ expect(screen.getByTestId('description-edit-icon')).not.toBeNull();
+
+ fireEvent.mouseLeave(section);
+
+ expect(screen.queryByTestId('description-edit-icon')).toBeNull();
+ });
+
+ it('does not show the edit icon when editing is not allowed', () => {
+ const description = 'Some description';
+ render(
+ ,
+ );
+
+ fireEvent.mouseEnter(screen.getByTestId('conversation-details-description'));
+
+ expect(screen.queryByTestId('description-edit-icon')).toBeNull();
+ });
+
+ it('enters edit mode when clicking the edit icon', async () => {
+ const description = 'Some description';
+ render( );
+
+ const section = screen.getByTestId('conversation-details-description');
+ fireEvent.mouseEnter(section);
+
+ const editIcon = screen.getByTestId('description-edit-icon');
+ await userEvent.click(editIcon);
+
+ expect(screen.getByTestId('description-textarea')).not.toBeNull();
+ });
+ });
+
+ describe('rendered description', () => {
+ it('renders line breaks and links like chat messages', () => {
+ const description = 'First line\nhttps://wire.com';
+
+ render( );
+
+ const link = screen.getByRole('link', {name: 'https://wire.com'});
+ expect(link).toHaveAttribute('href', 'https://wire.com');
+ expect(link).toHaveAttribute('target', '_blank');
+ expect(screen.getByTestId('description-text').innerHTML).toContain('First line ');
+ });
+
+ it('renders markdown-like headings, lists, quotes, and links like chat messages', () => {
+ render(
+ Important\n[Wire](https://wire.com)'}
+ onDescriptionChange={onDescriptionChange}
+ />,
+ );
+
+ const descriptionText = screen.getByTestId('description-text');
+ const link = screen.getByRole('link', {name: 'Wire'});
+
+ expect(descriptionText.querySelector('.md-heading--1')).not.toBeNull();
+ expect(descriptionText.querySelector('li')).toHaveTextContent('First item');
+ expect(descriptionText.querySelector('.md-blockquote')).toHaveTextContent('Important');
+ expect(link).toHaveAttribute('href', 'https://wire.com');
+ });
+
+ it('escapes html when rendering the description', () => {
+ render(
+ \nhttps://wire.com'}
+ onDescriptionChange={onDescriptionChange}
+ />,
+ );
+
+ expect(screen.getByTestId('description-text').querySelector('img')).toBeNull();
+ expect(screen.getByText(' ')).not.toBeNull();
+ });
+ });
+
+ describe('editing state', () => {
+ it('enters edit mode when clicking the description text and editing is allowed', async () => {
+ const description = 'Existing description';
+ render( );
+
+ const descriptionText = screen.getByText(description);
+ await userEvent.click(descriptionText);
+
+ const textarea = screen.getByTestId('description-textarea');
+ expect(textarea).not.toBeNull();
+ expect(textarea).toHaveValue(description);
+ });
+
+ it('does not enter edit mode when clicking description text and editing is not allowed', async () => {
+ const description = 'Existing description';
+ render(
+ ,
+ );
+
+ await userEvent.click(screen.getByText(description));
+
+ expect(screen.queryByTestId('description-textarea')).toBeNull();
+ });
+
+ it('enters edit mode when clicking the placeholder', async () => {
+ render( );
+
+ const placeholder = screen.getByText('conversationDetailsDescriptionPlaceholder');
+ await userEvent.click(placeholder);
+
+ expect(screen.getByTestId('description-textarea')).not.toBeNull();
+ });
+
+ it('saves on blur and calls onDescriptionChange', async () => {
+ const description = 'Old description';
+ render( );
+
+ await userEvent.click(screen.getByText(description));
+
+ const textarea = screen.getByTestId('description-textarea');
+ await userEvent.clear(textarea);
+ await userEvent.type(textarea, 'New description');
+
+ fireEvent.blur(textarea);
+
+ expect(onDescriptionChange).toHaveBeenCalledWith('New description');
+ });
+
+ it('allows multiple lines and saves them on blur', async () => {
+ const description = 'Old description';
+ render( );
+
+ await userEvent.click(screen.getByText(description));
+
+ const textarea = screen.getByTestId('description-textarea');
+ await userEvent.clear(textarea);
+ fireEvent.change(textarea, {target: {value: 'First line\nSecond line'}});
+
+ expect(onDescriptionChange).not.toHaveBeenCalled();
+
+ fireEvent.blur(textarea);
+
+ expect(onDescriptionChange).toHaveBeenCalledWith('First line\nSecond line');
+ });
+
+ it('cancels editing on Escape key without saving', async () => {
+ const description = 'Original text';
+ render( );
+
+ await userEvent.click(screen.getByText(description));
+
+ const textarea = screen.getByTestId('description-textarea');
+ await userEvent.clear(textarea);
+ await userEvent.type(textarea, 'Changed text');
+ await userEvent.keyboard('{Escape}');
+
+ expect(onDescriptionChange).not.toHaveBeenCalled();
+ expect(screen.queryByTestId('description-textarea')).toBeNull();
+ expect(screen.getByText(description)).not.toBeNull();
+ });
+
+ it('does not call onDescriptionChange when value is unchanged', async () => {
+ const description = 'Same text';
+ render( );
+
+ await userEvent.click(screen.getByText(description));
+
+ const textarea = screen.getByTestId('description-textarea');
+ fireEvent.blur(textarea);
+
+ expect(onDescriptionChange).not.toHaveBeenCalled();
+ });
+
+ it('enforces the max character limit', async () => {
+ render( );
+
+ await userEvent.click(screen.getByText('conversationDetailsDescriptionPlaceholder'));
+
+ const textarea = screen.getByTestId('description-textarea');
+ expect(textarea).toHaveAttribute('maxLength', String(MAX_DESCRIPTION_LENGTH));
+ });
+ });
+});
diff --git a/apps/webapp/src/script/page/RightSidebar/conversationDetails/components/conversationDetailsDescription/conversationDetailsDescription.tsx b/apps/webapp/src/script/page/RightSidebar/conversationDetails/components/conversationDetailsDescription/conversationDetailsDescription.tsx
new file mode 100644
index 00000000000..7a4651e8f61
--- /dev/null
+++ b/apps/webapp/src/script/page/RightSidebar/conversationDetails/components/conversationDetailsDescription/conversationDetailsDescription.tsx
@@ -0,0 +1,163 @@
+/*
+ * Wire
+ * Copyright (C) 2026 Wire Swiss GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see http://www.gnu.org/licenses/.
+ *
+ */
+
+import {FC, KeyboardEvent, MouseEvent, useEffect, useRef, useState} from 'react';
+
+import * as Icon from 'Components/icon';
+import {isEnterKey, isEscapeKey} from 'Util/keyboardUtil';
+import {translate} from 'Util/localizerUtil';
+import {renderMessage} from 'Util/messageRenderer';
+
+const MAX_DESCRIPTION_LENGTH = 200;
+
+interface ConversationDetailsDescriptionProps {
+ canEdit?: boolean;
+ description?: string;
+ onDescriptionChange: (description: string) => void;
+}
+
+const ConversationDetailsDescription: FC = ({
+ canEdit = true,
+ description = '',
+ onDescriptionChange,
+}) => {
+ const [isEditing, setIsEditing] = useState(false);
+ const [isHovered, setIsHovered] = useState(false);
+ const [draftValue, setDraftValue] = useState(description);
+ const textareaRef = useRef(null);
+
+ useEffect(() => {
+ setDraftValue(description);
+ }, [description]);
+
+ useEffect(() => {
+ if (isEditing && textareaRef.current) {
+ textareaRef.current.focus();
+ }
+ }, [isEditing]);
+
+ const startEditing = () => {
+ if (!canEdit) {
+ return;
+ }
+
+ setDraftValue(description);
+ setIsEditing(true);
+ };
+
+ const saveDescription = () => {
+ setIsEditing(false);
+ const trimmed = draftValue.trim();
+
+ if (trimmed !== description) {
+ onDescriptionChange(trimmed);
+ }
+ };
+
+ const cancelEditing = () => {
+ setDraftValue(description);
+ setIsEditing(false);
+ };
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (isEscapeKey(event)) {
+ event.preventDefault();
+ cancelEditing();
+ }
+ };
+
+ const handleContentClick = (event: MouseEvent) => {
+ const target = event.target as HTMLElement;
+
+ if (target.closest('a')) {
+ return;
+ }
+
+ startEditing();
+ };
+
+ const handleContentKeyDown = (event: KeyboardEvent) => {
+ if (isEnterKey(event)) {
+ event.preventDefault();
+ startEditing();
+ }
+ };
+
+ const hasDescription = description.length > 0;
+ const renderedDescription = renderMessage(description);
+
+ return (
+ setIsHovered(true)}
+ onMouseLeave={() => setIsHovered(false)}
+ >
+
+
{translate('conversationDetailsDescription')}
+ {canEdit && isHovered && !isEditing && hasDescription && (
+
+
+
+ )}
+
+
+ {isEditing ? (
+
+ );
+};
+
+export {ConversationDetailsDescription};
diff --git a/apps/webapp/src/script/page/RightSidebar/conversationDetails/components/conversationDetailsDescription/index.ts b/apps/webapp/src/script/page/RightSidebar/conversationDetails/components/conversationDetailsDescription/index.ts
new file mode 100644
index 00000000000..bbf3b2305be
--- /dev/null
+++ b/apps/webapp/src/script/page/RightSidebar/conversationDetails/components/conversationDetailsDescription/index.ts
@@ -0,0 +1,20 @@
+/*
+ * Wire
+ * Copyright (C) 2026 Wire Swiss GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see http://www.gnu.org/licenses/.
+ *
+ */
+
+export {ConversationDetailsDescription} from './conversationDetailsDescription';
diff --git a/apps/webapp/src/script/page/RightSidebar/conversationDetails/conversationDetails.test.tsx b/apps/webapp/src/script/page/RightSidebar/conversationDetails/conversationDetails.test.tsx
index e0fc3134877..a1daa6e2087 100644
--- a/apps/webapp/src/script/page/RightSidebar/conversationDetails/conversationDetails.test.tsx
+++ b/apps/webapp/src/script/page/RightSidebar/conversationDetails/conversationDetails.test.tsx
@@ -103,6 +103,8 @@ const getDefaultParams = () => {
getNextConversation: () =>
Promise.resolve(new Conversation('', '', CONVERSATION_PROTOCOL.PROTEUS, translateForTest)),
refreshUnavailableParticipants: () => Promise.resolve(),
+ loadConversationDescription: () => {},
+ updateConversationDescription: () => Promise.resolve(),
conversationRoleRepository: conversationRoleRepository as ConversationRoleRepository,
} as unknown as ConversationRepository,
integrationRepository: {getServiceFromUser: (): null => null} as unknown as IntegrationRepository,
@@ -120,6 +122,34 @@ const getDefaultParams = () => {
};
describe('ConversationDetails', () => {
+ it('renders the channel description section for group conversations', () => {
+ const conversation = new Conversation();
+ conversation.type(CONVERSATION_TYPE.REGULAR);
+ const otherUser = new User('other-user');
+ jest.spyOn(otherUser as any, 'isConnected').mockReturnValue(true);
+ conversation.participating_user_ets([otherUser]);
+
+ const defaultProps = getDefaultParams();
+
+ const {getByTestId} = render( );
+
+ expect(getByTestId('conversation-details-description')).toBeDefined();
+ });
+
+ it('does not render the channel description section for 1:1 conversations', () => {
+ const conversation = new Conversation();
+ conversation.type(CONVERSATION_TYPE.ONE_TO_ONE);
+ const otherUser = new User('other-user');
+ jest.spyOn(otherUser as any, 'isConnected').mockReturnValue(true);
+ conversation.participating_user_ets([otherUser]);
+
+ const defaultProps = getDefaultParams();
+
+ const {queryByTestId} = render( );
+
+ expect(queryByTestId('conversation-details-description')).toBeNull();
+ });
+
it("returns the right actions depending on the conversation's type for non group creators", () => {
const conversation = new Conversation('', '', CONVERSATION_PROTOCOL.PROTEUS, translateForTest);
const otherUser = new User('other-user', '', translateForTest);
diff --git a/apps/webapp/src/script/page/RightSidebar/conversationDetails/conversationDetails.tsx b/apps/webapp/src/script/page/RightSidebar/conversationDetails/conversationDetails.tsx
index ab9285eafae..02c0c090b2d 100644
--- a/apps/webapp/src/script/page/RightSidebar/conversationDetails/conversationDetails.tsx
+++ b/apps/webapp/src/script/page/RightSidebar/conversationDetails/conversationDetails.tsx
@@ -46,6 +46,7 @@ import {useKoSubscribableChildren} from 'Util/componentUtil';
import {sortUsersByPriority} from 'Util/stringUtil';
import {formatDuration} from 'Util/timeUtil';
+import {ConversationDetailsDescription} from './components/conversationDetailsDescription';
import {ConversationDetailsHeader} from './components/conversationDetailsHeader';
import {ConversationDetailsOptions} from './components/conversationDetailsOptions';
import {ConversationDetailsParticipants} from './components/conversationDetailsParticipants';
@@ -113,6 +114,7 @@ const ConversationDetails = forwardRef
firstUserEntity: firstParticipant,
isGroupOrChannel,
cellsState,
+ description,
} = useKoSubscribableChildren(activeConversation, [
'isMutable',
'showNotificationsNothing',
@@ -131,9 +133,11 @@ const ConversationDetails = forwardRef
'firstUserEntity',
'isGroupOrChannel',
'cellsState',
+ 'description',
]);
const {isTemporaryGuest} = useKoSubscribableChildren(firstParticipant!, ['isTemporaryGuest']);
+ const canEditDescription = roleRepository.isUserGroupAdmin(activeConversation, selfUser);
const {isTeam, classifiedDomains, team, isSelfDeletingMessagesEnforced, getEnforcedSelfDeletingMessagesTimeout} =
useKoSubscribableChildren(teamState, [
@@ -244,6 +248,9 @@ const ConversationDetails = forwardRef
const showAllParticipants = () => togglePanel(PanelState.CONVERSATION_PARTICIPANTS, activeConversation);
+ const updateConversationDescription = (newDescription: string) =>
+ conversationRepository.updateConversationDescription(activeConversation, newDescription);
+
const updateConversationReceiptMode = (receiptMode: RECEIPT_MODE) =>
conversationRepository.updateConversationReceiptMode(activeConversation, {receipt_mode: receiptMode});
@@ -327,6 +334,12 @@ const ConversationDetails = forwardRef
conversation={activeConversation}
/>
+
+
{showActionAddParticipants && (
();
+
+ /**
+ * Load encrypted conversation description from backend and decrypt it with the MLS group secret.
+ */
+ public async loadConversationDescription(conversationEntity: Conversation): Promise {
+ if (!conversationEntity.groupId) {
+ this.logger.warn('Cannot load conversation description without MLS group ID', {
+ conversationId: conversationEntity.id,
+ });
+ return;
+ }
+
+ try {
+ const {version, description} = await this.conversationService.getConversationDescription(
+ conversationEntity.qualifiedId,
+ conversationEntity.groupId,
+ );
+ this.descriptionVersions.set(conversationEntity.id, version);
+ conversationEntity.description(description);
+ } catch (error) {
+ this.logger.warn('Failed to load conversation description', {conversationId: conversationEntity.id, error});
+ }
+ }
+
+ /**
+ * Encrypt and update conversation description through the backend API.
+ *
+ * @param conversationEntity Conversation to update
+ * @param description New description plaintext
+ */
+ public async updateConversationDescription(conversationEntity: Conversation, description: string): Promise {
+ if (!conversationEntity.groupId) {
+ throw new Error('Cannot update conversation description without MLS group ID');
+ }
+
+ const baseVersion = this.descriptionVersions.get(conversationEntity.id) ?? 0;
+ const previousDescription = conversationEntity.description();
+ const action = previousDescription.length > 0 ? 'edit' : 'add';
+ const {version} = await this.conversationService.updateConversationDescription(
+ conversationEntity.qualifiedId,
+ conversationEntity.groupId,
+ description,
+ baseVersion,
+ );
+ this.descriptionVersions.set(conversationEntity.id, version);
+ conversationEntity.description(description);
+
+ await this.eventRepository.injectEvent(
+ EventBuilder.buildDescriptionUpdate(conversationEntity, description, action),
+ EventRepository.SOURCE.INJECTED,
+ );
+ }
+
+ private async onDescriptionUpdate(conversationEntity: Conversation, eventJson: IncomingEvent) {
+ const eventData = eventJson.data as {ciphertext?: string; description?: string; version?: number};
+
+ if (eventData.description !== undefined) {
+ return this.addEventToConversation(conversationEntity, eventJson);
+ }
+
+ if (!conversationEntity.groupId || !eventData.ciphertext) {
+ this.logger.warn('Cannot decrypt conversation description update event', {
+ conversationId: conversationEntity.id,
+ hasCiphertext: Boolean(eventData.ciphertext),
+ hasGroupId: Boolean(conversationEntity.groupId),
+ });
+ return {conversationEntity};
+ }
+
+ const previousDescription = conversationEntity.description();
+ const action = previousDescription.length > 0 ? 'edit' : 'add';
+ const description = await this.conversationService.decryptConversationDescriptionCiphertext(
+ eventData.ciphertext,
+ conversationEntity.groupId,
+ );
+
+ if (eventData.version !== undefined) {
+ this.descriptionVersions.set(conversationEntity.id, eventData.version);
+ }
+ conversationEntity.description(description);
+
+ const descriptionUpdateEvent: DescriptionUpdateEvent = {
+ conversation: eventJson.conversation,
+ data: {action, description},
+ from: eventJson.from,
+ id: ('id' in eventJson && eventJson.id) || createUuid(),
+ qualified_conversation: eventJson.qualified_conversation,
+ qualified_from: eventJson.qualified_from,
+ server_time: eventJson.server_time,
+ time: eventJson.time,
+ type: ClientEvent.CONVERSATION.DESCRIPTION_UPDATE,
+ };
+
+ return this.addEventToConversation(conversationEntity, descriptionUpdateEvent);
+ }
+
private readonly inject1to1MigratedToMLS = async (conversation: Conversation) => {
const currentTimestamp = this.serverTimeHandler.toServerTimestamp();
const protocolUpdateEvent = EventBuilder.build1to1MigratedToMLS(conversation, currentTimestamp);
@@ -3755,6 +3854,9 @@ export class ConversationRepository {
case CONVERSATION_EVENT.MESSAGE_TIMER_UPDATE:
case ClientEvent.CONVERSATION.DELETE_EVERYWHERE:
+ case ClientEvent.CONVERSATION.DESCRIPTION_UPDATE:
+ return this.onDescriptionUpdate(conversationEntity, eventJson);
+
case ClientEvent.CONVERSATION.FILE_TYPE_RESTRICTED:
case ClientEvent.CONVERSATION.INCOMING_MESSAGE_TOO_BIG:
case ClientEvent.CONVERSATION.KNOCK:
diff --git a/apps/webapp/src/script/repositories/conversation/ConversationService.test.ts b/apps/webapp/src/script/repositories/conversation/ConversationService.test.ts
index 1204c0d7d67..bed49511b11 100644
--- a/apps/webapp/src/script/repositories/conversation/ConversationService.test.ts
+++ b/apps/webapp/src/script/repositories/conversation/ConversationService.test.ts
@@ -29,6 +29,12 @@ import {MessageCategory} from '../../message/MessageCategory';
import type {APIClient} from '../../service/apiClientSingleton';
import type {Core} from '../../service/coreSingleton';
import {ConversationService} from './ConversationService';
+import {decryptDescription, encryptDescription} from './descriptionCrypto';
+
+jest.mock('./descriptionCrypto', () => ({
+ decryptDescription: jest.fn(),
+ encryptDescription: jest.fn(),
+}));
type EventServiceLike = Pick;
@@ -101,6 +107,54 @@ describe('ConversationService', () => {
});
});
+ describe('conversation description', () => {
+ const conversationId: QualifiedId = {id: 'conv-id', domain: 'wire.com'};
+ const groupId = 'group-id';
+ const secret = btoa('12345678901234567890123456789012');
+
+ const makeService = (conversationApi: Record) => {
+ const apiClient = {
+ api: {conversation: conversationApi},
+ } as unknown as APIClient;
+ const core = {
+ service: {mls: {exportSecretKey: jest.fn().mockResolvedValue(secret)}},
+ } as unknown as Core;
+
+ return {service: new ConversationService({} as EventService, {} as StorageService, apiClient, core), core};
+ };
+
+ it('fetches and decrypts the encrypted conversation description', async () => {
+ const getConversationDescription = jest.fn().mockResolvedValue({version: 7, ciphertext: 'ciphertext'});
+ const {service, core} = makeService({getConversationDescription});
+ jest.mocked(decryptDescription).mockResolvedValueOnce('decrypted description');
+
+ const result = await service.getConversationDescription(conversationId, groupId);
+
+ expect(result).toEqual({version: 7, description: 'decrypted description'});
+ expect(getConversationDescription).toHaveBeenCalledWith(conversationId);
+ expect(core.service?.mls?.exportSecretKey).toHaveBeenCalledWith(groupId, 32);
+ expect(decryptDescription).toHaveBeenCalledWith('ciphertext', expect.any(Uint8Array));
+ });
+
+ it('fetches current version before updating the encrypted conversation description', async () => {
+ const getConversationDescription = jest.fn().mockResolvedValue({version: 7, ciphertext: 'current-ciphertext'});
+ const putConversationDescription = jest.fn().mockResolvedValue(undefined);
+ const {service, core} = makeService({getConversationDescription, putConversationDescription});
+ jest.mocked(encryptDescription).mockResolvedValueOnce('new-ciphertext');
+
+ const result = await service.updateConversationDescription(conversationId, groupId, 'new description', 3);
+
+ expect(result).toEqual({version: 8});
+ expect(getConversationDescription).toHaveBeenCalledWith(conversationId);
+ expect(core.service?.mls?.exportSecretKey).toHaveBeenCalledWith(groupId, 32);
+ expect(encryptDescription).toHaveBeenCalledWith('new description', expect.any(Uint8Array));
+ expect(putConversationDescription).toHaveBeenCalledWith(conversationId, {
+ base_version: 7,
+ ciphertext: 'new-ciphertext',
+ });
+ });
+ });
+
describe('getSafeConversationById', () => {
const conversationId: QualifiedId = {id: 'conv-id', domain: 'wire.com'};
diff --git a/apps/webapp/src/script/repositories/conversation/ConversationService.ts b/apps/webapp/src/script/repositories/conversation/ConversationService.ts
index 9a446dd3515..3c0d1527d68 100644
--- a/apps/webapp/src/script/repositories/conversation/ConversationService.ts
+++ b/apps/webapp/src/script/repositories/conversation/ConversationService.ts
@@ -58,6 +58,7 @@ import {StorageSchemata} from 'Repositories/storage/storageSchemata';
import {getLogger} from 'Util/logger';
import {MLSCapableConversation} from './ConversationSelectors';
+import {decryptDescription, encryptDescription} from './descriptionCrypto';
import {MessageCategory} from '../../message/MessageCategory';
import {APIClient} from '../../service/apiClientSingleton';
@@ -191,6 +192,73 @@ export class ConversationService {
});
}
+ private get mlsService() {
+ const mlsService = this.core.service?.mls;
+
+ if (!mlsService) {
+ throw new Error('MLS service not available');
+ }
+
+ return mlsService;
+ }
+
+ private async getDescriptionSecret(groupId: string): Promise {
+ const secret = await this.mlsService.exportSecretKey(groupId, 32);
+ return Uint8Array.from(atob(secret), char => char.charCodeAt(0));
+ }
+
+ /**
+ * Get and decrypt the encrypted conversation description.
+ *
+ * @param conversationId ID of the conversation
+ * @param groupId MLS group ID used to derive the description encryption key
+ * @returns The description plaintext and version
+ */
+ async getConversationDescription(conversationId: QualifiedId, groupId: string): Promise<{version: number; description: string}> {
+ const {version, ciphertext} = await this.apiClient.api.conversation.getConversationDescription(conversationId);
+
+ if (!ciphertext) {
+ return {version, description: ''};
+ }
+
+ const secret = await this.getDescriptionSecret(groupId);
+ const description = await decryptDescription(ciphertext, secret);
+
+ return {version, description};
+ }
+
+ async decryptConversationDescriptionCiphertext(ciphertext: string, groupId: string): Promise {
+ const secret = await this.getDescriptionSecret(groupId);
+ return decryptDescription(ciphertext, secret);
+ }
+
+ /**
+ * Encrypt and update the conversation description.
+ *
+ * @param conversationId ID of the conversation
+ * @param groupId MLS group ID used to derive the description encryption key
+ * @param description new description plaintext
+ * @param baseVersion current version for optimistic concurrency
+ * @returns The new version number
+ */
+ async updateConversationDescription(
+ conversationId: QualifiedId,
+ groupId: string,
+ description: string,
+ baseVersion: number,
+ ): Promise<{version: number}> {
+ const currentDescription = await this.apiClient.api.conversation.getConversationDescription(conversationId);
+ const secret = await this.getDescriptionSecret(groupId);
+ const ciphertext = await encryptDescription(description, secret);
+
+ await this.apiClient.api.conversation.putConversationDescription(conversationId, {
+ base_version: currentDescription.version,
+ ciphertext,
+ });
+
+ return {version: currentDescription.version + 1};
+ }
+
/**
* Update the conversation protocol.
*
diff --git a/apps/webapp/src/script/repositories/conversation/EventBuilder/EventBuilder.ts b/apps/webapp/src/script/repositories/conversation/EventBuilder/EventBuilder.ts
index b677043f17c..25942ae0dea 100644
--- a/apps/webapp/src/script/repositories/conversation/EventBuilder/EventBuilder.ts
+++ b/apps/webapp/src/script/repositories/conversation/EventBuilder/EventBuilder.ts
@@ -104,6 +104,11 @@ export type DeleteEvent = ConversationEvent<
CONVERSATION.MESSAGE_DELETE,
{deleted_time: number; message_id: string; time: string}
>;
+export type DescriptionUpdateAction = 'add' | 'edit';
+export type DescriptionUpdateEvent = ConversationEvent<
+ CONVERSATION.DESCRIPTION_UPDATE,
+ {action: DescriptionUpdateAction; description: string}
+>;
export type FederationStopEvent = ConversationEvent;
export type GroupCreationEventData = {
allTeamMembers: boolean;
@@ -274,6 +279,7 @@ export type ClientConversationEvent =
| ConfirmationEvent
| FederationStopEvent
| DeleteEvent
+ | DescriptionUpdateEvent
| DeleteEverywhereEvent
| DegradedMessageEvent
| ButtonActionConfirmationEvent
@@ -385,6 +391,23 @@ export const EventBuilder = {
};
},
+ buildDescriptionUpdate(
+ conversationEntity: Conversation,
+ description: string,
+ action: DescriptionUpdateAction,
+ ): DescriptionUpdateEvent {
+ const selfUser = getConversationSelfUser(conversationEntity);
+
+ return {
+ ...buildQualifiedId(conversationEntity),
+ data: {action, description},
+ from: selfUser.id,
+ id: createUuid(),
+ time: conversationEntity.getNextIsoDate(),
+ type: ClientEvent.CONVERSATION.DESCRIPTION_UPDATE,
+ };
+ },
+
buildE2EIDegraded(
conversationEntity: Conversation,
type: E2EIVerificationMessageType,
diff --git a/apps/webapp/src/script/repositories/conversation/EventMapper.ts b/apps/webapp/src/script/repositories/conversation/EventMapper.ts
index d035a184fff..dcf8a60455c 100644
--- a/apps/webapp/src/script/repositories/conversation/EventMapper.ts
+++ b/apps/webapp/src/script/repositories/conversation/EventMapper.ts
@@ -34,6 +34,7 @@ import {CompositeMessage} from 'Repositories/entity/message/CompositeMessage';
import {ContentMessage} from 'Repositories/entity/message/ContentMessage';
import {DecryptErrorMessage} from 'Repositories/entity/message/DecryptErrorMessage';
import {DeleteMessage} from 'Repositories/entity/message/DeleteMessage';
+import {DescriptionUpdateMessage} from 'Repositories/entity/message/DescriptionUpdateMessage';
import {E2EIVerificationMessage} from 'Repositories/entity/message/E2EIVerificationMessage';
import {FailedToAddUsersMessage} from 'Repositories/entity/message/FailedToAddUsersMessage';
import {FederationStopMessage} from 'Repositories/entity/message/FederationStopMessage';
@@ -302,6 +303,11 @@ export class EventMapper {
break;
}
+ case ClientEvent.CONVERSATION.DESCRIPTION_UPDATE: {
+ messageEntity = this._mapEventDescriptionUpdate(event);
+ break;
+ }
+
case ClientEvent.CONVERSATION.ASSET_ADD: {
messageEntity = addMetadata(this._mapEventAssetAdd(event), event);
break;
@@ -807,6 +813,13 @@ export class EventMapper {
return new RenameMessage(eventData.name, from, qualified_from?.domain, this.translate);
}
+ /**
+ * Maps JSON data of conversation.description-update message into message entity.
+ */
+ private _mapEventDescriptionUpdate({data: eventData}: LegacyEventRecord) {
+ return new DescriptionUpdateMessage(eventData.description, eventData.action, this.translate);
+ }
+
/**
* Maps JSON data of conversation.receipt-mode-update message into message entity.
*
diff --git a/apps/webapp/src/script/repositories/conversation/descriptionCrypto.test.ts b/apps/webapp/src/script/repositories/conversation/descriptionCrypto.test.ts
new file mode 100644
index 00000000000..9273dd6456f
--- /dev/null
+++ b/apps/webapp/src/script/repositories/conversation/descriptionCrypto.test.ts
@@ -0,0 +1,89 @@
+/*
+ * Wire
+ * Copyright (C) 2026 Wire Swiss GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see http://www.gnu.org/licenses/.
+ *
+ */
+
+import {encryptDescription, decryptDescription} from './descriptionCrypto';
+
+describe('descriptionCrypto', () => {
+ const generateTestKey = async (): Promise => {
+ const key = new Uint8Array(32);
+ crypto.getRandomValues(key);
+ return key;
+ };
+
+ it('encrypts and decrypts a description round-trip', async () => {
+ const secret = await generateTestKey();
+ const plaintext = 'Team channel for discussions';
+
+ const ciphertextBase64 = await encryptDescription(plaintext, secret);
+ const decrypted = await decryptDescription(ciphertextBase64, secret);
+
+ expect(decrypted).toBe(plaintext);
+ });
+
+ it('produces different ciphertext for same plaintext (random IV)', async () => {
+ const secret = await generateTestKey();
+ const plaintext = 'Same text';
+
+ const a = await encryptDescription(plaintext, secret);
+ const b = await encryptDescription(plaintext, secret);
+
+ expect(a).not.toBe(b);
+ });
+
+ it('fails to decrypt with wrong key', async () => {
+ const secret1 = await generateTestKey();
+ const secret2 = await generateTestKey();
+ const plaintext = 'Secret text';
+
+ const ciphertextBase64 = await encryptDescription(plaintext, secret1);
+
+ await expect(decryptDescription(ciphertextBase64, secret2)).rejects.toThrow();
+ });
+
+ it('handles empty string', async () => {
+ const secret = await generateTestKey();
+
+ const ciphertextBase64 = await encryptDescription('', secret);
+ const decrypted = await decryptDescription(ciphertextBase64, secret);
+
+ expect(decrypted).toBe('');
+ });
+
+ it('handles unicode text', async () => {
+ const secret = await generateTestKey();
+ const plaintext = '日本語テスト 🎉 émojis';
+
+ const ciphertextBase64 = await encryptDescription(plaintext, secret);
+ const decrypted = await decryptDescription(ciphertextBase64, secret);
+
+ expect(decrypted).toBe(plaintext);
+ });
+
+ it('produces base64 AES-CBC output with a random encrypted prefix block', async () => {
+ const secret = await generateTestKey();
+ const ciphertextBase64 = await encryptDescription('test', secret);
+
+ // Should be valid base64
+ const raw = Uint8Array.from(atob(ciphertextBase64), c => c.charCodeAt(0));
+
+ // AES-CBC ciphertext is block-aligned and includes one random prefix block
+ expect(raw.length).toBeGreaterThanOrEqual(32);
+ expect(raw.length % 16).toBe(0);
+ });
+});
diff --git a/apps/webapp/src/script/repositories/conversation/descriptionCrypto.ts b/apps/webapp/src/script/repositories/conversation/descriptionCrypto.ts
new file mode 100644
index 00000000000..0df3148b9a1
--- /dev/null
+++ b/apps/webapp/src/script/repositories/conversation/descriptionCrypto.ts
@@ -0,0 +1,79 @@
+/*
+ * Wire
+ * Copyright (C) 2026 Wire Swiss GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see http://www.gnu.org/licenses/.
+ *
+ */
+
+/**
+ * AES-CBC encryption/decryption for conversation description metadata.
+ *
+ * Wire format matches iOS `zmEncryptPrefixingIV` / `zmDecryptPrefixedIV`:
+ * AES-256-CBC with PKCS#7 padding and a zero IV.
+ * Plaintext is prefixed with one random AES block before encryption.
+ * Decryption decrypts the full payload and drops the first plaintext block.
+ *
+ * Despite the iOS method name, the IV is not stored in the payload; randomization
+ * comes from encrypting the random first block in CBC mode.
+ *
+ * Key: 32-byte secret derived from MLS epoch via exportSecretKey(groupId, 32).
+ */
+
+const BLOCK_LENGTH = 16;
+const ALGORITHM = 'AES-CBC';
+const ZERO_IV = new Uint8Array(BLOCK_LENGTH);
+
+async function importKey(rawKey: Uint8Array): Promise {
+ const keyData = new Uint8Array(rawKey).buffer as ArrayBuffer;
+ return crypto.subtle.importKey('raw', keyData, ALGORITHM, false, ['encrypt', 'decrypt']);
+}
+
+/**
+ * Encrypt a UTF-8 description string.
+ *
+ * @param plaintext - The description text
+ * @param secret - 32-byte MLS-derived secret
+ * @returns Base64-encoded AES-CBC blob compatible with iOS zmEncryptPrefixingIV
+ */
+export async function encryptDescription(plaintext: string, secret: Uint8Array): Promise {
+ const key = await importKey(secret);
+ const randomPrefix = crypto.getRandomValues(new Uint8Array(BLOCK_LENGTH));
+ const encoded = new TextEncoder().encode(plaintext);
+
+ const prefixedPlaintext = new Uint8Array(BLOCK_LENGTH + encoded.length);
+ prefixedPlaintext.set(randomPrefix, 0);
+ prefixedPlaintext.set(encoded, BLOCK_LENGTH);
+
+ const ciphertext = await crypto.subtle.encrypt({name: ALGORITHM, iv: ZERO_IV}, key, prefixedPlaintext);
+
+ return btoa(String.fromCharCode(...new Uint8Array(ciphertext)));
+}
+
+/**
+ * Decrypt a base64-encoded description blob.
+ *
+ * @param ciphertextBase64 - Base64-encoded AES-CBC blob
+ * @param secret - 32-byte MLS-derived secret (same epoch as encryption)
+ * @returns The decrypted plaintext string
+ */
+export async function decryptDescription(ciphertextBase64: string, secret: Uint8Array): Promise {
+ const key = await importKey(secret);
+ const ciphertext = Uint8Array.from(atob(ciphertextBase64), c => c.charCodeAt(0));
+
+ const decrypted = await crypto.subtle.decrypt({name: ALGORITHM, iv: ZERO_IV}, key, ciphertext);
+ const plaintext = new Uint8Array(decrypted).slice(BLOCK_LENGTH);
+
+ return new TextDecoder().decode(plaintext);
+}
diff --git a/apps/webapp/src/script/repositories/entity/Conversation.ts b/apps/webapp/src/script/repositories/entity/Conversation.ts
index 50b5b977cc2..b59fbb8e3cb 100644
--- a/apps/webapp/src/script/repositories/entity/Conversation.ts
+++ b/apps/webapp/src/script/repositories/entity/Conversation.ts
@@ -165,6 +165,7 @@ export class Conversation {
public readonly initialMessage: ko.Observable;
public readonly messageTimer: ko.PureComputed;
public readonly name: ko.Observable;
+ public readonly description: ko.Observable;
public readonly notificationState: ko.PureComputed;
public readonly participating_user_ets: ko.ObservableArray;
public readonly participating_user_ids: ko.ObservableArray;
@@ -220,6 +221,7 @@ export class Conversation {
this.accessCodeHasPassword = ko.observable();
this.creator = '';
this.name = ko.observable('');
+ this.description = ko.observable('');
this.teamId = '';
this.type = ko.observable(CONVERSATION_TYPE.REGULAR);
this.groupConversationType = ko.observable(GROUP_CONVERSATION_TYPE.GROUP_CONVERSATION);
diff --git a/apps/webapp/src/script/repositories/entity/message/DescriptionUpdateMessage.ts b/apps/webapp/src/script/repositories/entity/message/DescriptionUpdateMessage.ts
new file mode 100644
index 00000000000..bee408d2852
--- /dev/null
+++ b/apps/webapp/src/script/repositories/entity/message/DescriptionUpdateMessage.ts
@@ -0,0 +1,42 @@
+/*
+ * Wire
+ * Copyright (C) 2026 Wire Swiss GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see http://www.gnu.org/licenses/.
+ *
+ */
+
+import {ClientEvent} from 'Repositories/event/Client';
+import {type Translate} from 'Util/localizerUtil';
+
+import {SystemMessage} from './SystemMessage';
+
+import {SystemMessageType} from '../../../message/SystemMessageType';
+
+export type DescriptionUpdateAction = 'add' | 'edit';
+
+export class DescriptionUpdateMessage extends SystemMessage {
+ public readonly description: string;
+ public readonly action: DescriptionUpdateAction;
+
+ constructor(description: string, action: DescriptionUpdateAction = 'edit', translate: Translate) {
+ super(translate);
+
+ this.type = ClientEvent.CONVERSATION.DESCRIPTION_UPDATE;
+ this.system_message_type = SystemMessageType.CONVERSATION_DESCRIPTION_UPDATE;
+ this.description = description;
+ this.action = action;
+ this.caption = translate(action === 'add' ? 'conversationDescriptionAddedYou' : 'conversationDescriptionEditedYou');
+ }
+}
diff --git a/apps/webapp/src/script/repositories/event/Client.ts b/apps/webapp/src/script/repositories/event/Client.ts
index ec016033827..895930a8eb1 100644
--- a/apps/webapp/src/script/repositories/event/Client.ts
+++ b/apps/webapp/src/script/repositories/event/Client.ts
@@ -32,6 +32,7 @@ export enum CONVERSATION {
COMPOSITE_MESSAGE_ADD = 'conversation.composite-message-add',
CONFIRMATION = 'conversation.confirmation',
DELETE_EVERYWHERE = 'conversation.delete-everywhere',
+ DESCRIPTION_UPDATE = 'conversation.description-update',
FILE_TYPE_RESTRICTED = 'conversation.file-type-restricted',
GROUP_CREATION = 'conversation.group-creation',
INCOMING_MESSAGE_TOO_BIG = 'conversation.incoming-message-too-big',
diff --git a/apps/webapp/src/style/content/conversation/message-list.less b/apps/webapp/src/style/content/conversation/message-list.less
index 9969058fb3a..d39de3f9d30 100644
--- a/apps/webapp/src/style/content/conversation/message-list.less
+++ b/apps/webapp/src/style/content/conversation/message-list.less
@@ -185,6 +185,11 @@
&--svg {
line-height: 0;
+ &.message-header-icon--top {
+ align-self: flex-start;
+ margin-top: 2px;
+ }
+
svg:not(.filled) path {
fill: var(--foreground);
}
@@ -223,6 +228,38 @@
flex: 1;
}
+ &--description {
+ display: block;
+ color: var(--background);
+
+ > span {
+ display: block;
+ }
+
+ .md-heading {
+ margin: 4px 0 8px;
+ font-size: var(--font-size-base);
+ font-weight: var(--font-weight-bold);
+ line-height: var(--line-height-lg);
+
+ &--1 {
+ font-size: var(--font-size-xlarge);
+ }
+
+ &--2 {
+ font-size: var(--font-size-large);
+ }
+
+ &--3 {
+ font-size: var(--font-size-base);
+ }
+
+ & + br {
+ display: none;
+ }
+ }
+ }
+
a {
cursor: pointer;
}
diff --git a/apps/webapp/src/style/modal/group-creation.less b/apps/webapp/src/style/modal/group-creation.less
index ef11a8d2064..3286f7c722b 100644
--- a/apps/webapp/src/style/modal/group-creation.less
+++ b/apps/webapp/src/style/modal/group-creation.less
@@ -23,6 +23,50 @@
min-height: 4rem;
}
+ .modal-input-wrapper:focus-within .group-creation__description-label {
+ color: var(--accent-color-500);
+ }
+
+ .group-creation__description-label {
+ display: block;
+ margin-bottom: 2px;
+ color: var(--text-input-color);
+ font-weight: var(--font-weight-semibold);
+ }
+
+ .group-creation__description-input {
+ width: 100%;
+ min-height: 96px;
+ margin-right: 0;
+ margin-left: 0;
+ padding: 12px 16px;
+ box-sizing: border-box;
+ border: 1px solid var(--text-input-border);
+ border-radius: 12px;
+ background: var(--text-input-background);
+ color: var(--text-input-color);
+ font-size: var(--font-size-base);
+ font-weight: var(--font-weight-regular);
+ line-height: var(--line-height-lg);
+ outline: none;
+ resize: vertical;
+
+ &::placeholder {
+ color: var(--text-input-placeholder);
+ opacity: 1;
+ }
+
+ &:hover {
+ border-color: var(--text-input-border-hover);
+ }
+
+ &:focus,
+ &:focus-visible,
+ &:active {
+ border-color: var(--accent-color-500);
+ }
+ }
+
.modal__content {
height: 490px;
}
@@ -32,7 +76,10 @@
}
.modal__info {
- margin: 8px 4px 22px;
+ margin: 0 2px 16px;
+ color: var(--gray-90);
+ font-size: var(--font-size-small);
+ line-height: var(--line-height-xs);
}
.modal__select-description {
diff --git a/apps/webapp/src/style/panel/conversation-details.less b/apps/webapp/src/style/panel/conversation-details.less
index 156d736ee53..71657d393ab 100644
--- a/apps/webapp/src/style/panel/conversation-details.less
+++ b/apps/webapp/src/style/panel/conversation-details.less
@@ -124,6 +124,172 @@
}
}
+ &__description {
+ padding: 0 20px;
+ margin-top: 16px;
+ margin-bottom: 8px;
+
+ &-label {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ }
+
+ &-heading {
+ margin: 0;
+ color: var(--background);
+ font-size: var(--font-size-small);
+ font-weight: 600;
+ line-height: var(--line-height-md);
+ }
+
+ &-edit-button {
+ display: flex;
+ padding: 2px;
+ border: none;
+ background: transparent;
+ cursor: pointer;
+ }
+
+ &-edit-icon {
+ width: 12px;
+ height: 12px;
+
+ path {
+ fill: var(--foreground-fade-56);
+ }
+ }
+
+ &-content {
+ display: block;
+ width: 100%;
+ padding: 0;
+ border: none;
+ background: transparent;
+ cursor: pointer;
+ text-align: left;
+ }
+
+ &-text {
+ margin: 4px 0 0;
+ color: var(--background);
+ font-size: var(--font-size-small);
+ line-height: var(--line-height-md);
+ word-break: break-word;
+
+ li {
+ display: list-item;
+ line-height: var(--line-height-lg);
+
+ &::after {
+ display: none;
+ }
+ }
+
+ li::marker {
+ color: currentColor;
+ }
+
+ && ul,
+ && ol {
+ padding-left: 24px;
+ margin-top: 0;
+ margin-bottom: 0;
+ line-height: 0.85;
+ list-style-position: outside;
+ }
+
+ && ul {
+ list-style-type: disc;
+ }
+
+ && ol {
+ list-style-type: decimal;
+ }
+
+ ol ol {
+ list-style: lower-alpha;
+ }
+
+ ol ol ol {
+ list-style: lower-roman;
+ }
+
+ a:hover {
+ text-decoration: underline;
+ }
+
+ .md-heading {
+ margin: 4px 0 8px;
+ font-size: var(--font-size-base);
+ font-weight: var(--font-weight-bold);
+ line-height: var(--line-height-lg);
+
+ &--1 {
+ font-size: var(--font-size-xlarge);
+ }
+
+ &--2 {
+ font-size: var(--font-size-large);
+ }
+
+ &--3 {
+ font-size: var(--font-size-base);
+ }
+
+ & + br {
+ display: none;
+ }
+ }
+
+ .md-blockquote {
+ position: relative;
+ padding: 0 12px 0 16px;
+ margin: 0;
+
+ &::before {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 4px;
+ height: 100%;
+ border-radius: 2px;
+ margin-right: 12px;
+ background-color: var(--message-quote-bg);
+ content: '';
+ }
+ }
+ }
+
+ &-placeholder {
+ margin: 4px 0 0;
+ color: var(--foreground-fade-40);
+ font-size: var(--font-size-small);
+ font-style: italic;
+ line-height: var(--line-height-md);
+ }
+
+ &-input {
+ width: 100%;
+ min-height: 64px;
+ padding: 8px;
+ border: 1px solid var(--gray-60);
+ border-radius: 8px;
+ margin-top: 4px;
+ background-color: var(--app-bg);
+ color: var(--background);
+ font-family: inherit;
+ font-size: var(--font-size-small);
+ line-height: var(--line-height-md);
+ outline: none;
+ resize: vertical;
+
+ &:focus {
+ border-color: var(--accent-color);
+ }
+ }
+ }
+
&__list-head {
.panel-header;
width: 100%;
diff --git a/apps/webapp/test/e2e_tests/pageManager/webapp/pages/conversationDetails.page.ts b/apps/webapp/test/e2e_tests/pageManager/webapp/pages/conversationDetails.page.ts
index 13280a4686b..c25e9f20b6d 100644
--- a/apps/webapp/test/e2e_tests/pageManager/webapp/pages/conversationDetails.page.ts
+++ b/apps/webapp/test/e2e_tests/pageManager/webapp/pages/conversationDetails.page.ts
@@ -44,6 +44,12 @@ export class ConversationDetailsPage {
readonly editConversationNameButton: Locator;
readonly textFieldForConversationName: Locator;
+ readonly descriptionSection: Locator;
+ readonly descriptionContent: Locator;
+ readonly descriptionTextarea: Locator;
+ readonly descriptionEditIcon: Locator;
+ readonly descriptionHeading: Locator;
+
readonly protocol: Locator;
constructor(page: Page) {
@@ -68,6 +74,12 @@ export class ConversationDetailsPage {
this.editConversationNameButton = this.page.getByRole('button', {name: 'Change conversation name'});
this.textFieldForConversationName = this.page.locator('textarea[data-uie-name="enter-name"]');
+ this.descriptionSection = this.conversationDetails.locator('[data-uie-name="conversation-details-description"]');
+ this.descriptionContent = this.conversationDetails.locator('[data-uie-name="description-content"]');
+ this.descriptionTextarea = this.conversationDetails.locator('[data-uie-name="description-textarea"]');
+ this.descriptionEditIcon = this.conversationDetails.locator('[data-uie-name="description-edit-icon"]');
+ this.descriptionHeading = this.descriptionSection.locator('.conversation-details__description-heading');
+
this.protocol = this.conversationDetails.getByLabel('Protocol');
}
@@ -245,4 +257,21 @@ export class ConversationDetailsPage {
await this.textFieldForConversationName.fill(newConversationName);
await this.textFieldForConversationName.press('Enter');
}
+
+ async setDescription(description: string) {
+ await this.descriptionContent.click();
+ await this.descriptionTextarea.fill(description);
+ await this.descriptionTextarea.press('Enter');
+ }
+
+ async clearDescription() {
+ await this.descriptionContent.click();
+ await this.descriptionTextarea.fill('');
+ await this.descriptionTextarea.press('Enter');
+ }
+
+ async cancelDescriptionEdit() {
+ await this.descriptionContent.click();
+ await this.descriptionTextarea.press('Escape');
+ }
}
diff --git a/apps/webapp/test/e2e_tests/specs/ConversationDescription/conversationDescription.spec.ts b/apps/webapp/test/e2e_tests/specs/ConversationDescription/conversationDescription.spec.ts
new file mode 100644
index 00000000000..cb8bd85cc2d
--- /dev/null
+++ b/apps/webapp/test/e2e_tests/specs/ConversationDescription/conversationDescription.spec.ts
@@ -0,0 +1,202 @@
+/*
+ * Wire
+ * Copyright (C) 2026 Wire Swiss GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see http://www.gnu.org/licenses/.
+ *
+ */
+
+import {User} from 'test/e2e_tests/data/user';
+import {PageManager} from 'test/e2e_tests/pageManager';
+import {test, expect, withLogin, Team} from 'test/e2e_tests/test.fixtures';
+import {createGroup} from 'test/e2e_tests/utils/userActions';
+
+test.describe('Conversation Description', () => {
+ let team: Team;
+ let userA: User;
+ let userB: User;
+ const groupName = 'Description Test Group';
+
+ test.beforeEach(async ({createTeam, createUser}) => {
+ userB = await createUser();
+ team = await createTeam('Desc Team', {users: [userB]});
+ userA = team.owner;
+ });
+
+ const openConversationDetails = async (pages: PageManager['webapp']['pages']) => {
+ await pages.conversationList().getConversation(groupName).open();
+ await pages.conversation().clickConversationInfoButton();
+ await pages.conversationDetails().waitForSidebar();
+ };
+
+ test(
+ 'I want to see the description section with a placeholder in a new group conversation',
+ {tag: ['@regression']},
+ async ({createPage}) => {
+ const userAPages = await PageManager.from(createPage(withLogin(userA))).then(pm => pm.webapp.pages);
+
+ await createGroup(userAPages, groupName, [userB]);
+ await openConversationDetails(userAPages);
+
+ const details = userAPages.conversationDetails();
+
+ await expect(details.descriptionSection).toBeVisible();
+ await expect(details.descriptionHeading).toBeVisible();
+ await expect(details.descriptionContent).toContainText('Enter a description');
+ },
+ );
+
+ test(
+ 'I want to set a description for a group conversation',
+ {tag: ['@regression']},
+ async ({createPage}) => {
+ const userAPages = await PageManager.from(createPage(withLogin(userA))).then(pm => pm.webapp.pages);
+ const description = 'This is a test description for the group';
+
+ await createGroup(userAPages, groupName, [userB]);
+ await openConversationDetails(userAPages);
+
+ const details = userAPages.conversationDetails();
+
+ await details.setDescription(description);
+
+ await expect(details.descriptionContent).toContainText(description);
+ await expect(details.descriptionTextarea).not.toBeVisible();
+ },
+ );
+
+ test(
+ 'I want to edit an existing description',
+ {tag: ['@regression']},
+ async ({createPage}) => {
+ const userAPages = await PageManager.from(createPage(withLogin(userA))).then(pm => pm.webapp.pages);
+ const originalDescription = 'Original description';
+ const updatedDescription = 'Updated description';
+
+ await createGroup(userAPages, groupName, [userB]);
+ await openConversationDetails(userAPages);
+
+ const details = userAPages.conversationDetails();
+
+ // Set initial description
+ await details.setDescription(originalDescription);
+ await expect(details.descriptionContent).toContainText(originalDescription);
+
+ // Edit the description
+ await details.setDescription(updatedDescription);
+ await expect(details.descriptionContent).toContainText(updatedDescription);
+ },
+ );
+
+ test(
+ 'I want to cancel editing a description with Escape',
+ {tag: ['@regression']},
+ async ({createPage}) => {
+ const userAPages = await PageManager.from(createPage(withLogin(userA))).then(pm => pm.webapp.pages);
+ const description = 'Should not change';
+
+ await createGroup(userAPages, groupName, [userB]);
+ await openConversationDetails(userAPages);
+
+ const details = userAPages.conversationDetails();
+
+ // Set initial description
+ await details.setDescription(description);
+
+ // Start editing, type something, then cancel
+ await details.descriptionContent.click();
+ await details.descriptionTextarea.fill('This will be discarded');
+ await details.descriptionTextarea.press('Escape');
+
+ // Verify original description is preserved
+ await expect(details.descriptionTextarea).not.toBeVisible();
+ await expect(details.descriptionContent).toContainText(description);
+ },
+ );
+
+ test(
+ 'I want the description to persist after reopening conversation details',
+ {tag: ['@regression']},
+ async ({createPage}) => {
+ const userAPages = await PageManager.from(createPage(withLogin(userA))).then(pm => pm.webapp.pages);
+ const description = 'Persistent description';
+
+ await createGroup(userAPages, groupName, [userB]);
+ await openConversationDetails(userAPages);
+
+ const details = userAPages.conversationDetails();
+
+ // Set description
+ await details.setDescription(description);
+ await expect(details.descriptionContent).toContainText(description);
+
+ // Close and reopen the panel
+ await userAPages.conversation().clickConversationInfoButton();
+ await userAPages.conversation().clickConversationInfoButton();
+ await details.waitForSidebar();
+
+ // Verify description persists
+ await expect(details.descriptionContent).toContainText(description);
+ },
+ );
+
+ test(
+ 'I want to see the edit icon when hovering over the description',
+ {tag: ['@regression']},
+ async ({createPage}) => {
+ const userAPages = await PageManager.from(createPage(withLogin(userA))).then(pm => pm.webapp.pages);
+ const description = 'Hover test description';
+
+ await createGroup(userAPages, groupName, [userB]);
+ await openConversationDetails(userAPages);
+
+ const details = userAPages.conversationDetails();
+
+ await details.setDescription(description);
+
+ // Edit icon should not be visible before hover
+ await expect(details.descriptionEditIcon).not.toBeVisible();
+
+ // Hover over the description section
+ await details.descriptionSection.hover();
+
+ // Edit icon should appear
+ await expect(details.descriptionEditIcon).toBeVisible();
+ },
+ );
+
+ test(
+ 'I want to enter edit mode by clicking the edit icon',
+ {tag: ['@regression']},
+ async ({createPage}) => {
+ const userAPages = await PageManager.from(createPage(withLogin(userA))).then(pm => pm.webapp.pages);
+ const description = 'Edit icon test';
+
+ await createGroup(userAPages, groupName, [userB]);
+ await openConversationDetails(userAPages);
+
+ const details = userAPages.conversationDetails();
+
+ await details.setDescription(description);
+
+ // Hover and click edit icon
+ await details.descriptionSection.hover();
+ await details.descriptionEditIcon.click();
+
+ // Textarea should appear with current description
+ await expect(details.descriptionTextarea).toBeVisible();
+ await expect(details.descriptionTextarea).toHaveValue(description);
+ },
+ );
+});
diff --git a/libraries/api-client/src/conversation/conversationApi/conversationApi.test.ts b/libraries/api-client/src/conversation/conversationApi/conversationApi.test.ts
index 47fdba1e27d..7e409e68a50 100644
--- a/libraries/api-client/src/conversation/conversationApi/conversationApi.test.ts
+++ b/libraries/api-client/src/conversation/conversationApi/conversationApi.test.ts
@@ -58,6 +58,41 @@ const conversationApi = new ConversationAPI(client, {
const generateQualifiedId = () => ({domain, id: randomUUID()});
describe('ConversationAPI', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('getConversationDescription', () => {
+ it('gets the encrypted conversation description', async () => {
+ const conversationId = generateQualifiedId();
+ const description = {version: 1, ciphertext: 'encrypted-description'};
+ jest.spyOn(client, 'sendJSON').mockResolvedValueOnce({data: description} as AxiosResponse);
+
+ const result = await conversationApi.getConversationDescription(conversationId);
+
+ expect(result).toBe(description);
+ expect(client.sendJSON).toHaveBeenCalledWith({
+ method: 'get',
+ url: `/conversations/${conversationId.domain}/${conversationId.id}/description`,
+ });
+ });
+ });
+
+ describe('putConversationDescription', () => {
+ it('updates the encrypted conversation description', async () => {
+ const conversationId = generateQualifiedId();
+ const descriptionUpdate = {base_version: 1, ciphertext: 'new-encrypted-description'};
+
+ await conversationApi.putConversationDescription(conversationId, descriptionUpdate);
+
+ expect(client.sendJSON).toHaveBeenCalledWith({
+ data: descriptionUpdate,
+ method: 'put',
+ url: `/conversations/${conversationId.domain}/${conversationId.id}/description`,
+ });
+ });
+ });
+
describe('getConversationList', () => {
it('returns a full list of conversations', async () => {
const allIds = Array.from({length: 10}, generateQualifiedId);
diff --git a/libraries/api-client/src/conversation/conversationApi/conversationApi.ts b/libraries/api-client/src/conversation/conversationApi/conversationApi.ts
index 1f42e18f178..3daeba8be79 100644
--- a/libraries/api-client/src/conversation/conversationApi/conversationApi.ts
+++ b/libraries/api-client/src/conversation/conversationApi/conversationApi.ts
@@ -61,6 +61,8 @@ import {
} from '../conversationError';
import {
ConversationAccessUpdateData,
+ ConversationDescriptionResponse,
+ ConversationDescriptionUpdateData,
ConversationJoinData,
ConversationMemberUpdateData,
ConversationMessageTimerUpdateData,
@@ -98,6 +100,7 @@ export class ConversationAPI {
CODE: 'code',
CODE_CHECK: 'code-check',
CONVERSATIONS: 'conversations',
+ DESCRIPTION: 'description',
SUBCONVERSATIONS: 'subconversations',
GROUP_INFO: 'groupinfo',
MLS: 'mls',
@@ -810,6 +813,40 @@ export class ConversationAPI {
return response.data;
}
+ /**
+ * Get encrypted conversation description.
+ * @param conversationId The conversation ID
+ * @see https://nginz-https.bella.wire.link/v17/api/swagger-ui/#/default/get-conversation-description
+ */
+ public async getConversationDescription(conversationId: QualifiedId): Promise {
+ const config: AxiosRequestConfig = {
+ method: 'get',
+ url: `${this.generateBaseConversationUrl(conversationId)}/${ConversationAPI.URL.DESCRIPTION}`,
+ };
+
+ const response = await this.client.sendJSON(config);
+ return response.data;
+ }
+
+ /**
+ * Update encrypted conversation description.
+ * @param conversationId The conversation ID
+ * @param descriptionData The encrypted description update data
+ * @see https://nginz-https.bella.wire.link/v17/api/swagger-ui/#/default/update-conversation-description
+ */
+ public async putConversationDescription(
+ conversationId: QualifiedId,
+ descriptionData: ConversationDescriptionUpdateData,
+ ): Promise {
+ const config: AxiosRequestConfig = {
+ data: descriptionData,
+ method: 'put',
+ url: `${this.generateBaseConversationUrl(conversationId)}/${ConversationAPI.URL.DESCRIPTION}`,
+ };
+
+ await this.client.sendJSON(config);
+ }
+
/**
* Update the message timer for a conversation.
* @param conversationId The conversation ID
diff --git a/libraries/api-client/src/conversation/data/conversationDescriptionData.ts b/libraries/api-client/src/conversation/data/conversationDescriptionData.ts
new file mode 100644
index 00000000000..6ff5364adcb
--- /dev/null
+++ b/libraries/api-client/src/conversation/data/conversationDescriptionData.ts
@@ -0,0 +1,30 @@
+/*
+ * Wire
+ * Copyright (C) 2026 Wire Swiss GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see http://www.gnu.org/licenses/.
+ *
+ */
+
+/** Response from GET /conversations/:domain/:id/description */
+export interface ConversationDescriptionResponse {
+ version: number;
+ ciphertext: string;
+}
+
+/** Request body for PUT /conversations/:domain/:id/description */
+export interface ConversationDescriptionUpdateData {
+ base_version: number;
+ ciphertext: string;
+}
diff --git a/libraries/api-client/src/conversation/data/index.ts b/libraries/api-client/src/conversation/data/index.ts
index a7cb00f9919..2a45f504f7f 100644
--- a/libraries/api-client/src/conversation/data/index.ts
+++ b/libraries/api-client/src/conversation/data/index.ts
@@ -21,6 +21,7 @@ export * from './conversationAccessUpdateData';
export * from './conversationCodeUpdateData';
export * from './conversationConnectRequestData';
export * from './conversationCreateData';
+export * from './conversationDescriptionData';
export * from './conversationJoinData';
export * from './conversationMemberJoinData';
export * from './conversationMemberLeaveData';