3028 easier to invite users to a chat (#3387)

* chore: inital work for invite page updates

* open user dialog on click user in invite list
This commit is contained in:
ggurdin 2025-07-10 09:42:44 -04:00 committed by GitHub
parent f58c1aa808
commit ad3546a209
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 1040 additions and 378 deletions

View file

@ -15,7 +15,6 @@ import 'package:fluffychat/pages/chat_members/chat_members.dart';
import 'package:fluffychat/pages/chat_permissions_settings/chat_permissions_settings.dart';
import 'package:fluffychat/pages/chat_search/chat_search_page.dart';
import 'package:fluffychat/pages/device_settings/device_settings.dart';
import 'package:fluffychat/pages/invitation_selection/invitation_selection.dart';
import 'package:fluffychat/pages/login/login.dart';
import 'package:fluffychat/pages/new_group/new_group.dart';
import 'package:fluffychat/pages/new_private_chat/new_private_chat.dart';
@ -34,6 +33,7 @@ import 'package:fluffychat/pangea/activity_generator/activity_generator.dart';
import 'package:fluffychat/pangea/activity_planner/activity_planner_page.dart';
import 'package:fluffychat/pangea/analytics_page/analytics_page.dart';
import 'package:fluffychat/pangea/analytics_summary/progress_indicators_enum.dart';
import 'package:fluffychat/pangea/chat_settings/pages/pangea_invitation_selection.dart';
import 'package:fluffychat/pangea/common/widgets/pangea_side_view.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/find_your_people/find_your_people.dart';
@ -576,8 +576,13 @@ abstract class AppRoutes {
pageBuilder: (context, state) => defaultPageBuilder(
context,
state,
InvitationSelection(
PangeaInvitationSelection(
roomId: state.pathParameters['roomid']!,
initialFilter: state.uri.queryParameters['filter'] != null
? InvitationFilter.fromString(
state.uri.queryParameters['filter']!,
)
: null,
),
),
redirect: loggedOutRedirect,
@ -657,8 +662,14 @@ abstract class AppRoutes {
pageBuilder: (context, state) => defaultPageBuilder(
context,
state,
InvitationSelection(
PangeaInvitationSelection(
roomId: state.pathParameters['roomid']!,
initialFilter:
state.uri.queryParameters['filter'] != null
? InvitationFilter.fromString(
state.uri.queryParameters['filter']!,
)
: null,
),
),
redirect: loggedOutRedirect,

View file

@ -5038,5 +5038,28 @@
"saveActivity": "Save this activity",
"yourSavedActivities": "Saved Activities",
"failedToPlayVideo": "Failed to play video",
"done": "Done",
"inThisSpace": "In this space",
"myContacts": "My contacts",
"inviteAllInSpace": "Invite all in this space",
"spaceParticipantsHaveBeenInvitedToTheChat": "All space participants has been invited to the chat",
"numKnocking": "{count} knocking",
"@numKnocking": {
"type": "String",
"placeholders": {
"count": {
"type": "int"
}
}
},
"numInvited": "{count} invited",
"@numInvited": {
"type": "String",
"placeholders": {
"count": {
"type": "int"
}
}
},
"saved": "Saved"
}

View file

@ -2,13 +2,10 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pages/invitation_selection/invitation_selection_view.dart';
import 'package:fluffychat/pangea/bot/utils/bot_name.dart';
import 'package:fluffychat/pangea/common/config/environment.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
import 'package:fluffychat/widgets/matrix.dart';
import '../../utils/localized_exception_extension.dart';
@ -34,122 +31,26 @@ class InvitationSelectionController extends State<InvitationSelection> {
String? get roomId => widget.roomId;
// #Pangea
List<User>? get participants {
final room = Matrix.of(context).client.getRoomById(roomId!);
return room?.getParticipants();
}
List<Membership> get _membershipOrder => [
Membership.join,
Membership.invite,
Membership.knock,
Membership.leave,
Membership.ban,
];
String? membershipCopy(Membership? membership) => switch (membership) {
Membership.ban => L10n.of(context).banned,
Membership.invite => L10n.of(context).invited,
Membership.join => null,
Membership.knock => L10n.of(context).knocking,
Membership.leave => L10n.of(context).leftTheChat,
null => null,
};
int _sortUsers(User a, User b) {
// sort yourself to the top
final client = Matrix.of(context).client;
if (a.id == client.userID) return -1;
if (b.id == client.userID) return 1;
// sort the bot to the bottom
if (a.id == BotName.byEnvironment) return 1;
if (b.id == BotName.byEnvironment) return -1;
if (participants != null) {
final participantA = participants!.firstWhereOrNull((u) => u.id == a.id);
final participantB = participants!.firstWhereOrNull((u) => u.id == b.id);
// sort all participants first, with admins first, then moderators, then the rest
if (participantA?.membership == null &&
participantB?.membership != null) {
return 1;
}
if (participantA?.membership != null &&
participantB?.membership == null) {
return -1;
}
if (participantA?.membership != null &&
participantB?.membership != null) {
final aIndex = _membershipOrder.indexOf(participantA!.membership);
final bIndex = _membershipOrder.indexOf(participantB!.membership);
if (aIndex != bIndex) {
return aIndex.compareTo(bIndex);
}
}
}
// finally, sort by displayname
final aName = a.calcDisplayname().toLowerCase();
final bName = b.calcDisplayname().toLowerCase();
return aName.compareTo(bName);
}
// Pangea#
Future<List<User>> getContacts(BuildContext context) async {
final client = Matrix.of(context).client;
final room = client.getRoomById(roomId!)!;
final participants = (room.summary.mJoinedMemberCount ?? 0) > 100
? room.getParticipants()
// #Pangea
// : await room.requestParticipants();
: await room.requestParticipants(
[Membership.join, Membership.invite, Membership.knock],
false,
true,
);
// Pangea#
: await room.requestParticipants();
participants.removeWhere(
(u) => ![Membership.join, Membership.invite].contains(u.membership),
);
final contacts = client.rooms
.where((r) => r.isDirectChat)
// #Pangea
// .map((r) => r.unsafeGetUserFromMemoryOrFallback(r.directChatMatrixID!))
.map(
(r) => r
.getParticipants()
.firstWhereOrNull((u) => u.id != client.userID),
)
.where((u) => u != null)
.cast<User>()
// Pangea#
.map((r) => r.unsafeGetUserFromMemoryOrFallback(r.directChatMatrixID!))
.toList();
// #Pangea
final mutuals = client.rooms
.where((r) => r.isSpace)
.map((r) => r.getParticipants())
.expand((element) => element)
.toList();
for (final user in mutuals) {
final index = contacts.indexWhere((u) => u.id == user.id);
if (index == -1) {
contacts.add(user);
}
}
contacts.sort(_sortUsers);
contacts.sort(
(a, b) => a.calcDisplayname().toLowerCase().compareTo(
b.calcDisplayname().toLowerCase(),
),
);
return contacts;
// contacts.sort(
// (a, b) => a.calcDisplayname().toLowerCase().compareTo(
// b.calcDisplayname().toLowerCase(),
// ),
// );
// return contacts;
//Pangea#
}
void inviteAction(BuildContext context, String id, String displayname) async {
@ -162,10 +63,7 @@ class InvitationSelectionController extends State<InvitationSelection> {
if (success.error == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
// #Pangea
// content: Text(L10n.of(context).contactHasBeenInvitedToTheGroup),
content: Text(L10n.of(context).contactHasBeenInvitedToTheChat),
// Pangea#
content: Text(L10n.of(context).contactHasBeenInvitedToTheGroup),
),
);
}
@ -186,25 +84,12 @@ class InvitationSelectionController extends State<InvitationSelection> {
}
currentSearchTerm = text;
if (currentSearchTerm.isEmpty) return;
//#Pangea
String pangeaSearchText = text;
if (!pangeaSearchText.startsWith("@")) {
pangeaSearchText = "@$pangeaSearchText";
}
if (!pangeaSearchText.contains(":")) {
pangeaSearchText = "$pangeaSearchText:${Environment.homeServer}";
}
//#Pangea
if (loading) return;
setState(() => loading = true);
final matrix = Matrix.of(context);
SearchUserDirectoryResponse response;
try {
// response = await matrix.client.searchUserDirectory(text, limit: 10);
//#Pangea
response =
await matrix.client.searchUserDirectory(pangeaSearchText, limit: 10);
//#Pangea
response = await matrix.client.searchUserDirectory(text, limit: 10);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text((e).toLocalizedString(context))),
@ -223,22 +108,6 @@ class InvitationSelectionController extends State<InvitationSelection> {
],
);
}
//#Pangea
final participants = Matrix.of(context)
.client
.getRoomById(roomId!)
?.getParticipants()
.where(
(user) =>
[Membership.join, Membership.invite].contains(user.membership),
)
.toList();
foundProfiles.removeWhere(
(profile) =>
participants?.indexWhere((u) => u.id == profile.userId) != -1 &&
BotName.byEnvironment != profile.userId,
);
//Pangea#
});
}

