Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
d03edb8
feat: add channel description section to conversation details panel
cristianoliveira Jun 17, 2026
82bdc6d
feat: add inline editing flow for channel description
cristianoliveira Jun 17, 2026
2288c46
feat: wire description through API client to conversation details
cristianoliveira Jun 17, 2026
4245f1b
fix: make edit icon clickable to trigger description editing
cristianoliveira Jun 17, 2026
7490b72
feat: cancel description editing on Escape key
cristianoliveira Jun 17, 2026
fc12edf
refactor: align description data model with encrypted group metadata …
cristianoliveira Jun 17, 2026
36323ed
test(e2e): add E2E tests for conversation description editing flow
cristianoliveira Jun 17, 2026
74f3cb1
feat: add AES-GCM crypto for encrypted group description metadata
cristianoliveira Jun 18, 2026
7553d0d
feat: group description message
cristianoliveira Jun 18, 2026
9d3a78d
feat: working version with events
cristianoliveira Jun 18, 2026
d0bc312
feat: update description on event
cristianoliveira Jun 18, 2026
909be50
feat: disable edit for non-admins
cristianoliveira Jun 18, 2026
b56401f
feat: render conversation descriptions like messages
cristianoliveira Jun 18, 2026
c4d4e37
feat: add optional group description on creation
cristianoliveira Jun 18, 2026
c721d6e
fix: style rendered group descriptions
cristianoliveira Jun 18, 2026
e35b182
fix: use pin icon for group descriptions
cristianoliveira Jun 18, 2026
bb23ee0
fix: render info panel description markdown lists
cristianoliveira Jun 18, 2026
e00e4c2
fix: make group description textarea resizable
cristianoliveira Jun 18, 2026
05fc513
feat(conversation): add description input component
cristianoliveira Jun 19, 2026
10e6a94
Merge origin/dev into hackathon26/group-description
cristianoliveira Jun 19, 2026
ffed0a5
fix: update generated translations
cristianoliveira Jun 19, 2026
ad7b53f
fix: resolve description merge type errors
cristianoliveira Jun 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,4 @@ lib/
storybook-static/

plans/
output
7 changes: 7 additions & 0 deletions apps/webapp/src/i18n/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions apps/webapp/src/i18n/pt-BR.json
Original file line number Diff line number Diff line change
Expand Up @@ -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]",
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,15 @@
}
}, [activeConversation, isVirtualizedMessagesListEnabled]);

useEffect(() => {
if (!activeConversation) {
return;
}

// TODO: Remove when description is part of the API Conversation payload

Check warning on line 162 in apps/webapp/src/script/components/Conversation/Conversation.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Complete the task associated to this "TODO" comment.

See more on https://sonarcloud.io/project/issues?id=wireapp_wire-webapp&issues=AZ7a0n07_c1oKk55xR-R&open=AZ7a0n07_c1oKk55xR-R&pullRequest=21584
void conversationRepository.loadConversationDescription(activeConversation);
}, [activeConversation, conversationRepository]);

const uploadImages = useCallback(
(images: File[]) => {
if (!activeConversation || isHittingUploadLimit(images, repositories.asset, translate)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(<MemberMessage {...props} />));
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(<MemberMessage {...props} />));

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('<br>');
});

