feat: Implement polls

This commit is contained in:
Christian Kußowski 2025-11-02 13:10:55 +01:00
parent f3ea64086b
commit 71fa853f05
No known key found for this signature in database
GPG key ID: E067ECD60F1A0652
12 changed files with 582 additions and 63 deletions

View file

@ -3411,5 +3411,34 @@
}
}
},
"donate": "Donate"
"donate": "Donate",
"startedAPoll": "{username} started a poll.",
"@startedAPoll": {
"type": "String",
"placeholders": {
"username": {
"type": "String"
}
}
},
"poll": "Poll",
"startPoll": "Start poll",
"endPoll": "End poll",
"answersVisible": "Answers visible",
"answersHidden": "Answers hidden",
"pollQuestion": "Poll question",
"answerOption": "Answer option",
"addAnswerOption": "Add answer option",
"allowMultipleAnswers": "Allow multiple answers",
"pollHasBeenEnded": "Poll has been ended",
"countVotes": "{count} votes",
"@countVotes": {
"type": "int",
"placeholders": {
"count": {
"type": "int"
}
}
},
"answersWillBeVisibleWhenPollHasEnded": "Answers will be visible when poll has ended"
}

View file

@ -20,7 +20,9 @@ import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pages/chat/chat_view.dart';
import 'package:fluffychat/pages/chat/event_info_dialog.dart';
import 'package:fluffychat/pages/chat/start_poll_bottom_sheet.dart';
import 'package:fluffychat/pages/chat_details/chat_details.dart';
import 'package:fluffychat/utils/adaptive_bottom_sheet.dart';
import 'package:fluffychat/utils/error_reporter.dart';
import 'package:fluffychat/utils/file_selector.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions/event_extension.dart';
@ -1138,26 +1140,34 @@ class ChatController extends State<ChatPageWithRoom>
FocusScope.of(context).requestFocus(inputFocus);
}
void onAddPopupMenuButtonSelected(String choice) {
room.client.getConfig(); // Preload server file configuration.
void onAddPopupMenuButtonSelected(AddPopupMenuActions choice) {
room.client.getConfig();
if (choice == 'file') {
sendFileAction();
}
if (choice == 'image') {
sendFileAction(type: FileSelectorType.images);
}
if (choice == 'video') {
sendFileAction(type: FileSelectorType.videos);
}
if (choice == 'camera') {
openCameraAction();
}
if (choice == 'camera-video') {
openVideoCameraAction();
}
if (choice == 'location') {
sendLocationAction();
switch (choice) {
case AddPopupMenuActions.image:
sendFileAction(type: FileSelectorType.images);
return;
case AddPopupMenuActions.video:
sendFileAction(type: FileSelectorType.videos);
return;
case AddPopupMenuActions.file:
sendFileAction();
return;
case AddPopupMenuActions.poll:
showAdaptiveBottomSheet(
context: context,
builder: (context) => StartPollBottomSheet(room: room),
);
return;
case AddPopupMenuActions.photoCamera:
openCameraAction();
return;
case AddPopupMenuActions.videoCamera:
openVideoCameraAction();
return;
case AddPopupMenuActions.location:
sendLocationAction();
return;
}
}
@ -1358,3 +1368,13 @@ class ChatController extends State<ChatPageWithRoom>
);
}
}
enum AddPopupMenuActions {
image,
video,
file,
poll,
photoCamera,
videoCamera,
location,
}

View file

