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