Skip to content

Commit 2fca6a5

Browse files
compose_box: Replace compose box with a banner in DMs with deactivated users
Fixes: zulip#675 Co-authored-by: Rajesh Malviya <[email protected]>
1 parent 4eb6fc3 commit 2fca6a5

File tree

4 files changed

+201
-28
lines changed

4 files changed

+201
-28
lines changed

assets/l10n/app_en.arb

Lines changed: 4 additions & 0 deletions
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

Lines changed: 69 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,6 @@ class _ContentInput extends StatelessWidget {
284284
@override
285285
Widget build(BuildContext context) {
286286
ColorScheme colorScheme = Theme.of(context).colorScheme;
287-
288287
return InputDecorator(
289288
decoration: const InputDecoration(),
290289
child: ConstrainedBox(
@@ -505,7 +504,10 @@ Future<void> _uploadFiles({
505504
}
506505

507506
abstract class _AttachUploadsButton extends StatelessWidget {
508-
const _AttachUploadsButton({required this.contentController, required this.contentFocusNode});
507+
const _AttachUploadsButton({
508+
required this.contentController,
509+
required this.contentFocusNode,
510+
});
509511

510512
final ComposeContentController contentController;
511513
final FocusNode contentFocusNode;
@@ -606,7 +608,10 @@ Future<Iterable<_File>> _getFilePickerFiles(BuildContext context, FileType type)
606608
}
607609

608610
class _AttachFileButton extends _AttachUploadsButton {
609-
const _AttachFileButton({required super.contentController, required super.contentFocusNode});
611+
const _AttachFileButton({
612+
required super.contentController,
613+
required super.contentFocusNode,
614+
});
610615

611616
@override
612617
IconData get icon => Icons.attach_file;
@@ -622,7 +627,10 @@ class _AttachFileButton extends _AttachUploadsButton {
622627
}
623628

624629
class _AttachMediaButton extends _AttachUploadsButton {
625-
const _AttachMediaButton({required super.contentController, required super.contentFocusNode});
630+
const _AttachMediaButton({
631+
required super.contentController,
632+
required super.contentFocusNode,
633+
});
626634

627635
@override
628636
IconData get icon => Icons.image;
@@ -639,7 +647,10 @@ class _AttachMediaButton extends _AttachUploadsButton {
639647
}
640648

641649
class _AttachFromCameraButton extends _AttachUploadsButton {
642-
const _AttachFromCameraButton({required super.contentController, required super.contentFocusNode});
650+
const _AttachFromCameraButton({
651+
required super.contentController,
652+
required super.contentFocusNode,
653+
});
643654

644655
@override
645656
IconData get icon => Icons.camera_alt;
@@ -850,11 +861,13 @@ class _ComposeBoxLayout extends StatelessWidget {
850861
required this.sendButton,
851862
required this.contentController,
852863
required this.contentFocusNode,
864+
this.placeholder,
853865
});
854866

855867
final Widget? topicInput;
856868
final Widget contentInput;
857869
final Widget sendButton;
870+
final Widget? placeholder;
858871
final ComposeContentController contentController;
859872
final FocusNode contentFocusNode;
860873

@@ -883,7 +896,7 @@ class _ComposeBoxLayout extends StatelessWidget {
883896
minimum: const EdgeInsets.fromLTRB(8, 0, 8, 8),
884897
child: Padding(
885898
padding: const EdgeInsets.only(top: 8.0),
886-
child: Column(children: [
899+
child: placeholder ?? Column(children: [
887900
Row(crossAxisAlignment: CrossAxisAlignment.end, children: [
888901
Expanded(
889902
child: Theme(
@@ -900,9 +913,18 @@ class _ComposeBoxLayout extends StatelessWidget {
900913
data: themeData.copyWith(
901914
iconTheme: themeData.iconTheme.copyWith(color: colorScheme.onSurfaceVariant)),
902915
child: Row(children: [
903-
_AttachFileButton(contentController: contentController, contentFocusNode: contentFocusNode),
904-
_AttachMediaButton(contentController: contentController, contentFocusNode: contentFocusNode),
905-
_AttachFromCameraButton(contentController: contentController, contentFocusNode: contentFocusNode),
916+
_AttachFileButton(
917+
contentController: contentController,
918+
contentFocusNode: contentFocusNode,
919+
),
920+
_AttachMediaButton(
921+
contentController: contentController,
922+
contentFocusNode: contentFocusNode,
923+
),
924+
_AttachFromCameraButton(
925+
contentController: contentController,
926+
contentFocusNode: contentFocusNode,
927+
),
906928
])),
907929
])))); }
908930
}
@@ -982,6 +1004,29 @@ class _FixedDestinationComposeBox extends StatefulWidget {
9821004
State<_FixedDestinationComposeBox> createState() => _FixedDestinationComposeBoxState();
9831005
}
9841006

1007+
class _ErrorBanner extends StatelessWidget {
1008+
const _ErrorBanner({required this.label});
1009+
1010+
final String label;
1011+
1012+
@override
1013+
Widget build(BuildContext context) {
1014+
return Container(
1015+
padding: const EdgeInsets.all(8),
1016+
decoration: BoxDecoration(
1017+
color: const Color.fromRGBO(238, 222, 221, 1),
1018+
border: Border.all(color: const Color.fromRGBO(132, 41, 36, 0.4)),
1019+
borderRadius: BorderRadius.circular(5)),
1020+
child: Text(label,
1021+
maxLines: 2,
1022+
overflow: TextOverflow.ellipsis,
1023+
style: const TextStyle(fontSize: 18,
1024+
color: Color.fromRGBO(133, 42, 35, 1)),
1025+
),
1026+
);
1027+
}
1028+
}
1029+
9851030
class _FixedDestinationComposeBoxState extends State<_FixedDestinationComposeBox> implements ComposeBoxController<_FixedDestinationComposeBox> {
9861031
@override ComposeTopicController? get topicController => null;
9871032

@@ -998,6 +1043,19 @@ class _FixedDestinationComposeBoxState extends State<_FixedDestinationComposeBox
9981043
super.dispose();
9991044
}
10001045

1046+
Widget? _placeholder(BuildContext context) {
1047+
if (widget.narrow case DmNarrow(:final otherRecipientIds)) {
1048+
final store = PerAccountStoreWidget.of(context);
1049+
final showPlaceholder = otherRecipientIds.any((id) =>
1050+
!(store.users[id]?.isActive ?? true));
1051+
if (showPlaceholder) {
1052+
return _ErrorBanner(label: ZulipLocalizations.of(context)
1053+
.errorBannerDeactivatedDmLabel);
1054+
}
1055+
}
1056+
return null;
1057+
}
1058+
10011059
@override
10021060
Widget build(BuildContext context) {
10031061
return _ComposeBoxLayout(
@@ -1013,7 +1071,8 @@ class _FixedDestinationComposeBoxState extends State<_FixedDestinationComposeBox
10131071
topicController: null,
10141072
contentController: _contentController,
10151073
getDestination: () => widget.narrow.destination,
1016-
));
1074+
),
1075+
placeholder: _placeholder(context));
10171076
}
10181077
}
10191078

lib/widgets/message_list.dart

Lines changed: 19 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -117,24 +117,25 @@ class _MessageListPageState extends State<MessageListPage> implements MessageLis
117117
// https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=147%3A9088&mode=dev
118118
body: Builder(
119119
builder: (BuildContext context) => Center(
120-
child: Column(children: [
121-
MediaQuery.removePadding(
122-
// Scaffold knows about the app bar, and so has run this
123-
// BuildContext, which is under `body`, through
124-
// MediaQuery.removePadding with `removeTop: true`.
125-
context: context,
126-
127-
// The compose box, when present, pads the bottom inset.
128-
// TODO this copies the details of when the compose box is shown;
129-
// if those details get complicated, refactor to avoid copying.
130-
// TODO(#311) If we have a bottom nav, it will pad the bottom
131-
// inset, and this should always be true.
132-
removeBottom: widget.narrow is! CombinedFeedNarrow,
133-
134-
child: Expanded(
135-
child: MessageList(narrow: widget.narrow))),
136-
ComposeBox(controllerKey: _composeBoxKey, narrow: widget.narrow),
137-
]))));
120+
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch,
121+
children: [
122+
MediaQuery.removePadding(
123+
// Scaffold knows about the app bar, and so has run this
124+
// BuildContext, which is under `body`, through
125+
// MediaQuery.removePadding with `removeTop: true`.
126+
context: context,
127+
128+
// The compose box, when present, pads the bottom inset.
129+
// TODO this copies the details of when the compose box is shown;
130+
// if those details get complicated, refactor to avoid copying.
131+
// TODO(#311) If we have a bottom nav, it will pad the bottom
132+
// inset, and this should always be true.
133+
removeBottom: widget.narrow is! CombinedFeedNarrow,
134+
135+
child: Expanded(
136+
child: MessageList(narrow: widget.narrow))),
137+
ComposeBox(controllerKey: _composeBoxKey, narrow: widget.narrow),
138+
]))));
138139
}
139140
}
140141

test/widgets/compose_box_test.dart

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

0 commit comments

Comments
 (0)