Skip to content

Commit 052f203

Browse files
sm-sayedirajveermalviya
authored andcommitted
compose_box: Replace compose box with a banner in DMs with deactivated users
Fixes: #675 Co-authored-by: Rajesh Malviya <[email protected]>
1 parent cc45115 commit 052f203

File tree

4 files changed

+195
-23
lines changed

4 files changed

+195
-23
lines changed

assets/l10n/app_en.arb

+4
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,10 @@
180180
"@successMessageLinkCopied": {
181181
"description": "Message when link of a message was copied to the user's system clipboard."
182182
},
183+
"errorBannerDeactivatedDmLabel": "You cannot send messages to deactivated users.",
184+
"@errorBannerDeactivatedDmLabel": {
185+
"description": "Label text for error banner when sending a message to one or multiple deactivated users."
186+
},
183187
"composeBoxAttachFilesTooltip": "Attach files",
184188
"@composeBoxAttachFilesTooltip": {
185189
"description": "Tooltip for compose box icon to attach a file to the message."

lib/widgets/compose_box.dart

+63-23
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import '../model/store.dart';
1616
import 'autocomplete.dart';
1717
import 'dialog.dart';
1818
import 'store.dart';
19+
import 'theme.dart';
1920

2021
const double _inputVerticalPadding = 8;
2122
const double _sendButtonSize = 36;
@@ -850,11 +851,13 @@ class _ComposeBoxLayout extends StatelessWidget {
850851
required this.sendButton,
851852
required this.contentController,
852853
required this.contentFocusNode,
854+
this.blockingErrorBanner,
853855
});
854856

855857
final Widget? topicInput;
856858
final Widget contentInput;
857859
final Widget sendButton;
860+
final Widget? blockingErrorBanner;
858861
final ComposeContentController contentController;
859862
final FocusNode contentFocusNode;
860863

@@ -883,28 +886,30 @@ class _ComposeBoxLayout extends StatelessWidget {
883886
minimum: const EdgeInsets.fromLTRB(8, 0, 8, 8),
884887
child: Padding(
885888
padding: const EdgeInsets.only(top: 8.0),
886-
child: Column(children: [
887-
Row(crossAxisAlignment: CrossAxisAlignment.end, children: [
888-
Expanded(
889-
child: Theme(
890-
data: inputThemeData,
891-
child: Column(children: [
892-
if (topicInput != null) topicInput!,
893-
if (topicInput != null) const SizedBox(height: 8),
894-
contentInput,
895-
]))),
896-
const SizedBox(width: 8),
897-
sendButton,
898-
]),
899-
Theme(
900-
data: themeData.copyWith(
901-
iconTheme: themeData.iconTheme.copyWith(color: colorScheme.onSurfaceVariant)),
902-
child: Row(children: [
903-
_AttachFileButton(contentController: contentController, contentFocusNode: contentFocusNode),
904-
_AttachMediaButton(contentController: contentController, contentFocusNode: contentFocusNode),
905-
_AttachFromCameraButton(contentController: contentController, contentFocusNode: contentFocusNode),
906-
])),
907-
])))); }
889+
child: blockingErrorBanner != null
890+
? SizedBox(width: double.infinity, child: blockingErrorBanner)
891+
: Column(children: [
892+
Row(crossAxisAlignment: CrossAxisAlignment.end, children: [
893+
Expanded(
894+
child: Theme(
895+
data: inputThemeData,
896+
child: Column(children: [
897+
if (topicInput != null) topicInput!,
898+
if (topicInput != null) const SizedBox(height: 8),
899+
contentInput,
900+
]))),
901+
const SizedBox(width: 8),
902+
sendButton,
903+
]),
904+
Theme(
905+
data: themeData.copyWith(
906+
iconTheme: themeData.iconTheme.copyWith(color: colorScheme.onSurfaceVariant)),
907+
child: Row(children: [
908+
_AttachFileButton(contentController: contentController, contentFocusNode: contentFocusNode),
909+
_AttachMediaButton(contentController: contentController, contentFocusNode: contentFocusNode),
910+
_AttachFromCameraButton(contentController: contentController, contentFocusNode: contentFocusNode),
911+
])),
912+
])))); }
908913
}
909914

910915
abstract class ComposeBoxController<T extends StatefulWidget> extends State<T> {
@@ -973,6 +978,27 @@ class _StreamComposeBoxState extends State<_StreamComposeBox> implements Compose
973978
}
974979
}
975980

