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
/// before actually updating the message.
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
// Eg. Updating the message while the previous call is in progress.
_messageAttachmentsUploadCompleter
@@ -464,6 +467,31 @@ class Channel {
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;
} catch (error) {
if (error is DioError && error.type != DioErrorType.response) {
@@ -536,17 +564,21 @@ class Channel {
Duration(seconds: timeoutOrExpirationDate.toInt()),
);
}
return updateMessage(
message.copyWith(
pinned: true,
pinExpires: pinExpires,
),
);
return partiallyUpdateMessage(message, {
'set': {
'pinned': true,
if (pinExpires != null) 'pin_expires': pinExpires.toIso8601String(),
}
});
}
/// Unpins provided message
Future<UpdateMessageResponse> unpinMessage(Message message) =>
updateMessage(message.copyWith(pinned: false));
partiallyUpdateMessage(message, {
'set': {
'pinned': false,
}
});
/// Send a file to this channel
Future<SendFileResponse> sendFile(
+30 -13
View File
@@ -315,7 +315,7 @@ class StreamChatClient {
await connectUser(User(id: userId), newToken);
try {
handler.resolve(
return handler.resolve(
await httpClient.request(
err.requestOptions.path,
cancelToken: err.requestOptions.cancelToken,
@@ -343,10 +343,12 @@ class StreamChatClient {
),
);
} on DioError {
handler.reject(err);
return handler.reject(err);
}
}
}
return handler.next(err);
}
LogHandlerFunction _getDefaultLogHandler() {
@@ -1349,6 +1351,20 @@ class StreamChatClient {
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
Future<EmptyResponse> deleteMessage(Message message) async {
final response = await delete('/messages/${message.id}');
@@ -1387,20 +1403,21 @@ class StreamChatClient {
)
.toUtc();
}
return updateMessage(
message.copyWith(
pinned: true,
pinExpires: pinExpires,
),
);
return partiallyUpdateMessage(message.id, {
'set': {
'pinned': true,
if (pinExpires != null) 'pin_expires': pinExpires.toIso8601String(),
}
});
}
/// Unpins provided message
Future<UpdateMessageResponse> unpinMessage(Message message) => updateMessage(
message.copyWith(
pinned: false,
),
);
Future<UpdateMessageResponse> unpinMessage(Message message) =>
partiallyUpdateMessage(message.id, {
'set': {
'pinned': false,
}
});
}
/// The class that handles the state of the channel listening to the events
@@ -434,7 +434,7 @@ void main() {
await channelClient.watch();
when(
() => mockDio.post<String>(
() => mockDio.put<String>(
'/messages/${message.id}',
data: anything,
),
@@ -449,7 +449,7 @@ void main() {
await channelClient.pinMessage(message, 30);
verify(() =>
mockDio.post<String>('/messages/${message.id}', data: anything))
mockDio.put<String>('/messages/${message.id}', data: anything))
.called(1);
});
@@ -479,7 +479,7 @@ void main() {
await channelClient.watch();
when(
() => mockDio.post<String>(
() => mockDio.put<String>(
'/messages/${message.id}',
data: anything,
),
@@ -494,7 +494,7 @@ void main() {
await channelClient.pinMessage(message);
verify(() =>
mockDio.post<String>('/messages/${message.id}', data: anything))
mockDio.put<String>('/messages/${message.id}', data: anything))
.called(1);
});
@@ -524,7 +524,7 @@ void main() {
await channelClient.watch();
when(
() => mockDio.post<String>(
() => mockDio.put<String>(
'/messages/${message.id}',
data: anything,
),
@@ -539,7 +539,7 @@ void main() {
await channelClient.unpinMessage(message);
verify(() =>
mockDio.post<String>('/messages/${message.id}', data: anything))
mockDio.put<String>('/messages/${message.id}', data: anything))
.called(1);
});
});
+41 -8
View File
@@ -745,6 +745,39 @@ void main() {
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 {
final mockDio = MockDio();
@@ -1094,7 +1127,7 @@ void main() {
final message = Message(text: 'Hello');
when(
() => mockDio.post<String>(
() => mockDio.put<String>(
'/messages/${message.id}',
data: anything,
),
@@ -1108,15 +1141,15 @@ void main() {
await client.pinMessage(message, timeout);
verify(() => mockDio.post<String>('/messages/${message.id}',
data: {'message': anything})).called(1);
verify(() => mockDio.put<String>('/messages/${message.id}',
data: {'set': anything})).called(1);
});
test('should complete successfully with a null value', () async {
final message = Message(text: 'Hello');
when(
() => mockDio.post<String>(
() => mockDio.put<String>(
'/messages/${message.id}',
data: anything,
),
@@ -1130,15 +1163,15 @@ void main() {
await client.pinMessage(message);
verify(() => mockDio.post<String>('/messages/${message.id}',
data: {'message': anything})).called(1);
verify(() => mockDio.put<String>('/messages/${message.id}',
data: {'set': anything})).called(1);
});
test('should unpin message successfully', () async {
final message = Message(text: 'Hello');
when(
() => mockDio.post<String>(
() => mockDio.put<String>(
'/messages/${message.id}',
data: anything,
),
@@ -1152,7 +1185,7 @@ void main() {
await client.unpinMessage(message);
verify(() => mockDio.post<String>('/messages/${message.id}',
verify(() => mockDio.put<String>('/messages/${message.id}',
data: anything)).called(1);
});
});
@@ -27,6 +27,8 @@ class MessageActionsModal extends StatefulWidget {
this.showResendMessage = true,
this.showThreadReplyMessage = true,
this.showFlagButton = true,
this.showPinButton = true,
this.showPinHighlight = false,
this.showUserAvatar = DisplayWidget.show,
this.editMessageInputBuilder,
this.messageShape,
@@ -80,6 +82,12 @@ class MessageActionsModal extends StatefulWidget {
/// Flag for showing flag action
final bool showFlagButton;
/// Flag for showing pin action
final bool showPinButton;
/// Display Pin Highlight
final bool showPinHighlight;
/// Flag for reversing message
final bool reverse;
@@ -226,6 +234,7 @@ class _MessageActionsModalState extends State<MessageActionsModal> {
showSendingIndicator: false,
shape: widget.messageShape,
attachmentShape: widget.attachmentShape,
showPinHighlight: false,
textBuilder: widget.textBuilder,
),
),
@@ -263,6 +272,8 @@ class _MessageActionsModalState extends State<MessageActionsModal> {
_buildCopyButton(context),
if (widget.showFlagButton)
_buildFlagButton(context),
if (widget.showPinButton)
_buildPinButton(context),
if (widget.showDeleteMessage)
_buildDeleteButton(context),
...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 {
setState(() {
_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) {
final isDeleteFailed =
widget.message.status == MessageSendingStatus.failed_delete;
@@ -157,6 +157,7 @@ class MessageListView extends StatefulWidget {
this.onSystemMessageTap,
this.onAttachmentTap,
this.onLinkTap,
this.pinPermissions = const [],
this.textBuilder,
this.usernameBuilder,
}) : super(key: key);
@@ -268,6 +269,9 @@ class MessageListView extends StatefulWidget {
/// Callback for when link is tapped
final void Function(String link)? onLinkTap;
/// A List of user types that have permission to pin messages
final List<String> pinPermissions;
@override
_MessageListViewState createState() => _MessageListViewState();
}
@@ -811,6 +815,10 @@ class _MessageListViewState extends State<MessageListView> {
) {
final isMyMessage = message.user!.id == StreamChat.of(context).user!.id;
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);
return MessageWidget(
@@ -864,6 +872,7 @@ class _MessageListViewState extends State<MessageListView> {
textBuilder: widget.textBuilder,
usernameBuilder: widget.usernameBuilder,
onLinkTap: widget.onLinkTap,
showPinButton: widget.pinPermissions.contains(currentUserMember.role),
);
}
@@ -950,6 +959,11 @@ class _MessageListViewState extends State<MessageListView> {
? BorderSide.none
: 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);
Widget child = MessageWidget(
key: ValueKey<String>('MESSAGE-${message.id}'),
@@ -1063,6 +1077,7 @@ class _MessageListViewState extends State<MessageListView> {
textBuilder: widget.textBuilder,
usernameBuilder: widget.usernameBuilder,
onLinkTap: widget.onLinkTap,
showPinButton: widget.pinPermissions.contains(currentUserMember.role),
);
if (!message.isDeleted &&
@@ -162,6 +162,7 @@ class MessageReactionsModal extends StatelessWidget {
showReactionPickerIndicator: showReactions &&
(message.status == MessageSendingStatus.sent),
textBuilder: textBuilder,
showPinHighlight: false,
),
),
if (message.latestReactions?.isNotEmpty == true) ...[
@@ -83,6 +83,8 @@ class MessageWidget extends StatefulWidget {
this.showResendMessage = true,
this.showCopyMessage = true,
this.showFlagButton = true,
this.showPinButton = true,
this.showPinHighlight = true,
this.onUserAvatarTap,
this.onLinkTap,
this.onMessageActions,
@@ -371,6 +373,12 @@ class MessageWidget extends StatefulWidget {
/// Show flag action
final bool showFlagButton;
/// Show flag action
final bool showPinButton;
/// Display Pin Highlight
final bool showPinHighlight;
/// Builder for respective attachment types
final Map<String, AttachmentBuilder> attachmentBuilders;
@@ -454,7 +462,12 @@ class _MessageWidgetState extends State<MessageWidget>
widget.showUserAvatar != DisplayWidget.gone ? avatarWidth + 8.5 : 0.5;
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: InkWell(
onTap: () {
@@ -481,148 +494,165 @@ class _MessageWidgetState extends State<MessageWidget>
? AlignmentDirectional.bottomEnd
: AlignmentDirectional.bottomStart,
children: [
Column(
crossAxisAlignment: widget.reverse
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
if (widget.showUserAvatar == DisplayWidget.show &&
widget.message.user != null) ...[
_buildUserAvatar(),
const SizedBox(width: 4),
],
if (widget.showUserAvatar == DisplayWidget.hide)
SizedBox(width: avatarWidth + 4),
Flexible(
child: PortalEntry(
portal: Container(
transform: Matrix4.translationValues(
widget.reverse ? 12 : -12, 0, 0),
constraints: const BoxConstraints(
maxWidth: 22 * 6.0),
child: _buildReactionIndicator(context),
),
portalAnchor:
Alignment(widget.reverse ? 1 : -1, -1),
childAnchor:
Alignment(widget.reverse ? -1 : 1, -1),
child: Stack(
clipBehavior: Clip.none,
children: [
Padding(
padding: widget.showReactions
? EdgeInsets.only(
top: widget
.message
.reactionCounts
?.isNotEmpty ==
true
? 18
: 0,
)
: EdgeInsets.zero,
child: (widget.message.isDeleted &&
!isFailedState)
? Container(
// ignore: lines_longer_than_80_chars
margin: EdgeInsets.symmetric(
horizontal:
Padding(
padding: EdgeInsets.only(
bottom:
isPinned && widget.showPinHighlight ? 8.0 : 0.0,
),
child: Column(
crossAxisAlignment: widget.reverse
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (widget.message.pinned &&
widget.message.pinnedBy != null &&
widget.showPinHighlight)
_buildPinnedMessage(widget.message),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
if (widget.showUserAvatar ==
DisplayWidget.show &&
widget.message.user != null) ...[
_buildUserAvatar(),
const SizedBox(width: 4),
],
if (widget.showUserAvatar == DisplayWidget.hide)
SizedBox(width: avatarWidth + 4),
Flexible(
child: PortalEntry(
portal: Container(
transform: Matrix4.translationValues(
widget.reverse ? 12 : -12, 0, 0),
constraints: const BoxConstraints(
maxWidth: 22 * 6.0),
child: _buildReactionIndicator(context),
),
portalAnchor:
Alignment(widget.reverse ? 1 : -1, -1),
childAnchor:
Alignment(widget.reverse ? -1 : 1, -1),
child: Stack(
clipBehavior: Clip.none,
children: [
Padding(
padding: widget.showReactions
? EdgeInsets.only(
top: widget
.message
.reactionCounts
?.isNotEmpty ==
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
widget.showUserAvatar ==
// ignore: lines_longer_than_80_chars
(widget.showUserAvatar ==
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
(widget.showUserAvatar ==
DisplayWidget.gone
? 0
: 4.0),
),
shape: widget.shape ??
RoundedRectangleBorder(
side: widget.borderSide ??
BorderSide(
color: widget
// ignore: lines_longer_than_80_chars
.messageTheme
// ignore: lines_longer_than_80_chars
.messageBorderColor ??
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(),
],
),
shape: widget.shape ??
RoundedRectangleBorder(
side: widget
.borderSide ??
BorderSide(
color: widget
// ignore: lines_longer_than_80_chars
.messageTheme
// ignore: lines_longer_than_80_chars
.messageBorderColor ??
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)
SizedBox(height: context.textScaleFactor * 18.0),
],
],
),
if (showBottomRow)
SizedBox(height: context.textScaleFactor * 18.0),
],
),
),
if (showBottomRow)
Padding(
padding: EdgeInsets.only(left: leftPadding),
padding: EdgeInsets.only(
left: leftPadding,
bottom:
isPinned && widget.showPinHighlight ? 6.0 : 0.0,
),
child: _bottomRow,
),
if (isFailedState)
@@ -929,6 +959,7 @@ class _MessageWidgetState extends State<MessageWidget>
!isFailedState &&
widget.onThreadTap != null,
showFlagButton: widget.showFlagButton,
showPinButton: widget.showPinButton,
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 isPinned => widget.message.pinned;
Color? _getBackgroundColor() {
if (hasQuotedMessage) {
return widget.messageTheme.messageBackgroundColor;
@@ -889,6 +889,18 @@ class StreamSvgIcon extends StatelessWidget {
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
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