chore: change activity roles event so admins can finish activity for everyone (#3665)

This commit is contained in:
ggurdin 2025-08-08 11:33:17 -04:00 committed by GitHub
parent 32688f332a
commit c2134d2f3e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 175 additions and 166 deletions

View file

@ -10,7 +10,6 @@ import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pages/chat/chat.dart';
import 'package:fluffychat/pages/chat/events/pangea_message_reactions.dart';
import 'package:fluffychat/pages/chat/events/room_creation_state_event.dart';
import 'package:fluffychat/pangea/chat/widgets/activity_role_state_message.dart';
import 'package:fluffychat/pangea/chat/widgets/activity_state_event.dart';
import 'package:fluffychat/pangea/common/widgets/pressable_button.dart';
import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
@ -133,10 +132,6 @@ class Message extends StatelessWidget {
if (event.type == PangeaEventTypes.activityPlan) {
return ActivityStateEvent(event: event);
}
if (event.type == PangeaEventTypes.activityRole) {
return ActivityRoleStateMessage(event);
}
// Pangea#
return StateMessage(event);

View file

@ -11,7 +11,6 @@ import 'package:fluffychat/pangea/activity_planner/activity_participant_indicato
import 'package:fluffychat/pangea/activity_planner/activity_results_carousel.dart';
import 'package:fluffychat/pangea/activity_planner/activity_role_model.dart';
import 'package:fluffychat/pangea/activity_planner/activity_room_extension.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:fluffychat/widgets/mxc_image.dart';
@ -100,14 +99,6 @@ class ActivityFinishedStatusMessageState
}
Future<void> _archiveToAnalytics() async {
final role = widget.room.activityRole(widget.room.client.userID!);
if (role == null) {
throw Exception(
"Cannot archive activity without a role for user ${widget.room.client.userID!}",
);
}
role.archivedAt = DateTime.now();
await widget.room.archiveActivity();
await MatrixState.pangeaController.putAnalytics
.sendActivityAnalytics(widget.room.id);
@ -119,11 +110,12 @@ class ActivityFinishedStatusMessageState
}
final roles = widget.room.activityRoles;
return roles.where((role) {
return widget.room.activitySummary!.summary!.participants.any(
(p) => p.participantId == role.userId,
);
}).toList();
return roles?.roles.where((role) {
return widget.room.activitySummary!.summary!.participants.any(
(p) => p.participantId == role.userId,
);
}).toList() ??
[];
}
@override

View file

@ -44,10 +44,14 @@ class ActivityRoleModel {
return other is ActivityRoleModel &&
other.userId == userId &&
other.role == role &&
other.finishedAt == finishedAt;
other.finishedAt == finishedAt &&
other.archivedAt == archivedAt;
}
@override
int get hashCode =>
userId.hashCode ^ role.hashCode ^ (finishedAt?.hashCode ?? 0);
userId.hashCode ^
role.hashCode ^
(finishedAt?.hashCode ?? 0) ^
(archivedAt?.hashCode ?? 0);
}

View file

@ -0,0 +1,67 @@
import 'package:collection/collection.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/pangea/activity_planner/activity_role_model.dart';
class ActivityRolesModel {
final Event? event;
late List<ActivityRoleModel> _roles;
ActivityRolesModel({this.event, List<ActivityRoleModel>? roles}) {
assert(
event != null || roles != null,
"Either event or roles must be provided",
);
if (roles != null) {
_roles = roles;
} else {
final rolesList = event!.content["roles"] as List<dynamic>? ?? [];
try {
_roles = rolesList
.map<ActivityRoleModel>((e) => ActivityRoleModel.fromJson(e))
.toList();
} catch (e) {
_roles = [];
}
}
}
List<ActivityRoleModel> get roles => _roles;
ActivityRoleModel? role(String userId) {
return _roles.firstWhereOrNull((r) => r.userId == userId);
}
/// If this user already has a role, replace it with the new one.
/// Otherwise, add the new role.
void updateRole(ActivityRoleModel role) {
final index = _roles.indexWhere((r) => r.userId == role.userId);
index != -1 ? _roles[index] = role : _roles.add(role);
}
void finishAll() {
for (final role in _roles) {
if (role.isFinished) continue;
role.finishedAt = DateTime.now();
}
}
static ActivityRolesModel get empty => ActivityRolesModel(
roles: [],
);
Map<String, dynamic> toJson() {
return {
"roles": _roles.map((role) => role.toJson()).toList(),
};
}
static ActivityRolesModel fromJson(Map<String, dynamic> json) {
final roles = (json["roles"] as List<dynamic>?)
?.map<ActivityRoleModel>((e) => ActivityRoleModel.fromJson(e))
.toList();
return ActivityRolesModel(roles: roles);
}
}

View file