981+
class _ErrorBanner extends StatelessWidget {
982+
const _ErrorBanner({required this.label});
983+
984+
final String label;
985+
986+
@override
987+
Widget build(BuildContext context) {
988+
final designVariables = DesignVariables.of(context);
989+
return Container(
990+
padding: const EdgeInsets.all(8),
991+
decoration: BoxDecoration(
992+
color: designVariables.errorBannerBackground,
993+
border: Border.all(color: designVariables.errorBannerBorder),
994+
borderRadius: BorderRadius.circular(5)),
995+
child: Text(label,
996+
style: TextStyle(fontSize: 18, color: designVariables.errorBannerLabel),
997+
),
998+
);
999+
}
1000+
}
1001+
9761002
class _FixedDestinationComposeBox extends StatefulWidget {
9771003
const _FixedDestinationComposeBox({super.key, required this.narrow});
9781004

@@ -998,6 +1024,19 @@ class _FixedDestinationComposeBoxState extends State<_FixedDestinationComposeBox
9981024
super.dispose();
9991025
}
10001026

1027+
Widget? _errorBanner(BuildContext context) {
1028+
if (widget.narrow case DmNarrow(:final otherRecipientIds)) {
1029+
final store = PerAccountStoreWidget.of(context);
1030+
final hasDeactivatedUser = otherRecipientIds.any((id) =>
1031+
!(store.users[id]?.isActive ?? true));
1032+
if (hasDeactivatedUser) {
1033+
return _ErrorBanner(label: ZulipLocalizations.of(context)
1034+
.errorBannerDeactivatedDmLabel);
1035+
}
1036+
}
1037+
return null;
1038+
}
1039+
10011040
@override
10021041
Widget build(BuildContext context) {
10031042
return _ComposeBoxLayout(
@@ -1013,7 +1052,8 @@ class _FixedDestinationComposeBoxState extends State<_FixedDestinationComposeBox
10131052
topicController: null,
10141053
contentController: _contentController,
10151054
getDestination: () => widget.narrow.destination,
1016-
));
1055+
),
1056+
blockingErrorBanner: _errorBanner(context));
10171057
}
10181058
}
10191059

lib/widgets/theme.dart

