Merge branch 'v4' of https://github.com/GetStream/stream-chat-flutter into feat/capabilities
Conflicts: packages/stream_chat_flutter/lib/src/message_input/message_input.dart
This commit is contained in:
@@ -505,6 +505,7 @@ class Channel {
|
|||||||
Future<SendMessageResponse> sendMessage(
|
Future<SendMessageResponse> sendMessage(
|
||||||
Message message, {
|
Message message, {
|
||||||
bool skipPush = false,
|
bool skipPush = false,
|
||||||
|
bool skipEnrichUrl = false,
|
||||||
}) async {
|
}) async {
|
||||||
_checkInitialized();
|
_checkInitialized();
|
||||||
// Cancelling previous completer in case it's called again in the process
|
// Cancelling previous completer in case it's called again in the process
|
||||||
@@ -552,6 +553,7 @@ class Channel {
|
|||||||
id!,
|
id!,
|
||||||
type,
|
type,
|
||||||
skipPush: skipPush,
|
skipPush: skipPush,
|
||||||
|
skipEnrichUrl: skipEnrichUrl,
|
||||||
);
|
);
|
||||||
state!.addMessage(response.message);
|
state!.addMessage(response.message);
|
||||||
if (cooldown > 0) cooldownStartedAt = DateTime.now();
|
if (cooldown > 0) cooldownStartedAt = DateTime.now();
|
||||||
@@ -568,7 +570,10 @@ class Channel {
|
|||||||
///
|
///
|
||||||
/// Waits for a [_messageAttachmentsUploadCompleter] to complete
|
/// Waits for a [_messageAttachmentsUploadCompleter] to complete
|
||||||
/// before actually updating the message.
|
/// before actually updating the message.
|
||||||
Future<UpdateMessageResponse> updateMessage(Message message) async {
|
Future<UpdateMessageResponse> updateMessage(
|
||||||
|
Message message, {
|
||||||
|
bool skipEnrichUrl = false,
|
||||||
|
}) async {
|
||||||
final originalMessage = message;
|
final originalMessage = message;
|
||||||
|
|
||||||
// Cancelling previous completer in case it's called again in the process
|
// Cancelling previous completer in case it's called again in the process
|
||||||
@@ -606,7 +611,10 @@ class Channel {
|
|||||||
message = await attachmentsUploadCompleter.future;
|
message = await attachmentsUploadCompleter.future;
|
||||||
}
|
}
|
||||||
|
|
||||||
final response = await _client.updateMessage(message);
|
final response = await _client.updateMessage(
|
||||||
|
message,
|
||||||
|
skipEnrichUrl: skipEnrichUrl,
|
||||||
|
);
|
||||||
|
|
||||||
final m = response.message.copyWith(
|
final m = response.message.copyWith(
|
||||||
ownReactions: message.ownReactions,
|
ownReactions: message.ownReactions,
|
||||||
@@ -636,12 +644,14 @@ class Channel {
|
|||||||
Message message, {
|
Message message, {
|
||||||
Map<String, Object?>? set,
|
Map<String, Object?>? set,
|
||||||
List<String>? unset,
|
List<String>? unset,
|
||||||
|
bool skipEnrichUrl = false,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final response = await _client.partialUpdateMessage(
|
final response = await _client.partialUpdateMessage(
|
||||||
message.id,
|
message.id,
|
||||||
set: set,
|
set: set,
|
||||||
unset: unset,
|
unset: unset,
|
||||||
|
skipEnrichUrl: skipEnrichUrl,
|
||||||
);
|
);
|
||||||
|
|
||||||
final updatedMessage = response.message.copyWith(
|
final updatedMessage = response.message.copyWith(
|
||||||
|
|||||||
@@ -1169,12 +1169,14 @@ class StreamChatClient {
|
|||||||
String channelId,
|
String channelId,
|
||||||
String channelType, {
|
String channelType, {
|
||||||
bool skipPush = false,
|
bool skipPush = false,
|
||||||
|
bool skipEnrichUrl = false,
|
||||||
}) =>
|
}) =>
|
||||||
_chatApi.message.sendMessage(
|
_chatApi.message.sendMessage(
|
||||||
channelId,
|
channelId,
|
||||||
channelType,
|
channelType,
|
||||||
message,
|
message,
|
||||||
skipPush: skipPush,
|
skipPush: skipPush,
|
||||||
|
skipEnrichUrl: skipEnrichUrl,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Lists all the message replies for the [parentId]
|
/// Lists all the message replies for the [parentId]
|
||||||
@@ -1198,8 +1200,14 @@ class StreamChatClient {
|
|||||||
);
|
);
|
||||||
|
|
||||||
/// Update the given message
|
/// Update the given message
|
||||||
Future<UpdateMessageResponse> updateMessage(Message message) =>
|
Future<UpdateMessageResponse> updateMessage(
|
||||||
_chatApi.message.updateMessage(message);
|
Message message, {
|
||||||
|
bool skipEnrichUrl = false,
|
||||||
|
}) =>
|
||||||
|
_chatApi.message.updateMessage(
|
||||||
|
message,
|
||||||
|
skipEnrichUrl: skipEnrichUrl,
|
||||||
|
);
|
||||||
|
|
||||||
/// Partially update the given [messageId]
|
/// Partially update the given [messageId]
|
||||||
/// Use [set] to define values to be set
|
/// Use [set] to define values to be set
|
||||||
@@ -1208,11 +1216,13 @@ class StreamChatClient {
|
|||||||
String messageId, {
|
String messageId, {
|
||||||
Map<String, Object?>? set,
|
Map<String, Object?>? set,
|
||||||
List<String>? unset,
|
List<String>? unset,
|
||||||
|
bool skipEnrichUrl = false,
|
||||||
}) =>
|
}) =>
|
||||||
_chatApi.message.partialUpdateMessage(
|
_chatApi.message.partialUpdateMessage(
|
||||||
messageId,
|
messageId,
|
||||||
set: set,
|
set: set,
|
||||||
unset: unset,
|
unset: unset,
|
||||||
|
skipEnrichUrl: skipEnrichUrl,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Deletes the given message
|
/// Deletes the given message
|
||||||
|
|||||||
@@ -16,12 +16,14 @@ class MessageApi {
|
|||||||
String channelType,
|
String channelType,
|
||||||
Message message, {
|
Message message, {
|
||||||
bool skipPush = false,
|
bool skipPush = false,
|
||||||
|
bool skipEnrichUrl = false,
|
||||||
}) async {
|
}) async {
|
||||||
final response = await _client.post(
|
final response = await _client.post(
|
||||||
'/channels/$channelType/$channelId/message',
|
'/channels/$channelType/$channelId/message',
|
||||||
data: {
|
data: {
|
||||||
'message': message,
|
'message': message,
|
||||||
'skip_push': skipPush,
|
'skip_push': skipPush,
|
||||||
|
'skip_enrich_url': skipEnrichUrl,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return SendMessageResponse.fromJson(response.data);
|
return SendMessageResponse.fromJson(response.data);
|
||||||
@@ -51,11 +53,15 @@ class MessageApi {
|
|||||||
|
|
||||||
/// Updates the given [message]
|
/// Updates the given [message]
|
||||||
Future<UpdateMessageResponse> updateMessage(
|
Future<UpdateMessageResponse> updateMessage(
|
||||||
Message message,
|
Message message, {
|
||||||
) async {
|
bool skipEnrichUrl = false,
|
||||||
|
}) async {
|
||||||
final response = await _client.post(
|
final response = await _client.post(
|
||||||
'/messages/${message.id}',
|
'/messages/${message.id}',
|
||||||
data: {'message': message},
|
data: {
|
||||||
|
'message': message,
|
||||||
|
'skip_enrich_url': skipEnrichUrl,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
return UpdateMessageResponse.fromJson(response.data);
|
return UpdateMessageResponse.fromJson(response.data);
|
||||||
}
|
}
|
||||||
@@ -67,12 +73,14 @@ class MessageApi {
|
|||||||
String messageId, {
|
String messageId, {
|
||||||
Map<String, Object?>? set,
|
Map<String, Object?>? set,
|
||||||
List<String>? unset,
|
List<String>? unset,
|
||||||
|
bool skipEnrichUrl = false,
|
||||||
}) async {
|
}) async {
|
||||||
final response = await _client.put(
|
final response = await _client.put(
|
||||||
'/messages/$messageId',
|
'/messages/$messageId',
|
||||||
data: {
|
data: {
|
||||||
if (set != null) 'set': set,
|
if (set != null) 'set': set,
|
||||||
if (unset != null) 'unset': unset,
|
if (unset != null) 'unset': unset,
|
||||||
|
'skip_enrich_url': skipEnrichUrl,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return UpdateMessageResponse.fromJson(response.data);
|
return UpdateMessageResponse.fromJson(response.data);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
import 'package:json_annotation/json_annotation.dart';
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
|
import 'package:stream_chat/src/core/api/responses.dart';
|
||||||
import 'package:stream_chat/src/core/models/action.dart';
|
import 'package:stream_chat/src/core/models/action.dart';
|
||||||
import 'package:stream_chat/src/core/models/attachment_file.dart';
|
import 'package:stream_chat/src/core/models/attachment_file.dart';
|
||||||
import 'package:stream_chat/src/core/util/serializer.dart';
|
import 'package:stream_chat/src/core/util/serializer.dart';
|
||||||
@@ -66,6 +67,21 @@ class Attachment extends Equatable {
|
|||||||
topLevelFields + dbSpecificTopLevelFields,
|
topLevelFields + dbSpecificTopLevelFields,
|
||||||
));
|
));
|
||||||
|
|
||||||
|
factory Attachment.fromOGAttachment(OGAttachmentResponse ogAttachment) =>
|
||||||
|
Attachment(
|
||||||
|
type: ogAttachment.type,
|
||||||
|
title: ogAttachment.title,
|
||||||
|
titleLink: ogAttachment.titleLink,
|
||||||
|
text: ogAttachment.text,
|
||||||
|
imageUrl: ogAttachment.imageUrl,
|
||||||
|
thumbUrl: ogAttachment.thumbUrl,
|
||||||
|
authorName: ogAttachment.authorName,
|
||||||
|
authorLink: ogAttachment.authorLink,
|
||||||
|
assetUrl: ogAttachment.assetUrl,
|
||||||
|
ogScrapeUrl: ogAttachment.ogScrapeUrl,
|
||||||
|
uploadState: const UploadState.success(),
|
||||||
|
);
|
||||||
|
|
||||||
///The attachment type based on the URL resource. This can be: audio,
|
///The attachment type based on the URL resource. This can be: audio,
|
||||||
///image or video
|
///image or video
|
||||||
final String? type;
|
final String? type;
|
||||||
@@ -229,6 +245,33 @@ class Attachment extends Equatable {
|
|||||||
extraData: extraData ?? this.extraData,
|
extraData: extraData ?? this.extraData,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Attachment merge(Attachment? other) {
|
||||||
|
if (other == null) return this;
|
||||||
|
return copyWith(
|
||||||
|
type: other.type,
|
||||||
|
titleLink: other.titleLink,
|
||||||
|
title: other.title,
|
||||||
|
thumbUrl: other.thumbUrl,
|
||||||
|
text: other.text,
|
||||||
|
pretext: other.pretext,
|
||||||
|
ogScrapeUrl: other.ogScrapeUrl,
|
||||||
|
imageUrl: other.imageUrl,
|
||||||
|
footerIcon: other.footerIcon,
|
||||||
|
footer: other.footer,
|
||||||
|
fields: other.fields,
|
||||||
|
fallback: other.fallback,
|
||||||
|
color: other.color,
|
||||||
|
authorName: other.authorName,
|
||||||
|
authorLink: other.authorLink,
|
||||||
|
authorIcon: other.authorIcon,
|
||||||
|
assetUrl: other.assetUrl,
|
||||||
|
actions: other.actions,
|
||||||
|
file: other.file,
|
||||||
|
uploadState: other.uploadState,
|
||||||
|
extraData: other.extraData,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [
|
List<Object?> get props => [
|
||||||
id,
|
id,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ void main() {
|
|||||||
data: {
|
data: {
|
||||||
'message': message,
|
'message': message,
|
||||||
'skip_push': false,
|
'skip_push': false,
|
||||||
|
'skip_enrich_url': false,
|
||||||
},
|
},
|
||||||
)).thenAnswer((_) async => successResponse(path, data: {
|
)).thenAnswer((_) async => successResponse(path, data: {
|
||||||
'message': message.toJson(),
|
'message': message.toJson(),
|
||||||
@@ -58,6 +59,7 @@ void main() {
|
|||||||
data: {
|
data: {
|
||||||
'message': message,
|
'message': message,
|
||||||
'skip_push': true,
|
'skip_push': true,
|
||||||
|
'skip_enrich_url': false,
|
||||||
},
|
},
|
||||||
)).thenAnswer((_) async => successResponse(path, data: {
|
)).thenAnswer((_) async => successResponse(path, data: {
|
||||||
'message': message.toJson(),
|
'message': message.toJson(),
|
||||||
@@ -137,7 +139,10 @@ void main() {
|
|||||||
|
|
||||||
when(() => client.post(
|
when(() => client.post(
|
||||||
path,
|
path,
|
||||||
data: {'message': message},
|
data: {
|
||||||
|
'message': message,
|
||||||
|
'skip_enrich_url': false,
|
||||||
|
},
|
||||||
)).thenAnswer(
|
)).thenAnswer(
|
||||||
(_) async => successResponse(path, data: {'message': message.toJson()}),
|
(_) async => successResponse(path, data: {'message': message.toJson()}),
|
||||||
);
|
);
|
||||||
@@ -162,7 +167,11 @@ void main() {
|
|||||||
|
|
||||||
when(() => client.put(
|
when(() => client.put(
|
||||||
path,
|
path,
|
||||||
data: {'set': set, 'unset': unset},
|
data: {
|
||||||
|
'set': set,
|
||||||
|
'unset': unset,
|
||||||
|
'skip_enrich_url': false,
|
||||||
|
},
|
||||||
)).thenAnswer(
|
)).thenAnswer(
|
||||||
(_) async => successResponse(path, data: {'message': message.toJson()}),
|
(_) async => successResponse(path, data: {'message': message.toJson()}),
|
||||||
);
|
);
|
||||||
@@ -180,7 +189,11 @@ void main() {
|
|||||||
|
|
||||||
verify(() => client.put(
|
verify(() => client.put(
|
||||||
path,
|
path,
|
||||||
data: {'set': set, 'unset': unset},
|
data: {
|
||||||
|
'set': set,
|
||||||
|
'unset': unset,
|
||||||
|
'skip_enrich_url': false,
|
||||||
|
},
|
||||||
)).called(1);
|
)).called(1);
|
||||||
verifyNoMoreInteractions(client);
|
verifyNoMoreInteractions(client);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,6 +14,10 @@
|
|||||||
|
|
||||||
- Videos can now be auto-played in `FullScreenMedia`
|
- Videos can now be auto-played in `FullScreenMedia`
|
||||||
|
|
||||||
|
🔄 Changed
|
||||||
|
|
||||||
|
- Add `didUpdateWidget` override in `MessageInput` widget to handle changes to `focusNode`.
|
||||||
|
|
||||||
## 3.3.2
|
## 3.3.2
|
||||||
|
|
||||||
- Updated `stream_chat_flutter_core` dependency to [`3.3.1`](https://pub.dev/packages/stream_chat_flutter_core/changelog).
|
- Updated `stream_chat_flutter_core` dependency to [`3.3.1`](https://pub.dev/packages/stream_chat_flutter_core/changelog).
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import 'package:stream_chat_flutter/src/commands_overlay.dart';
|
|||||||
import 'package:stream_chat_flutter/src/emoji/emoji.dart';
|
import 'package:stream_chat_flutter/src/emoji/emoji.dart';
|
||||||
import 'package:stream_chat_flutter/src/emoji_overlay.dart';
|
import 'package:stream_chat_flutter/src/emoji_overlay.dart';
|
||||||
import 'package:stream_chat_flutter/src/extension.dart';
|
import 'package:stream_chat_flutter/src/extension.dart';
|
||||||
|
import 'package:stream_chat_flutter/src/message_input/tld.dart';
|
||||||
import 'package:stream_chat_flutter/src/multi_overlay.dart';
|
import 'package:stream_chat_flutter/src/multi_overlay.dart';
|
||||||
import 'package:stream_chat_flutter/src/quoted_message_widget.dart';
|
import 'package:stream_chat_flutter/src/quoted_message_widget.dart';
|
||||||
import 'package:stream_chat_flutter/src/user_mentions_overlay.dart';
|
import 'package:stream_chat_flutter/src/user_mentions_overlay.dart';
|
||||||
@@ -335,25 +336,15 @@ class MessageInput extends StatefulWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
MessageInputState createState() => MessageInputState();
|
MessageInputState createState() => MessageInputState();
|
||||||
|
|
||||||
/// Use this method to get the current [StreamChatState] instance
|
|
||||||
static MessageInputState of(BuildContext context) {
|
|
||||||
MessageInputState? messageInputState;
|
|
||||||
messageInputState = context.findAncestorStateOfType<MessageInputState>();
|
|
||||||
assert(
|
|
||||||
messageInputState != null,
|
|
||||||
'You must have a MessageInput widget as ancestor of your widget tree',
|
|
||||||
);
|
|
||||||
return messageInputState!;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// State of [MessageInput]
|
/// State of [MessageInput]
|
||||||
class MessageInputState extends State<MessageInput>
|
class MessageInputState extends State<MessageInput>
|
||||||
with RestorationMixin<MessageInput> {
|
with RestorationMixin<MessageInput> {
|
||||||
final _imagePicker = ImagePicker();
|
final _imagePicker = ImagePicker();
|
||||||
late final _focusNode = widget.focusNode ?? FocusNode();
|
late FocusNode _focusNode = widget.focusNode ?? FocusNode();
|
||||||
bool _inputEnabled = true;
|
bool _inputEnabled = true;
|
||||||
|
|
||||||
bool get _commandEnabled => _effectiveController.value.command != null;
|
bool get _commandEnabled => _effectiveController.value.command != null;
|
||||||
bool _showCommandsOverlay = false;
|
bool _showCommandsOverlay = false;
|
||||||
bool _showMentionsOverlay = false;
|
bool _showMentionsOverlay = false;
|
||||||
@@ -371,6 +362,7 @@ class MessageInputState extends State<MessageInput>
|
|||||||
_effectiveController.value.status != MessageSendingStatus.sending;
|
_effectiveController.value.status != MessageSendingStatus.sending;
|
||||||
|
|
||||||
RestorableMessageInputController? _controller;
|
RestorableMessageInputController? _controller;
|
||||||
|
|
||||||
MessageInputController get _effectiveController =>
|
MessageInputController get _effectiveController =>
|
||||||
widget.messageInputController ?? _controller!.value;
|
widget.messageInputController ?? _controller!.value;
|
||||||
|
|
||||||
@@ -398,11 +390,7 @@ class MessageInputState extends State<MessageInput>
|
|||||||
if (widget.messageInputController == null) {
|
if (widget.messageInputController == null) {
|
||||||
_createLocalController();
|
_createLocalController();
|
||||||
} else {
|
} else {
|
||||||
_effectiveController.textEditingController
|
_initialiseEffectiveController();
|
||||||
.removeListener(_onChangedDebounced);
|
|
||||||
_effectiveController.textEditingController
|
|
||||||
.addListener(_onChangedDebounced);
|
|
||||||
if (!_isEditing && _timeOut <= 0) _startSlowMode();
|
|
||||||
}
|
}
|
||||||
_focusNode.addListener(_focusNodeListener);
|
_focusNode.addListener(_focusNodeListener);
|
||||||
}
|
}
|
||||||
@@ -418,6 +406,14 @@ class MessageInputState extends State<MessageInput>
|
|||||||
unregisterFromRestoration(_controller!);
|
unregisterFromRestoration(_controller!);
|
||||||
_controller!.dispose();
|
_controller!.dispose();
|
||||||
_controller = null;
|
_controller = null;
|
||||||
|
_initialiseEffectiveController();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update _focusNode
|
||||||
|
if (widget.focusNode != null && oldWidget.focusNode != widget.focusNode) {
|
||||||
|
_focusNode.removeListener(_focusNodeListener);
|
||||||
|
_focusNode = widget.focusNode!;
|
||||||
|
_focusNode.addListener(_focusNodeListener);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,6 +436,13 @@ class MessageInputState extends State<MessageInput>
|
|||||||
int _timeOut = 0;
|
int _timeOut = 0;
|
||||||
Timer? _slowModeTimer;
|
Timer? _slowModeTimer;
|
||||||
|
|
||||||
|
void _initialiseEffectiveController() {
|
||||||
|
_effectiveController.textEditingController
|
||||||
|
.removeListener(_onChangedDebounced);
|
||||||
|
_effectiveController.textEditingController.addListener(_onChangedDebounced);
|
||||||
|
if (!_isEditing && _timeOut <= 0) _startSlowMode();
|
||||||
|
}
|
||||||
|
|
||||||
void _startSlowMode() {
|
void _startSlowMode() {
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return;
|
return;
|
||||||
@@ -487,110 +490,119 @@ class MessageInputState extends State<MessageInput>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return MessageValueListenableBuilder(
|
return MessageValueListenableBuilder(
|
||||||
valueListenable: _effectiveController,
|
valueListenable: _effectiveController,
|
||||||
builder: (context, value, _) {
|
builder: (context, value, _) {
|
||||||
Widget child = DecoratedBox(
|
Widget child = DecoratedBox(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _messageInputTheme.inputBackgroundColor,
|
color: _messageInputTheme.inputBackgroundColor,
|
||||||
),
|
),
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onPanUpdate: (details) {
|
onPanUpdate: (details) {
|
||||||
if (details.delta.dy > 0) {
|
if (details.delta.dy > 0) {
|
||||||
_focusNode.unfocus();
|
_focusNode.unfocus();
|
||||||
if (_openFilePickerSection) {
|
if (_openFilePickerSection) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_openFilePickerSection = false;
|
_openFilePickerSection = false;
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
},
|
child: Column(
|
||||||
child: Column(
|
mainAxisSize: MainAxisSize.min,
|
||||||
mainAxisSize: MainAxisSize.min,
|
children: [
|
||||||
children: [
|
if (_hasQuotedMessage)
|
||||||
if (_hasQuotedMessage)
|
Padding(
|
||||||
Padding(
|
padding: const EdgeInsets.fromLTRB(8, 8, 8, 0),
|
||||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 0),
|
child: Row(
|
||||||
child: Row(
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
children: [
|
||||||
children: [
|
Padding(
|
||||||
Padding(
|
padding: const EdgeInsets.all(8),
|
||||||
padding: const EdgeInsets.all(8),
|
child: StreamSvgIcon.reply(
|
||||||
child: StreamSvgIcon.reply(
|
color: _streamChatTheme.colorTheme.disabled,
|
||||||
color: _streamChatTheme.colorTheme.disabled,
|
),
|
||||||
),
|
),
|
||||||
),
|
Text(
|
||||||
Text(
|
context.translations.replyToMessageLabel,
|
||||||
context.translations.replyToMessageLabel,
|
style:
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
const TextStyle(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
icon: StreamSvgIcon.closeSmall(),
|
icon: StreamSvgIcon.closeSmall(),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
_effectiveController.clearQuotedMessage();
|
_effectiveController.clearQuotedMessage();
|
||||||
_focusNode.unfocus();
|
_focusNode.unfocus();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else if (_effectiveController.ogAttachment != null)
|
||||||
|
OGAttachmentPreview(
|
||||||
|
attachment: _effectiveController.ogAttachment!,
|
||||||
|
onDismissPreviewPressed: () {
|
||||||
|
_effectiveController.clearOGAttachment();
|
||||||
|
_focusNode.unfocus();
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
||||||
child: _buildTextField(context),
|
|
||||||
),
|
|
||||||
if (_effectiveController.value.parentId != null &&
|
|
||||||
!widget.hideSendAsDm)
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
right: 12,
|
child: _buildTextField(context),
|
||||||
left: 12,
|
|
||||||
bottom: 12,
|
|
||||||
),
|
|
||||||
child: _buildDmCheckbox(),
|
|
||||||
),
|
),
|
||||||
_buildFilePickerSection(),
|
if (_effectiveController.value.parentId != null &&
|
||||||
],
|
!widget.hideSendAsDm)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
right: 12,
|
||||||
|
left: 12,
|
||||||
|
bottom: 12,
|
||||||
|
),
|
||||||
|
child: _buildDmCheckbox(),
|
||||||
|
),
|
||||||
|
_buildFilePickerSection(),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
if (!_isEditing) {
|
||||||
if (!_isEditing) {
|
child = Material(
|
||||||
child = Material(
|
elevation: 8,
|
||||||
elevation: 8,
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return MultiOverlay(
|
||||||
|
childAnchor: Alignment.topCenter,
|
||||||
|
overlayAnchor: Alignment.bottomCenter,
|
||||||
|
overlayOptions: [
|
||||||
|
OverlayOptions(
|
||||||
|
visible: _showCommandsOverlay,
|
||||||
|
widget: _buildCommandsOverlayEntry(),
|
||||||
|
),
|
||||||
|
OverlayOptions(
|
||||||
|
visible: _focusNode.hasFocus &&
|
||||||
|
_effectiveController.text.isNotEmpty &&
|
||||||
|
_effectiveController.baseOffset > 0 &&
|
||||||
|
_effectiveController.text
|
||||||
|
.substring(
|
||||||
|
0,
|
||||||
|
_effectiveController.baseOffset,
|
||||||
|
)
|
||||||
|
.contains(':'),
|
||||||
|
widget: _buildEmojiOverlay(),
|
||||||
|
),
|
||||||
|
OverlayOptions(
|
||||||
|
visible: _showMentionsOverlay,
|
||||||
|
widget: _buildMentionsOverlayEntry(),
|
||||||
|
),
|
||||||
|
...widget.customOverlays,
|
||||||
|
],
|
||||||
child: child,
|
child: child,
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
return MultiOverlay(
|
);
|
||||||
childAnchor: Alignment.topCenter,
|
|
||||||
overlayAnchor: Alignment.bottomCenter,
|
|
||||||
overlayOptions: [
|
|
||||||
OverlayOptions(
|
|
||||||
visible: _showCommandsOverlay,
|
|
||||||
widget: _buildCommandsOverlayEntry(),
|
|
||||||
),
|
|
||||||
OverlayOptions(
|
|
||||||
visible: _focusNode.hasFocus &&
|
|
||||||
_effectiveController.text.isNotEmpty &&
|
|
||||||
_effectiveController.baseOffset > 0 &&
|
|
||||||
_effectiveController.text
|
|
||||||
.substring(
|
|
||||||
0,
|
|
||||||
_effectiveController.baseOffset,
|
|
||||||
)
|
|
||||||
.contains(':'),
|
|
||||||
widget: _buildEmojiOverlay(),
|
|
||||||
),
|
|
||||||
OverlayOptions(
|
|
||||||
visible: _showMentionsOverlay,
|
|
||||||
widget: _buildMentionsOverlayEntry(),
|
|
||||||
),
|
|
||||||
...widget.customOverlays,
|
|
||||||
],
|
|
||||||
child: child,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Flex _buildTextField(BuildContext context) => Flex(
|
Flex _buildTextField(BuildContext context) => Flex(
|
||||||
@@ -917,6 +929,7 @@ class MessageInputState extends State<MessageInput>
|
|||||||
_actionsShrunk = value.isNotEmpty && actionsLength > 1;
|
_actionsShrunk = value.isNotEmpty && actionsLength > 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
_checkContainsUrl(value, context);
|
||||||
_checkCommands(value, context);
|
_checkCommands(value, context);
|
||||||
_checkMentions(value, context);
|
_checkMentions(value, context);
|
||||||
_checkEmoji(value, context);
|
_checkEmoji(value, context);
|
||||||
@@ -939,14 +952,75 @@ class MessageInputState extends State<MessageInput>
|
|||||||
return context.translations.writeAMessageLabel;
|
return context.translations.writeAMessageLabel;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _checkEmoji(String s, BuildContext context) {
|
String? _lastSearchedContainsUrlText;
|
||||||
if (s.isNotEmpty &&
|
CancelableOperation? _enrichUrlOperation;
|
||||||
|
final _urlRegex = RegExp(
|
||||||
|
r'(?:(?:https?|ftp):\/\/)?[\w/\-?=%.]+\.[\w/\-?=%.]+',
|
||||||
|
);
|
||||||
|
|
||||||
|
void _checkContainsUrl(String value, BuildContext context) async {
|
||||||
|
// Cancel the previous operation if it's still running
|
||||||
|
_enrichUrlOperation?.cancel();
|
||||||
|
|
||||||
|
// If the text is same as the last time, don't do anything
|
||||||
|
if (_lastSearchedContainsUrlText == value) return;
|
||||||
|
_lastSearchedContainsUrlText = value;
|
||||||
|
|
||||||
|
final matchedUrls = _urlRegex.allMatches(value).toList()
|
||||||
|
..removeWhere((it) => it.group(0)?.split('.').last.isValidTLD() == false);
|
||||||
|
|
||||||
|
// Reset the og attachment if the text doesn't contain any url
|
||||||
|
if (matchedUrls.isEmpty) {
|
||||||
|
_effectiveController
|
||||||
|
..text = value
|
||||||
|
..clearOGAttachment();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final firstMatchedUrl = matchedUrls.first.group(0)!;
|
||||||
|
|
||||||
|
// If the parsed url matches the ogAttachment url, don't do anything
|
||||||
|
if (_effectiveController.ogAttachment?.titleLink == firstMatchedUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final client = StreamChat.of(context).client;
|
||||||
|
|
||||||
|
_enrichUrlOperation = CancelableOperation.fromFuture(
|
||||||
|
_enrichUrl(firstMatchedUrl, client),
|
||||||
|
).then(
|
||||||
|
(ogAttachment) {
|
||||||
|
final attachment = Attachment.fromOGAttachment(ogAttachment);
|
||||||
|
_effectiveController.setOGAttachment(attachment);
|
||||||
|
},
|
||||||
|
onError: (error, stackTrace) {
|
||||||
|
// Reset the ogAttachment if there was an error
|
||||||
|
_effectiveController.clearOGAttachment();
|
||||||
|
widget.onError?.call(error, stackTrace);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final _ogAttachmentCache = <String, OGAttachmentResponse>{};
|
||||||
|
|
||||||
|
Future<OGAttachmentResponse> _enrichUrl(
|
||||||
|
String url,
|
||||||
|
StreamChatClient client,
|
||||||
|
) async {
|
||||||
|
var response = _ogAttachmentCache[url];
|
||||||
|
if (response == null) {
|
||||||
|
final client = StreamChat.of(context).client;
|
||||||
|
response = await client.enrichUrl(url);
|
||||||
|
_ogAttachmentCache[url] = response;
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _checkEmoji(String value, BuildContext context) {
|
||||||
|
if (value.isNotEmpty &&
|
||||||
_effectiveController.baseOffset > 0 &&
|
_effectiveController.baseOffset > 0 &&
|
||||||
_effectiveController.text
|
_effectiveController.text
|
||||||
.substring(
|
.substring(0, _effectiveController.baseOffset)
|
||||||
0,
|
|
||||||
_effectiveController.baseOffset,
|
|
||||||
)
|
|
||||||
.contains(':')) {
|
.contains(':')) {
|
||||||
final textToSelection = _effectiveController.text.substring(
|
final textToSelection = _effectiveController.text.substring(
|
||||||
0,
|
0,
|
||||||
@@ -962,14 +1036,11 @@ class MessageInputState extends State<MessageInput>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _checkMentions(String s, BuildContext context) {
|
void _checkMentions(String value, BuildContext context) {
|
||||||
if (s.isNotEmpty &&
|
if (value.isNotEmpty &&
|
||||||
_effectiveController.baseOffset > 0 &&
|
_effectiveController.baseOffset > 0 &&
|
||||||
_effectiveController.text
|
_effectiveController.text
|
||||||
.substring(
|
.substring(0, _effectiveController.baseOffset)
|
||||||
0,
|
|
||||||
_effectiveController.baseOffset,
|
|
||||||
)
|
|
||||||
.split(' ')
|
.split(' ')
|
||||||
.last
|
.last
|
||||||
.contains('@')) {
|
.contains('@')) {
|
||||||
@@ -985,11 +1056,11 @@ class MessageInputState extends State<MessageInput>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _checkCommands(String s, BuildContext context) {
|
void _checkCommands(String value, BuildContext context) {
|
||||||
if (s.startsWith('/')) {
|
if (value.startsWith('/')) {
|
||||||
final allCommands = StreamChannel.of(context).channel.config?.commands;
|
final allCommands = StreamChannel.of(context).channel.config?.commands;
|
||||||
final command =
|
final command =
|
||||||
allCommands?.firstWhereOrNull((it) => it.name == s.substring(1));
|
allCommands?.firstWhereOrNull((it) => it.name == value.substring(1));
|
||||||
if (command != null) {
|
if (command != null) {
|
||||||
return _setCommand(command);
|
return _setCommand(command);
|
||||||
} else if (!_showCommandsOverlay) {
|
} else if (!_showCommandsOverlay) {
|
||||||
@@ -1051,10 +1122,7 @@ class MessageInputState extends State<MessageInput>
|
|||||||
}
|
}
|
||||||
|
|
||||||
final splits = _effectiveController.text
|
final splits = _effectiveController.text
|
||||||
.substring(
|
.substring(0, _effectiveController.selectionStart)
|
||||||
0,
|
|
||||||
_effectiveController.selectionStart,
|
|
||||||
)
|
|
||||||
.split('@');
|
.split('@');
|
||||||
final query = splits.last.toLowerCase();
|
final query = splits.last.toLowerCase();
|
||||||
|
|
||||||
@@ -1103,10 +1171,7 @@ class MessageInputState extends State<MessageInput>
|
|||||||
}
|
}
|
||||||
|
|
||||||
final splits = _effectiveController.text
|
final splits = _effectiveController.text
|
||||||
.substring(
|
.substring(0, _effectiveController.baseOffset)
|
||||||
0,
|
|
||||||
_effectiveController.baseOffset,
|
|
||||||
)
|
|
||||||
.split(':');
|
.split(':');
|
||||||
|
|
||||||
final query = splits.last.toLowerCase();
|
final query = splits.last.toLowerCase();
|
||||||
@@ -1154,11 +1219,14 @@ class MessageInputState extends State<MessageInput>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildAttachments() {
|
Widget _buildAttachments() {
|
||||||
if (_effectiveController.attachments.isEmpty) return const Offstage();
|
final nonOGAttachments = _effectiveController.attachments.where(
|
||||||
final fileAttachments = _effectiveController.attachments
|
(it) => it.titleLink == null,
|
||||||
|
);
|
||||||
|
if (nonOGAttachments.isEmpty) return const Offstage();
|
||||||
|
final fileAttachments = nonOGAttachments
|
||||||
.where((it) => it.type == 'file')
|
.where((it) => it.type == 'file')
|
||||||
.toList(growable: false);
|
.toList(growable: false);
|
||||||
final remainingAttachments = _effectiveController.attachments
|
final remainingAttachments = nonOGAttachments
|
||||||
.where((it) => it.type != 'file')
|
.where((it) => it.type != 'file')
|
||||||
.toList(growable: false);
|
.toList(growable: false);
|
||||||
return Column(
|
return Column(
|
||||||
@@ -1243,11 +1311,7 @@ class MessageInputState extends State<MessageInput>
|
|||||||
focusElevation: 0,
|
focusElevation: 0,
|
||||||
hoverElevation: 0,
|
hoverElevation: 0,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
_effectiveController.value = _effectiveController.value.copyWith(
|
_effectiveController.removeAttachmentById(attachment.id);
|
||||||
attachments: _effectiveController.attachments
|
|
||||||
.where((it) => it.id != attachment.id)
|
|
||||||
.toList(),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
fillColor:
|
fillColor:
|
||||||
_streamChatTheme.colorTheme.textHighEmphasis.withOpacity(0.5),
|
_streamChatTheme.colorTheme.textHighEmphasis.withOpacity(0.5),
|
||||||
@@ -1449,14 +1513,6 @@ class MessageInputState extends State<MessageInput>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add an attachment to the sending message
|
|
||||||
/// Use this to add custom type attachments
|
|
||||||
///
|
|
||||||
/// Note: Only meant to be used from outside the state.
|
|
||||||
void addAttachment(Attachment attachment) {
|
|
||||||
_addAttachments([attachment]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Adds an attachment to the [messageInputController.attachments] map
|
/// Adds an attachment to the [messageInputController.attachments] map
|
||||||
void _addAttachments(Iterable<Attachment> attachments) {
|
void _addAttachments(Iterable<Attachment> attachments) {
|
||||||
final limit = widget.attachmentLimit;
|
final limit = widget.attachmentLimit;
|
||||||
@@ -1591,6 +1647,8 @@ class MessageInputState extends State<MessageInput>
|
|||||||
|
|
||||||
/// Sends the current message
|
/// Sends the current message
|
||||||
Future<void> sendMessage() async {
|
Future<void> sendMessage() async {
|
||||||
|
final skipEnrichUrl = _effectiveController.ogAttachment == null;
|
||||||
|
|
||||||
var message = _effectiveController.value;
|
var message = _effectiveController.value;
|
||||||
|
|
||||||
var shouldKeepFocus = widget.shouldKeepFocusAfterMessage;
|
var shouldKeepFocus = widget.shouldKeepFocusAfterMessage;
|
||||||
@@ -1611,10 +1669,16 @@ class MessageInputState extends State<MessageInput>
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
Future sendingFuture;
|
Future sendingFuture;
|
||||||
if (!_isEditing) {
|
if (_isEditing) {
|
||||||
sendingFuture = channel.sendMessage(message);
|
sendingFuture = channel.updateMessage(
|
||||||
|
message,
|
||||||
|
skipEnrichUrl: skipEnrichUrl,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
sendingFuture = channel.updateMessage(message);
|
sendingFuture = channel.sendMessage(
|
||||||
|
message,
|
||||||
|
skipEnrichUrl: skipEnrichUrl,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldKeepFocus) {
|
if (shouldKeepFocus) {
|
||||||
@@ -1723,3 +1787,78 @@ class MessageInputState extends State<MessageInput>
|
|||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Preview of an Open Graph attachment.
|
||||||
|
class OGAttachmentPreview extends StatelessWidget {
|
||||||
|
/// Returns a new instance of [OGAttachmentPreview]
|
||||||
|
const OGAttachmentPreview({
|
||||||
|
Key? key,
|
||||||
|
required this.attachment,
|
||||||
|
this.onDismissPreviewPressed,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
/// The attachment to be rendered.
|
||||||
|
final Attachment attachment;
|
||||||
|
|
||||||
|
/// Called when the dismiss button is pressed.
|
||||||
|
final VoidCallback? onDismissPreviewPressed;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final chatTheme = StreamChatTheme.of(context);
|
||||||
|
final textTheme = chatTheme.textTheme;
|
||||||
|
final colorTheme = chatTheme.colorTheme;
|
||||||
|
|
||||||
|
final attachmentTitle = attachment.title;
|
||||||
|
final attachmentText = attachment.text;
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
child: Icon(
|
||||||
|
Icons.link,
|
||||||
|
color: colorTheme.accentPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border(
|
||||||
|
left: BorderSide(
|
||||||
|
color: colorTheme.accentPrimary,
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.only(left: 6),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (attachmentTitle != null)
|
||||||
|
Text(
|
||||||
|
attachmentTitle.trim(),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: textTheme.body.copyWith(fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
if (attachmentText != null)
|
||||||
|
Text(
|
||||||
|
attachmentText,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: textTheme.body.copyWith(fontWeight: FontWeight.w400),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
icon: StreamSvgIcon.closeSmall(),
|
||||||
|
onPressed: onDismissPreviewPressed,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+53
-19
@@ -1,8 +1,8 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||||
import 'package:stream_chat/stream_chat.dart';
|
|
||||||
|
|
||||||
/// A value listenable builder related to a [Message].
|
/// A value listenable builder related to a [Message].
|
||||||
///
|
///
|
||||||
@@ -17,33 +17,46 @@ class MessageInputController extends ValueNotifier<Message> {
|
|||||||
/// message.
|
/// message.
|
||||||
factory MessageInputController({
|
factory MessageInputController({
|
||||||
Message? message,
|
Message? message,
|
||||||
|
Map<RegExp, TextStyleBuilder>? textPatternStyle,
|
||||||
}) =>
|
}) =>
|
||||||
MessageInputController._(
|
MessageInputController._(
|
||||||
initialMessage: message ?? Message(),
|
initialMessage: message ?? Message(),
|
||||||
|
textPatternStyle: textPatternStyle,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Creates a controller for an editable text field from an initial [text].
|
/// Creates a controller for an editable text field from an initial [text].
|
||||||
factory MessageInputController.fromText(String? text) =>
|
factory MessageInputController.fromText(
|
||||||
|
String? text, {
|
||||||
|
Map<RegExp, TextStyleBuilder>? textPatternStyle,
|
||||||
|
}) =>
|
||||||
MessageInputController._(
|
MessageInputController._(
|
||||||
initialMessage: Message(text: text),
|
initialMessage: Message(text: text),
|
||||||
|
textPatternStyle: textPatternStyle,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Creates a controller for an editable text field from initial
|
/// Creates a controller for an editable text field from initial
|
||||||
/// [attachments].
|
/// [attachments].
|
||||||
factory MessageInputController.fromAttachments(
|
factory MessageInputController.fromAttachments(
|
||||||
List<Attachment> attachments,
|
List<Attachment> attachments, {
|
||||||
) =>
|
Map<RegExp, TextStyleBuilder>? textPatternStyle,
|
||||||
|
}) =>
|
||||||
MessageInputController._(
|
MessageInputController._(
|
||||||
initialMessage: Message(attachments: attachments),
|
initialMessage: Message(attachments: attachments),
|
||||||
|
textPatternStyle: textPatternStyle,
|
||||||
);
|
);
|
||||||
|
|
||||||
MessageInputController._({
|
MessageInputController._({
|
||||||
required Message initialMessage,
|
required Message initialMessage,
|
||||||
}) : _textEditingController =
|
Map<RegExp, TextStyleBuilder>? textPatternStyle,
|
||||||
TextEditingController.fromValue(TextEditingValue(
|
}) : _textEditingController = MessageTextFieldController.fromValue(
|
||||||
text: initialMessage.text ?? '',
|
initialMessage.text == null
|
||||||
composing: TextRange.collapsed(initialMessage.text?.length ?? 0),
|
? const TextEditingValue()
|
||||||
)),
|
: TextEditingValue(
|
||||||
|
text: initialMessage.text!,
|
||||||
|
composing: TextRange.collapsed(initialMessage.text!.length),
|
||||||
|
),
|
||||||
|
textPatternStyle: textPatternStyle,
|
||||||
|
),
|
||||||
_initialMessage = initialMessage,
|
_initialMessage = initialMessage,
|
||||||
super(initialMessage) {
|
super(initialMessage) {
|
||||||
addListener(_textEditingSyncer);
|
addListener(_textEditingSyncer);
|
||||||
@@ -52,10 +65,7 @@ class MessageInputController extends ValueNotifier<Message> {
|
|||||||
void _textEditingSyncer() {
|
void _textEditingSyncer() {
|
||||||
final cleanText = value.command == null
|
final cleanText = value.command == null
|
||||||
? value.text
|
? value.text
|
||||||
: value.text?.replaceFirst(
|
: value.text?.replaceFirst('/${value.command} ', '');
|
||||||
'/${value.command} ',
|
|
||||||
'',
|
|
||||||
);
|
|
||||||
|
|
||||||
if (cleanText != _textEditingController.text) {
|
if (cleanText != _textEditingController.text) {
|
||||||
final previousOffset = _textEditingController.value.selection.start;
|
final previousOffset = _textEditingController.value.selection.start;
|
||||||
@@ -73,8 +83,9 @@ class MessageInputController extends ValueNotifier<Message> {
|
|||||||
Message get message => value;
|
Message get message => value;
|
||||||
|
|
||||||
/// Returns the controller of the text field linked to this controller.
|
/// Returns the controller of the text field linked to this controller.
|
||||||
TextEditingController get textEditingController => _textEditingController;
|
MessageTextFieldController get textEditingController =>
|
||||||
final TextEditingController _textEditingController;
|
_textEditingController;
|
||||||
|
final MessageTextFieldController _textEditingController;
|
||||||
|
|
||||||
/// Returns the text of the message.
|
/// Returns the text of the message.
|
||||||
String get text => _textEditingController.text;
|
String get text => _textEditingController.text;
|
||||||
@@ -174,6 +185,30 @@ class MessageInputController extends ValueNotifier<Message> {
|
|||||||
attachments = [];
|
attachments = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only used to store the value locally in order to remove it if we call
|
||||||
|
// [clearOGAttachment] or [setOGAttachment] again.
|
||||||
|
Attachment? _ogAttachment;
|
||||||
|
|
||||||
|
/// Returns the og attachment of the message if set
|
||||||
|
Attachment? get ogAttachment =>
|
||||||
|
attachments.firstWhereOrNull((it) => it.id == _ogAttachment?.id);
|
||||||
|
|
||||||
|
/// Sets the og attachment in the message.
|
||||||
|
void setOGAttachment(Attachment attachment) {
|
||||||
|
attachments = [...attachments]
|
||||||
|
..remove(_ogAttachment)
|
||||||
|
..insert(0, attachment);
|
||||||
|
_ogAttachment = attachment;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes the og attachment.
|
||||||
|
void clearOGAttachment() {
|
||||||
|
if (_ogAttachment != null) {
|
||||||
|
removeAttachment(_ogAttachment!);
|
||||||
|
}
|
||||||
|
_ogAttachment = null;
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns the list of mentioned users in the message.
|
/// Returns the list of mentioned users in the message.
|
||||||
List<User> get mentionedUsers => value.mentionedUsers;
|
List<User> get mentionedUsers => value.mentionedUsers;
|
||||||
|
|
||||||
@@ -220,9 +255,8 @@ class MessageInputController extends ValueNotifier<Message> {
|
|||||||
/// Sets the [value] to the initial [Message] value.
|
/// Sets the [value] to the initial [Message] value.
|
||||||
void reset({bool resetId = true}) {
|
void reset({bool resetId = true}) {
|
||||||
if (resetId) {
|
if (resetId) {
|
||||||
_initialMessage = _initialMessage.copyWith(
|
final newId = const Uuid().v4();
|
||||||
id: const Uuid().v4(),
|
_initialMessage = _initialMessage.copyWith(id: newId);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
value = _initialMessage;
|
value = _initialMessage;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:stream_chat_flutter/src/message_input/tld.dart';
|
||||||
|
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||||
|
|
||||||
|
/// A function that takes a [BuildContext] and returns a [TextStyle].
|
||||||
|
typedef TextStyleBuilder = TextStyle? Function(
|
||||||
|
BuildContext context,
|
||||||
|
String text,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Controller for the [StreamTextField] widget.
|
||||||
|
class MessageTextFieldController extends TextEditingController {
|
||||||
|
/// Returns a new MessageTextFieldController
|
||||||
|
MessageTextFieldController({
|
||||||
|
String? text,
|
||||||
|
this.textPatternStyle,
|
||||||
|
}) : super(text: text);
|
||||||
|
|
||||||
|
/// Returns a new MessageTextFieldController with the given text [value].
|
||||||
|
MessageTextFieldController.fromValue(
|
||||||
|
TextEditingValue? value, {
|
||||||
|
this.textPatternStyle,
|
||||||
|
}) : super.fromValue(value);
|
||||||
|
|
||||||
|
/// A map of style to apply to the text matching the RegExp patterns.
|
||||||
|
final Map<RegExp, TextStyleBuilder>? textPatternStyle;
|
||||||
|
|
||||||
|
/// Builds a [TextSpan] from the current text,
|
||||||
|
/// highlighting the matches for [textPatternStyle].
|
||||||
|
@override
|
||||||
|
TextSpan buildTextSpan({
|
||||||
|
required BuildContext context,
|
||||||
|
TextStyle? style,
|
||||||
|
required bool withComposing,
|
||||||
|
}) {
|
||||||
|
final pattern = textPatternStyle ??
|
||||||
|
{
|
||||||
|
RegExp(r'(?:(?:https?|ftp):\/\/)?[\w/\-?=%.]+\.[\w/\-?=%.]+'):
|
||||||
|
(context, text) {
|
||||||
|
if (!text.split('.').last.isValidTLD()) return null;
|
||||||
|
return TextStyle(
|
||||||
|
color: MessageInputTheme.of(context).linkHighlightColor,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (pattern.isEmpty) {
|
||||||
|
return super.buildTextSpan(
|
||||||
|
context: context,
|
||||||
|
style: style,
|
||||||
|
withComposing: withComposing,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return TextSpan(text: text, style: style).splitMapJoin(
|
||||||
|
RegExp(pattern.keys.map((it) => it.pattern).join('|')),
|
||||||
|
onMatch: (match) {
|
||||||
|
final text = match[0]!;
|
||||||
|
final key = pattern.keys.firstWhere((it) => it.hasMatch(text));
|
||||||
|
return TextSpan(
|
||||||
|
text: text,
|
||||||
|
style: pattern[key]?.call(
|
||||||
|
context,
|
||||||
|
text,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension _TextSpanX on TextSpan {
|
||||||
|
TextSpan splitMapJoin(
|
||||||
|
Pattern pattern, {
|
||||||
|
TextSpan Function(Match)? onMatch,
|
||||||
|
TextSpan Function(TextSpan)? onNonMatch,
|
||||||
|
}) {
|
||||||
|
final children = <TextSpan>[];
|
||||||
|
|
||||||
|
toPlainText().splitMapJoin(
|
||||||
|
pattern,
|
||||||
|
onMatch: (match) {
|
||||||
|
final span = TextSpan(text: match.group(0), style: style);
|
||||||
|
final updated = onMatch?.call(match);
|
||||||
|
children.add(updated ?? span);
|
||||||
|
return span.toPlainText();
|
||||||
|
},
|
||||||
|
onNonMatch: (text) {
|
||||||
|
final span = TextSpan(text: text, style: style);
|
||||||
|
final updatedSpan = onNonMatch?.call(span);
|
||||||
|
children.add(updatedSpan ?? span);
|
||||||
|
return span.toPlainText();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return TextSpan(style: style, children: children);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import 'package:flutter/gestures.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/rendering.dart';
|
import 'package:flutter/rendering.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
|
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||||
|
|
||||||
export 'package:flutter/services.dart'
|
export 'package:flutter/services.dart'
|
||||||
show
|
show
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -259,6 +259,7 @@ class StreamChatThemeData {
|
|||||||
sendButtonIdleColor: colorTheme.disabled,
|
sendButtonIdleColor: colorTheme.disabled,
|
||||||
inputBackgroundColor: colorTheme.barsBg,
|
inputBackgroundColor: colorTheme.barsBg,
|
||||||
inputTextStyle: textTheme.body,
|
inputTextStyle: textTheme.body,
|
||||||
|
linkHighlightColor: colorTheme.accentPrimary,
|
||||||
idleBorderGradient: LinearGradient(
|
idleBorderGradient: LinearGradient(
|
||||||
colors: [
|
colors: [
|
||||||
colorTheme.disabled,
|
colorTheme.disabled,
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ class MessageInputThemeData with Diagnosticable {
|
|||||||
this.idleBorderGradient,
|
this.idleBorderGradient,
|
||||||
this.borderRadius,
|
this.borderRadius,
|
||||||
this.expandButtonColor,
|
this.expandButtonColor,
|
||||||
|
this.linkHighlightColor,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Duration of the [MessageInput] send button animation
|
/// Duration of the [MessageInput] send button animation
|
||||||
@@ -73,6 +74,9 @@ class MessageInputThemeData with Diagnosticable {
|
|||||||
/// Background color of [MessageInput] send button
|
/// Background color of [MessageInput] send button
|
||||||
final Color? sendButtonColor;
|
final Color? sendButtonColor;
|
||||||
|
|
||||||
|
/// Color of a link
|
||||||
|
final Color? linkHighlightColor;
|
||||||
|
|
||||||
/// Background color of [MessageInput] action buttons
|
/// Background color of [MessageInput] action buttons
|
||||||
final Color? actionButtonColor;
|
final Color? actionButtonColor;
|
||||||
|
|
||||||
@@ -110,6 +114,7 @@ class MessageInputThemeData with Diagnosticable {
|
|||||||
Color? actionButtonColor,
|
Color? actionButtonColor,
|
||||||
Color? sendButtonColor,
|
Color? sendButtonColor,
|
||||||
Color? actionButtonIdleColor,
|
Color? actionButtonIdleColor,
|
||||||
|
Color? linkHighlightColor,
|
||||||
Color? sendButtonIdleColor,
|
Color? sendButtonIdleColor,
|
||||||
Color? expandButtonColor,
|
Color? expandButtonColor,
|
||||||
TextStyle? inputTextStyle,
|
TextStyle? inputTextStyle,
|
||||||
@@ -133,6 +138,7 @@ class MessageInputThemeData with Diagnosticable {
|
|||||||
activeBorderGradient: activeBorderGradient ?? this.activeBorderGradient,
|
activeBorderGradient: activeBorderGradient ?? this.activeBorderGradient,
|
||||||
idleBorderGradient: idleBorderGradient ?? this.idleBorderGradient,
|
idleBorderGradient: idleBorderGradient ?? this.idleBorderGradient,
|
||||||
borderRadius: borderRadius ?? this.borderRadius,
|
borderRadius: borderRadius ?? this.borderRadius,
|
||||||
|
linkHighlightColor: linkHighlightColor ?? this.linkHighlightColor,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Linearly interpolate from one [MessageInputThemeData] to another.
|
/// Linearly interpolate from one [MessageInputThemeData] to another.
|
||||||
@@ -161,6 +167,8 @@ class MessageInputThemeData with Diagnosticable {
|
|||||||
Color.lerp(a.sendButtonIdleColor, b.sendButtonIdleColor, t),
|
Color.lerp(a.sendButtonIdleColor, b.sendButtonIdleColor, t),
|
||||||
sendAnimationDuration: a.sendAnimationDuration,
|
sendAnimationDuration: a.sendAnimationDuration,
|
||||||
inputDecoration: a.inputDecoration,
|
inputDecoration: a.inputDecoration,
|
||||||
|
linkHighlightColor:
|
||||||
|
Color.lerp(a.linkHighlightColor, b.linkHighlightColor, t),
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Merges [this] [MessageInputThemeData] with the [other]
|
/// Merges [this] [MessageInputThemeData] with the [other]
|
||||||
@@ -181,6 +189,7 @@ class MessageInputThemeData with Diagnosticable {
|
|||||||
idleBorderGradient: other.idleBorderGradient,
|
idleBorderGradient: other.idleBorderGradient,
|
||||||
borderRadius: other.borderRadius,
|
borderRadius: other.borderRadius,
|
||||||
expandButtonColor: other.expandButtonColor,
|
expandButtonColor: other.expandButtonColor,
|
||||||
|
linkHighlightColor: other.linkHighlightColor,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,7 +209,8 @@ class MessageInputThemeData with Diagnosticable {
|
|||||||
inputDecoration == other.inputDecoration &&
|
inputDecoration == other.inputDecoration &&
|
||||||
idleBorderGradient == other.idleBorderGradient &&
|
idleBorderGradient == other.idleBorderGradient &&
|
||||||
activeBorderGradient == other.activeBorderGradient &&
|
activeBorderGradient == other.activeBorderGradient &&
|
||||||
borderRadius == other.borderRadius;
|
borderRadius == other.borderRadius &&
|
||||||
|
linkHighlightColor == other.linkHighlightColor;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode =>
|
int get hashCode =>
|
||||||
@@ -215,7 +225,8 @@ class MessageInputThemeData with Diagnosticable {
|
|||||||
inputDecoration.hashCode ^
|
inputDecoration.hashCode ^
|
||||||
idleBorderGradient.hashCode ^
|
idleBorderGradient.hashCode ^
|
||||||
activeBorderGradient.hashCode ^
|
activeBorderGradient.hashCode ^
|
||||||
borderRadius.hashCode;
|
borderRadius.hashCode ^
|
||||||
|
linkHighlightColor.hashCode;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
||||||
@@ -232,6 +243,7 @@ class MessageInputThemeData with Diagnosticable {
|
|||||||
..add(DiagnosticsProperty('activeBorderGradient', activeBorderGradient))
|
..add(DiagnosticsProperty('activeBorderGradient', activeBorderGradient))
|
||||||
..add(DiagnosticsProperty('idleBorderGradient', idleBorderGradient))
|
..add(DiagnosticsProperty('idleBorderGradient', idleBorderGradient))
|
||||||
..add(DiagnosticsProperty('borderRadius', borderRadius))
|
..add(DiagnosticsProperty('borderRadius', borderRadius))
|
||||||
..add(ColorProperty('expandButtonColor', expandButtonColor));
|
..add(ColorProperty('expandButtonColor', expandButtonColor))
|
||||||
|
..add(ColorProperty('linkHighlightColor', linkHighlightColor));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ export 'src/mention_tile.dart';
|
|||||||
export 'src/message_action.dart';
|
export 'src/message_action.dart';
|
||||||
export 'src/message_input/countdown_button.dart';
|
export 'src/message_input/countdown_button.dart';
|
||||||
export 'src/message_input/message_input.dart';
|
export 'src/message_input/message_input.dart';
|
||||||
|
export 'src/message_input/message_input_controller.dart';
|
||||||
|
export 'src/message_input/message_text_field_controller.dart';
|
||||||
export 'src/message_input/stream_attachment_picker.dart';
|
export 'src/message_input/stream_attachment_picker.dart';
|
||||||
export 'src/message_input/stream_message_send_button.dart';
|
export 'src/message_input/stream_message_send_button.dart';
|
||||||
export 'src/message_input/stream_message_text_field.dart';
|
export 'src/message_input/stream_message_text_field.dart';
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
|
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets(
|
testWidgets(
|
||||||
@@ -7,7 +7,6 @@ export 'src/better_stream_builder.dart';
|
|||||||
export 'src/channel_list_core.dart' hide ChannelListCoreState;
|
export 'src/channel_list_core.dart' hide ChannelListCoreState;
|
||||||
export 'src/channels_bloc.dart';
|
export 'src/channels_bloc.dart';
|
||||||
export 'src/lazy_load_scroll_view.dart';
|
export 'src/lazy_load_scroll_view.dart';
|
||||||
export 'src/message_input_controller.dart';
|
|
||||||
export 'src/message_list_core.dart' hide MessageListCoreState;
|
export 'src/message_list_core.dart' hide MessageListCoreState;
|
||||||
export 'src/message_search_bloc.dart';
|
export 'src/message_search_bloc.dart';
|
||||||
export 'src/message_search_list_core.dart' hide MessageSearchListCoreState;
|
export 'src/message_search_list_core.dart' hide MessageSearchListCoreState;
|
||||||
|
|||||||
Reference in New Issue
Block a user