@ -6,6 +6,7 @@ import 'package:matrix/matrix.dart';
import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart';
import 'package:fluffychat/pangea/activity_planner/activity_role_model.dart';
import 'package:fluffychat/pangea/activity_planner/activity_roles_model.dart';
import 'package:fluffychat/pangea/activity_planner/bookmarked_activities_repo.dart';
import 'package:fluffychat/pangea/activity_summary/activity_summary_model.dart';
import 'package:fluffychat/pangea/activity_summary/activity_summary_repo.dart';
@ -15,6 +16,7 @@ import 'package:fluffychat/pangea/chat_settings/utils/download_chat.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
extension ActivityRoomExtension on Room {
Future<void> sendActivityPlan(
@ -34,48 +36,83 @@ extension ActivityRoomExtension on Room {
}
}
Future<void> startActivity({
Future<void> joinActivity({
String? role,
}) async {
final currentRoles = activityRoles ?? ActivityRolesModel.empty;
final activityRole = ActivityRoleModel(
userId: client.userID!,
role: role,
);
currentRoles.updateRole(activityRole);
await client.setRoomStateWithKey(
id,
PangeaEventTypes.activityRole,
client.userID!,
ActivityRoleModel(
userId: client.userID!,
role: role,
).toJson(),
"",
currentRoles.toJson(),
);
}
Future<void> continueActivity() async {
final role = activityRole(client.userID!);
if (role == null || !role.isFinished || role.isArchived) return;
final currentRoles = activityRoles ?? ActivityRolesModel.empty;
final role = currentRoles.role(client.userID!);
if (role == null || !role.isFinished) return;
role.finishedAt = null;
final syncFuture = client.waitForRoomInSync(id);
role.finishedAt = null; // Reset finished state
currentRoles.updateRole(role);
await client.setRoomStateWithKey(
id,
PangeaEventTypes.activityRole,
client.userID!,
role.toJson(),
"",
currentRoles.toJson(),
);
await syncFuture;
}
Future<void> finishActivity() async {
final role = activityRole(client.userID!);
if (role == null) return;
if (isRoomAdmin) {
await _finishActivityForAll();
return;
}
final currentRoles = activityRoles ?? ActivityRolesModel.empty;
final role = currentRoles.role(client.userID!);
if (role == null || role.isFinished) return;
role.finishedAt = DateTime.now();
final syncFuture = client.waitForRoomInSync(id);
currentRoles.updateRole(role);
await client.setRoomStateWithKey(
id,
PangeaEventTypes.activityRole,
client.userID!,
role.toJson(),
"",
currentRoles.toJson(),
);
}
Future<void> _finishActivityForAll() async {
final currentRoles = activityRoles ?? ActivityRolesModel.empty;
currentRoles.finishAll();
await client.setRoomStateWithKey(
id,
PangeaEventTypes.activityRole,
"",
currentRoles.toJson(),
);
}
Future<void> archiveActivity() async {
final currentRoles = activityRoles ?? ActivityRolesModel.empty;
final role = currentRoles.role(client.userID!);
if (role == null || !role.isFinished) return;
role.archivedAt = DateTime.now();
currentRoles.updateRole(role);
await client.setRoomStateWithKey(
id,
PangeaEventTypes.activityRole,
"",
currentRoles.toJson(),
);
await syncFuture;
}
Future<void> setActivitySummary(
@ -161,19 +198,6 @@ extension ActivityRoomExtension on Room {
}
}
Future<void> archiveActivity() async {
final role = activityRole(client.userID!);
if (role == null) return;
role.archivedAt = DateTime.now();
await client.setRoomStateWithKey(
id,
PangeaEventTypes.activityRole,
client.userID!,
role.toJson(),
);
}
ActivityPlanModel? get activityPlan {
final stateEvent = getState(PangeaEventTypes.activityPlan);
if (stateEvent == null) return null;
@ -193,26 +217,6 @@ extension ActivityRoomExtension on Room {
}
}
ActivityRoleModel? activityRole(String userId) {
final stateEvent = getState(PangeaEventTypes.activityRole, userId);
if (stateEvent == null) return null;
try {
return ActivityRoleModel.fromJson(stateEvent.content);
} catch (e, s) {
ErrorHandler.logError(
e: e,
s: s,
data: {
"roomID": id,
"userId": userId,
"stateEvent": stateEvent.content,
},
);
return null;
}
}
ActivitySummaryModel? get activitySummary {
final stateEvent = getState(PangeaEventTypes.activitySummary);
if (stateEvent == null) return null;
@ -232,14 +236,23 @@ extension ActivityRoomExtension on Room {
}
}
List<StrippedStateEvent> get _activityRoleEvents {
return states[PangeaEventTypes.activityRole]?.values.toList() ?? [];
}
ActivityRolesModel? get activityRoles {
final content = getState(PangeaEventTypes.activityRole)?.content;
if (content == null) return null;
List<ActivityRoleModel> get activityRoles {
return _activityRoleEvents
.map((r) => ActivityRoleModel.fromJson(r.content))
.toList();
try {
return ActivityRolesModel.fromJson(content);
} catch (e, s) {
ErrorHandler.logError(
e: e,
s: s,
data: {
"roomID": id,
"stateEvent": content,
},
);
return null;
}
}
bool get showActivityChatUI {
@ -250,32 +263,35 @@ extension ActivityRoomExtension on Room {
bool get isActiveInActivity {
if (!showActivityChatUI) return false;
final role = activityRole(client.userID!);
final role = activityRoles?.role(client.userID!);
return role != null && !role.isFinished;
}
bool get isInactiveInActivity {
if (!showActivityChatUI) return false;
final role = activityRole(client.userID!);
final role = activityRoles?.role(client.userID!);
return role == null || role.isFinished;
}
bool get hasCompletedActivity =>
activityRole(client.userID!)?.isFinished ?? false;
activityRoles?.role(client.userID!)?.isFinished ?? false;
bool get activityIsFinished {
final roles = activityRoles.where((r) => r.userId != BotName.byEnvironment);
return roles.isNotEmpty &&
roles.every((r) {
if (r.isFinished) return true;
final roles = activityRoles?.roles.where(
(r) => r.userId != BotName.byEnvironment,
);
// if the user is in the chat (not null && membership is join),
// then the activity is not finished for them
final user = getParticipants().firstWhereOrNull(
(u) => u.id == r.userId,
);
return user == null || user.membership != Membership.join;
});
if (roles == null || roles.isEmpty) return false;
return roles.every((r) {
if (r.isFinished) return true;
// if the user is in the chat (not null && membership is join),
// then the activity is not finished for them
final user = getParticipants().firstWhereOrNull(
(u) => u.id == r.userId,
);
return user == null || user.membership != Membership.join;
});
}
int? get numberOfParticipants {
@ -284,6 +300,9 @@ extension ActivityRoomExtension on Room {
int get remainingRoles {
if (numberOfParticipants == null) return 0;
return max(0, numberOfParticipants! - activityRoles.length);
return max(0, numberOfParticipants! - (activityRoles?.roles.length ?? 0));
}
bool get isHiddenActivityRoom =>
activityRoles?.role(client.userID!)?.isArchived ?? false;
}

View file

@ -85,7 +85,7 @@ class ActivityUnfinishedStatusMessageState
? () {
showFutureLoadingDialog(
context: context,
future: widget.room.startActivity,
future: widget.room.joinActivity,
);
}
: null,

View file

@ -1,63 +0,0 @@
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/l10n/l10n.dart';
import '../../../config/app_config.dart';
class ActivityRoleStateMessage extends StatelessWidget {
final Event event;
const ActivityRoleStateMessage(this.event, {super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final senderName = event.senderId == event.room.client.userID
? L10n.of(context).you
: event.senderFromMemoryOrFallback.calcDisplayname();
String role = L10n.of(context).participant;
bool finished = false;
try {
final roleContent = event.content['role'] as String?;
if (roleContent != null) {
role = roleContent;
}
finished = event.content['finishedAt'] != null;
} catch (e) {
// If the role is not found, we keep the default participant role.
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Center(
child: Padding(
padding: const EdgeInsets.all(4),
child: Material(
color: theme.colorScheme.surface.withAlpha(128),
borderRadius: BorderRadius.circular(AppConfig.borderRadius / 3),
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
child: Text(
finished
? L10n.of(context).finishedTheActivity(senderName)
: L10n.of(context).joinedTheActivity(senderName, role),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12 * AppConfig.fontSizeFactor,
decoration:
event.redacted ? TextDecoration.lineThrough : null,
),
),
),
),
),
),
);
}
}

View file

@ -16,7 +16,7 @@ class ActivityStateEvent extends StatelessWidget {
Widget build(BuildContext context) {
try {
final activity = ActivityPlanModel.fromJson(event.content);
final roles = event.room.activityRoles;
final roles = event.room.activityRoles?.roles ?? [];
return Container(
padding: const EdgeInsets.symmetric(
@ -37,7 +37,7 @@ class ActivityStateEvent extends StatelessWidget {
Wrap(
spacing: 12.0,
runSpacing: 12.0,
children: event.room.activityRoles.map((role) {
children: roles.map((role) {
return ActivityParticipantIndicator(
role: role,
displayname: role.userId.localpart,

View file

@ -35,8 +35,5 @@ extension RoomInformationRoomExtension on Room {
getState(EventTypes.RoomCreate)?.content.tryGet<String>('type') ==
PangeaRoomTypes.analytics;
bool get isHiddenActivityRoom =>
activityRole(client.userID!)?.isArchived ?? false;
bool get isHiddenRoom => isAnalyticsRoom || isHiddenActivityRoom;
}

View file

@ -59,7 +59,6 @@ extension IsStateExtension on Event {
isEventTypeKnown ||
[
PangeaEventTypes.activityPlan,
PangeaEventTypes.activityRole,
].contains(type);
// we're filtering out some state events that we don't want to render
@ -70,7 +69,6 @@ extension IsStateExtension on Event {
EventTypes.RoomTombstone,
EventTypes.CallInvite,
PangeaEventTypes.activityPlan,
PangeaEventTypes.activityRole,
};
// Pangea#
}