+21
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,9 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
141141
channelColorSwatches: ChannelColorSwatches.light,
142142
atMentionMarker: const HSLColor.fromAHSL(0.5, 0, 0, 0.2).toColor(),
143143
dmHeaderBg: const HSLColor.fromAHSL(1, 46, 0.35, 0.93).toColor(),
144+
errorBannerBackground: const HSLColor.fromAHSL(1, 4, 0.33, 0.90).toColor(),
145+
errorBannerBorder: const HSLColor.fromAHSL(0.4, 3, 0.57, 0.33).toColor(),
146+
errorBannerLabel: const HSLColor.fromAHSL(1, 4, 0.58, 0.33).toColor(),
144147
loginOrDivider: const Color(0xffdedede),
145148
loginOrDividerText: const Color(0xff575757),
146149
sectionCollapseIcon: const Color(0x7f1e2e48),
@@ -163,6 +166,9 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
163166
// TODO(#95) need proper dark-theme color (this is ad hoc)
164167
atMentionMarker: const HSLColor.fromAHSL(0.4, 0, 0, 1).toColor(),
165168
dmHeaderBg: const HSLColor.fromAHSL(1, 46, 0.15, 0.2).toColor(),
169+
errorBannerBackground: const HSLColor.fromAHSL(1, 0, 0.61, 0.19).toColor(),
170+
errorBannerBorder: const HSLColor.fromAHSL(0.4, 3, 0.73, 0.74).toColor(),
171+
errorBannerLabel: const HSLColor.fromAHSL(1, 2, 0.73, 0.80).toColor(),
166172
loginOrDivider: const Color(0xff424242),
167173
loginOrDividerText: const Color(0xffa8a8a8),
168174
// TODO(#95) need proper dark-theme color (this is ad hoc)
@@ -185,6 +191,9 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
185191
required this.channelColorSwatches,
186192
required this.atMentionMarker,
187193
required this.dmHeaderBg,
194+
required this.errorBannerBackground,
195+
required this.errorBannerBorder,
196+
required this.errorBannerLabel,
188197
required this.loginOrDivider,
189198
required this.loginOrDividerText,
190199
required this.sectionCollapseIcon,
@@ -218,6 +227,9 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
218227
// Not named variables in Figma; taken from older Figma drafts, or elsewhere.
219228
final Color atMentionMarker;
220229
final Color dmHeaderBg;
230+
final Color errorBannerBackground;
231+
final Color errorBannerBorder;
232+
final Color errorBannerLabel;
221233
final Color loginOrDivider; // TODO(#95) need proper dark-theme color (this is ad hoc)
222234
final Color loginOrDividerText; // TODO(#95) need proper dark-theme color (this is ad hoc)
223235
final Color sectionCollapseIcon;
@@ -238,6 +250,9 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
238250
ChannelColorSwatches? channelColorSwatches,
239251
Color? atMentionMarker,
240252
Color? dmHeaderBg,
253+
Color? errorBannerBackground,
254+
Color? errorBannerBorder,
255+
Color? errorBannerLabel,
241256
Color? loginOrDivider,
242257
Color? loginOrDividerText,
243258
Color? sectionCollapseIcon,
@@ -257,6 +272,9 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
257272
channelColorSwatches: channelColorSwatches ?? this.channelColorSwatches,
258273
atMentionMarker: atMentionMarker ?? this.atMentionMarker,
259274
dmHeaderBg: dmHeaderBg ?? this.dmHeaderBg,
275+
errorBannerBackground: errorBannerBackground ?? this.errorBannerBackground,
276+
errorBannerBorder: errorBannerBorder ?? this.errorBannerBorder,
277+
errorBannerLabel: errorBannerLabel ?? this.errorBannerLabel,
260278
loginOrDivider: loginOrDivider ?? this.loginOrDivider,
261279
loginOrDividerText: loginOrDividerText ?? this.loginOrDividerText,
262280
sectionCollapseIcon: sectionCollapseIcon ?? this.sectionCollapseIcon,
@@ -283,6 +301,9 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
283301
channelColorSwatches: ChannelColorSwatches.lerp(channelColorSwatches, other.channelColorSwatches, t),
284302
atMentionMarker: Color.lerp(atMentionMarker, other.atMentionMarker, t)!,
285303
dmHeaderBg: Color.lerp(dmHeaderBg, other.dmHeaderBg, t)!,
304+
errorBannerBackground: Color.lerp(errorBannerBackground, other.errorBannerBackground, t)!,
305+
errorBannerBorder: Color.lerp(errorBannerBorder, other.errorBannerBorder, t)!,
306+
errorBannerLabel: Color.lerp(errorBannerLabel, other.errorBannerLabel, t)!,
286307
loginOrDivider: Color.lerp(loginOrDivider, other.loginOrDivider, t)!,
287308
loginOrDividerText: Color.lerp(loginOrDividerText, other.loginOrDividerText, t)!,
288309
sectionCollapseIcon: Color.lerp(sectionCollapseIcon, other.sectionCollapseIcon, t)!,

test/widgets/compose_box_test.dart