it('escapes html in the conversation description shown in chat', () => {
const message = createMemberMessage({systemType: SystemMessageType.CONVERSATION_CREATE}, [generateUser()]);
const props = {
...baseProps,
message,
conversationDescription: '<img src=x onerror=alert(1)>\nhttps://wire.com',
};

const {getByTestId, getByText} = render(withTheme(<MemberMessage {...props} />));

expect(getByTestId('group-creation-description-text').querySelector('img')).toBeNull();
expect(getByText('<img src=x onerror=alert(1)>')).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(<MemberMessage {...props} />));
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 = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => (
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 16 16">
<path d="M10.7 1.3 14.7 5.3 13.3 6.7 12.6 6 9.5 9.1 9.8 12.2 8.4 13.6 5.8 11 2.1 14.7 1.3 13.9 5 10.2 2.4 7.6 3.8 6.2 6.9 6.5 10 3.4 9.3 2.7 10.7 1.3Z" />
</svg>
);

interface MemberMessageProps {
classifiedDomains?: string[];
hasReadReceiptsTurnedOn: boolean;
Expand All @@ -42,6 +49,7 @@ interface MemberMessageProps {
onClickParticipants: (participants: User[]) => void;
shouldShowInvitePeople: boolean;
conversationName: string;
conversationDescription?: string;
isCellsConversation: boolean;
}

Expand All @@ -56,6 +64,7 @@ export const MemberMessage = ({
onClickCancelRequest,
classifiedDomains,
conversationName,
conversationDescription,
isCellsConversation,
}: MemberMessageProps) => {
const {translate} = useApplicationContext();
Expand All @@ -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,
Expand Down Expand Up @@ -103,6 +115,31 @@ export const MemberMessage = ({
</div>
)}

{shouldShowDescription && (
<div
className="message-header"
data-uie-name="label-group-creation-description"
role="status"
aria-live="polite"
>
<div
className="message-header-icon message-header-icon--svg message-header-icon--top text-foreground"
aria-hidden="true"
>
<PinIcon />
</div>
<div className="message-header-label message-header-label--description">
<strong data-uie-name="group-creation-description-label">
{translate('conversationDescriptionLabel')}
</strong>
<span
data-uie-name="group-creation-description-text"
dangerouslySetInnerHTML={{__html: renderedConversationDescription}}
/>
</div>
</div>
)}

{hasUsers && (
<div className="message-header" role="status" aria-live="polite">
<div className="message-header-icon message-header-icon--svg text-foreground" aria-hidden="true">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -262,6 +263,7 @@ export const MessageWrapper = ({
<MemberMessage
message={message}
conversationName={displayName}
conversationDescription={conversationDescription}
onClickInvitePeople={onClickInvitePeople}
onClickParticipants={onClickParticipants}
onClickCancelRequest={onClickCancelRequest}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,18 @@

import {render, screen} from '@testing-library/react';

import en from 'I18n/en-US.json';
import {DescriptionUpdateMessage} from 'Repositories/entity/message/DescriptionUpdateMessage';
import {MessageTimerUpdateMessage} from 'Repositories/entity/message/MessageTimerUpdateMessage';
import {ReceiptModeUpdateMessage} from 'Repositories/entity/message/ReceiptModeUpdateMessage';
import {RenameMessage} from 'Repositories/entity/message/RenameMessage';
import {translate} from 'Util/localizerUtil';
import {setStrings, translate} from 'Util/localizerUtil';
import {translateForTest} from 'Util/test/translateForTest';

import {SystemMessage} from './SystemMessage';

setStrings({en});

jest.mock('Components/icon', () => ({
EditIcon: () => {
return <span data-uie-name="editicon" className="editicon"></span>;
Expand All @@ -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);
Expand All @@ -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(<SystemMessage message={message} />);

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(<SystemMessage message={message} />);

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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -52,6 +53,10 @@ export const SystemMessage = ({message}: SystemMessageProps) => {
);
}

if (message instanceof DescriptionUpdateMessage) {
return <SystemMessageBase message={message} isSenderNameVisible icon={<Icon.EditIcon />} />;
}

if (message instanceof MessageTimerUpdateMessage) {
return <SystemMessageBase message={message} isSenderNameVisible icon={<Icon.TimerIcon />} />;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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%',
};
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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();

Expand All @@ -45,6 +50,23 @@ export const ConversationDetails = () => {
) : (
<>
<ConversationNameInput />
{conversationType === ConversationType.Group && (
<div css={conversationDescriptionInputWrapperCss}>
<label css={conversationDescriptionLabelCss} htmlFor="enter-group-description">
{translate('conversationDescriptionOptionalLabel')}
</label>
<textarea
css={conversationDescriptionInputCss}
id="enter-group-description"
data-uie-name="enter-group-description"
name="enter-group-description"
maxLength={200}
value={conversationDescription}
onChange={event => setConversationDescription(event.target.value)}
onBlur={event => setConversationDescription(event.target.value.trim())}
/>
</div>
)}
{conversationType === ConversationType.Group ? <Preference /> : <ChannelSettings />}
</>
);
Expand Down
Loading
Loading