Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/stream_chat_flutter/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
- Added a `size` (`StreamLoadingSpinnerSize`) parameter to `StreamScrollViewLoadingWidget`.
- Added `onReactionTap` to `StreamMessageItem` and `StreamMessageListView`, reporting the tapped message's `BuildContext` and a `ReactionTapDetails` with the tapped `message` and `reaction` (the reaction is `null` for a clustered or overflow chip that maps to no single reaction).
- Added an `unreadIndicator` parameter to `StreamBackButton` that overlays a widget (typically a `StreamUnreadIndicator`) on the button's top-end corner. Pass `StreamUnreadIndicator(excludeCid: cid)` to show the total unread count of other channels, or `StreamUnreadIndicator.channels(cid: cid)` for a single channel's count.
- Added snackbar feedback for user actions: message copy, pin/unpin, delete, flag, mark unread, and mute/unmute user; ending a poll; audio-recording permission denial; and message send/edit failures (shown only when no `onError` handler is provided).

⚠️ Deprecated

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import 'package:stream_chat_flutter/src/poll/stream_poll_comments_sheet.dart';
import 'package:stream_chat_flutter/src/poll/stream_poll_options_sheet.dart';
import 'package:stream_chat_flutter/src/poll/stream_poll_results_sheet.dart';
import 'package:stream_chat_flutter/src/stream_chat.dart';
import 'package:stream_chat_flutter/src/utils/extensions.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
import 'package:stream_core_flutter/chat.dart';

Expand Down Expand Up @@ -136,10 +137,24 @@ class _DefaultStreamPollAttachmentState extends State<DefaultStreamPollAttachmen
final channel = StreamChannel.of(context).channel;

Future<void> onEndVote() async {
final translations = context.translations;
final messenger = StreamSnackbarMessenger.maybeOf(context);

final confirm = await showPollEndVoteDialog(context: context);
if (confirm == null || !confirm) return;

channel.closePoll(poll).ignore();
try {
await channel.closePoll(poll);
messenger?.show(
StreamSnackbar(message: Text(translations.endVoteSuccessMessage), variant: .success),
replace: true,
);
} on Exception catch (_) {
messenger?.show(
StreamSnackbar(message: Text(translations.endVoteErrorMessage), variant: .error),
replace: true,
);
}
}