+107
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import 'package:http/http.dart' as http;
66
import 'package:flutter/material.dart';
77
import 'package:flutter_test/flutter_test.dart';
88
import 'package:image_picker/image_picker.dart';
9+
import 'package:zulip/api/model/events.dart';
910
import 'package:zulip/api/model/model.dart';
1011
import 'package:zulip/api/route/messages.dart';
1112
import 'package:zulip/model/localizations.dart';
@@ -366,4 +367,110 @@ void main() {
366367
// TODO test what happens when capturing/uploading fails
367368
});
368369
});
370+
371+
group('compose box in DMs with deactivated users', () {
372+
Finder contentFieldFinder() => find.descendant(
373+
of: find.byType(ComposeBox),
374+
matching: find.byType(TextField));
375+
376+
Finder attachButtonFinder(IconData icon) => find.descendant(
377+
of: find.byType(ComposeBox),
378+
matching: find.widgetWithIcon(IconButton, icon));
379+
380+
void checkComposeBoxParts({required bool areShown}) {
381+
check(contentFieldFinder().evaluate().length).equals(areShown ? 1 : 0);
382+
check(attachButtonFinder(Icons.attach_file).evaluate().length).equals(areShown ? 1 : 0);
383+
check(attachButtonFinder(Icons.image).evaluate().length).equals(areShown ? 1 : 0);
384+
check(attachButtonFinder(Icons.camera_alt).evaluate().length).equals(areShown ? 1 : 0);
385+
}
386+
387+
void checkBanner({required bool isShown}) {
388+
final bannerTextFinder = find.text(GlobalLocalizations.zulipLocalizations
389+
.errorBannerDeactivatedDmLabel);
390+
check(bannerTextFinder.evaluate().length).equals(isShown ? 1 : 0);
391+
}
392+
393+
void checkComposeBox({required bool isShown}) {
394+
checkComposeBoxParts(areShown: isShown);
395+
checkBanner(isShown: !isShown);
396+
}
397+
398+
Future<void> changeUserStatus(WidgetTester tester,
399+
{required User user, required bool isActive}) async {
400+
await store.handleEvent(RealmUserUpdateEvent(id: 1,
401+
userId: user.userId, isActive: isActive));
402+
await tester.pump();
403+
}
404+
405+
DmNarrow dmNarrowWith(User otherUser) => DmNarrow.withUser(otherUser.userId,
406+
selfUserId: eg.selfUser.userId);
407+
408+
DmNarrow groupDmNarrowWith(List<User> otherUsers) => DmNarrow.withOtherUsers(
409+
otherUsers.map((u) => u.userId), selfUserId: eg.selfUser.userId);
410+
411+
group('1:1 DMs', () {
412+
testWidgets('compose box replaced with a banner', (tester) async {
413+
final deactivatedUser = eg.user(isActive: false);
414+
await prepareComposeBox(tester, narrow: dmNarrowWith(deactivatedUser),
415+
users: [deactivatedUser]);
416+
checkComposeBox(isShown: false);
417+
});
418+
419+
testWidgets('active user becomes deactivated -> '
420+
'compose box is replaced with a banner', (tester) async {
421+
final activeUser = eg.user(isActive: true);
422+
await prepareComposeBox(tester, narrow: dmNarrowWith(activeUser),
423+
users: [activeUser]);
424+
checkComposeBox(isShown: true);
425+
426+
await changeUserStatus(tester, user: activeUser, isActive: false);
427+
checkComposeBox(isShown: false);
428+
});
429+
430+
testWidgets('deactivated user becomes active -> '
431+
'banner is replaced with the compose box', (tester) async {
432+
final deactivatedUser = eg.user(isActive: false);
433+
await prepareComposeBox(tester, narrow: dmNarrowWith(deactivatedUser),
434+
users: [deactivatedUser]);
435+
checkComposeBox(isShown: false);
436+
437+
await changeUserStatus(tester, user: deactivatedUser, isActive: true);
438+
checkComposeBox(isShown: true);
439+
});
440+
});
441+
442+
group('group DMs', () {
443+
testWidgets('compose box replaced with a banner', (tester) async {
444+
final deactivatedUsers = [eg.user(isActive: false), eg.user(isActive: false)];
445+
await prepareComposeBox(tester, narrow: groupDmNarrowWith(deactivatedUsers),
446+
users: deactivatedUsers);
447+
checkComposeBox(isShown: false);
448+
});
449+
450+
testWidgets('at least one user becomes deactivated -> '
451+
'compose box is replaced with a banner', (tester) async {
452+
final activeUsers = [eg.user(isActive: true), eg.user(isActive: true)];
453+
await prepareComposeBox(tester, narrow: groupDmNarrowWith(activeUsers),
454+
users: activeUsers);
455+
checkComposeBox(isShown: true);
456+
457+
await changeUserStatus(tester, user: activeUsers[0], isActive: false);
458+
checkComposeBox(isShown: false);
459+
});
460+
461+
testWidgets('all deactivated users become active -> '
462+
'banner is replaced with the compose box', (tester) async {
463+
final deactivatedUsers = [eg.user(isActive: false), eg.user(isActive: false)];
464+
await prepareComposeBox(tester, narrow: groupDmNarrowWith(deactivatedUsers),
465+
users: deactivatedUsers);
466+
checkComposeBox(isShown: false);
467+
468+
await changeUserStatus(tester, user: deactivatedUsers[0], isActive: true);
469+
checkComposeBox(isShown: false);
470+
471+
await changeUserStatus(tester, user: deactivatedUsers[1], isActive: true);
472+
checkComposeBox(isShown: true);
473+
});
474+
});
475+
});
369476
}

0 commit comments

Comments
 (0)