Skip to content

Commit d06abe7

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

File tree

4 files changed

+173
-20
lines changed

4 files changed

+173
-20
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: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -850,11 +850,13 @@ class _ComposeBoxLayout extends StatelessWidget {
850850
required this.sendButton,
851851
required this.contentController,
852852
required this.contentFocusNode,
853+
this.placeholder,
853854
});
854855

855856
final Widget? topicInput;
856857
final Widget contentInput;
857858
final Widget sendButton;
859+
final Widget? placeholder;
858860
final ComposeContentController contentController;
859861
final FocusNode contentFocusNode;
860862

@@ -883,7 +885,7 @@ class _ComposeBoxLayout extends StatelessWidget {
883885
minimum: const EdgeInsets.fromLTRB(8, 0, 8, 8),
884886
child: Padding(
885887
padding: const EdgeInsets.only(top: 8.0),
886-
child: Column(children: [
888+
child: placeholder ?? Column(children: [
887889
Row(crossAxisAlignment: CrossAxisAlignment.end, children: [
888890
Expanded(
889891
child: Theme(
@@ -982,6 +984,29 @@ class _FixedDestinationComposeBox extends StatefulWidget {
982984
State<_FixedDestinationComposeBox> createState() => _FixedDestinationComposeBoxState();
983985
}
984986

987+
class _ErrorBanner extends StatelessWidget {
988+
const _ErrorBanner({required this.label});
989+
990+
final String label;
991+
992+
@override
993+
Widget build(BuildContext context) {
994+
return Container(
995+
padding: const EdgeInsets.all(8),
996+
decoration: BoxDecoration(
997+
color: const Color.fromRGBO(238, 222, 221, 1),
998+
border: Border.all(color: const Color.fromRGBO(132, 41, 36, 0.4)),
999+
borderRadius: BorderRadius.circular(5)),
1000+
child: Text(label,
1001+
maxLines: 2,
1002+
overflow: TextOverflow.ellipsis,
1003+
style: const TextStyle(fontSize: 18,
1004+
color: Color.fromRGBO(133, 42, 35, 1)),
1005+
),
1006+
);
1007+
}
1008+
}
1009+
9851010
class _FixedDestinationComposeBoxState extends State<_FixedDestinationComposeBox> implements ComposeBoxController<_FixedDestinationComposeBox> {
9861011
@override ComposeTopicController? get topicController => null;
9871012

@@ -998,6 +1023,19 @@ class _FixedDestinationComposeBoxState extends State<_FixedDestinationComposeBox
9981023
super.dispose();
9991024
}
10001025

1026+
Widget? _placeholder(BuildContext context) {
1027+
if (widget.narrow case DmNarrow(:final otherRecipientIds)) {
1028+
final store = PerAccountStoreWidget.of(context);
1029+
final showPlaceholder = otherRecipientIds.any((id) =>
1030+
!(store.users[id]?.isActive ?? true));
1031+
if (showPlaceholder) {
1032+
return _ErrorBanner(label: ZulipLocalizations.of(context)
1033+
.errorBannerDeactivatedDmLabel);
1034+
}
1035+
}
1036+
return null;
1037+
}
1038+
10011039
@override
10021040
Widget build(BuildContext context) {
10031041
return _ComposeBoxLayout(
@@ -1013,7 +1051,8 @@ class _FixedDestinationComposeBoxState extends State<_FixedDestinationComposeBox
10131051
topicController: null,
10141052
contentController: _contentController,
10151053
getDestination: () => widget.narrow.destination,
1016-
));
1054+
),
1055+
placeholder: _placeholder(context));
10171056
}
10181057
}
10191058

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)