View file

@ -1,21 +1,9 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:go_router/go_router.dart';
import 'package:matrix/matrix.dart';
import 'package:universal_html/html.dart' as html;
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pages/invitation_selection/invitation_selection.dart';
import 'package:fluffychat/pangea/analytics_misc/level_display_name.dart';
import 'package:fluffychat/pangea/chat_settings/constants/room_settings_constants.dart';
import 'package:fluffychat/pangea/common/config/environment.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/pangea/spaces/constants/space_constants.dart';
import 'package:fluffychat/utils/stream_extension.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/layouts/max_width_body.dart';
import 'package:fluffychat/widgets/matrix.dart';
@ -41,81 +29,20 @@ class InvitationSelectionView extends StatelessWidget {
);
}
// #Pangea
// final groupName = room.name.isEmpty ? L10n.of(context).group : room.name;
// Pangea#
final groupName = room.name.isEmpty ? L10n.of(context).group : room.name;
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
leading: const Center(child: BackButton()),
titleSpacing: 0,
title: Text(L10n.of(context).inviteContact),
// #Pangea
actions: [
if (room.isSpace && room.classCode != null)
PopupMenuButton<int>(
icon: const Icon(Icons.share_outlined),
onSelected: (value) async {
final spaceCode = room.classCode!;
String toCopy = spaceCode;
if (value == 0) {
final String initialUrl =
kIsWeb ? html.window.origin! : Environment.frontendURL;
toCopy =
"$initialUrl/#/join_with_link?${SpaceConstants.classCode}=${room.classCode}";
}
await Clipboard.setData(ClipboardData(text: toCopy));
ScaffoldMessenger.of(
context,
).showSnackBar(
SnackBar(
content: Text(
L10n.of(context).copiedToClipboard,
),
),
);
},
itemBuilder: (BuildContext context) => <PopupMenuEntry<int>>[
PopupMenuItem<int>(
value: 0,
child: ListTile(
leading: const Icon(Icons.share_outlined),
title: Text(L10n.of(context).shareSpaceLink),
contentPadding: const EdgeInsets.all(0),
),
),
PopupMenuItem<int>(
value: 1,
child: ListTile(
leading: const Icon(Icons.share_outlined),
title: Text(
L10n.of(context).shareInviteCode(room.classCode!),
),
contentPadding: const EdgeInsets.all(0),
),
),
],
),
],
// Pangea#
),
body: MaxWidthBody(
innerPadding: const EdgeInsets.symmetric(vertical: 8),
// #Pangea
withScrolling: false,
// Pangea#
child: Column(
children: [
Padding(
// #Pangea
// padding: const EdgeInsets.all(16.0),
padding: const EdgeInsets.only(
bottom: 16.0,
left: 16.0,
right: 16.0,
),
// Pangea#
padding: const EdgeInsets.all(16.0),
child: TextField(
textInputAction: TextInputAction.search,
decoration: InputDecoration(
@ -129,10 +56,7 @@ class InvitationSelectionView extends StatelessWidget {
color: theme.colorScheme.onPrimaryContainer,
fontWeight: FontWeight.normal,
),
// #Pangea
hintText: L10n.of(context).inviteStudentByUserName,
// hintText: L10n.of(context).inviteContactToGroup(groupName),
// Pangea#
hintText: L10n.of(context).inviteContactToGroup(groupName),
prefixIcon: controller.loading
? const Padding(
padding: EdgeInsets.symmetric(
@ -151,134 +75,69 @@ class InvitationSelectionView extends StatelessWidget {
onChanged: controller.searchUserWithCoolDown,
),
),
// #Pangea
// StreamBuilder<Object>(
Expanded(
child: StreamBuilder<Object>(
// stream: room.client.onRoomState.stream
// .where((update) => update.roomId == room.id),
stream: room.client.onRoomState.stream
.where((update) => update.roomId == room.id)
.rateLimit(const Duration(seconds: 1)),
// Pangea#
builder: (context, snapshot) {
final participants =
room.getParticipants().map((user) => user.id).toSet();
return controller.foundProfiles.isNotEmpty
? ListView.builder(
// #Pangea
// physics: const NeverScrollableScrollPhysics(),
// shrinkWrap: true,
// Pangea#
itemCount: controller.foundProfiles.length,
itemBuilder: (BuildContext context, int i) =>
_InviteContactListTile(
profile: controller.foundProfiles[i],
isMember: participants.contains(
controller.foundProfiles[i].userId,
),
onTap: () => controller.inviteAction(
context,
controller.foundProfiles[i].userId,
controller.foundProfiles[i].displayName ??
controller
.foundProfiles[i].userId.localpart ??
L10n.of(context).user,
),
StreamBuilder<Object>(
stream: room.client.onRoomState.stream
.where((update) => update.roomId == room.id),
builder: (context, snapshot) {
final participants =
room.getParticipants().map((user) => user.id).toSet();
return controller.foundProfiles.isNotEmpty
? ListView.builder(
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
itemCount: controller.foundProfiles.length,
itemBuilder: (BuildContext context, int i) =>
_InviteContactListTile(
profile: controller.foundProfiles[i],
isMember: participants
.contains(controller.foundProfiles[i].userId),
onTap: () => controller.inviteAction(
context,
controller.foundProfiles[i].userId,
controller.foundProfiles[i].displayName ??
controller.foundProfiles[i].userId.localpart ??
L10n.of(context).user,
),
)
: FutureBuilder<List<User>>(
future: controller.getContacts(context),
builder: (BuildContext context, snapshot) {
if (!snapshot.hasData) {
return const Center(
child: CircularProgressIndicator.adaptive(
strokeWidth: 2,
),
);
}
final contacts = snapshot.data!;
return ListView.builder(
// #Pangea
// physics: const NeverScrollableScrollPhysics(),
// shrinkWrap: true,
// itemCount: contacts.length,
// itemBuilder: (BuildContext context, int i) =>
// _InviteContactListTile(
itemCount: contacts.length + 1,
itemBuilder: (BuildContext context, int i) {
if (i == contacts.length) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: SizedBox(
width: 450,
child: CachedNetworkImage(
imageUrl:
"${AppConfig.assetsBaseURL}/${RoomSettingsConstants.referFriendAsset}",
errorWidget: (context, url, error) =>
const SizedBox(),
placeholder: (context, url) =>
const Center(
child: CircularProgressIndicator
.adaptive(),
),
),
),
);
}
return _InviteContactListTile(
// Pangea#
user: contacts[i],
profile: Profile(
avatarUrl: contacts[i].avatarUrl,
displayName: contacts[i].displayName ??
contacts[i].id.localpart ??
L10n.of(context).user,
userId: contacts[i].id,
),
isMember:
participants.contains(contacts[i].id),
onTap: () => controller.inviteAction(
context,
contacts[i].id,
contacts[i].displayName ??
contacts[i].id.localpart ??
L10n.of(context).user,
),
);
},
),
)
: FutureBuilder<List<User>>(
future: controller.getContacts(context),
builder: (BuildContext context, snapshot) {
if (!snapshot.hasData) {
return const Center(
child: CircularProgressIndicator.adaptive(
strokeWidth: 2,
),
);
},
);
},
),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: theme.colorScheme.primaryContainer,
elevation: 5.0,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
room.isSpace
? L10n.of(context).goToSpaceButton
: L10n.of(context).goToChat,
style: TextStyle(
color: theme.colorScheme.onPrimaryContainer,
),
),
],
),
onPressed: () => context.go(
room.isSpace
? "/rooms?spaceId=${room.id}"
: "/rooms/${room.id}",
),
),
}
final contacts = snapshot.data!;
return ListView.builder(
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
itemCount: contacts.length,
itemBuilder: (BuildContext context, int i) =>
_InviteContactListTile(
user: contacts[i],
profile: Profile(
avatarUrl: contacts[i].avatarUrl,
displayName: contacts[i].displayName ??
contacts[i].id.localpart ??
L10n.of(context).user,
userId: contacts[i].id,
),
isMember: participants.contains(contacts[i].id),
onTap: () => controller.inviteAction(
context,
contacts[i].id,
contacts[i].displayName ??
contacts[i].id.localpart ??
L10n.of(context).user,
),
),
);
},
);
},
),
],
),
@ -302,9 +161,7 @@ class _InviteContactListTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
// #Pangea
// final theme = Theme.of(context);
// Pangea#
final theme = Theme.of(context);
final l10n = L10n.of(context);
return ListTile(
@ -322,32 +179,14 @@ class _InviteContactListTile extends StatelessWidget {
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
// #Pangea
// subtitle: Text(
// profile.userId,
// maxLines: 1,
// overflow: TextOverflow.ellipsis,
// style: TextStyle(
// color: theme.colorScheme.secondary,
// ),
// ),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// https://github.com/pangeachat/client/issues/3047
const SizedBox(height: 2.0),
Text(
profile.userId,
style: const TextStyle(
fontSize: 12.0,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
LevelDisplayName(userId: profile.userId),
],
subtitle: Text(
profile.userId,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: theme.colorScheme.secondary,
),
),
// Pangea#
trailing: TextButton.icon(
onPressed: isMember ? null : onTap,
label: Text(isMember ? l10n.participant : l10n.invite),

View file

@ -175,7 +175,7 @@ class PangeaChatDetailsView extends StatelessWidget {
onPressed: room.isDirectChat
? null
: () => context.push(
'/rooms/${controller.roomId}/details/members',
'/rooms/${controller.roomId}/details/invite?filter=participants',
),
icon: const Icon(
Icons.group_outlined,
@ -359,7 +359,13 @@ class RoomDetailsButtonRowState extends State<RoomDetailsButtonRow> {
ButtonDetails(
title: l10n.invite,
icon: const Icon(Icons.person_add_outlined, size: 30.0),
onPressed: () => context.go('/rooms/${room.id}/details/invite'),
onPressed: () {
String filter = 'knocking';
if (room.getParticipants([Membership.knock]).isEmpty) {
filter = room.pangeaSpaceParents.isNotEmpty ? 'space' : 'contacts';
}
context.go('/rooms/${room.id}/details/invite?filter=$filter');
},
visible: (room.canInvite && !room.isDirectChat) || room.isSpace,
enabled: room.canInvite && !room.isDirectChat,
),

View file

@ -0,0 +1,437 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/bot/utils/bot_name.dart';
import 'package:fluffychat/pangea/chat_settings/pages/pangea_invitation_selection_view.dart';
import 'package:fluffychat/pangea/common/config/environment.dart';
import 'package:fluffychat/utils/localized_exception_extension.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
import 'package:fluffychat/widgets/matrix.dart';
enum InvitationFilter {
space,
contacts,
knocking,
invited,
participants,
public;
static InvitationFilter? fromString(String value) {
switch (value) {
case 'space':
return InvitationFilter.space;
case 'contacts':
return InvitationFilter.contacts;
case 'invited':
return InvitationFilter.invited;
case 'knocking':
return InvitationFilter.knocking;
case 'public':
return InvitationFilter.public;
case 'participants':
return InvitationFilter.participants;
default:
return null;
}
}
String get string {
switch (this) {
case InvitationFilter.space:
return 'space';
case InvitationFilter.contacts:
return 'contacts';
case InvitationFilter.invited:
return 'invited';
case InvitationFilter.knocking:
return 'knocking';
case InvitationFilter.public:
return 'public';
case InvitationFilter.participants:
return 'participants';
}
}
}
class PangeaInvitationSelection extends StatefulWidget {
final String roomId;
final InvitationFilter? initialFilter;
const PangeaInvitationSelection({
super.key,
required this.roomId,
this.initialFilter,
});
@override
PangeaInvitationSelectionController createState() =>
PangeaInvitationSelectionController();
}
class PangeaInvitationSelectionController
extends State<PangeaInvitationSelection> {
TextEditingController controller = TextEditingController();
bool loading = true;
List<Profile> foundProfiles = [];
Timer? coolDown;
InvitationFilter filter = InvitationFilter.knocking;
@override
void initState() {
super.initState();
if (widget.initialFilter != null &&
availableFilters.contains(widget.initialFilter)) {
filter = widget.initialFilter!;
} else if (spaceParent != null) {
filter = InvitationFilter.space;
} else if (_room?.getParticipants([Membership.knock]).isEmpty ?? true) {
filter = InvitationFilter.contacts;
}
if (filter == InvitationFilter.public) {
searchUser(context, '');
}
_room?.requestParticipants(
[
Membership.join,
Membership.invite,
Membership.knock,
],
false,
true,
).then((_) {
if (mounted) {
setState(() {
loading = false;
});
}
});
spaceParent?.requestParticipants(
[
Membership.join,
Membership.invite,
Membership.knock,
],
false,
true,
).then((_) {
if (mounted) setState(() {});
});
controller.addListener(() {
setState(() {});
});
}
String filterLabel(InvitationFilter filter) {
final l10n = L10n.of(context);
switch (filter) {
case InvitationFilter.space:
return l10n.inThisSpace;
case InvitationFilter.contacts:
return l10n.myContacts;
case InvitationFilter.invited:
return l10n.numInvited(_room?.summary.mInvitedMemberCount ?? 0);
case InvitationFilter.knocking:
return l10n.numKnocking(
participants?.where((u) => u.membership == Membership.knock).length ??
0,
);
case InvitationFilter.public:
return l10n.public;
case InvitationFilter.participants:
return l10n.classRoster;
}
}
Room? get _room => Matrix.of(context).client.getRoomById(widget.roomId);
Room? get spaceParent {
final spaceParents = _room?.spaceParents;
if (spaceParents == null || spaceParents.isEmpty) {
return null;
}
final client = Matrix.of(context).client;
for (final parent in spaceParents) {
if (parent.roomId == null) continue;
final space = client.getRoomById(parent.roomId!);
if (space != null) return space;
}
return null;
}
List<InvitationFilter> get availableFilters => InvitationFilter.values
.where(
(f) => switch (f) {
InvitationFilter.space => spaceParent != null,
InvitationFilter.contacts => true,
InvitationFilter.invited => participants?.any(
(u) => u.membership == Membership.invite,
) ??
false,
InvitationFilter.knocking => participants?.any(
(u) => u.membership == Membership.knock,
) ??
false,
InvitationFilter.public => true,
InvitationFilter.participants => true,
},
)
.toList();
List<User>? get participants {
return _room?.getParticipants();
}
List<Membership> get _membershipOrder => [
Membership.join,
Membership.invite,
Membership.knock,
Membership.leave,
Membership.ban,
];
String? membershipCopy(Membership? membership) => switch (membership) {
Membership.ban => L10n.of(context).banned,
Membership.invite => L10n.of(context).invited,
Membership.join => null,
Membership.knock => L10n.of(context).knocking,
Membership.leave => L10n.of(context).leftTheChat,
null => null,
};
int _sortUsers(User a, User b) {
// sort yourself to the top
final client = Matrix.of(context).client;
if (a.id == client.userID) return -1;
if (b.id == client.userID) return 1;
// sort the bot to the bottom
if (a.id == BotName.byEnvironment) return 1;
if (b.id == BotName.byEnvironment) return -1;
if (participants != null) {
final participantA = participants!.firstWhereOrNull((u) => u.id == a.id);
final participantB = participants!.firstWhereOrNull((u) => u.id == b.id);
// sort all participants first, with admins first, then moderators, then the rest
if (participantA?.membership == null &&
participantB?.membership != null) {
return 1;
}
if (participantA?.membership != null &&
participantB?.membership == null) {
return -1;
}
if (participantA?.membership != null &&
participantB?.membership != null) {
final aIndex = _membershipOrder.indexOf(participantA!.membership);
final bIndex = _membershipOrder.indexOf(participantB!.membership);
if (aIndex != bIndex) {
return aIndex.compareTo(bIndex);
}
}
}
// finally, sort by displayname
final aName = a.calcDisplayname().toLowerCase();
final bName = b.calcDisplayname().toLowerCase();
return aName.compareTo(bName);
}
void setFilter(InvitationFilter newFilter) {
if (filter == newFilter) return;
if (newFilter == InvitationFilter.public) {
searchUser(context, '');
}
setState(() => filter = newFilter);
}
List<User> filteredContacts() {
List<User> contacts = [];
switch (filter) {
case InvitationFilter.space:
contacts = spaceParent?.getParticipants() ?? [];
case InvitationFilter.contacts:
contacts = getContacts(context);
case InvitationFilter.invited:
contacts = participants
?.where(
(u) => u.membership == Membership.invite,
)
.toList() ??
[];
case InvitationFilter.knocking:
contacts = participants
?.where(
(u) => u.membership == Membership.knock,
)
.toList() ??
[];
default:
contacts = participants ?? [];
}
final search = controller.text.toLowerCase();
contacts = contacts
.where(
(u) =>
u.calcDisplayname().toLowerCase().contains(search) ||
u.id.toLowerCase().contains(search),
)
.toList();
contacts.sort(_sortUsers);
return contacts;
}
List<User> getContacts(BuildContext context) {
final client = Matrix.of(context).client;
participants!.removeWhere(
(u) => ![Membership.join, Membership.invite].contains(u.membership),
);
final contacts = client.rooms
.where((r) => r.isDirectChat)
.map((r) => r.unsafeGetUserFromMemoryOrFallback(r.directChatMatrixID!))
.toList();
contacts.sort(
(a, b) => a.calcDisplayname().toLowerCase().compareTo(
b.calcDisplayname().toLowerCase(),
),
);
return contacts;
}
void searchUserWithCoolDown(String text) async {
if (filter != InvitationFilter.public) return;
coolDown?.cancel();
coolDown = Timer(
const Duration(milliseconds: 500),
() => searchUser(context, text),
);
}
Future<void> searchUser(BuildContext context, String text) async {
coolDown?.cancel();
if (text.isEmpty) {
setState(() => foundProfiles = []);
}
String pangeaSearchText = text;
if (!pangeaSearchText.startsWith("@")) {
pangeaSearchText = "@$pangeaSearchText";
}
if (!pangeaSearchText.contains(":")) {
pangeaSearchText = "$pangeaSearchText:${Environment.homeServer}";
}
setState(() => loading = true);
final matrix = Matrix.of(context);
SearchUserDirectoryResponse response;
try {
response =
await matrix.client.searchUserDirectory(pangeaSearchText, limit: 100);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text((e).toLocalizedString(context))),
);
return;
} finally {
setState(() => loading = false);
}
setState(() {
foundProfiles = List<Profile>.from(response.results);
if (text.isValidMatrixId &&
foundProfiles.indexWhere((profile) => text == profile.userId) == -1) {
setState(
() => foundProfiles = [
Profile.fromJson({'user_id': text}),
],
);
}
final participants = this
.participants
?.where(
(user) =>
[Membership.join, Membership.invite].contains(user.membership),
)
.toList();
foundProfiles.removeWhere(
(profile) =>
participants?.indexWhere((u) => u.id == profile.userId) != -1 &&
BotName.byEnvironment != profile.userId,
);
});
}
void inviteAction(BuildContext context, String id, String displayname) async {
final room = Matrix.of(context).client.getRoomById(widget.roomId)!;
final success = await showFutureLoadingDialog(
context: context,
future: () => room.invite(id),
);
if (success.error == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(L10n.of(context).contactHasBeenInvitedToTheChat),
),
);
}
}
Future<void> inviteAllInSpace() async {
if (_room == null) return;
final spaceParticipants =
spaceParent?.getParticipants([Membership.join]) ?? [];
if (spaceParticipants.isEmpty) return;
final List<Future> futures = [];
for (final user in spaceParticipants) {
if (participants?.any((u) => u.id == user.id) ?? false) {
// User is already in the room
continue;
}
if (user.id == Matrix.of(context).client.userID) continue;
futures.add(_room!.invite(user.id));
}
await showFutureLoadingDialog(
context: context,
future: () async {
await Future.wait(futures);
return null; // No error
},
).then((result) {
if (result.error == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
L10n.of(context).spaceParticipantsHaveBeenInvitedToTheChat,
),
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(result.error.toString())),
);
}
});
}
@override
Widget build(BuildContext context) => PangeaInvitationSelectionView(this);
}

View file

@ -0,0 +1,477 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:collection/collection.dart';
import 'package:go_router/go_router.dart';
import 'package:matrix/matrix.dart';
import 'package:universal_html/html.dart' as html;
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/analytics_misc/level_display_name.dart';
import 'package:fluffychat/pangea/chat_settings/constants/room_settings_constants.dart';
import 'package:fluffychat/pangea/chat_settings/pages/pangea_invitation_selection.dart';
import 'package:fluffychat/pangea/common/config/environment.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/pangea/spaces/constants/space_constants.dart';
import 'package:fluffychat/utils/fluffy_share.dart';
import 'package:fluffychat/utils/stream_extension.dart';
import 'package:fluffychat/widgets/adaptive_dialogs/user_dialog.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/layouts/max_width_body.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:fluffychat/widgets/member_actions_popup_menu_button.dart';
class PangeaInvitationSelectionView extends StatelessWidget {
final PangeaInvitationSelectionController controller;
const PangeaInvitationSelectionView(this.controller, {super.key});
@override
Widget build(BuildContext context) {
final room =
Matrix.of(context).client.getRoomById(controller.widget.roomId);
if (room == null) {
return Scaffold(
appBar: AppBar(
title: Text(L10n.of(context).oopsSomethingWentWrong),
),
body: Center(
child: Text(L10n.of(context).youAreNoLongerParticipatingInThisChat),
),
);
}
final theme = Theme.of(context);
final contacts = controller.filteredContacts();
final alias = room.canonicalAlias.isEmpty
? controller.widget.roomId
: room.canonicalAlias;
return Scaffold(
appBar: AppBar(
leading: const Center(child: BackButton()),
titleSpacing: 0,
title: Text(L10n.of(context).inviteContact),
),
body: MaxWidthBody(
maxWidth: 800.0,
withScrolling: false,
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
children: [
TextField(
controller: controller.controller,
textInputAction: TextInputAction.search,
decoration: InputDecoration(
filled: true,
fillColor: theme.colorScheme.secondaryContainer,
border: OutlineInputBorder(
borderSide: BorderSide.none,
borderRadius: BorderRadius.circular(99),
),
hintStyle: TextStyle(
color: theme.colorScheme.onPrimaryContainer,
fontWeight: FontWeight.normal,
),
hintText: L10n.of(context).search,
prefixIcon: controller.loading
? const Padding(
padding: EdgeInsets.symmetric(
vertical: 10.0,
horizontal: 12,
),
child: SizedBox.square(
dimension: 24,
child: CircularProgressIndicator.adaptive(
strokeWidth: 2,
),
),
)
: const Icon(Icons.search_outlined),
),
onChanged: controller.searchUserWithCoolDown,
),
const SizedBox(height: 12.0),
Align(
alignment: Alignment.centerLeft,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
spacing: 12.0,
children: controller.availableFilters.map((filter) {
return FilterChip(
label: Text(controller.filterLabel(filter)),
onSelected: (_) => controller.setFilter(filter),
selected: controller.filter == filter,
);
}).toList(),
),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 40.0),
child: StreamBuilder<Object>(
stream: room.client.onRoomState.stream
.where((update) => update.roomId == room.id)
.rateLimit(const Duration(seconds: 1)),
builder: (context, snapshot) {
final participants =
room.getParticipants().map((user) => user.id).toSet();
return controller.filter == InvitationFilter.public
? ListView.builder(
itemCount: controller.foundProfiles.length,
itemBuilder: (BuildContext context, int i) =>
_InviteContactListTile(
profile: controller.foundProfiles[i],
isMember: participants.contains(
controller.foundProfiles[i].userId,
),
onTap: () => controller.inviteAction(
context,
controller.foundProfiles[i].userId,
controller.foundProfiles[i].displayName ??
controller
.foundProfiles[i].userId.localpart ??
L10n.of(context).user,
),
controller: controller,
),
)
: ListView.builder(
itemCount: contacts.length + 2,
itemBuilder: (BuildContext context, int i) {
if (i == 0) {
return controller.filter ==
InvitationFilter.space &&
controller.spaceParent != null
? ListTile(
leading: Avatar(
mxContent:
controller.spaceParent!.avatar,
name: controller.spaceParent!
.getLocalizedDisplayname(),
borderRadius: BorderRadius.circular(
AppConfig.borderRadius / 4,
),
),
title: Text(
controller.spaceParent!
.getLocalizedDisplayname(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
L10n.of(context).countParticipants(
controller.spaceParent!.summary
.mJoinedMemberCount ??
1,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: TextButton.icon(
onPressed:
controller.inviteAllInSpace,
label: Text(
L10n.of(context).inviteAllInSpace,
),
icon: const Icon(Icons.add),
),
)
: const SizedBox();
}
i--;
if (i == contacts.length) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: SizedBox(
width: 450,
child: CachedNetworkImage(
imageUrl:
"${AppConfig.assetsBaseURL}/${RoomSettingsConstants.referFriendAsset}",
errorWidget: (context, url, error) =>
const SizedBox(),
placeholder: (context, url) =>
const Center(
child: CircularProgressIndicator
.adaptive(),
),
),
),
);
}
return _InviteContactListTile(
user: contacts[i],
profile: Profile(
avatarUrl: contacts[i].avatarUrl,
displayName: contacts[i].displayName ??
contacts[i].id.localpart ??
L10n.of(context).user,
userId: contacts[i].id,
),
isMember:
participants.contains(contacts[i].id),
onTap: () => controller.inviteAction(
context,
contacts[i].id,
contacts[i].displayName ??
contacts[i].id.localpart ??
L10n.of(context).user,
),
controller: controller,
);
},
);
},
),
),
),
Row(
spacing: 12.0,
children: [
if (room.isSpace && room.classCode != null)
Expanded(
child: PopupMenuButton<int>(
borderRadius: BorderRadius.circular(32.0),
child: IgnorePointer(
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor:
theme.colorScheme.primaryContainer,
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 16,
),
),
child: Row(
spacing: 34.0,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.share_outlined,
color: theme.colorScheme.onPrimaryContainer,
),
Text(
L10n.of(context).share,
style: TextStyle(
color: theme.colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
),
],
),
onPressed: () {},
),
),
onSelected: (value) async {
final spaceCode = room.classCode!;
String toCopy = spaceCode;
if (value == 0) {
final String initialUrl = kIsWeb
? html.window.origin!
: Environment.frontendURL;
toCopy =
"$initialUrl/#/join_with_link?${SpaceConstants.classCode}=${room.classCode}";
}
await Clipboard.setData(ClipboardData(text: toCopy));
ScaffoldMessenger.of(
context,
).showSnackBar(
SnackBar(
content: Text(
L10n.of(context).copiedToClipboard,
),
),
);
},
itemBuilder: (BuildContext context) =>
<PopupMenuEntry<int>>[
PopupMenuItem<int>(
value: 0,
child: ListTile(
leading: const Icon(Icons.share_outlined),
title: Text(L10n.of(context).shareSpaceLink),
contentPadding: const EdgeInsets.all(0),
),
),
PopupMenuItem<int>(
value: 1,
child: ListTile(
leading: const Icon(Icons.share_outlined),
title: Text(
L10n.of(context)
.shareInviteCode(room.classCode!),
),
contentPadding: const EdgeInsets.all(0),
),
),
],
),
)
else
Expanded(
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: theme.colorScheme.primaryContainer,
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 16,
),
),
child: Row(
spacing: 34.0,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.share_outlined,
color: theme.colorScheme.onPrimaryContainer,
),
Text(
L10n.of(context).share,
style: TextStyle(
color: theme.colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
),
],
),
onPressed: () => FluffyShare.share(
"${Environment.frontendURL}/#/join_with_alias?alias=$alias",
context,
),
),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: theme.colorScheme.primaryContainer,
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 16,
),
),
child: Row(
spacing: 34.0,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.check,
color: theme.colorScheme.onPrimaryContainer,
),
Text(
L10n.of(context).done,
style: TextStyle(
color: theme.colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
),
],
),
onPressed: () => context.go(
room.isSpace
? "/rooms?spaceId=${room.id}"
: "/rooms/${room.id}",
),
),
],
),
],
),
),
),
);
}
}
class _InviteContactListTile extends StatelessWidget {
final Profile profile;
final User? user;
final bool isMember;
final void Function() onTap;
final PangeaInvitationSelectionController controller;
const _InviteContactListTile({
required this.profile,
this.user,
required this.isMember,
required this.onTap,
required this.controller,
});
@override
Widget build(BuildContext context) {
final l10n = L10n.of(context);
final participant = controller.participants?.firstWhereOrNull(
(p) => p.id == profile.userId,
);
final membership = participant?.membership;
return ListTile(
onTap: participant != null
? () => showMemberActionsPopupMenu(
context: context,
user: participant,
)
: null,
leading: Avatar(
mxContent: profile.avatarUrl,
name: profile.displayName,
presenceUserId: profile.userId,
onTap: () => UserDialog.show(
context: context,
profile: profile,
),
),
title: Text(
profile.displayName ?? profile.userId.localpart ?? l10n.user,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// https://github.com/pangeachat/client/issues/3047
const SizedBox(height: 2.0),
Text(
profile.userId,
style: const TextStyle(
fontSize: 12.0,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
LevelDisplayName(userId: profile.userId),
],
),
trailing: [Membership.invite, Membership.knock].contains(membership)
? Container(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
margin: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
controller.membershipCopy(membership)!,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(context).colorScheme.onSecondaryContainer,
),
),
)
: TextButton.icon(
onPressed: isMember ? null : onTap,
label: Text(isMember ? l10n.participant : l10n.invite),
icon: Icon(isMember ? Icons.check : Icons.add),
),
);
}
}