You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
fluffychat/lib/pangea/chat_settings/pages/pangea_chat_details.dart

823 lines
31 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:go_router/go_router.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/pages/chat_details/chat_details.dart';
import 'package:fluffychat/pages/chat_details/participant_list_item.dart';
import 'package:fluffychat/pangea/analytics_misc/level_display_name.dart';
import 'package:fluffychat/pangea/bot/utils/bot_name.dart';
import 'package:fluffychat/pangea/bot/widgets/bot_face_svg.dart';
import 'package:fluffychat/pangea/chat_settings/models/bot_options_model.dart';
import 'package:fluffychat/pangea/chat_settings/utils/delete_room.dart';
import 'package:fluffychat/pangea/chat_settings/widgets/conversation_bot/conversation_bot_settings.dart';
import 'package:fluffychat/pangea/chat_settings/widgets/delete_space_dialog.dart';
import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/pangea/spaces/utils/load_participants_util.dart';
import 'package:fluffychat/pangea/spaces/widgets/download_space_analytics_dialog.dart';
import 'package:fluffychat/pangea/spaces/widgets/leaderboard_participant_list.dart';
import 'package:fluffychat/utils/fluffy_share.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart';
import 'package:fluffychat/utils/url_launcher.dart';
import 'package:fluffychat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
import 'package:fluffychat/widgets/hover_builder.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 PangeaChatDetailsView extends StatelessWidget {
final ChatDetailsController controller;
const PangeaChatDetailsView(this.controller, {super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final room = Matrix.of(context).client.getRoomById(controller.roomId!);
if (room == null || room.membership == Membership.leave) {
return Scaffold(
appBar: AppBar(
title: Text(L10n.of(context).oopsSomethingWentWrong),
),
body: Center(
child: Text(L10n.of(context).youAreNoLongerParticipatingInThisChat),
),
);
}
return StreamBuilder(
stream: room.client.onRoomState.stream
.where((update) => update.roomId == room.id),
builder: (context, snapshot) {
var members = room.getParticipants().toList()
..sort((b, a) => a.powerLevel.compareTo(b.powerLevel));
members = members.take(10).toList();
final actualMembersCount = (room.summary.mInvitedMemberCount ?? 0) +
(room.summary.mJoinedMemberCount ?? 0);
final displayname = room.getLocalizedDisplayname(
MatrixLocals(L10n.of(context)),
);
return Scaffold(
appBar: AppBar(
leading: controller.widget.embeddedCloseButton ??
(room.isSpace
? FluffyThemes.isColumnMode(context)
? const SizedBox()
: BackButton(
onPressed: () =>
context.go("/rooms?spaceId=${room.id}"),
)
: const Center(child: BackButton())),
),
body: MaxWidthBody(
maxWidth: 900,
showBorder: false,
child: ListView.builder(
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
itemCount: 2,
itemBuilder: (BuildContext context, int i) => i == 0
? Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Row(
children: [
Padding(
padding: const EdgeInsets.all(32.0),
child: Stack(
children: [
Hero(
tag:
controller.widget.embeddedCloseButton !=
null
? 'embedded_content_banner'
: 'content_banner',
child: Avatar(
mxContent: room.avatar,
name: displayname,
userId: room.directChatMatrixID,
size: Avatar.defaultSize * 2.5,
borderRadius: room.isSpace
? BorderRadius.circular(24.0)
: null,
),
),
if (!room.isDirectChat &&
room.canChangeStateEvent(
EventTypes.RoomAvatar,
))
Positioned(
bottom: 0,
right: 0,
child: FloatingActionButton.small(
onPressed: controller.setAvatarAction,
heroTag: null,
child: const Icon(
Icons.camera_alt_outlined,
),
),
),
],
),
),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextButton.icon(
onPressed: () => room.isDirectChat
? null
: room.canChangeStateEvent(
EventTypes.RoomName,
)
? controller.setDisplaynameAction()
: FluffyShare.share(
displayname,
context,
copyOnly: true,
),
icon: Icon(
room.isDirectChat
? Icons.chat_bubble_outline
: room.canChangeStateEvent(
EventTypes.RoomName,
)
? Icons.edit_outlined
: Icons.copy_outlined,
size: 16,
),
style: TextButton.styleFrom(
foregroundColor:
theme.colorScheme.onSurface,
),
label: Text(
room.isDirectChat
? L10n.of(context).directChat
: displayname,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 18),
),
),
TextButton.icon(
onPressed: () => room.isDirectChat
? null
: context.push(
'/rooms/${controller.roomId}/details/members',
),
icon: const Icon(
Icons.group_outlined,
size: 14,
),
style: TextButton.styleFrom(
foregroundColor:
theme.colorScheme.secondary,
),
label: Text(
L10n.of(context).countParticipants(
actualMembersCount,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
],
),
Stack(
children: [
if (room.isRoomAdmin)
Positioned(
right: 4,
top: 4,
child: IconButton(
onPressed: controller.setTopicAction,
icon: const Icon(Icons.edit_outlined),
),
),
Padding(
padding: const EdgeInsets.only(
left: 32.0,
right: 32.0,
top: 16.0,
bottom: 16.0,
),
child: SelectableLinkify(
text: room.topic.isEmpty
? room.isSpace
? L10n.of(context).noSpaceDescriptionYet
: L10n.of(context).noChatDescriptionYet
: room.topic,
options: const LinkifyOptions(humanize: false),
linkStyle: const TextStyle(
color: Colors.blueAccent,
decorationColor: Colors.blueAccent,
),
style: TextStyle(
fontSize: 14,
fontStyle: room.topic.isEmpty
? FontStyle.italic
: FontStyle.normal,
color: theme.textTheme.bodyMedium!.color,
decorationColor:
theme.textTheme.bodyMedium!.color,
),
onOpen: (url) =>
UrlLauncher(context, url.url).launchUrl(),
),
),
],
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: RoomDetailsButtonRow(
controller: controller,
room: room,
),
),
],
)
: Padding(
padding: const EdgeInsets.all(16.0),
child: RoomParticipantsSection(room: room),
),
),
),
);
},
);
}
}
class RoomDetailsButtonRow extends StatefulWidget {
final ChatDetailsController controller;
final Room room;
const RoomDetailsButtonRow({
super.key,
required this.controller,
required this.room,
});
@override
State<RoomDetailsButtonRow> createState() => RoomDetailsButtonRowState();
}
class RoomDetailsButtonRowState extends State<RoomDetailsButtonRow> {
StreamSubscription? notificationChangeSub;
@override
void initState() {
super.initState();
notificationChangeSub ??= Matrix.of(context)
.client
.onSync
.stream
.where(
(syncUpdate) =>
syncUpdate.accountData?.any(
(accountData) => accountData.type == 'm.push_rules',
) ??
false,
)
.listen(
(u) => setState(() {}),
);
}
@override
void dispose() {
notificationChangeSub?.cancel();
super.dispose();
}
final double _buttonWidth = 125.0;
final double _buttonHeight = 84.0;
final double _miniButtonWidth = 50.0;
Room get room => widget.room;
List<ButtonDetails> _buttons(BuildContext context) {
final L10n l10n = L10n.of(context);
return [
ButtonDetails(
title: l10n.activities,
icon: const Icon(Icons.event_note_outlined, size: 30.0),
onPressed: () => context.go("/rooms/${room.id}/details/planner"),
visible: room.canChangeStateEvent(PangeaEventTypes.activityPlan) ||
room.isSpace,
enabled: room.canChangeStateEvent(PangeaEventTypes.activityPlan),
),
ButtonDetails(
title: l10n.permissions,
icon: const Icon(Icons.edit_attributes_outlined, size: 30.0),
onPressed: () => context.go('/rooms/${room.id}/details/permissions'),
visible: (room.isRoomAdmin && !room.isDirectChat) || room.isSpace,
enabled: room.isRoomAdmin && !room.isDirectChat,
),
ButtonDetails(
title: l10n.access,
icon: const Icon(Icons.shield_outlined, size: 30.0),
onPressed: () => context.go('/rooms/${room.id}/details/access'),
visible: room.isSpace && room.spaceParents.isEmpty,
enabled: room.isSpace && room.isRoomAdmin,
),
ButtonDetails(
title: room.pushRuleState == PushRuleState.notify
? l10n.notificationsOn
: l10n.notificationsOff,
icon: Icon(
room.pushRuleState == PushRuleState.notify
? Icons.notifications_on_outlined
: Icons.notifications_off_outlined,
size: 30.0,
),
onPressed: () => showFutureLoadingDialog(
context: context,
future: () => room.setPushRuleState(
room.pushRuleState == PushRuleState.notify
? PushRuleState.mentionsOnly
: PushRuleState.notify,
),
),
visible: !room.isSpace,
),
ButtonDetails(
title: l10n.invite,
icon: const Icon(Icons.person_add_outlined, size: 30.0),
onPressed: () => context.go('/rooms/${room.id}/details/invite'),
visible: (room.canInvite && !room.isDirectChat) || room.isSpace,
enabled: room.canInvite && !room.isDirectChat,
),
ButtonDetails(
title: l10n.addSubspace,
icon: const Icon(Icons.add_outlined, size: 30.0),
onPressed: widget.controller.addSubspace,
visible: room.isSpace &&
room.canChangeStateEvent(
EventTypes.SpaceChild,
),
showInMainView: false,
),
ButtonDetails(
title: l10n.downloadSpaceAnalytics,
icon: const Icon(Icons.download_outlined, size: 30.0),
onPressed: () {
showDialog(
context: context,
builder: (context) => DownloadAnalyticsDialog(space: room),
);
},
visible: room.isSpace && room.isRoomAdmin,
showInMainView: false,
),
ButtonDetails(
title: l10n.download,
icon: const Icon(Icons.download_outlined, size: 30.0),
onPressed: widget.controller.downloadChatAction,
visible: room.ownPowerLevel >= 50 && !room.isSpace,
),
ButtonDetails(
title: l10n.botSettings,
icon: const BotFace(
width: 30.0,
expression: BotExpression.idle,
),
onPressed: () => showDialog<BotOptionsModel?>(
context: context,
builder: (BuildContext context) => ConversationBotSettingsDialog(
room: room,
onSubmit: widget.controller.setBotOptions,
),
),
visible: !room.isSpace && !room.isDirectChat && room.canInvite,
),
ButtonDetails(
title: l10n.chatCapacity,
icon: const Icon(Icons.reduce_capacity, size: 30.0),
onPressed: widget.controller.setRoomCapacity,
visible:
!room.isSpace && !room.isDirectChat && room.canSendDefaultStates,
),
ButtonDetails(
title: l10n.leave,
icon: const Icon(Icons.logout_outlined, size: 30.0),
onPressed: () async {
final confirmed = await showOkCancelAlertDialog(
useRootNavigator: false,
context: context,
title: L10n.of(context).areYouSure,
okLabel: L10n.of(context).leave,
cancelLabel: L10n.of(context).no,
message: room.isSpace
? L10n.of(context).leaveSpaceDescription
: L10n.of(context).leaveRoomDescription,
isDestructive: true,
);
if (confirmed != OkCancelResult.ok) return;
final resp = await showFutureLoadingDialog(
context: context,
future: room.isSpace ? room.leaveSpace : room.leave,
);
if (!resp.isError) {
context.go("/rooms?spaceId=clear");
}
},
visible: room.membership == Membership.join,
showInMainView: false,
),
ButtonDetails(
title: l10n.delete,
icon: const Icon(Icons.delete_outline, size: 30.0),
onPressed: () async {
if (room.isSpace) {
final resp = await showDialog<bool?>(
context: context,
builder: (_) => DeleteSpaceDialog(space: room),
);
if (resp == true) {
context.go("/rooms?spaceId=clear");
}
} else {
final confirmed = await showOkCancelAlertDialog(
context: context,
title: L10n.of(context).areYouSure,
okLabel: L10n.of(context).delete,
cancelLabel: L10n.of(context).cancel,
isDestructive: true,
message: room.isSpace
? L10n.of(context).deleteSpaceDesc
: L10n.of(context).deleteChatDesc,
);
if (confirmed != OkCancelResult.ok) return;
final resp = await showFutureLoadingDialog(
context: context,
future: room.delete,
);
if (resp.isError) return;
context.go("/rooms?spaceId=clear");
}
},
visible: room.isRoomAdmin,
showInMainView: false,
),
];
}
@override
Widget build(BuildContext context) {
final buttons = _buttons(context)
.where(
(button) => button.visible,
)
.toList();
return Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: LayoutBuilder(
builder: (context, constraints) {
final availableWidth = constraints.maxWidth;
final fullButtonCapacity =
(availableWidth / _buttonWidth).floor() - 1;
final miniButtonCapacity =
(availableWidth / _miniButtonWidth).floor() - 1;
final mini = fullButtonCapacity < 4;
final capacity = mini ? miniButtonCapacity : fullButtonCapacity;
List<ButtonDetails> mainViewButtons =
buttons.where((button) => button.showInMainView).toList();
final List<ButtonDetails> otherButtons =
buttons.where((button) => !button.showInMainView).toList();
if (capacity < mainViewButtons.length) {
otherButtons.addAll(mainViewButtons.skip(capacity));
mainViewButtons = mainViewButtons.take(capacity).toList();
}
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(mainViewButtons.length + 1, (index) {
if (index == mainViewButtons.length) {
if (otherButtons.isEmpty) {
return const SizedBox();
}
return PopupMenuButton(
useRootNavigator: true,
onSelected: (button) => button.onPressed?.call(),
itemBuilder: (context) {
return otherButtons
.map(
(button) => PopupMenuItem(
value: button,
child: Row(
children: [
button.icon,
const SizedBox(width: 8),
Text(button.title),
],
),
),
)
.toList();
},
child: RoomDetailsButton(
mini: mini,
buttonDetails: ButtonDetails(
title: L10n.of(context).more,
icon: const Icon(Icons.more_horiz_outlined),
visible: true,
),
width: mini ? _miniButtonWidth : _buttonWidth,
height: mini ? _miniButtonWidth : _buttonHeight,
),
);
}
final button = buttons[index];
return RoomDetailsButton(
mini: mini,
buttonDetails: button,
width: mini ? _miniButtonWidth : _buttonWidth,
height: mini ? _miniButtonWidth : _buttonHeight,
);
}),
);
},
),
);
}
}
class RoomDetailsButton extends StatelessWidget {
final bool mini;
// final bool visible;
// final bool enabled;
// final String title;
// final Widget icon;
// final VoidCallback? onPressed;
final double width;
final double height;
final ButtonDetails buttonDetails;
const RoomDetailsButton({
super.key,
required this.buttonDetails,
// required this.visible,
// required this.title,
// required this.icon,
required this.mini,
required this.width,
required this.height,
// this.enabled = true,
// this.onPressed,
});
@override
Widget build(BuildContext context) {
if (!buttonDetails.visible) {
return const SizedBox();
}
return TooltipVisibility(
visible: mini,
child: Tooltip(
message: buttonDetails.title,
child: AbsorbPointer(
absorbing: !buttonDetails.enabled,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: HoverBuilder(
builder: (context, hovered) {
return GestureDetector(
onTap: buttonDetails.onPressed,
child: Opacity(
opacity: buttonDetails.enabled ? 1.0 : 0.5,
child: Container(
width: width,
height: height,
decoration: BoxDecoration(
color: hovered
? Theme.of(context)
.colorScheme
.primary
.withAlpha(50)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.all(12.0),
child: mini
? buttonDetails.icon
: Column(
spacing: 12.0,
mainAxisAlignment: MainAxisAlignment.center,
children: [
buttonDetails.icon,
Text(
buttonDetails.title,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 12.0),
),
],
),
),
),
);
},
),
),
),
),
);
}
}
class ButtonDetails {
final String title;
final Widget icon;
final VoidCallback? onPressed;
final bool visible;
final bool enabled;
final bool showInMainView;
const ButtonDetails({
required this.title,
required this.icon,
required this.visible,
this.onPressed,
this.enabled = true,
this.showInMainView = true,
});
}
class RoomParticipantsSection extends StatelessWidget {
final Room room;
const RoomParticipantsSection({
required this.room,
super.key,
});
final double _width = 100.0;
final double _padding = 12.0;
double get _fullWidth => _width + (_padding * 2);
@override
Widget build(BuildContext context) {
final List<User> members = room.getParticipants().toList()
..sort((b, a) => a.powerLevel.compareTo(b.powerLevel));
final actualMembersCount = (room.summary.mInvitedMemberCount ?? 0) +
(room.summary.mJoinedMemberCount ?? 0);
return LayoutBuilder(
builder: (context, constraints) {
final availableWidth = constraints.maxWidth;
final capacity = (availableWidth / _fullWidth).floor();
return LoadParticipantsUtil(
space: room,
builder: (participantsLoader) {
if (capacity < 4) {
return Column(
children: [
...members.map((member) => ParticipantListItem(member)),
if (actualMembersCount - members.length > 0)
ListTile(
title: Text(
L10n.of(context).loadCountMoreParticipants(
(actualMembersCount - members.length),
),
),
leading: CircleAvatar(
backgroundColor:
Theme.of(context).scaffoldBackgroundColor,
child: const Icon(
Icons.group_outlined,
color: Colors.grey,
),
),
onTap: () => context.push(
'/rooms/${room.id}/details/members',
),
trailing: const Icon(Icons.chevron_right_outlined),
),
],
);
}
final filteredParticipants =
participantsLoader.filteredParticipants("");
return Wrap(
alignment: WrapAlignment.center,
runAlignment: WrapAlignment.center,
children: [
...filteredParticipants.mapIndexed((index, user) {
final publicProfile = participantsLoader.getPublicProfile(
user.id,
);
LinearGradient? gradient = index.leaderboardGradient;
if (user.id == BotName.byEnvironment ||
publicProfile == null ||
publicProfile.level == null) {
gradient = null;
}
return Padding(
padding: EdgeInsets.all(_padding),
child: SizedBox(
width: _width,
child: Column(
children: [
Stack(
alignment: Alignment.center,
children: [
if (gradient != null)
CircleAvatar(
radius: _width / 2,
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: gradient,
),
),
)
else
SizedBox(
height: _width,
width: _width,
),
Builder(
builder: (context) {
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () => showMemberActionsPopupMenu(
context: context,
user: user,
),
child: Center(
child: Avatar(
mxContent: user.avatarUrl,
name: user.calcDisplayname(),
size: _width - 6.0,
presenceUserId: user.id,
showPresence: false,
),
),
),
);
},
),
],
),
Text(
user.calcDisplayname(),
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
),
LevelDisplayName(
userId: user.id,
textStyle: Theme.of(context).textTheme.labelSmall,
),
],
),
),
);
}),
],
);
},
);
},
);
}
}