Future<void> onAddComment() async {
Expand Down
100 changes: 100 additions & 0 deletions packages/stream_chat_flutter/lib/src/localization/translations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,43 @@ abstract class Translations {
/// The text of an error shown when marking a message as unread fails
String get markUnreadError;

/// The text shown when a message is successfully marked as unread
String get messageMarkedAsUnreadText;

/// The text of an error shown when deleting a message fails
String get deleteMessageError;

/// The text shown when a message is successfully pinned or unpinned, where
/// [pinned] is the message's resulting pinned state (`true` after a pin).
String togglePinUnpinMessageSuccessText({required bool pinned});

/// The text of an error shown when pinning or unpinning a message fails, where
/// [pinned] is the pinned state that was being applied (`true` for a pin).
String togglePinUnpinMessageErrorText({required bool pinned});

/// The text of an error shown when flagging a message fails
String get flagMessageError;

/// The text shown when a message is copied to the clipboard
String get messageCopiedToClipboardText;

/// The text of an error shown when sending a message fails
String get sendMessageError;

/// The text of an error shown when editing a message fails
String get editMessageError;

/// The text shown when a [user] is successfully muted or unmuted
/// based on [isMuted]
String toggleMuteUnmuteUserSuccessText({
required String user,
required bool isMuted,
});

/// The text of an error shown when muting or unmuting a user fails
/// based on [isMuted]
String toggleMuteUnmuteUserErrorText({required bool isMuted});

/// The text for showing delete/retry-delete based on [isDeleteFailed]
String toggleDeleteRetryDeleteMessageText({required bool isDeleteFailed});

Expand Down Expand Up @@ -594,6 +631,12 @@ abstract class Translations {
/// The label for "End Poll".
String get endVoteLabel;

/// The text shown when a poll is successfully ended
String get endVoteSuccessMessage;

/// The text of an error shown when ending a poll fails
String get endVoteErrorMessage;

/// The label for "Poll Results".
String get pollResultsLabel;

Expand Down Expand Up @@ -638,6 +681,9 @@ abstract class Translations {
/// The label for "Hold to record"
String get holdToRecordLabel;

/// The message shown when audio recording permission is denied
String get audioRecordingPermissionMessage;

/// The label for "Send Anyway"
String get sendAnywayLabel;

Expand Down Expand Up @@ -1303,6 +1349,51 @@ Attachment limit exceeded: it's not possible to add more than $limit attachments
'Error marking message unread. Cannot mark unread messages older than the'
' newest 100 channel messages.';

@override
String get messageMarkedAsUnreadText => 'Message marked as unread';

@override
String get deleteMessageError => 'Error deleting message';

@override
String togglePinUnpinMessageSuccessText({required bool pinned}) {
if (pinned) return 'Message pinned';
return 'Message unpinned';
}

@override
String togglePinUnpinMessageErrorText({required bool pinned}) {
if (pinned) return 'Error pinning message';
return 'Error removing message pin';
}

@override
String get flagMessageError => 'Error adding flag';

@override
String get messageCopiedToClipboardText => 'Message copied to clipboard';

@override
String get sendMessageError => 'Send message request failed';

@override
String get editMessageError => 'Edit message request failed';

@override
String toggleMuteUnmuteUserSuccessText({
required String user,
required bool isMuted,
}) {
if (isMuted) return '$user has been unmuted';
return '$user has been muted';
}

@override
String toggleMuteUnmuteUserErrorText({required bool isMuted}) {
if (isMuted) return 'Error unmuting a user, please try again';
return 'Error muting a user, please try again';
}

@override
String createPollLabel({bool isNew = false}) {
if (isNew) return 'Create a new poll';
Expand Down Expand Up @@ -1457,6 +1548,12 @@ Attachment limit exceeded: it's not possible to add more than $limit attachments
@override
String get endVoteLabel => 'End Poll';

@override
String get endVoteSuccessMessage => 'Poll ended';

@override
String get endVoteErrorMessage => 'Failed to end the poll';

@override
String get pollResultsLabel => 'Poll Results';

Expand Down Expand Up @@ -1510,6 +1607,9 @@ Attachment limit exceeded: it's not possible to add more than $limit attachments
@override
String get holdToRecordLabel => 'Hold to record. Release to save.';

@override
String get audioRecordingPermissionMessage => 'Please allow Audio permissions in settings.';

@override
String get sendAnywayLabel => 'Send Anyway';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,15 +58,25 @@ class StreamAudioRecorderController extends ValueNotifier<AudioRecorderState>
final AudioRecorder _recorder;

/// Starts a new recording session.
Future<void> startRecord() async {
///
/// When audio permission is denied, [permissionDeniedMessage] (if provided) is
/// surfaced through [RecordStateIdle.message] so the message input can prompt
/// the user to grant access.
Future<void> startRecord({String? permissionDeniedMessage}) async {
// Only start the recorder if it is currently idle.
if (value case RecordStateIdle()) {
// Return if the recorder does not have permission to record audio.
final hasPermission = await _recorder.hasPermission(request: false);
if (!hasPermission) {
/// Request permission to record audio.
/// User has to start the recording session again to record audio.
await _recorder.hasPermission(request: true);
// Request permission to record audio. The user has to start the
// recording session again to record audio.
final granted = await _recorder.hasPermission(request: true);
if (!granted && permissionDeniedMessage != null) {
// Cancel any pending info timer so it can't clear the denial message.
_infoTimer?.cancel();
_infoTimer = null;
value = RecordStateIdle(message: permissionDeniedMessage);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
return;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -298,8 +298,12 @@ class _StreamChatMessageInputContent extends StatelessWidget {
// Return if the recording is already started.
if (audioRecorderController.isRecording) return;

// Capture the message before the async gap to avoid using a
// potentially unmounted BuildContext after awaiting.
final permissionDeniedMessage = context.translations.audioRecordingPermissionMessage;

await widget.feedback.onRecordStart(context);
return audioRecorderController.startRecord();
return audioRecorderController.startRecord(permissionDeniedMessage: permissionDeniedMessage);
},
onLongPressEnd: (_) async {
// Return if the recording not yet started or already locked.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1471,13 +1471,15 @@ class DefaultStreamMessageComposerState extends State<DefaultStreamMessageCompos
required Message message,
required Channel channel,
}) async {
try {
// A message is considered fresh if it doesn't have a remoteCreatedAt.
final isFreshMessage = message.remoteCreatedAt == null;
// A message is considered fresh if it doesn't have a remoteCreatedAt.
final isFreshMessage = message.remoteCreatedAt == null;

// Note: edited messages which are bounced back with an error needs to be
// sent as new messages as the backend doesn't store them.
final isUpdate = !isFreshMessage && !message.isBouncedWithError;

// Note: edited messages which are bounced back with an error needs to be
// sent as new messages as the backend doesn't store them.
final resp = await switch (!isFreshMessage && !message.isBouncedWithError) {
try {
final resp = await switch (isUpdate) {
true => channel.updateMessage(message),
false => channel.sendMessage(message),
};
Expand All @@ -1488,6 +1490,16 @@ class DefaultStreamMessageComposerState extends State<DefaultStreamMessageCompos
return widget.props.onError?.call(e, stk);
}

if (!mounted) return;

final translations = context.translations;
final message = isUpdate ? translations.editMessageError : translations.sendMessageError;

StreamSnackbarMessenger.maybeOf(context)?.show(
StreamSnackbar(message: Text(message), variant: .error),
replace: true,
);

rethrow;
}
}
Expand Down
Loading
Loading