@ -132,16 +132,15 @@ class ChatInputRow extends StatelessWidget {
alignment: Alignment.center,
decoration: const BoxDecoration(),
clipBehavior: Clip.hardEdge,
child: PopupMenuButton<String>(
child: PopupMenuButton<AddPopupMenuActions>(
useRootNavigator: true,
icon: const Icon(Icons.add_circle_outline),
iconColor: theme.colorScheme.onPrimaryContainer,
onSelected: controller.onAddPopupMenuButtonSelected,
itemBuilder: (BuildContext context) =>
<PopupMenuEntry<String>>[
itemBuilder: (BuildContext context) => [
if (PlatformInfos.isMobile)
PopupMenuItem<String>(
value: 'location',
PopupMenuItem(
value: AddPopupMenuActions.location,
child: ListTile(
leading: CircleAvatar(
backgroundColor:
@ -154,8 +153,22 @@ class ChatInputRow extends StatelessWidget {
contentPadding: const EdgeInsets.all(0),
),
),
PopupMenuItem<String>(
value: 'image',
PopupMenuItem(
value: AddPopupMenuActions.poll,
child: ListTile(
leading: CircleAvatar(
backgroundColor:
theme.colorScheme.onPrimaryContainer,
foregroundColor:
theme.colorScheme.primaryContainer,
child: const Icon(Icons.poll_outlined),
),
title: Text(L10n.of(context).startPoll),
contentPadding: const EdgeInsets.all(0),
),
),
PopupMenuItem(
value: AddPopupMenuActions.image,
child: ListTile(
leading: CircleAvatar(
backgroundColor:
@ -168,8 +181,8 @@ class ChatInputRow extends StatelessWidget {
contentPadding: const EdgeInsets.all(0),
),
),
PopupMenuItem<String>(
value: 'video',
PopupMenuItem(
value: AddPopupMenuActions.video,
child: ListTile(
leading: CircleAvatar(
backgroundColor:
@ -183,8 +196,8 @@ class ChatInputRow extends StatelessWidget {
contentPadding: const EdgeInsets.all(0),
),
),
PopupMenuItem<String>(
value: 'file',
PopupMenuItem(
value: AddPopupMenuActions.file,
child: ListTile(
leading: CircleAvatar(
backgroundColor:
@ -217,8 +230,8 @@ class ChatInputRow extends StatelessWidget {
onSelected: controller.onAddPopupMenuButtonSelected,
iconColor: theme.colorScheme.onPrimaryContainer,
itemBuilder: (context) => [
PopupMenuItem<String>(
value: 'camera-video',
PopupMenuItem(
value: AddPopupMenuActions.videoCamera,
child: ListTile(
leading: CircleAvatar(
backgroundColor:
@ -231,8 +244,8 @@ class ChatInputRow extends StatelessWidget {
contentPadding: const EdgeInsets.all(0),
),
),
PopupMenuItem<String>(
value: 'camera',
PopupMenuItem(
value: AddPopupMenuActions.photoCamera,
child: ListTile(
leading: CircleAvatar(
backgroundColor:
@ -394,7 +407,7 @@ class _ChatAccountPicker extends StatelessWidget {
onSelected: (mxid) => _popupMenuButtonSelected(mxid, context),
itemBuilder: (BuildContext context) => clients
.map(
(client) => PopupMenuItem<String>(
(client) => PopupMenuItem(
value: client!.userID,
child: FutureBuilder<Profile>(
future: client.fetchOwnProfile(),

View file

@ -83,6 +83,7 @@ class Message extends StatelessWidget {
EventTypes.Sticker,
EventTypes.Encrypted,
EventTypes.CallInvite,
PollEventContent.startType,
}.contains(event.type)) {
if (event.type.startsWith('m.call.')) {
return const SizedBox.shrink();

View file

@ -6,6 +6,7 @@ import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/setting_keys.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pages/chat/events/poll.dart';
import 'package:fluffychat/pages/chat/events/video_player.dart';
import 'package:fluffychat/utils/adaptive_bottom_sheet.dart';
import 'package:fluffychat/utils/date_time_extension.dart';
@ -234,27 +235,11 @@ class MessageContent extends StatelessWidget {
textmessage:
default:
if (event.redacted) {
return FutureBuilder<User?>(
future: event.redactedBecause?.fetchSenderUser(),
builder: (context, snapshot) {
final reason =
event.redactedBecause?.content.tryGet<String>('reason');
final redactedBy = snapshot.data?.calcDisplayname() ??
event.redactedBecause?.senderId.localpart ??
L10n.of(context).user;
return _ButtonContent(
label: reason == null
? L10n.of(context).redactedBy(redactedBy)
: L10n.of(context).redactedByBecause(
redactedBy,
reason,
),
icon: '🗑️',
textColor: buttonTextColor.withAlpha(128),
onPressed: () => onInfoTab!(event),
fontSize: fontSize,
);
},
return RedactionWidget(
event: event,
buttonTextColor: buttonTextColor,
onInfoTab: onInfoTab,
fontSize: fontSize,
);
}
var html = AppSettings.renderHtml.value && event.isRichMessage
@ -296,6 +281,21 @@ class MessageContent extends StatelessWidget {
),
);
}
case PollEventContent.startType:
if (event.redacted) {
return RedactionWidget(
event: event,
buttonTextColor: buttonTextColor,
onInfoTab: onInfoTab,
fontSize: fontSize,
);
}
return PollWidget(
event: event,
timeline: timeline,
textColor: textColor,
linkColor: linkColor,
);
case EventTypes.CallInvite:
return FutureBuilder<User?>(
future: event.fetchSenderUser(),
@ -333,6 +333,46 @@ class MessageContent extends StatelessWidget {
}
}
class RedactionWidget extends StatelessWidget {
const RedactionWidget({
super.key,
required this.event,
required this.buttonTextColor,
required this.onInfoTab,
required this.fontSize,
});
final Event event;
final Color buttonTextColor;
final void Function(Event p1)? onInfoTab;
final double fontSize;
@override
Widget build(BuildContext context) {
return FutureBuilder<User?>(
future: event.redactedBecause?.fetchSenderUser(),
builder: (context, snapshot) {
final reason = event.redactedBecause?.content.tryGet<String>('reason');
final redactedBy = snapshot.data?.calcDisplayname() ??
event.redactedBecause?.senderId.localpart ??
L10n.of(context).user;
return _ButtonContent(
label: reason == null
? L10n.of(context).redactedBy(redactedBy)
: L10n.of(context).redactedByBecause(
redactedBy,
reason,
),
icon: '🗑️',
textColor: buttonTextColor.withAlpha(128),
onPressed: () => onInfoTab!(event),
fontSize: fontSize,
);
},
);
}
}
class _ButtonContent extends StatelessWidget {
final void Function() onPressed;
final String label;

View file

@ -0,0 +1,233 @@
import 'package:flutter/material.dart';
import 'package:async/async.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:matrix/matrix.dart' hide Result;
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/setting_keys.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/utils/url_launcher.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
class PollWidget extends StatelessWidget {
final Event event;
final Timeline timeline;
final Color textColor;
final Color linkColor;
const PollWidget({
required this.event,
required this.timeline,
required this.textColor,
required this.linkColor,
super.key,
});
void _endPoll(BuildContext context) => showFutureLoadingDialog(
context: context,
future: () => event.endPoll(),
);
void _toggleVote(
BuildContext context,
String answerId,
int maxSelection,
) async {
final userId = event.room.client.userID!;
final answerIds = event.getPollResponses(timeline)[userId] ?? {};
if (!answerIds.remove(answerId)) {
answerIds.add(answerId);
if (answerIds.length > maxSelection) {
answerIds.clear();
answerIds.add(answerId);
}
}
showFutureLoadingDialog(
context: context,
future: () => event.answerPoll(answerIds.toList()),
);
}
@override
Widget build(BuildContext context) {
final eventContentResult = Result(() => event.parsedPollEventContent);
final eventContent = eventContentResult.asValue?.value;
if (eventContent == null) {
Logs().w('Invalid poll event', eventContentResult.error);
return const Text('Unable to parse poll event...');
}
final responses = event.getPollResponses(timeline);
final pollHasBeenEnded = event.getPollHasBeenEnded(timeline);
final canVote = event.room.canSendEvent(PollEventContent.responseType) &&
!pollHasBeenEnded;
final maxPolls = responses.length;
final answersVisible =
eventContent.pollStartContent.kind == PollKind.disclosed ||
pollHasBeenEnded;
return Padding(
padding: const EdgeInsets.symmetric(
vertical: 8,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Linkify(
text: eventContent.pollStartContent.question.mText,
textScaleFactor: MediaQuery.textScalerOf(context).scale(1),
style: TextStyle(
color: textColor,
fontSize: AppSettings.fontSizeFactor.value *
AppConfig.messageFontSize,
),
options: const LinkifyOptions(humanize: false),
linkStyle: TextStyle(
color: linkColor,
fontSize: AppSettings.fontSizeFactor.value *
AppConfig.messageFontSize,
decoration: TextDecoration.underline,
decorationColor: linkColor,
),
onOpen: (url) => UrlLauncher(context, url.url).launchUrl(),
),
),
Divider(color: linkColor.withAlpha(64)),
...eventContent.pollStartContent.answers.map(
(answer) {
final votedUserIds = responses.entries
.where((entry) => entry.value.contains(answer.id))
.map((entry) => entry.key)
.toSet();
return Material(
color: Colors.transparent,
clipBehavior: Clip.hardEdge,
child: CheckboxListTile.adaptive(
value: responses[event.room.client.userID!]
?.contains(answer.id) ??
false,
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
checkboxScaleFactor: 1.5,
checkboxShape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(32),
),
onChanged: !canVote
? null
: (_) => _toggleVote(
context,
answer.id,
eventContent.pollStartContent.maxSelections,
),
title: Text(
answer.mText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: textColor,
fontSize: AppConfig.messageFontSize *
AppSettings.fontSizeFactor.value,
),
),
subtitle: answersVisible
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
Text(
L10n.of(context)
.countVotes(votedUserIds.length),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: linkColor,
fontSize:
12 * AppSettings.fontSizeFactor.value,
),
),
const SizedBox(width: 2),
...votedUserIds.map((userId) {
final user = event.room
.getState(EventTypes.RoomMember, userId)
?.asUser(event.room);
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 2.0,
),
child: Avatar(
mxContent: user?.avatarUrl,
name: user?.calcDisplayname() ??
userId.localpart,
size: 12 *
AppSettings.fontSizeFactor.value,
),
);
}),
const SizedBox(width: 2),
],
),
),
LinearProgressIndicator(
color: linkColor,
backgroundColor: linkColor.withAlpha(128),
borderRadius:
BorderRadius.circular(AppConfig.borderRadius),
value: maxPolls == 0
? 0
: votedUserIds.length / maxPolls,
),
],
)
: null,
),
);
},
),
if (!pollHasBeenEnded && event.senderId == event.room.client.userID)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: OutlinedButton(
onPressed: () => _endPoll(context),
style: OutlinedButton.styleFrom(
foregroundColor: linkColor,
side: BorderSide(color: linkColor.withAlpha(64)),
),
child: Text(L10n.of(context).endPoll),
),
)
else if (!answersVisible)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
L10n.of(context).answersWillBeVisibleWhenPollHasEnded,
style: TextStyle(
color: linkColor,
fontSize: 12 * AppSettings.fontSizeFactor.value,
fontStyle: FontStyle.italic,
),
),
)
else if (pollHasBeenEnded)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
L10n.of(context).pollHasBeenEnded,
style: TextStyle(
color: linkColor,
fontSize: 12 * AppSettings.fontSizeFactor.value,
fontStyle: FontStyle.italic,
),
),
),
],
),
);
}
}

View file

@ -0,0 +1,171 @@
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/utils/localized_exception_extension.dart';
class StartPollBottomSheet extends StatefulWidget {
final Room room;
const StartPollBottomSheet({required this.room, super.key});
@override
State<StartPollBottomSheet> createState() => _StartPollBottomSheetState();
}
class _StartPollBottomSheetState extends State<StartPollBottomSheet> {
final TextEditingController _bodyController = TextEditingController();
bool _allowMultipleAnswers = false;
final List<TextEditingController> _answers = [
TextEditingController(),
TextEditingController(),
];
PollKind _pollKind = PollKind.disclosed;
bool _canCreate = false;
bool isLoading = false;
String? _txid;
void _createPoll() async {
try {
var id = 0;
_txid ??= widget.room.client.generateUniqueTransactionId();
await widget.room.startPoll(
question: _bodyController.text.trim(),
answers: _answers
.map(
(answerController) => PollAnswer(
id: (++id).toString(),
mText: answerController.text.trim(),
),
)
.toList(),
kind: _pollKind,
maxSelections: _allowMultipleAnswers ? _answers.length : 1,
txid: _txid,
);
Navigator.of(context).pop();
} catch (e, s) {
Logs().w('Unable to create poll', e, s);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toLocalizedString(context))),
);
}
}
void _updateCanCreate([_]) {
final newCanCreate = _bodyController.text.trim().isNotEmpty &&
!_answers.any((controller) => controller.text.trim().isEmpty);
if (_canCreate != newCanCreate) {
setState(() {
_canCreate = newCanCreate;
});
}
}
@override
Widget build(BuildContext context) {
const maxAnswers = 10;
return Scaffold(
appBar: AppBar(
leading: CloseButton(
onPressed: Navigator.of(context).pop,
),
title: Text(L10n.of(context).startPoll),
),
body: ListView(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
children: [
SegmentedButton<PollKind>(
selected: {_pollKind},
multiSelectionEnabled: false,
onSelectionChanged: (pollKind) => setState(() {
_pollKind = pollKind.first;
}),
segments: [
ButtonSegment(
value: PollKind.disclosed,
label: Text(
L10n.of(context).answersVisible,
),
),
ButtonSegment(
value: PollKind.undisclosed,
label: Text(
L10n.of(context).answersHidden,
),
),
],
),
const SizedBox(height: 32),
TextField(
controller: _bodyController,
minLines: 1,
maxLines: 4,
maxLength: 512,
onChanged: _updateCanCreate,
decoration: InputDecoration(
hintText: L10n.of(context).pollQuestion,
counter: const SizedBox.shrink(),
),
),
const Divider(height: 32),
..._answers.map(
(answerController) => Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: TextField(
controller: answerController,
onChanged: _updateCanCreate,
maxLength: 64,
decoration: InputDecoration(
counter: const SizedBox.shrink(),
hintText: L10n.of(context).answerOption,
suffixIcon: _answers.length == 2
? null
: IconButton(
icon: const Icon(Icons.cancel_outlined),
onPressed: () => setState(() {
_answers.remove(answerController..dispose());
}),
),
),
),
),
),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
icon: const Icon(Icons.add_outlined),
onPressed: _answers.length < maxAnswers
? () => setState(() {
_answers.add(TextEditingController());
})
: null,
label: Text(L10n.of(context).addAnswerOption),
),
),
ListTile(
contentPadding: EdgeInsets.zero,
leading: Switch.adaptive(
value: _allowMultipleAnswers,
onChanged: (allow) => setState(() {
_allowMultipleAnswers = allow;
}),
),
title: Text(L10n.of(context).allowMultipleAnswers),
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: !isLoading && _canCreate ? _createPoll : null,
child: isLoading
? const LinearProgressIndicator()
: Text(L10n.of(context).startPoll),
),
],
),
);
}
}

View file

@ -16,8 +16,8 @@ extension IsStateExtension on Event {
// always filter out edit and reaction relationships
!{RelationshipTypes.edit, RelationshipTypes.reaction}
.contains(relationshipType) &&
// always filter out m.key.* events
!type.startsWith('m.key.verification.') &&
// always filter out m.key.* and other known but unimportant events
!isKnownHiddenStates &&
// event types to hide: redaction and reaction events
// if a reaction has been redacted we also want it to be hidden in the timeline
!{EventTypes.Reaction, EventTypes.Redaction}.contains(type) &&
@ -39,4 +39,10 @@ extension IsStateExtension on Event {
EventTypes.RoomCreate,
EventTypes.RoomTombstone,
}.contains(type);
bool get isKnownHiddenStates =>
{
PollEventContent.responseType,
}.contains(type) ||
type.startsWith('m.key.verification.');
}

View file

@ -363,4 +363,10 @@ class MatrixLocals extends MatrixLocalizations {
@override
String get refreshingLastEvent => l10n.loadingPleaseWait;
@override
String startedAPoll(String senderName) => '$senderName started a poll';
@override
String get pollHasBeenEnded => l10n.pollHasBeenEnded;
}

View file

@ -180,7 +180,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
app_links: 05a6ec2341985eb05e9f97dc63f5837c39895c3f
audio_session: eaca2512cf2b39212d724f35d11f46180ad3a33e
desktop_drop: 248706031734554504f939cab1ad4c5fbc9c9c72
desktop_drop: 10a3e6a7fa9dbe350541f2574092fecfa345a07b
device_info_plus: 4fb280989f669696856f8b129e4a5e3cd6c48f76
dynamic_color: cb7c2a300ee67ed3bd96c3e852df3af0300bf610
emoji_picker_flutter: 51ca408e289d84d1e460016b2a28721ec754fcf7

View file

@ -1114,8 +1114,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: "krille/refactor-update-user-device-keys"
resolved-ref: d35d78f3b7c38671888e87b9aa80d2a93c811b82
ref: "krille/implement-polls-msc"
resolved-ref: "83c5b82f2ada7b3c47cbf8b8baaf88b5755f2d86"
url: "https://github.com/famedly/matrix-dart-sdk.git"
source: git
version: "3.0.2"

View file

@ -55,7 +55,7 @@ dependencies:
matrix: #^3.0.1
git:
url: https://github.com/famedly/matrix-dart-sdk.git
ref: krille/refactor-update-user-device-keys
ref: krille/implement-polls-msc
mime: ^2.0.0
native_imaging: ^0.2.0
opus_caf_converter_dart: ^1.0.1