Skip to content

Commit 98097fa

Browse files
authored
feat(ui): sync default message actions with design system (#2701)
1 parent 8fc3f86 commit 98097fa

25 files changed

Lines changed: 381 additions & 63 deletions

.github/workflows/update_goldens.yml

Lines changed: 86 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,21 @@ on:
77
description: "Which goldens to update"
88
required: true
99
type: choice
10-
default: sdk
10+
default: both
1111
options:
12+
- both
1213
- sdk
1314
- docs
14-
- both
1515

1616
jobs:
1717
update_sdk_goldens:
1818
if: inputs.target == 'sdk' || inputs.target == 'both'
1919
runs-on: ubuntu-latest
20+
outputs:
21+
changed: ${{ steps.detect.outputs.changed }}
2022
steps:
2123
- name: 📚 Checkout branch
2224
uses: actions/checkout@v6
23-
with:
24-
ssh-key: ${{ secrets.BOT_SSH_PRIVATE_KEY }}
2525

2626
- name: 🐦 Install Flutter
2727
uses: subosito/flutter-action@v2
@@ -40,22 +40,37 @@ jobs:
4040
continue-on-error: true
4141
run: melos run update:goldens:sdk
4242

43-
- name: 📤 Commit Changes
44-
uses: stefanzweifel/git-auto-commit-action@v7
43+
- name: 🔍 Detect changed goldens
44+
id: detect
45+
run: |
46+
if [[ -z "$(git status --porcelain -- 'packages/**/test/**/goldens/**/*.png')" ]]; then
47+
echo "changed=false" >> "$GITHUB_OUTPUT"
48+
else
49+
echo "changed=true" >> "$GITHUB_OUTPUT"
50+
fi
51+
52+
- name: 📦 Bundle SDK goldens
53+
if: steps.detect.outputs.changed == 'true'
54+
run: |
55+
find packages -path '*/test/*/goldens/*' -name '*.png' -print0 \
56+
| tar --null -czf sdk-goldens.tgz -T -
57+
58+
- name: 📤 Upload SDK goldens artifact
59+
if: steps.detect.outputs.changed == 'true'
60+
uses: actions/upload-artifact@v4
4561
with:
46-
commit_message: "chore: Update SDK Goldens"
47-
file_pattern: "packages/**/test/**/goldens/**/*.png"
48-
commit_user_name: "Stream SDK Bot"
49-
commit_user_email: "60655709+Stream-SDK-Bot@users.noreply.github.com"
62+
name: sdk-goldens
63+
path: sdk-goldens.tgz
64+
retention-days: 1
5065

5166
update_docs_goldens:
5267
if: inputs.target == 'docs' || inputs.target == 'both'
5368
runs-on: macos-latest
69+
outputs:
70+
changed: ${{ steps.detect.outputs.changed }}
5471
steps:
5572
- name: 📚 Checkout branch
5673
uses: actions/checkout@v6
57-
with:
58-
ssh-key: ${{ secrets.BOT_SSH_PRIVATE_KEY }}
5974

6075
- name: 🐦 Install Flutter
6176
uses: subosito/flutter-action@v2
@@ -74,10 +89,67 @@ jobs:
7489
continue-on-error: true
7590
run: melos run update:goldens:docs
7691

92+
- name: 🔍 Detect changed goldens
93+
id: detect
94+
run: |
95+
if [[ -z "$(git status --porcelain -- 'docs/**/test/**/goldens/**/*.png')" ]]; then
96+
echo "changed=false" >> "$GITHUB_OUTPUT"
97+
else
98+
echo "changed=true" >> "$GITHUB_OUTPUT"
99+
fi
100+
101+
- name: 📦 Bundle docs goldens
102+
if: steps.detect.outputs.changed == 'true'
103+
run: |
104+
find docs -path '*/test/*/goldens/*' -name '*.png' -print0 \
105+
| tar --null -czf docs-goldens.tgz -T -
106+
107+
- name: 📤 Upload docs goldens artifact
108+
if: steps.detect.outputs.changed == 'true'
109+
uses: actions/upload-artifact@v4
110+
with:
111+
name: docs-goldens
112+
path: docs-goldens.tgz
113+
retention-days: 1
114+
115+
commit_goldens:
116+
needs: [update_sdk_goldens, update_docs_goldens]
117+
# `always()` lets this run even when one regen job was skipped (e.g. target=sdk).
118+
# We commit only when at least one regen produced actual changes — no diff means
119+
# no artifact uploaded, so a workflow run with no golden changes ends as a no-op.
120+
if: |
121+
always() &&
122+
(needs.update_sdk_goldens.outputs.changed == 'true' ||
123+
needs.update_docs_goldens.outputs.changed == 'true')
124+
runs-on: ubuntu-latest
125+
steps:
126+
- name: 📚 Checkout branch
127+
uses: actions/checkout@v6
128+
with:
129+
ssh-key: ${{ secrets.BOT_SSH_PRIVATE_KEY }}
130+
131+
- name: 📥 Download SDK goldens
132+
if: needs.update_sdk_goldens.outputs.changed == 'true'
133+
uses: actions/download-artifact@v4
134+
with:
135+
name: sdk-goldens
136+
137+
- name: 📥 Download docs goldens
138+
if: needs.update_docs_goldens.outputs.changed == 'true'
139+
uses: actions/download-artifact@v4
140+
with:
141+
name: docs-goldens
142+
143+
- name: 📦 Extract goldens
144+
run: |
145+
for tgz in sdk-goldens.tgz docs-goldens.tgz; do
146+
[[ -f "$tgz" ]] && tar -xzf "$tgz" && rm "$tgz"
147+
done
148+
77149
- name: 📤 Commit Changes
78150
uses: stefanzweifel/git-auto-commit-action@v7
79151
with:
80-
commit_message: "chore: Update Docs Snapshots"
81-
file_pattern: "docs/**/test/**/goldens/macos/*.png"
152+
commit_message: "chore: Update Goldens"
153+
file_pattern: "**/test/**/goldens/**/*.png"
82154
commit_user_name: "Stream SDK Bot"
83155
commit_user_email: "60655709+Stream-SDK-Bot@users.noreply.github.com"
5.28 KB
Loading

docs/docs_screenshots/test/src/mocks.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ void setupMockChannel({
132132
when(() => channel.lastMessageAtStream).thenAnswer((_) => Stream.value(DateTime.parse('2020-06-22 12:00:00')));
133133
when(() => channel.state).thenReturn(channelState);
134134
when(() => channel.client).thenReturn(client);
135+
when(() => channel.config).thenReturn(ChannelConfig(mutes: true));
135136
when(channel.getRemainingCooldown).thenReturn(0);
136137
when(() => channel.isDistinct).thenReturn(false);
137138
when(() => channel.isMuted).thenReturn(false);

packages/stream_chat_flutter/CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,16 @@
44

55
- `StreamMessageComposer` no longer clobbers pre-populated composer state (text, quoted message, attachments) when the channel's draft stream emits its initial `null` (no draft on server). The reset now fires only on an actual non-null → null transition, distinguishing "no draft yet" from "draft was removed".
66

7+
✅ Added
8+
9+
- Added `BlockUser` / `UnblockUser` default message actions, dispatching to `StreamChatClient.blockUser` / `unblockUser`.
10+
711
🔄 Internal / Non-breaking
812

13+
- Reordered the default message actions to match the design system.
14+
- Renamed default English `threadReplyLabel` (`'Thread'``'Thread Reply'`) and `markAsUnreadLabel` (`'Mark as Unread'``'Mark Unread'`).
15+
- `StreamThreadHeader` default title now sources from the new `threadLabel` translation key instead of `threadReplyLabel`, restoring the short `'Thread'` header.
16+
917
- `ReactionIconResolver.supportedReactions` is now wired to the full emoji picker sheet opened by the "+" button in `StreamMessageReactionPicker` and the add-emoji chip in `ReactionDetailSheet`. Override `supportedReactions` on a custom resolver to control which reactions appear in the full picker grid.
1018

1119
- Composer UI primitives (`StreamMessageComposerInputField`, `VoiceRecordingCallback`, and the outer/inner layout containers) are now owned by `stream_chat_flutter` and exported from this package. They were previously supplied by `stream_core_flutter`. The public API of `StreamMessageComposer` / `StreamChatMessageInput` and its sub-components is unchanged.

packages/stream_chat_flutter/lib/src/localization/translations.dart

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ abstract class Translations {
3434
/// The label for "thread reply"
3535
String get threadReplyLabel;
3636

37+
/// The label for the thread view header ("Thread").
38+
String get threadLabel;
39+
3740
/// The text for showing if the message is only visible to you
3841
String get onlyVisibleToYouText;
3942

@@ -398,6 +401,9 @@ abstract class Translations {
398401
/// The text for "Mute User"/"Unmute User" based on the value of [isMuted].
399402
String toggleMuteUnmuteUserText({required bool isMuted});
400403

404+
/// The text for "Block User"/"Unblock User" based on the value of [isBlocked].
405+
String toggleBlockUnblockUserText({required bool isBlocked});
406+
401407
/// The text for "Are you sure you want to mute this group?"/"Are you sure you want to unmute this group?"
402408
/// based on the value of [isMuted].
403409
String toggleMuteUnmuteGroupQuestion({required bool isMuted});
@@ -754,7 +760,10 @@ class DefaultTranslations implements Translations {
754760
}
755761

756762
@override
757-
String get threadReplyLabel => 'Thread';
763+
String get threadReplyLabel => 'Thread Reply';
764+
765+
@override
766+
String get threadLabel => 'Thread';
758767

759768
@override
760769
String get onlyVisibleToYouText => 'Only visible to you';
@@ -930,7 +939,7 @@ class DefaultTranslations implements Translations {
930939
}
931940

932941
@override
933-
String get markAsUnreadLabel => 'Mark as Unread';
942+
String get markAsUnreadLabel => 'Mark Unread';
934943

935944
@override
936945
String unreadCountIndicatorLabel({required int unreadCount}) {
@@ -1135,6 +1144,12 @@ Attachment limit exceeded: it's not possible to add more than $limit attachments
11351144
}
11361145
}
11371146

1147+
@override
1148+
String toggleBlockUnblockUserText({required bool isBlocked}) {
1149+
if (isBlocked) return 'Unblock User';
1150+
return 'Block User';
1151+
}
1152+
11381153
@override
11391154
String toggleMuteUnmuteGroupQuestion({required bool isMuted}) {
11401155
if (isMuted) {

packages/stream_chat_flutter/lib/src/message_action/message_action.dart

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,30 @@ final class UnmuteUser extends MessageAction {
107107
final User user;
108108
}
109109

110+
/// Action to block a user, preventing direct messages from them
111+
final class BlockUser extends MessageAction {
112+
/// Create a new block user action
113+
const BlockUser({
114+
required super.message,
115+
required this.user,
116+
});
117+
118+
/// The user to be blocked.
119+
final User user;
120+
}
121+
122+
/// Action to unblock a previously blocked user
123+
final class UnblockUser extends MessageAction {
124+
/// Create a new unblock user action
125+
const UnblockUser({
126+
required super.message,
127+
required this.user,
128+
});
129+
130+
/// The user to be unblocked.
131+
final User user;
132+
}
133+
110134
/// Action to pin a message to make it prominently visible in the channel
111135
final class PinMessage extends MessageAction {
112136
/// Create a new pin message action

packages/stream_chat_flutter/lib/src/message_action/message_actions_builder.dart

Lines changed: 61 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,37 @@ class StreamMessageActionsBuilder {
153153
);
154154
}
155155

156+
// Pinning a private message is not allowed, simply because pinning a
157+
// message is meant to bring attention to that message, that is not possible
158+
// with a message that is only visible to a subset of users.
159+
if (canPinMessage && !isPrivateMessage) {
160+
final isPinned = message.pinned;
161+
final label = context.translations.togglePinUnpinText;
162+
163+
final action = switch (isPinned) {
164+
true => UnpinMessage(message: message),
165+
false => PinMessage(message: message),
166+
};
167+
168+
messageActions.add(
169+
StreamContextMenuAction(
170+
value: action,
171+
label: Text(label.call(pinned: isPinned)),
172+
leading: Icon(icons.pin),
173+
),
174+
);
175+
}
176+
177+
if (message.text case final text? when text.isNotEmpty) {
178+
messageActions.add(
179+
StreamContextMenuAction(
180+
value: CopyMessage(message: message),
181+
label: Text(context.translations.copyMessageLabel),
182+
leading: Icon(icons.copy),
183+
),
184+
);
185+
}
186+
156187
// Mark unread action is only available for other users' messages.
157188
if (canReceiveReadEvents && !isSentByCurrentUser) {
158189
StreamContextMenuAction<MessageAction> markUnreadAction() {
@@ -175,16 +206,6 @@ class StreamMessageActionsBuilder {
175206
}
176207
}
177208

178-
if (message.text case final text? when text.isNotEmpty) {
179-
messageActions.add(
180-
StreamContextMenuAction(
181-
value: CopyMessage(message: message),
182-
label: Text(context.translations.copyMessageLabel),
183-
leading: Icon(icons.copy),
184-
),
185-
);
186-
}
187-
188209
if (!containsPoll && !containsGiphy) {
189210
if (canUpdateAnyMessage || (canUpdateOwnMessage && isSentByCurrentUser)) {
190211
messageActions.add(
@@ -197,39 +218,6 @@ class StreamMessageActionsBuilder {
197218
}
198219
}
199220

200-
// Pinning a private message is not allowed, simply because pinning a
201-
// message is meant to bring attention to that message, that is not possible
202-
// with a message that is only visible to a subset of users.
203-
if (canPinMessage && !isPrivateMessage) {
204-
final isPinned = message.pinned;
205-
final label = context.translations.togglePinUnpinText;
206-
207-
final action = switch (isPinned) {
208-
true => UnpinMessage(message: message),
209-
false => PinMessage(message: message),
210-
};
211-
212-
messageActions.add(
213-
StreamContextMenuAction(
214-
value: action,
215-
label: Text(label.call(pinned: isPinned)),
216-
leading: Icon(icons.pin),
217-
),
218-
);
219-
}
220-
221-
if (canDeleteAnyMessage || (canDeleteOwnMessage && isSentByCurrentUser)) {
222-
final label = context.translations.toggleDeleteRetryDeleteMessageText;
223-
224-
messageActions.add(
225-
StreamContextMenuAction.destructive(
226-
value: DeleteMessage(message: message),
227-
leading: Icon(icons.delete),
228-
label: Text(label.call(isDeleteFailed: false)),
229-
),
230-
);
231-
}
232-
233221
if (!isSentByCurrentUser) {
234222
messageActions.add(
235223
StreamContextMenuAction(
@@ -259,6 +247,36 @@ class StreamMessageActionsBuilder {
259247
);
260248
}
261249

250+
if (message.user case final messageUser? when !isSentByCurrentUser) {
251+
final isBlocked = currentUser?.blockedUserIds.contains(messageUser.id) ?? false;
252+
final label = context.translations.toggleBlockUnblockUserText;
253+
254+
final action = switch (isBlocked) {
255+
true => UnblockUser(message: message, user: messageUser),
256+
false => BlockUser(message: message, user: messageUser),
257+
};
258+
259+
messageActions.add(
260+
StreamContextMenuAction.destructive(
261+
value: action,
262+
label: Text(label.call(isBlocked: isBlocked)),
263+
leading: Icon(isBlocked ? icons.userCheck : icons.noSign),
264+
),
265+
);
266+
}
267+
268+
if (canDeleteAnyMessage || (canDeleteOwnMessage && isSentByCurrentUser)) {
269+
final label = context.translations.toggleDeleteRetryDeleteMessageText;
270+
271+
messageActions.add(
272+
StreamContextMenuAction.destructive(
273+
value: DeleteMessage(message: message),
274+
leading: Icon(icons.delete),
275+
label: Text(label.call(isDeleteFailed: false)),
276+
),
277+
);
278+
}
279+
262280
return messageActions;
263281
}
264282
}

packages/stream_chat_flutter/lib/src/message_widget/stream_message_item.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -838,6 +838,8 @@ class DefaultStreamMessageItem extends StatelessWidget {
838838
MarkUnread() => channel.markUnread(action.message.id),
839839
MuteUser() => channel.client.muteUser(action.user.id),
840840
UnmuteUser() => channel.client.unmuteUser(action.user.id),
841+
BlockUser() => channel.client.blockUser(action.user.id),
842+
UnblockUser() => channel.client.unblockUser(action.user.id),
841843
PinMessage() => channel.pinMessage(action.message),
842844
UnpinMessage() => channel.unpinMessage(action.message),
843845
ResendMessage() => channel.retryMessage(action.message),

0 commit comments

Comments
 (0)