Merge pull request #457 from GetStream/feat/pinned

feat: Added pin message functionality
This commit is contained in:
Salvatore Giordano
2021-06-08 10:02:33 +02:00
committed by GitHub
10 changed files with 386 additions and 163 deletions
+39 -7
View File
@@ -420,6 +420,9 @@ 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) async {
final currentMessage =
state?.messages.firstWhere((e) => e.id == message.id);
// Cancelling previous completer in case it's called again in the process // Cancelling previous completer in case it's called again in the process
// Eg. Updating the message while the previous call is in progress. // Eg. Updating the message while the previous call is in progress.
_messageAttachmentsUploadCompleter _messageAttachmentsUploadCompleter
@@ -464,6 +467,31 @@ class Channel {
state?.addMessage(m); state?.addMessage(m);
return response;
} catch (error) {
if (error is DioError && error.type != DioErrorType.response) {
state?.retryQueue?.add([message]);
} else if (error is ApiError) {
if (currentMessage != null) {
state?.addMessage(currentMessage);
}
}
rethrow;
}
}
/// Partially updates the [message] in this channel.
Future<UpdateMessageResponse> partiallyUpdateMessage(
Message message, Map data) async {
try {
final response = await _client.partiallyUpdateMessage(message.id, data);
final m = response.message.copyWith(
ownReactions: message.ownReactions,
);
state?.addMessage(m);
return response; return response;
} catch (error) { } catch (error) {
if (error is DioError && error.type != DioErrorType.response) { if (error is DioError && error.type != DioErrorType.response) {
@@ -536,17 +564,21 @@ class Channel {
Duration(seconds: timeoutOrExpirationDate.toInt()), Duration(seconds: timeoutOrExpirationDate.toInt()),
); );
} }
return updateMessage( return partiallyUpdateMessage(message, {
message.copyWith( 'set': {
pinned: true, 'pinned': true,
pinExpires: pinExpires, if (pinExpires != null) 'pin_expires': pinExpires.toIso8601String(),
), }
); });
} }
/// Unpins provided message /// Unpins provided message
Future<UpdateMessageResponse> unpinMessage(Message message) => Future<UpdateMessageResponse> unpinMessage(Message message) =>
updateMessage(message.copyWith(pinned: false)); partiallyUpdateMessage(message, {
'set': {
'pinned': false,
}
});
/// Send a file to this channel /// Send a file to this channel
Future<SendFileResponse> sendFile( Future<SendFileResponse> sendFile(
+30 -13
View File
@@ -315,7 +315,7 @@ class StreamChatClient {
await connectUser(User(id: userId), newToken); await connectUser(User(id: userId), newToken);
try { try {
handler.resolve( return handler.resolve(
await httpClient.request( await httpClient.request(
err.requestOptions.path, err.requestOptions.path,
cancelToken: err.requestOptions.cancelToken, cancelToken: err.requestOptions.cancelToken,
@@ -343,10 +343,12 @@ class StreamChatClient {
), ),
); );
} on DioError { } on DioError {
handler.reject(err); return handler.reject(err);
} }
} }
} }
return handler.next(err);
} }
LogHandlerFunction _getDefaultLogHandler() { LogHandlerFunction _getDefaultLogHandler() {
@@ -1349,6 +1351,20 @@ class StreamChatClient {
return decode(response.data, UpdateMessageResponse.fromJson); return decode(response.data, UpdateMessageResponse.fromJson);
} }
/// Partially update the given message
/// Use 'set' in map to set values
/// User 'unset' in map to unset values
Future<UpdateMessageResponse> partiallyUpdateMessage(
String id,
Map data,
) async {
final response = await put(
'/messages/$id',
data: data,
);
return decode(response.data, UpdateMessageResponse.fromJson);
}
/// Deletes the given message /// Deletes the given message
Future<EmptyResponse> deleteMessage(Message message) async { Future<EmptyResponse> deleteMessage(Message message) async {
final response = await delete('/messages/${message.id}'); final response = await delete('/messages/${message.id}');
@@ -1387,20 +1403,21 @@ class StreamChatClient {
) )
.toUtc(); .toUtc();
} }
return updateMessage( return partiallyUpdateMessage(message.id, {
message.copyWith( 'set': {
pinned: true, 'pinned': true,
pinExpires: pinExpires, if (pinExpires != null) 'pin_expires': pinExpires.toIso8601String(),
), }
); });
} }
/// Unpins provided message /// Unpins provided message
Future<UpdateMessageResponse> unpinMessage(Message message) => updateMessage( Future<UpdateMessageResponse> unpinMessage(Message message) =>
message.copyWith( partiallyUpdateMessage(message.id, {
pinned: false, 'set': {
), 'pinned': false,
); }
});
} }
/// The class that handles the state of the channel listening to the events /// The class that handles the state of the channel listening to the events
@@ -434,7 +434,7 @@ void main() {
await channelClient.watch(); await channelClient.watch();
when( when(
() => mockDio.post<String>( () => mockDio.put<String>(
'/messages/${message.id}', '/messages/${message.id}',
data: anything, data: anything,
), ),
@@ -449,7 +449,7 @@ void main() {
await channelClient.pinMessage(message, 30); await channelClient.pinMessage(message, 30);
verify(() => verify(() =>
mockDio.post<String>('/messages/${message.id}', data: anything)) mockDio.put<String>('/messages/${message.id}', data: anything))
.called(1); .called(1);
}); });
@@ -479,7 +479,7 @@ void main() {
await channelClient.watch(); await channelClient.watch();
when( when(
() => mockDio.post<String>( () => mockDio.put<String>(
'/messages/${message.id}', '/messages/${message.id}',
data: anything, data: anything,
), ),
@@ -494,7 +494,7 @@ void main() {
await channelClient.pinMessage(message); await channelClient.pinMessage(message);
verify(() => verify(() =>
mockDio.post<String>('/messages/${message.id}', data: anything)) mockDio.put<String>('/messages/${message.id}', data: anything))
.called(1); .called(1);
}); });
@@ -524,7 +524,7 @@ void main() {
await channelClient.watch(); await channelClient.watch();
when( when(
() => mockDio.post<String>( () => mockDio.put<String>(
'/messages/${message.id}', '/messages/${message.id}',
data: anything, data: anything,
), ),
@@ -539,7 +539,7 @@ void main() {
await channelClient.unpinMessage(message); await channelClient.unpinMessage(message);
verify(() => verify(() =>
mockDio.post<String>('/messages/${message.id}', data: anything)) mockDio.put<String>('/messages/${message.id}', data: anything))
.called(1); .called(1);
}); });
}); });
+41 -8
View File
@@ -745,6 +745,39 @@ void main() {
data: {'message': anything})).called(1); data: {'message': anything})).called(1);
}); });
test('partiallyUpdateMessage', () async {
final mockDio = MockDio();
when(() => mockDio.options).thenReturn(BaseOptions());
when(() => mockDio.interceptors).thenReturn(Interceptors());
final client = StreamChatClient('api-key', httpClient: mockDio);
final message = Message(
id: 'test',
text: 'demo',
);
when(
() => mockDio.put<String>(
'/messages/${message.id}',
data: {'set': anything},
),
).thenAnswer(
(_) async => Response(
data: jsonEncode({'message': message}),
statusCode: 200,
requestOptions: FakeRequestOptions(),
),
);
await client.partiallyUpdateMessage(message.id, {
'set': {'text': message.text}
});
verify(() => mockDio.put<String>('/messages/${message.id}',
data: {'set': anything})).called(1);
});
test('deleteMessage', () async { test('deleteMessage', () async {
final mockDio = MockDio(); final mockDio = MockDio();
@@ -1094,7 +1127,7 @@ void main() {
final message = Message(text: 'Hello'); final message = Message(text: 'Hello');
when( when(
() => mockDio.post<String>( () => mockDio.put<String>(
'/messages/${message.id}', '/messages/${message.id}',
data: anything, data: anything,
), ),
@@ -1108,15 +1141,15 @@ void main() {
await client.pinMessage(message, timeout); await client.pinMessage(message, timeout);
verify(() => mockDio.post<String>('/messages/${message.id}', verify(() => mockDio.put<String>('/messages/${message.id}',
data: {'message': anything})).called(1); data: {'set': anything})).called(1);
}); });
test('should complete successfully with a null value', () async { test('should complete successfully with a null value', () async {
final message = Message(text: 'Hello'); final message = Message(text: 'Hello');
when( when(
() => mockDio.post<String>( () => mockDio.put<String>(
'/messages/${message.id}', '/messages/${message.id}',
data: anything, data: anything,
), ),
@@ -1130,15 +1163,15 @@ void main() {
await client.pinMessage(message); await client.pinMessage(message);
verify(() => mockDio.post<String>('/messages/${message.id}', verify(() => mockDio.put<String>('/messages/${message.id}',
data: {'message': anything})).called(1); data: {'set': anything})).called(1);
}); });
test('should unpin message successfully', () async { test('should unpin message successfully', () async {
final message = Message(text: 'Hello'); final message = Message(text: 'Hello');
when( when(
() => mockDio.post<String>( () => mockDio.put<String>(
'/messages/${message.id}', '/messages/${message.id}',
data: anything, data: anything,
), ),
@@ -1152,7 +1185,7 @@ void main() {
await client.unpinMessage(message); await client.unpinMessage(message);
verify(() => mockDio.post<String>('/messages/${message.id}', verify(() => mockDio.put<String>('/messages/${message.id}',
data: anything)).called(1); data: anything)).called(1);
}); });
}); });
@@ -27,6 +27,8 @@ class MessageActionsModal extends StatefulWidget {
this.showResendMessage = true, this.showResendMessage = true,
this.showThreadReplyMessage = true, this.showThreadReplyMessage = true,
this.showFlagButton = true, this.showFlagButton = true,
this.showPinButton = true,
this.showPinHighlight = false,
this.showUserAvatar = DisplayWidget.show, this.showUserAvatar = DisplayWidget.show,
this.editMessageInputBuilder, this.editMessageInputBuilder,
this.messageShape, this.messageShape,
@@ -80,6 +82,12 @@ class MessageActionsModal extends StatefulWidget {
/// Flag for showing flag action /// Flag for showing flag action
final bool showFlagButton; final bool showFlagButton;
/// Flag for showing pin action
final bool showPinButton;
/// Display Pin Highlight
final bool showPinHighlight;
/// Flag for reversing message /// Flag for reversing message
final bool reverse; final bool reverse;
@@ -226,6 +234,7 @@ class _MessageActionsModalState extends State<MessageActionsModal> {
showSendingIndicator: false, showSendingIndicator: false,
shape: widget.messageShape, shape: widget.messageShape,
attachmentShape: widget.attachmentShape, attachmentShape: widget.attachmentShape,
showPinHighlight: false,
textBuilder: widget.textBuilder, textBuilder: widget.textBuilder,
), ),
), ),
@@ -263,6 +272,8 @@ class _MessageActionsModalState extends State<MessageActionsModal> {
_buildCopyButton(context), _buildCopyButton(context),
if (widget.showFlagButton) if (widget.showFlagButton)
_buildFlagButton(context), _buildFlagButton(context),
if (widget.showPinButton)
_buildPinButton(context),
if (widget.showDeleteMessage) if (widget.showDeleteMessage)
_buildDeleteButton(context), _buildDeleteButton(context),
...widget.customActions ...widget.customActions
@@ -364,6 +375,21 @@ class _MessageActionsModalState extends State<MessageActionsModal> {
} }
} }
void _togglePin() async {
final channel = StreamChannel.of(context).channel;
try {
if (!widget.message.pinned) {
await channel.pinMessage(widget.message);
} else {
await channel.unpinMessage(widget.message);
}
Navigator.pop(context);
} catch (e) {
_showErrorAlert();
}
}
void _showDeleteDialog() async { void _showDeleteDialog() async {
setState(() { setState(() {
_showActions = false; _showActions = false;
@@ -456,6 +482,29 @@ class _MessageActionsModalState extends State<MessageActionsModal> {
); );
} }
Widget _buildPinButton(BuildContext context) {
final streamChatThemeData = StreamChatTheme.of(context);
return InkWell(
onTap: _togglePin,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 16),
child: Row(
children: [
StreamSvgIcon.pin(
color: streamChatThemeData.primaryIconTheme.color,
size: 24,
),
const SizedBox(width: 16),
Text(
'${widget.message.pinned ? 'Unpin from' : 'Pin to'} Conversation',
style: streamChatThemeData.textTheme.body,
),
],
),
),
);
}
Widget _buildDeleteButton(BuildContext context) { Widget _buildDeleteButton(BuildContext context) {
final isDeleteFailed = final isDeleteFailed =
widget.message.status == MessageSendingStatus.failed_delete; widget.message.status == MessageSendingStatus.failed_delete;
@@ -157,6 +157,7 @@ class MessageListView extends StatefulWidget {
this.onSystemMessageTap, this.onSystemMessageTap,
this.onAttachmentTap, this.onAttachmentTap,
this.onLinkTap, this.onLinkTap,
this.pinPermissions = const [],
this.textBuilder, this.textBuilder,
this.usernameBuilder, this.usernameBuilder,
}) : super(key: key); }) : super(key: key);
@@ -268,6 +269,9 @@ class MessageListView extends StatefulWidget {
/// Callback for when link is tapped /// Callback for when link is tapped
final void Function(String link)? onLinkTap; final void Function(String link)? onLinkTap;
/// A List of user types that have permission to pin messages
final List<String> pinPermissions;
@override @override
_MessageListViewState createState() => _MessageListViewState(); _MessageListViewState createState() => _MessageListViewState();
} }
@@ -811,6 +815,10 @@ class _MessageListViewState extends State<MessageListView> {
) { ) {
final isMyMessage = message.user!.id == StreamChat.of(context).user!.id; final isMyMessage = message.user!.id == StreamChat.of(context).user!.id;
final isOnlyEmoji = message.text!.isOnlyEmoji; final isOnlyEmoji = message.text!.isOnlyEmoji;
final currentUser = StreamChat.of(context).user;
final members = StreamChannel.of(context).channel.state?.members ?? [];
final currentUserMember =
members.firstWhere((e) => e.user!.id == currentUser!.id);
final chatThemeData = StreamChatTheme.of(context); final chatThemeData = StreamChatTheme.of(context);
return MessageWidget( return MessageWidget(
@@ -864,6 +872,7 @@ class _MessageListViewState extends State<MessageListView> {
textBuilder: widget.textBuilder, textBuilder: widget.textBuilder,
usernameBuilder: widget.usernameBuilder, usernameBuilder: widget.usernameBuilder,
onLinkTap: widget.onLinkTap, onLinkTap: widget.onLinkTap,
showPinButton: widget.pinPermissions.contains(currentUserMember.role),
); );
} }
@@ -950,6 +959,11 @@ class _MessageListViewState extends State<MessageListView> {
? BorderSide.none ? BorderSide.none
: null; : null;
final currentUser = StreamChat.of(context).user;
final members = StreamChannel.of(context).channel.state?.members ?? [];
final currentUserMember =
members.firstWhere((e) => e.user!.id == currentUser!.id);
final chatThemeData = StreamChatTheme.of(context); final chatThemeData = StreamChatTheme.of(context);
Widget child = MessageWidget( Widget child = MessageWidget(
key: ValueKey<String>('MESSAGE-${message.id}'), key: ValueKey<String>('MESSAGE-${message.id}'),
@@ -1063,6 +1077,7 @@ class _MessageListViewState extends State<MessageListView> {
textBuilder: widget.textBuilder, textBuilder: widget.textBuilder,
usernameBuilder: widget.usernameBuilder, usernameBuilder: widget.usernameBuilder,
onLinkTap: widget.onLinkTap, onLinkTap: widget.onLinkTap,
showPinButton: widget.pinPermissions.contains(currentUserMember.role),
); );
if (!message.isDeleted && if (!message.isDeleted &&
@@ -162,6 +162,7 @@ class MessageReactionsModal extends StatelessWidget {
showReactionPickerIndicator: showReactions && showReactionPickerIndicator: showReactions &&
(message.status == MessageSendingStatus.sent), (message.status == MessageSendingStatus.sent),
textBuilder: textBuilder, textBuilder: textBuilder,
showPinHighlight: false,
), ),
), ),
if (message.latestReactions?.isNotEmpty == true) ...[ if (message.latestReactions?.isNotEmpty == true) ...[
@@ -83,6 +83,8 @@ class MessageWidget extends StatefulWidget {
this.showResendMessage = true, this.showResendMessage = true,
this.showCopyMessage = true, this.showCopyMessage = true,
this.showFlagButton = true, this.showFlagButton = true,
this.showPinButton = true,
this.showPinHighlight = true,
this.onUserAvatarTap, this.onUserAvatarTap,
this.onLinkTap, this.onLinkTap,
this.onMessageActions, this.onMessageActions,
@@ -371,6 +373,12 @@ class MessageWidget extends StatefulWidget {
/// Show flag action /// Show flag action
final bool showFlagButton; final bool showFlagButton;
/// Show flag action
final bool showPinButton;
/// Display Pin Highlight
final bool showPinHighlight;
/// Builder for respective attachment types /// Builder for respective attachment types
final Map<String, AttachmentBuilder> attachmentBuilders; final Map<String, AttachmentBuilder> attachmentBuilders;
@@ -454,7 +462,12 @@ class _MessageWidgetState extends State<MessageWidget>
widget.showUserAvatar != DisplayWidget.gone ? avatarWidth + 8.5 : 0.5; widget.showUserAvatar != DisplayWidget.gone ? avatarWidth + 8.5 : 0.5;
return Material( return Material(
type: MaterialType.transparency, type: widget.message.pinned && widget.showPinHighlight
? MaterialType.card
: MaterialType.transparency,
color: widget.message.pinned && widget.showPinHighlight
? StreamChatTheme.of(context).colorTheme.highlight
: null,
child: Portal( child: Portal(
child: InkWell( child: InkWell(
onTap: () { onTap: () {
@@ -481,148 +494,165 @@ class _MessageWidgetState extends State<MessageWidget>
? AlignmentDirectional.bottomEnd ? AlignmentDirectional.bottomEnd
: AlignmentDirectional.bottomStart, : AlignmentDirectional.bottomStart,
children: [ children: [
Column( Padding(
crossAxisAlignment: widget.reverse padding: EdgeInsets.only(
? CrossAxisAlignment.end bottom:
: CrossAxisAlignment.start, isPinned && widget.showPinHighlight ? 8.0 : 0.0,
mainAxisSize: MainAxisSize.min, ),
children: [ child: Column(
Row( crossAxisAlignment: widget.reverse
crossAxisAlignment: CrossAxisAlignment.end, ? CrossAxisAlignment.end
mainAxisSize: MainAxisSize.min, : CrossAxisAlignment.start,
children: <Widget>[ mainAxisSize: MainAxisSize.min,
if (widget.showUserAvatar == DisplayWidget.show && children: [
widget.message.user != null) ...[ if (widget.message.pinned &&
_buildUserAvatar(), widget.message.pinnedBy != null &&
const SizedBox(width: 4), widget.showPinHighlight)
], _buildPinnedMessage(widget.message),
if (widget.showUserAvatar == DisplayWidget.hide) Row(
SizedBox(width: avatarWidth + 4), crossAxisAlignment: CrossAxisAlignment.end,
Flexible( mainAxisSize: MainAxisSize.min,
child: PortalEntry( children: <Widget>[
portal: Container( if (widget.showUserAvatar ==
transform: Matrix4.translationValues( DisplayWidget.show &&
widget.reverse ? 12 : -12, 0, 0), widget.message.user != null) ...[
constraints: const BoxConstraints( _buildUserAvatar(),
maxWidth: 22 * 6.0), const SizedBox(width: 4),
child: _buildReactionIndicator(context), ],
), if (widget.showUserAvatar == DisplayWidget.hide)
portalAnchor: SizedBox(width: avatarWidth + 4),
Alignment(widget.reverse ? 1 : -1, -1), Flexible(
childAnchor: child: PortalEntry(
Alignment(widget.reverse ? -1 : 1, -1), portal: Container(
child: Stack( transform: Matrix4.translationValues(
clipBehavior: Clip.none, widget.reverse ? 12 : -12, 0, 0),
children: [ constraints: const BoxConstraints(
Padding( maxWidth: 22 * 6.0),
padding: widget.showReactions child: _buildReactionIndicator(context),
? EdgeInsets.only( ),
top: widget portalAnchor:
.message Alignment(widget.reverse ? 1 : -1, -1),
.reactionCounts childAnchor:
?.isNotEmpty == Alignment(widget.reverse ? -1 : 1, -1),
true child: Stack(
? 18 clipBehavior: Clip.none,
: 0, children: [
) Padding(
: EdgeInsets.zero, padding: widget.showReactions
child: (widget.message.isDeleted && ? EdgeInsets.only(
!isFailedState) top: widget
? Container( .message
// ignore: lines_longer_than_80_chars .reactionCounts
margin: EdgeInsets.symmetric( ?.isNotEmpty ==
horizontal: true
? 18
: 0,
)
: EdgeInsets.zero,
child: (widget.message.isDeleted &&
!isFailedState)
? Container(
// ignore: lines_longer_than_80_chars
margin: EdgeInsets.symmetric(
horizontal:
// ignore: lines_longer_than_80_chars
widget.showUserAvatar ==
// ignore: lines_longer_than_80_chars
DisplayWidget.gone
? 0
: 4.0),
child: DeletedMessage(
borderRadiusGeometry: widget
.borderRadiusGeometry,
borderSide:
widget.borderSide,
shape: widget.shape,
messageTheme:
widget.messageTheme,
),
)
: Card(
clipBehavior: Clip.antiAlias,
elevation: 0,
margin: EdgeInsets.symmetric(
horizontal: (isFailedState
? 15.0
: 0.0) +
// ignore: lines_longer_than_80_chars // ignore: lines_longer_than_80_chars
widget.showUserAvatar == (widget.showUserAvatar ==
// ignore: lines_longer_than_80_chars
DisplayWidget DisplayWidget
.gone .gone
? 0 ? 0
: 4.0), : 4.0),
child: DeletedMessage( ),
borderRadiusGeometry: widget shape: widget.shape ??
.borderRadiusGeometry, RoundedRectangleBorder(
borderSide: widget.borderSide, side: widget
shape: widget.shape, .borderSide ??
messageTheme: BorderSide(
widget.messageTheme, color: widget
), // ignore: lines_longer_than_80_chars
) .messageTheme
: Card( // ignore: lines_longer_than_80_chars
clipBehavior: Clip.antiAlias, .messageBorderColor ??
elevation: 0, Colors.grey,
margin: EdgeInsets.symmetric( ),
horizontal: (isFailedState borderRadius: widget
? 15.0 // ignore: lines_longer_than_80_chars
: 0.0) + .borderRadiusGeometry ??
// ignore: lines_longer_than_80_chars BorderRadius.zero,
(widget.showUserAvatar == ),
DisplayWidget.gone color: _getBackgroundColor(),
? 0 child: Column(
: 4.0), crossAxisAlignment:
), CrossAxisAlignment.end,
shape: widget.shape ?? mainAxisSize:
RoundedRectangleBorder( MainAxisSize.min,
side: widget.borderSide ?? children: <Widget>[
BorderSide( if (hasQuotedMessage)
color: widget _buildQuotedMessage(),
// ignore: lines_longer_than_80_chars if (hasNonUrlAttachments)
.messageTheme _parseAttachments(),
// ignore: lines_longer_than_80_chars if (!isGiphy)
.messageBorderColor ?? _buildTextBubble(),
Colors.grey, ],
), ),
borderRadius: widget
// ignore: lines_longer_than_80_chars
.borderRadiusGeometry ??
BorderRadius.zero,
),
color: _getBackgroundColor(),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.end,
mainAxisSize:
MainAxisSize.min,
children: <Widget>[
if (hasQuotedMessage)
_buildQuotedMessage(),
if (hasNonUrlAttachments)
_parseAttachments(),
if (!isGiphy)
_buildTextBubble(),
],
), ),
),
if (widget.showReactionPickerIndicator)
Positioned(
right: widget.reverse ? null : 4,
left: widget.reverse ? 4 : null,
top: -8,
child: CustomPaint(
painter: ReactionBubblePainter(
StreamChatTheme.of(context)
.colorTheme
.white,
Colors.transparent,
Colors.transparent,
tailCirclesSpace: 1,
), ),
),
if (widget.showReactionPickerIndicator)
Positioned(
right: widget.reverse ? null : 4,
left: widget.reverse ? 4 : null,
top: -8,
child: CustomPaint(
painter: ReactionBubblePainter(
StreamChatTheme.of(context)
.colorTheme
.white,
Colors.transparent,
Colors.transparent,
tailCirclesSpace: 1,
), ),
), ),
), ],
], ),
), ),
), ),
), ],
], ),
), if (showBottomRow)
if (showBottomRow) SizedBox(height: context.textScaleFactor * 18.0),
SizedBox(height: context.textScaleFactor * 18.0), ],
], ),
), ),
if (showBottomRow) if (showBottomRow)
Padding( Padding(
padding: EdgeInsets.only(left: leftPadding), padding: EdgeInsets.only(
left: leftPadding,
bottom:
isPinned && widget.showPinHighlight ? 6.0 : 0.0,
),
child: _bottomRow, child: _bottomRow,
), ),
if (isFailedState) if (isFailedState)
@@ -929,6 +959,7 @@ class _MessageWidgetState extends State<MessageWidget>
!isFailedState && !isFailedState &&
widget.onThreadTap != null, widget.onThreadTap != null,
showFlagButton: widget.showFlagButton, showFlagButton: widget.showFlagButton,
showPinButton: widget.showPinButton,
customActions: widget.customActions, customActions: widget.customActions,
), ),
)); ));
@@ -1125,8 +1156,38 @@ class _MessageWidgetState extends State<MessageWidget>
); );
} }
Widget _buildPinnedMessage(Message message) {
final pinnedBy = message.pinnedBy;
final pinnedByMe = StreamChat.of(context).user!.id == pinnedBy!.id;
return Padding(
padding: const EdgeInsets.only(left: 8, right: 8, top: 4, bottom: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
StreamSvgIcon.pin(
size: 16,
),
const SizedBox(
width: 4,
),
Text(
'Pinned by ${pinnedByMe ? 'You' : pinnedBy.name}',
style: TextStyle(
color: StreamChatTheme.of(context).colorTheme.grey,
fontSize: 13,
fontWeight: FontWeight.w400,
),
)
],
),
);
}
bool get isOnlyEmoji => widget.message.text!.isOnlyEmoji; bool get isOnlyEmoji => widget.message.text!.isOnlyEmoji;
bool get isPinned => widget.message.pinned;
Color? _getBackgroundColor() { Color? _getBackgroundColor() {
if (hasQuotedMessage) { if (hasQuotedMessage) {
return widget.messageTheme.messageBackgroundColor; return widget.messageTheme.messageBackgroundColor;
@@ -889,6 +889,18 @@ class StreamSvgIcon extends StatelessWidget {
height: size, height: size,
); );
/// [StreamSvgIcon] type
factory StreamSvgIcon.pin({
double? size,
Color? color,
}) =>
StreamSvgIcon(
assetName: 'icon_pin.svg',
color: color,
width: size,
height: size,
);
/// Name of icon asset /// Name of icon asset
final String? assetName; final String? assetName;
@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.694 1.52896C10.5304 1.36532 10.2937 1.29808 10.0685 1.35125C9.84327 1.40442 9.6616 1.57041 9.5884 1.78994L9.16613 3.05682L6.72027 5.50272C5.12809 5.14204 3.59999 5.79015 2.19557 7.19458C1.93481 7.45531 1.93481 7.87811 2.19557 8.13884L4.55625 10.4995L3.1399 11.9159C2.87915 12.1767 2.87915 12.5994 3.1399 12.8601C3.40065 13.1209 3.82342 13.1209 4.08417 12.8601L5.50051 11.4438L7.8612 13.8045C8.12193 14.0652 8.54473 14.0652 8.80547 13.8045C10.2099 12.4 10.858 10.872 10.4973 9.27978L12.9432 6.83391L14.2101 6.41161C14.4296 6.33843 14.5956 6.1568 14.6488 5.93158C14.7019 5.70636 14.6347 5.46967 14.4711 5.30604L10.694 1.52896ZM10.3832 3.62864L10.5137 3.23716L12.7629 5.48638L12.3714 5.61688C12.2731 5.64965 12.1837 5.70488 12.1104 5.77818L9.2776 8.61098C9.09873 8.78984 9.03633 9.05431 9.11627 9.29424C9.43453 10.2489 9.2382 11.2565 8.31287 12.3676L3.6324 7.68718C4.74353 6.76184 5.7511 6.56552 6.7058 6.88378C6.94573 6.96371 7.2102 6.90131 7.38906 6.72244L10.2219 3.88963C10.2951 3.81634 10.3504 3.72698 10.3832 3.62864Z" fill="#7A7A7A"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB