Merge branch 'main' of https://github.com/krille-chan/fluffychat
Some checks failed
Main Deploy Workflow / deploy_web (push) Has been cancelled
Main Deploy Workflow / deploy_playstore_internal (push) Has been cancelled

This commit is contained in:
Alexey 2026-04-23 10:59:25 +03:00
commit e12723fdaa
98 changed files with 1450 additions and 1014 deletions

View file

@ -212,6 +212,7 @@ class ChatController extends State<ChatPageWithRoom>
context: context,
future: room.leave,
);
if (!mounted) return;
if (success.error != null) return;
context.go('/rooms');
}
@ -463,21 +464,25 @@ class ChatController extends State<ChatPageWithRoom>
scrollUpBannerEventId = eventId;
});
bool firstUpdateReceived = false;
String? animateInEventId;
void _insert(int index) {
if (index > 0) return;
animateInEventId = timeline?.events.firstOrNull?.eventId;
}
void updateView() {
if (!mounted) return;
setReadMarker();
setState(() {
firstUpdateReceived = true;
});
setState(() {});
}
Future<void>? loadTimelineFuture;
Future<void> _getTimeline({String? eventContextId}) async {
await Matrix.of(context).client.roomsLoading;
await Matrix.of(context).client.accountDataLoading;
final matrix = Matrix.of(context);
await matrix.client.roomsLoading;
await matrix.client.accountDataLoading;
if (eventContextId != null &&
(!eventContextId.isValidMatrixId || eventContextId.sigil != '\$')) {
eventContextId = null;
@ -486,6 +491,7 @@ class ChatController extends State<ChatPageWithRoom>
timeline?.cancelSubscriptions();
timeline = await room.getTimeline(
onUpdate: updateView,
onInsert: _insert,
eventContextId: eventContextId,
);
} catch (e, s) {
@ -633,6 +639,7 @@ class ChatController extends State<ChatPageWithRoom>
Future<void> sendFileAction({FileType type = FileType.any}) async {
final files = await selectFiles(context, allowMultiple: true, type: type);
if (files.isEmpty) return;
if (!mounted) return;
await showAdaptiveDialog(
context: context,
builder: (c) => SendFileDialog(
@ -669,6 +676,7 @@ class ChatController extends State<ChatPageWithRoom>
FocusScope.of(context).requestFocus(FocusNode());
final file = await ImagePicker().pickImage(source: ImageSource.camera);
if (file == null) return;
if (!mounted) return;
await showAdaptiveDialog(
context: context,
@ -690,6 +698,7 @@ class ChatController extends State<ChatPageWithRoom>
maxDuration: const Duration(minutes: 1),
);
if (file == null) return;
if (!mounted) return;
await showAdaptiveDialog(
context: context,
@ -732,26 +741,27 @@ class ChatController extends State<ChatPageWithRoom>
mimeType: mimeType,
);
room
.sendFileEvent(
file,
inReplyTo: replyEvent,
threadRootEventId: activeThreadId,
extraContent: {
'info': {...file.info, 'duration': duration},
'org.matrix.msc3245.voice': {},
'org.matrix.msc1767.audio': {
'duration': duration,
'waveform': waveform,
},
try {
await room.sendFileEvent(
file,
inReplyTo: replyEvent,
threadRootEventId: activeThreadId,
extraContent: {
'info': {...file.info, 'duration': duration},
'org.matrix.msc3245.voice': {},
'org.matrix.msc1767.audio': {
'duration': duration,
'waveform': waveform,
},
)
.catchError((e) {
scaffoldMessenger.showSnackBar(
SnackBar(content: Text((e as Object).toLocalizedString(context))),
);
return null;
});
},
);
} catch (e) {
if (!mounted) return;
scaffoldMessenger.showSnackBar(
SnackBar(content: Text(e.toLocalizedString(context))),
);
return;
}
setState(() {
replyEvent = null;
});
@ -813,29 +823,30 @@ class ChatController extends State<ChatPageWithRoom>
Future<void> reportEventAction() async {
final event = selectedEvents.single;
final l10n = L10n.of(context);
final scaffoldMessenger = ScaffoldMessenger.of(context);
final score = await showModalActionPopup<int>(
context: context,
title: L10n.of(context).reportMessage,
message: L10n.of(context).howOffensiveIsThisContent,
cancelLabel: L10n.of(context).cancel,
title: l10n.reportMessage,
message: l10n.howOffensiveIsThisContent,
cancelLabel: l10n.cancel,
actions: [
AdaptiveModalAction(
value: -100,
label: L10n.of(context).extremeOffensive,
),
AdaptiveModalAction(value: -50, label: L10n.of(context).offensive),
AdaptiveModalAction(value: 0, label: L10n.of(context).inoffensive),
AdaptiveModalAction(value: -100, label: l10n.extremeOffensive),
AdaptiveModalAction(value: -50, label: l10n.offensive),
AdaptiveModalAction(value: 0, label: l10n.inoffensive),
],
);
if (score == null) return;
if (!mounted) return;
final reason = await showTextInputDialog(
context: context,
title: L10n.of(context).whyDoYouWantToReportThis,
okLabel: L10n.of(context).ok,
cancelLabel: L10n.of(context).cancel,
hintText: L10n.of(context).reason,
title: l10n.whyDoYouWantToReportThis,
okLabel: l10n.ok,
cancelLabel: l10n.cancel,
hintText: l10n.reason,
);
if (reason == null || reason.isEmpty) return;
if (!mounted) return;
final result = await showFutureLoadingDialog(
context: context,
future: () => Matrix.of(context).client.reportEvent(
@ -846,12 +857,13 @@ class ChatController extends State<ChatPageWithRoom>
),
);
if (result.error != null) return;
if (!mounted) return;
setState(() {
showEmojiPicker = false;
selectedEvents.clear();
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(L10n.of(context).contentHasBeenReported)),
scaffoldMessenger.showSnackBar(
SnackBar(content: Text(l10n.contentHasBeenReported)),
);
}
@ -867,6 +879,7 @@ class ChatController extends State<ChatPageWithRoom>
}
setState(selectedEvents.clear);
} catch (e, s) {
if (!mounted) return;
ErrorReporter(
context,
'Error while delete error events action',
@ -891,6 +904,7 @@ class ChatController extends State<ChatPageWithRoom>
: null;
if (reasonInput == null) return;
final reason = reasonInput.isEmpty ? null : reasonInput;
if (!mounted) return;
await showFutureLoadingDialog(
context: context,
futureWithProgress: (onProgress) async {
@ -1147,7 +1161,7 @@ class ChatController extends State<ChatPageWithRoom>
true,
false,
);
users.sort((a, b) => a.powerLevel.compareTo(b.powerLevel));
users.sort((a, b) => a.powerLevel.level.compareTo(b.powerLevel.level));
final via = users
.map((user) => user.id.domain)
.whereType<String>()
@ -1248,6 +1262,7 @@ class ChatController extends State<ChatPageWithRoom>
okLabel: L10n.of(context).unpin,
cancelLabel: L10n.of(context).cancel,
);
if (!mounted) return;
if (response == OkCancelResult.ok) {
final events = room.pinnedEventIds
..removeWhere((oldEvent) => oldEvent == eventId);
@ -1337,17 +1352,18 @@ class ChatController extends State<ChatPageWithRoom>
Future<void> onPhoneButtonTap() async {
// VoIP required Android SDK 21
if (PlatformInfos.isAndroid) {
DeviceInfoPlugin().androidInfo.then((value) {
if (value.version.sdkInt < 21) {
Navigator.pop(context);
showOkAlertDialog(
context: context,
title: L10n.of(context).unsupportedAndroidVersion,
message: L10n.of(context).unsupportedAndroidVersionLong,
okLabel: L10n.of(context).close,
);
}
});
final androidInfo = await DeviceInfoPlugin().androidInfo;
if (!mounted) return;
if (androidInfo.version.sdkInt < 21) {
Navigator.pop(context);
await showOkAlertDialog(
context: context,
title: L10n.of(context).unsupportedAndroidVersion,
message: L10n.of(context).unsupportedAndroidVersionLong,
okLabel: L10n.of(context).close,
);
return;
}
}
final callType = await showModalActionPopup<CallType>(
context: context,
@ -1368,11 +1384,13 @@ class ChatController extends State<ChatPageWithRoom>
],
);
if (callType == null) return;
if (!mounted) return;
final voipPlugin = Matrix.of(context).voipPlugin;
try {
await voipPlugin!.voip.inviteToCall(room, callType);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(e.toLocalizedString(context))));

View file

@ -77,7 +77,7 @@ class ChatEventList extends StatelessWidget {
return Column(
mainAxisSize: .min,
children: [
SeenByRow(event: events.first),
if (events.isNotEmpty) SeenByRow(event: events.first),
TypingIndicators(controller),
],
);
@ -117,9 +117,7 @@ class ChatEventList extends StatelessWidget {
// The message at this index:
final event = events[i];
final animateIn =
event.eventId == timeline.events.first.eventId &&
controller.firstUpdateReceived;
final animateIn = event.eventId == controller.animateInEventId;
final nextEvent = i + 1 < events.length ? events[i + 1] : null;
final previousEvent = i > 0 ? events[i - 1] : null;

View file

@ -189,6 +189,7 @@ class AudioPlayerState extends State<AudioPlayerWidget> {
});
} catch (e, s) {
Logs().v('Could not download audio file', e, s);
if (!mounted) rethrow;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(e.toLocalizedString(context))));
@ -208,6 +209,7 @@ class AudioPlayerState extends State<AudioPlayerWidget> {
),
);
}
if (!mounted) return;
audioPlayer.play().onError(
ErrorReporter(context, 'Unable to play audio message').onErrorCallback,

View file

@ -50,6 +50,7 @@ class _CuteContentState extends State<CuteContent> {
Future<void> addOverlay() async {
_isOverlayShown = true;
await Future.delayed(const Duration(milliseconds: 50));
if (!mounted) return;
OverlayEntry? overlay;
overlay = OverlayEntry(

View file

@ -1,4 +1,5 @@
import 'package:collection/collection.dart';
import 'package:fluffychat/config/setting_keys.dart';
import 'package:fluffychat/utils/code_highlight_theme.dart';
import 'package:fluffychat/utils/event_checkbox_extension.dart';
import 'package:fluffychat/widgets/avatar.dart';
@ -509,11 +510,15 @@ class HtmlMessage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final element = parser.parse(html).body ?? dom.Element.html('');
final configuredMaxLines = AppSettings.messagePreviewMaxLines.value;
final maxLines = !limitHeight || configuredMaxLines <= 0
? null
: configuredMaxLines;
return Text.rich(
_renderHtml(element, context),
style: TextStyle(fontSize: fontSize, color: textColor),
maxLines: limitHeight ? 64 : null,
overflow: TextOverflow.fade,
maxLines: maxLines,
overflow: maxLines == null ? TextOverflow.visible : TextOverflow.fade,
selectionColor: textColor.withAlpha(128),
);
}

View file

@ -205,6 +205,8 @@ class Message extends StatelessWidget {
final enterThread = this.enterThread;
final sender = event.senderFromMemoryOrFallback;
final fileSendingStatus = event.fileSendingStatus;
return _AnimateIn(
animateIn: animateIn,
child: Center(
@ -318,9 +320,33 @@ class Message extends StatelessWidget {
height: 16,
child: event.status == EventStatus.error
? const Icon(Icons.error, color: Colors.red)
: event.fileSendingStatus != null
? const CircularProgressIndicator.adaptive(
strokeWidth: 1,
: fileSendingStatus != null
? Stack(
children: [
Center(
child: switch (fileSendingStatus) {
FileSendingStatus
.generatingThumbnail =>
Icon(
Icons.compress_outlined,
size: 14,
),
FileSendingStatus.encrypting =>
Icon(
Icons.lock_outlined,
size: 14,
),
FileSendingStatus.uploading =>
Icon(
Icons.upload_outlined,
size: 14,
),
},
),
const CircularProgressIndicator.adaptive(
strokeWidth: 1,
),
],
)
: null,
),
@ -361,17 +387,20 @@ class Message extends StatelessWidget {
? const SizedBox(height: 12)
: Row(
children: [
if (sender.powerLevel >= 50)
if (sender.powerLevel.role !=
PowerLevelRole.user)
Padding(
padding: const EdgeInsets.only(
right: 2.0,
),
child: Icon(
sender.powerLevel >= 100
sender.powerLevel.role ==
PowerLevelRole
.moderator
? Icons
.admin_panel_settings
.add_moderator_outlined
: Icons
.add_moderator_outlined,
.admin_panel_settings,
size: 14,
color: theme
.colorScheme
@ -432,147 +461,161 @@ class Message extends StatelessWidget {
HapticFeedback.heavyImpact();
onSelect(event);
},
child: Container(
decoration: BoxDecoration(
color: noBubble
? Colors.transparent
: color,
borderRadius: borderRadius,
),
clipBehavior: Clip.antiAlias,
child: BubbleBackground(
colors: colors,
ignore:
noBubble ||
!ownMessage ||
MediaQuery.highContrastOf(context),
scrollController: scrollController,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(
AppConfig.borderRadius,
child: AnimatedOpacity(
duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve,
opacity:
event.status.isSending ||
event.type == EventTypes.Encrypted
? 0.5
: 1,
child: Container(
decoration: BoxDecoration(
color: noBubble
? Colors.transparent
: color,
borderRadius: borderRadius,
),
clipBehavior: Clip.antiAlias,
child: BubbleBackground(
colors: colors,
ignore:
noBubble ||
!ownMessage ||
MediaQuery.highContrastOf(context),
scrollController: scrollController,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(
AppConfig.borderRadius,
),
),
),
constraints: const BoxConstraints(
maxWidth:
FluffyThemes.columnWidth * 1.5,
),
child: Column(
mainAxisSize: .min,
crossAxisAlignment:
CrossAxisAlignment.start,
children: <Widget>[
if (event.inReplyToEventId(
includingFallback: false,
) !=
null)
FutureBuilder<Event?>(
future: event.getReplyEvent(
timeline,
),
builder: (BuildContext context, snapshot) {
final replyEvent =
snapshot.hasData
? snapshot.data!
: Event(
eventId:
event
.inReplyToEventId() ??
'\$fake_event_id',
content: {
'msgtype': 'm.text',
'body': '...',
},
senderId:
event.senderId,
type:
'm.room.message',
room: event.room,
status:
EventStatus.sent,
originServerTs:
DateTime.now(),
);
return Padding(
padding:
const EdgeInsets.only(
left: 16,
right: 16,
top: 8,
),
child: Material(
color: Colors.transparent,
borderRadius: ReplyContent
.borderRadius,
child: InkWell(
constraints: const BoxConstraints(
maxWidth:
FluffyThemes.columnWidth * 1.5,
),
child: Column(
mainAxisSize: .min,
crossAxisAlignment:
CrossAxisAlignment.start,
children: <Widget>[
if (event.inReplyToEventId(
includingFallback: false,
) !=
null)
FutureBuilder<Event?>(
future: event.getReplyEvent(
timeline,
),
builder: (BuildContext context, snapshot) {
final replyEvent =
snapshot.hasData
? snapshot.data!
: Event(
eventId:
event
.inReplyToEventId() ??
'\$fake_event_id',
content: {
'msgtype':
'm.text',
'body': '...',
},
senderId:
event.senderId,
type:
'm.room.message',
room: event.room,
status: EventStatus
.sent,
originServerTs:
DateTime.now(),
);
return Padding(
padding:
const EdgeInsets.only(
left: 16,
right: 16,
top: 8,
),
child: Material(
color:
Colors.transparent,
borderRadius:
ReplyContent
.borderRadius,
onTap: () =>
scrollToEventId(
replyEvent
.eventId,
child: InkWell(
borderRadius:
ReplyContent
.borderRadius,
onTap: () =>
scrollToEventId(
replyEvent
.eventId,
),
child: AbsorbPointer(
child: ReplyContent(
replyEvent,
ownMessage:
ownMessage,
timeline:
timeline,
),
child: AbsorbPointer(
child: ReplyContent(
replyEvent,
ownMessage:
ownMessage,
timeline: timeline,
),
),
),
),
);
},
),
MessageContent(
displayEvent,
textColor: textColor,
linkColor: linkColor,
onInfoTab: onInfoTab,
borderRadius: borderRadius,
timeline: timeline,
selected: selected,
bigEmojis: bigEmojis,
),
if (event.hasAggregatedEvents(
timeline,
RelationshipTypes.edit,
))
Padding(
padding: const EdgeInsets.only(
bottom: 8.0,
left: 16.0,
right: 16.0,
);
},
),
child: Row(
mainAxisSize:
MainAxisSize.min,
spacing: 4.0,
children: [
Icon(
Icons.edit_outlined,
color: textColor
.withAlpha(164),
size: 14,
),
Text(
displayEvent
.originServerTs
.localizedTimeShort(
context,
),
style: TextStyle(
MessageContent(
displayEvent,
textColor: textColor,
linkColor: linkColor,
onInfoTab: onInfoTab,
borderRadius: borderRadius,
timeline: timeline,
selected: selected,
bigEmojis: bigEmojis,
),
if (event.hasAggregatedEvents(
timeline,
RelationshipTypes.edit,
))
Padding(
padding:
const EdgeInsets.only(
bottom: 8.0,
left: 16.0,
right: 16.0,
),
child: Row(
mainAxisSize:
MainAxisSize.min,
spacing: 4.0,
children: [
Icon(
Icons.edit_outlined,
color: textColor
.withAlpha(164),
fontSize: 11,
size: 14,
),
),
],
Text(
displayEvent
.originServerTs
.localizedTimeShort(
context,
),
style: TextStyle(
color: textColor
.withAlpha(164),
fontSize: 11,
),
),
],
),
),
),
],
],
),
),
),
),
@ -959,15 +1002,10 @@ class __AnimateInState extends State<_AnimateIn> {
});
});
}
return AnimatedOpacity(
return AnimatedSize(
duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve,
opacity: _animationFinished ? 1 : 0,
child: AnimatedSize(
duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve,
child: _animationFinished ? widget.child : const SizedBox.shrink(),
),
child: _animationFinished ? widget.child : const SizedBox.shrink(),
);
}
}

View file

@ -61,9 +61,11 @@ class MessageContent extends StatelessWidget {
final client = Matrix.of(context).client;
final state = await client.getCryptoIdentityState();
if (!state.connected) {
if (!context.mounted) return;
final success = await context.push('/backup');
if (success != true) return;
}
if (!context.mounted) return;
event.requestKey();
final sender = event.senderFromMemoryOrFallback;
await showAdaptiveBottomSheet(

View file

@ -392,6 +392,10 @@ class InputBar extends StatelessWidget {
controller: controller,
focusNode: focusNode,
readOnly: readOnly,
onEditingComplete: () {
// To not lose focus on iOS:
// https://github.com/krille-chan/fluffychat/issues/2784
},
contextMenuBuilder: (c, e) => MarkdownContextBuilder(
editableTextState: e,
controller: controller,

View file

@ -15,6 +15,7 @@ class PinnedEvents extends StatelessWidget {
const PinnedEvents(this.controller, {super.key});
Future<void> _displayPinnedEventsDialog(BuildContext context) async {
final l10n = L10n.of(context);
final eventsResult = await showFutureLoadingDialog(
context: context,
future: () => Future.wait(
@ -25,13 +26,14 @@ class PinnedEvents extends StatelessWidget {
);
final events = eventsResult.result;
if (events == null) return;
if (!context.mounted) return;
final eventId = events.length == 1
? events.single?.eventId
: await showModalActionPopup<String>(
context: context,
title: L10n.of(context).pin,
cancelLabel: L10n.of(context).cancel,
title: l10n.pin,
cancelLabel: l10n.cancel,
actions: events
.map(
(event) => AdaptiveModalAction(
@ -39,7 +41,7 @@ class PinnedEvents extends StatelessWidget {
icon: const Icon(Icons.push_pin_outlined),
label:
event?.calcLocalizedBodyFallback(
MatrixLocals(L10n.of(context)),
MatrixLocals(l10n),
withSenderNamePrefix: true,
hideReply: true,
) ??

View file

@ -44,6 +44,7 @@ class RecordingViewModelState extends State<RecordingViewModel> {
room.client.getConfig(); // Preload server file configuration.
if (PlatformInfos.isAndroid) {
final info = await DeviceInfoPlugin().androidInfo;
if (!mounted) return;
if (info.version.sdkInt < 19) {
showOkAlertDialog(
context: context,
@ -76,6 +77,7 @@ class RecordingViewModelState extends State<RecordingViewModel> {
final result = await audioRecorder.hasPermission();
if (result != true) {
if (!mounted) return;
showOkAlertDialog(
context: context,
title: L10n.of(context).oopsSomethingWentWrong,
@ -97,10 +99,12 @@ class RecordingViewModelState extends State<RecordingViewModel> {
),
path: path ?? '',
);
if (!mounted) return;
setState(() => duration = Duration.zero);
_subscribe();
} catch (e, s) {
Logs().w('Unable to start voice message recording', e, s);
if (!mounted) return;
showOkAlertDialog(
context: context,
title: L10n.of(context).oopsSomethingWentWrong,

View file

@ -146,6 +146,7 @@ class SendFileDialogState extends State<SendFileDialog> {
scaffoldMessenger.clearSnackBars();
} catch (e) {
scaffoldMessenger.clearSnackBars();
if (!mounted || !widget.outerContext.mounted) rethrow;
final theme = Theme.of(context);
scaffoldMessenger.showSnackBar(
SnackBar(

View file

@ -81,6 +81,7 @@ class SendLocationDialogState extends State<SendLocationDialog> {
context: context,
future: () => widget.room.sendLocation(body, uri),
);
if (!mounted) return;
Navigator.of(context, rootNavigator: false).pop();
}

View file

@ -44,6 +44,7 @@ class _StartPollBottomSheetState extends State<StartPollBottomSheet> {
maxSelections: _allowMultipleAnswers ? _answers.length : 1,
txid: _txid,
);
if (!mounted) return;
Navigator.of(context).pop();
} catch (e, s) {
Logs().w('Unable to create poll', e, s);