Line data Source code
1 : import 'dart:io';
2 :
3 : import 'package:cwtch/config.dart';
4 : import 'package:cwtch/cwtch_icons_icons.dart';
5 : import 'package:cwtch/controllers/filesharing.dart' as filesharing;
6 : import 'package:cwtch/settings.dart';
7 : import 'package:cwtch/models/appstate.dart';
8 : import 'package:cwtch/models/contact.dart';
9 : import 'package:cwtch/models/contactlist.dart';
10 : import 'package:cwtch/models/groupmembers.dart';
11 : import 'package:cwtch/models/profile.dart';
12 : import 'package:cwtch/widgets/buttontextfield.dart';
13 : import 'package:cwtch/widgets/cwtchlabel.dart';
14 : import 'package:cwtch/widgets/DropdownContacts.dart';
15 : import 'package:cwtch/widgets/profileimage.dart';
16 : import 'package:flutter/services.dart';
17 : import 'package:flutter/material.dart';
18 : import 'package:provider/provider.dart';
19 : import 'package:cwtch/l10n/app_localizations.dart';
20 :
21 : import '../constants.dart';
22 : import '../main.dart';
23 : import '../themes/opaque.dart';
24 :
25 : enum RolesMenu { admin, op, mod, voice, user }
26 :
27 : class MembershipView extends StatefulWidget {
28 0 : @override
29 0 : _MembershipViewState createState() => _MembershipViewState();
30 : }
31 :
32 : class _MembershipViewState extends State<MembershipView> {
33 : String selectedContact = "";
34 : ScrollController membersScrollController = ScrollController();
35 : ScrollController bansScrollController = ScrollController();
36 :
37 : final scaffoldKey = GlobalKey<ScaffoldMessengerState>();
38 :
39 0 : @override
40 : void dispose() {
41 0 : super.dispose();
42 : }
43 :
44 : ScrollController peerSettingsScrollController = ScrollController();
45 :
46 0 : @override
47 : void initState() {
48 0 : super.initState();
49 : }
50 :
51 0 : @override
52 : Widget build(BuildContext context) {
53 0 : var handle = Provider.of<ContactInfoState>(context).nickname;
54 0 : if (handle.isEmpty) {
55 0 : handle = Provider.of<ContactInfoState>(context).onion;
56 : }
57 0 : return DefaultTabController(
58 : length: 2,
59 0 : child: Scaffold(
60 0 : appBar: AppBar(
61 0 : title: Container(height: Provider.of<Settings>(context).fontScaling * 24.0, clipBehavior: Clip.hardEdge, decoration: BoxDecoration(), child: Text(handle + " " + "t9n(Membership)")),
62 0 : bottom: TabBar(
63 : isScrollable: true,
64 0 : tabs: [
65 0 : Tab(key: Key("OpenGroupMembers"), icon: Icon(CwtchIcons.edit_member_list), text: "t9n(Members)"),
66 0 : Tab(key: Key("OpenGroupBans"), icon: Icon(CwtchIcons.block_peer), text: "t9n(Bans)"),
67 : ],
68 : ),
69 : ),
70 0 : body: _buildMembersList(),
71 : ),
72 : );
73 : }
74 :
75 0 : Widget _buildMembersList() {
76 0 : return Consumer<Settings>(
77 0 : builder: (context, settings, child) {
78 0 : return LayoutBuilder(
79 0 : builder: (BuildContext context, BoxConstraints viewportConstraints) {
80 0 : return TabBarView(
81 0 : children: [_tabHelper(_buildMembersTab(settings), "EditGroupMembers", this.membersScrollController), _tabHelper(_buildBansTab(settings), "EditGroupBans", this.bansScrollController)],
82 : );
83 : },
84 : );
85 : },
86 : );
87 : }
88 :
89 0 : Widget _tabHelper(Widget tab, String scrollKey, ScrollController sc) {
90 0 : return Consumer<Settings>(
91 0 : builder: (ccontext, settings, child) {
92 0 : return LayoutBuilder(
93 0 : builder: (BuildContext context, BoxConstraints viewportConstraints) {
94 0 : return Scrollbar(
95 0 : key: Key(scrollKey),
96 : trackVisibility: true,
97 : controller: sc,
98 0 : child: SingleChildScrollView(
99 : clipBehavior: Clip.antiAlias,
100 : controller: sc,
101 0 : child: ConstrainedBox(
102 0 : constraints: BoxConstraints(minHeight: viewportConstraints.maxHeight, maxWidth: viewportConstraints.maxWidth),
103 0 : child: Material(
104 0 : color: settings.theme.backgroundPaneColor,
105 0 : child: Container(width: viewportConstraints.maxWidth / 2, margin: EdgeInsetsGeometry.all(16.0), child: tab),
106 : ),
107 : ),
108 : ),
109 : );
110 : },
111 : );
112 : },
113 : );
114 : }
115 :
116 0 : Widget _buildMembersTab(Settings settings) {
117 0 : List<Widget> rolesLists = List<Widget>.empty(growable: true);
118 0 : rolesLists.addAll(_buildRolesBox("admin", "t9n(Admins)"));
119 0 : rolesLists.addAll(_buildRolesBox("op", "t9n(Operators)"));
120 0 : rolesLists.addAll(_buildRolesBox("mod", "t9n(Moderators)"));
121 0 : rolesLists.addAll(_buildRolesBox("voice", "t9n(Voices)"));
122 0 : rolesLists.addAll(_buildRolesBox("user", "t9n(Users)"));
123 : //rolesLists.addAll(_buildRolesBox("blocked", "t9n(Banned)"));
124 : //rolesLists.addAll(_buildRolesBox("", "dev/debug"));
125 :
126 0 : bool pending = Provider.of<ContactInfoState>(context).isOpPending;
127 :
128 0 : return Column(
129 : mainAxisAlignment: MainAxisAlignment.start,
130 : crossAxisAlignment: CrossAxisAlignment.stretch,
131 : spacing: 8.0,
132 0 : children: [
133 0 : Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: rolesLists),
134 0 : _addMemberBox(),
135 : ],
136 : );
137 : }
138 :
139 0 : Widget _buildBansTab(Settings settings) {
140 0 : List<Widget> rolesLists = List<Widget>.empty(growable: true);
141 0 : rolesLists.addAll(_buildRolesBox("blocked", "t9n(Banned)"));
142 0 : rolesLists.addAll(_buildRolesBox("", "dev/debug"));
143 0 : if (rolesLists.length == 0) {
144 0 : return Text("t9n(If someone is banned from the group, their handle will appear here.)");
145 : }
146 0 : rolesLists.add(Text("t9n(To unban someone, use the Add New Member control to add them back to the member list.)"));
147 :
148 0 : bool pending = Provider.of<ContactInfoState>(context).isOpPending;
149 :
150 0 : return Column(mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.stretch, children: rolesLists);
151 : }
152 :
153 0 : List<Widget> _buildRolesBox(String role, String header) {
154 0 : List<GroupMember> members = Provider.of<ContactInfoState>(context).getMembersOfRole(role);
155 0 : if (members.length == 0) return List<Widget>.empty(growable: true);
156 :
157 0 : List<Widget> retVal = List<Widget>.from([CwtchLabel(label: header)]);
158 0 : retVal.addAll(members.map((ev) => _memberListEntry(ev)).toList());
159 :
160 : return retVal;
161 : }
162 :
163 : // note this method closely resembles MembersDrawer::_contactEntry
164 0 : Widget _memberListEntry(dynamic member) {
165 0 : bool pending = Provider.of<ContactInfoState>(context).isOpPending;
166 0 : bool isMe = member.onion == Provider.of<ProfileInfoState>(context).onion;
167 0 : var contact = Provider.of<ProfileInfoState>(context).contactList.findContact(member.onion);
168 0 : String name = isMe ? Provider.of<ProfileInfoState>(context).nickname : contact?.nickname ?? member.onion;
169 : if (contact == null) {
170 0 : contact = ContactInfoState(member.onion, -1, member.onion); //cheeky
171 : }
172 :
173 0 : return Flex(
174 : direction: Axis.horizontal,
175 : mainAxisAlignment: MainAxisAlignment.spaceBetween,
176 : spacing: 16.0,
177 0 : children: <Widget>[
178 0 : Flexible(
179 : flex: 1,
180 : child: isMe
181 0 : ? ProfileImage.fromProfile(
182 0 : Provider.of<Settings>(context),
183 0 : Provider.of<ProfileInfoState>(context),
184 : 48.0, //diameter
185 : )
186 0 : : ProfileImage.fromContact(
187 0 : Provider.of<Settings>(context),
188 : contact!,
189 : 48.0, //diameter
190 : ),
191 : ),
192 0 : Expanded(
193 0 : child: Column(
194 0 : children: [
195 0 : Visibility(
196 0 : visible: name.isNotEmpty && name != member.onion,
197 0 : child: Container(
198 0 : height: 18.0 * Provider.of<Settings>(context).fontScaling + 18.0,
199 : clipBehavior: Clip.hardEdge,
200 0 : decoration: BoxDecoration(),
201 0 : padding: EdgeInsets.fromLTRB(0, 5, 0, 0),
202 0 : child: Text(
203 : name,
204 : semanticsLabel: name,
205 0 : style: TextStyle(fontFamily: "Inter", fontSize: 18.0 * Provider.of<Settings>(context).fontScaling, fontWeight: FontWeight.bold),
206 : softWrap: true,
207 : overflow: TextOverflow.ellipsis,
208 : ),
209 : ),
210 : ),
211 0 : Visibility(
212 0 : visible: !Provider.of<Settings>(context).streamerMode,
213 0 : child: Container(
214 : clipBehavior: Clip.hardEdge,
215 0 : decoration: BoxDecoration(),
216 0 : height: 14.0 * Provider.of<Settings>(context).fontScaling + 14.0,
217 0 : padding: EdgeInsets.fromLTRB(0, 0, 0, 0),
218 0 : child: ExcludeSemantics(
219 0 : child: Text(
220 0 : member.onion,
221 : softWrap: true,
222 0 : style: TextStyle(
223 : fontFamily: "RobotoMono",
224 0 : fontSize: 14.0 * Provider.of<Settings>(context).fontScaling,
225 0 : color: ((Provider.of<Settings>(context).theme.mainTextColor) as Color).withOpacity(0.8),
226 : ),
227 : overflow: TextOverflow.ellipsis,
228 : ),
229 : ),
230 : ),
231 : ),
232 : ],
233 : ),
234 : ),
235 0 : Flexible(
236 : flex: 1,
237 0 : child: Row(
238 : mainAxisAlignment: MainAxisAlignment.end,
239 0 : children: [
240 0 : Visibility(visible: member.role != "blocked", child: _changeRoleMenu(member.onion, member.role)),
241 0 : Visibility(
242 0 : visible: member.role != "blocked" && CanKick(member.role),
243 0 : child: IconButton(
244 : enableFeedback: true,
245 0 : splashRadius: Material.defaultSplashRadius / 2,
246 : tooltip: "t9n(kick member)",
247 0 : icon: Icon(CwtchIcons.block_peer, color: Provider.of<Settings>(context).current().mainTextColor),
248 : onPressed: pending
249 : ? null
250 0 : : () {
251 0 : _kickPressed(member.onion);
252 : },
253 : ),
254 : ),
255 : ],
256 : ),
257 : ),
258 : ],
259 : );
260 : }
261 :
262 0 : Widget _changeRoleMenu(String onion, String currentRole) {
263 0 : String? myRole = MyRole();
264 0 : return PopupMenuButton<RolesMenu>(
265 : enabled: true, //TODO
266 0 : icon: GroupMember.roleIcon(currentRole, 32.0),
267 : tooltip: "t9n(Change role)",
268 0 : splashRadius: Material.defaultSplashRadius / 2,
269 0 : onSelected: (RolesMenu item) {
270 : switch (item) {
271 0 : case RolesMenu.admin:
272 0 : _changeRolePressed(onion, 'admin');
273 : break;
274 0 : case RolesMenu.op:
275 0 : _changeRolePressed(onion, 'op');
276 : break;
277 0 : case RolesMenu.mod:
278 0 : _changeRolePressed(onion, 'mod');
279 : break;
280 0 : case RolesMenu.voice:
281 0 : _changeRolePressed(onion, 'voice');
282 : break;
283 0 : case RolesMenu.user:
284 0 : _changeRolePressed(onion, 'user');
285 : break;
286 : default:
287 : break;
288 : }
289 : },
290 0 : itemBuilder: (BuildContext context) => <PopupMenuEntry<RolesMenu>>[
291 0 : _popupItem(myRole, currentRole, 'admin', RolesMenu.admin),
292 0 : _popupItem(myRole, currentRole, 'op', RolesMenu.op),
293 0 : _popupItem(myRole, currentRole, 'mod', RolesMenu.mod),
294 0 : _popupItem(myRole, currentRole, 'voice', RolesMenu.voice),
295 0 : _popupItem(myRole, currentRole, 'user', RolesMenu.user),
296 : ],
297 : );
298 : }
299 :
300 0 : PopupMenuItem<RolesMenu> _popupItem(String? myRole, String? currentRole, String? targetRole, RolesMenu targetRoleEnum) {
301 0 : return PopupMenuItem<RolesMenu>(
302 0 : enabled: CanChangeRole(myRole, currentRole, targetRole),
303 : value: targetRoleEnum,
304 0 : child: Row(
305 : spacing: 12.0,
306 0 : children: [
307 0 : GroupMember.roleIcon(targetRole, 24.0),
308 0 : Text(GroupMember.roleL10n(targetRole), style: Provider.of<Settings>(context, listen: false).scaleFonts(defaultTextButtonStyle)),
309 : ],
310 : ),
311 : );
312 : }
313 :
314 0 : Widget _addMemberBox() {
315 0 : return Visibility(
316 0 : visible: CanInvite(),
317 0 : child: Column(
318 0 : children: [
319 0 : CwtchLabel(label: "t9n(Add new member)"),
320 0 : Visibility(
321 0 : visible: Provider.of<ContactInfoState>(context).modeLine.contains("o"),
322 0 : child: Text("t9n(This is an open group. You can invite a new member here, or they can join directly by using the invite string found in Conversation Settings.)"),
323 : ),
324 0 : _addMemberControl(Provider.of<ContactInfoState>(context).members),
325 : ],
326 : ),
327 : );
328 : }
329 :
330 0 : Widget _addMemberControl(List<GroupMember> members) {
331 0 : bool pending = Provider.of<ContactInfoState>(context).isOpPending;
332 0 : return Column(
333 0 : children: [
334 0 : DropdownContacts(
335 0 : filter: (contact) {
336 0 : return (!members.any((x) => x.onion == contact.onion) || members.any((x) => x.role == "blocked")) && !contact.isManaged && !contact.isGroup;
337 : },
338 0 : onChanged: (newVal) {
339 0 : setState(() {
340 0 : this.selectedContact = newVal;
341 : });
342 : },
343 : ),
344 0 : ElevatedButton(
345 0 : key: Key("addGroupMemberBtn"),
346 0 : onPressed: pending || this.selectedContact == "" ? null : _addPressed,
347 0 : style: ElevatedButton.styleFrom(
348 0 : minimumSize: Size(400, 75),
349 0 : maximumSize: Size(800, 75),
350 0 : shape: RoundedRectangleBorder(
351 0 : borderRadius: BorderRadius.horizontal(left: Radius.circular(180), right: Radius.circular(180)),
352 : ),
353 : ),
354 0 : child: Text("t9n(Add)", textAlign: TextAlign.center),
355 : ),
356 : ],
357 : );
358 : }
359 :
360 0 : void _addPressed() {
361 0 : setState(() {
362 0 : Provider.of<ContactInfoState>(context, listen: false).isOpPending = true;
363 0 : String handle = this.selectedContact;
364 0 : this.selectedContact = "";
365 :
366 0 : Provider.of<FlwtchState>(
367 0 : context,
368 : listen: false,
369 0 : ).cwtch.InviteMember(Provider.of<ProfileInfoState>(context, listen: false).onion, Provider.of<ContactInfoState>(context, listen: false).identifier, handle);
370 : });
371 : }
372 :
373 0 : void _kickPressed(String onion) {
374 0 : Provider.of<ContactInfoState>(context, listen: false).isOpPending = true;
375 0 : Provider.of<FlwtchState>(
376 0 : context,
377 : listen: false,
378 0 : ).cwtch.KickMember(Provider.of<ProfileInfoState>(context, listen: false).onion, Provider.of<ContactInfoState>(context, listen: false).identifier, onion);
379 : }
380 :
381 0 : void _changeRolePressed(String onion, String newRole) {
382 0 : Provider.of<ContactInfoState>(context, listen: false).isOpPending = true;
383 0 : Provider.of<FlwtchState>(
384 0 : context,
385 : listen: false,
386 0 : ).cwtch.ChangeRole(Provider.of<ProfileInfoState>(context, listen: false).onion, Provider.of<ContactInfoState>(context, listen: false).identifier, onion, newRole);
387 : }
388 :
389 0 : String? MyRole() {
390 0 : return Provider.of<ContactInfoState>(context).members.firstWhere((x) => x.onion == Provider.of<ProfileInfoState>(context, listen: false).onion).role;
391 : }
392 :
393 0 : bool CanInvite() {
394 0 : return Provider.of<ContactInfoState>(context).isOnline() &&
395 0 : (['admin', 'op'].contains(MyRole()) || Provider.of<ContactInfoState>(context).modeLine.contains('i') || Provider.of<ContactInfoState>(context).modeLine.contains('o'));
396 : }
397 :
398 0 : bool CanChangeRole(String? myRole, String? currentRole, String? targetRole) {
399 0 : if (currentRole == targetRole) {
400 : return false;
401 : }
402 :
403 0 : if (currentRole == 'admin') {
404 : // backend forbids demoting the last admin
405 0 : if (Provider.of<ContactInfoState>(context, listen: false).members.where((x) => x.role == 'admin').length == 1) {
406 : return false;
407 : }
408 : }
409 :
410 0 : return Provider.of<ContactInfoState>(context, listen: false).isOnline() &&
411 0 : GroupMember.isAbleToPromoteTo(myRole, currentRole, targetRole, Provider.of<ContactInfoState>(context, listen: false).modeLine);
412 : }
413 :
414 0 : bool CanKick(String? currentRole) {
415 0 : if (currentRole == 'admin' && Provider.of<ContactInfoState>(context, listen: false).getMembersOfRole('admin').length == 1) {
416 : // we don't allow kicking the last admin
417 : return false;
418 : }
419 0 : return Provider.of<ContactInfoState>(context).isOnline() && (['admin', 'op', 'mod'].contains(MyRole()));
420 : }
421 : }
|