diff --git a/packages/stream_chat/lib/src/api/channel.dart b/packages/stream_chat/lib/src/api/channel.dart index 1a74a674..afb92711 100644 --- a/packages/stream_chat/lib/src/api/channel.dart +++ b/packages/stream_chat/lib/src/api/channel.dart @@ -420,6 +420,9 @@ class Channel { /// Waits for a [_messageAttachmentsUploadCompleter] to complete /// before actually updating the message. Future 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 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 unpinMessage(Message message) => - updateMessage(message.copyWith(pinned: false)); + partiallyUpdateMessage(message, { + 'set': { + 'pinned': false, + } + }); /// Send a file to this channel Future sendFile( diff --git a/packages/stream_chat/lib/src/client.dart b/packages/stream_chat/lib/src/client.dart index 9d11e7f1..de199b84 100644 --- a/packages/stream_chat/lib/src/client.dart +++ b/packages/stream_chat/lib/src/client.dart @@ -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 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 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 unpinMessage(Message message) => updateMessage( - message.copyWith( - pinned: false, - ), - ); + Future unpinMessage(Message message) => + partiallyUpdateMessage(message.id, { + 'set': { + 'pinned': false, + } + }); } /// The class that handles the state of the channel listening to the events diff --git a/packages/stream_chat/test/src/api/channel_test.dart b/packages/stream_chat/test/src/api/channel_test.dart index 65e169d1..18a1a3e7 100644 --- a/packages/stream_chat/test/src/api/channel_test.dart +++ b/packages/stream_chat/test/src/api/channel_test.dart @@ -434,7 +434,7 @@ void main() { await channelClient.watch(); when( - () => mockDio.post( + () => mockDio.put( '/messages/${message.id}', data: anything, ), @@ -449,7 +449,7 @@ void main() { await channelClient.pinMessage(message, 30); verify(() => - mockDio.post('/messages/${message.id}', data: anything)) + mockDio.put('/messages/${message.id}', data: anything)) .called(1); }); @@ -479,7 +479,7 @@ void main() { await channelClient.watch(); when( - () => mockDio.post( + () => mockDio.put( '/messages/${message.id}', data: anything, ), @@ -494,7 +494,7 @@ void main() { await channelClient.pinMessage(message); verify(() => - mockDio.post('/messages/${message.id}', data: anything)) + mockDio.put('/messages/${message.id}', data: anything)) .called(1); }); @@ -524,7 +524,7 @@ void main() { await channelClient.watch(); when( - () => mockDio.post( + () => mockDio.put( '/messages/${message.id}', data: anything, ), @@ -539,7 +539,7 @@ void main() { await channelClient.unpinMessage(message); verify(() => - mockDio.post('/messages/${message.id}', data: anything)) + mockDio.put('/messages/${message.id}', data: anything)) .called(1); }); }); diff --git a/packages/stream_chat/test/src/client_test.dart b/packages/stream_chat/test/src/client_test.dart index 53792764..2450de7c 100644 --- a/packages/stream_chat/test/src/client_test.dart +++ b/packages/stream_chat/test/src/client_test.dart @@ -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( + '/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('/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( + () => mockDio.put( '/messages/${message.id}', data: anything, ), @@ -1108,15 +1141,15 @@ void main() { await client.pinMessage(message, timeout); - verify(() => mockDio.post('/messages/${message.id}', - data: {'message': anything})).called(1); + verify(() => mockDio.put('/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( + () => mockDio.put( '/messages/${message.id}', data: anything, ), @@ -1130,15 +1163,15 @@ void main() { await client.pinMessage(message); - verify(() => mockDio.post('/messages/${message.id}', - data: {'message': anything})).called(1); + verify(() => mockDio.put('/messages/${message.id}', + data: {'set': anything})).called(1); }); test('should unpin message successfully', () async { final message = Message(text: 'Hello'); when( - () => mockDio.post( + () => mockDio.put( '/messages/${message.id}', data: anything, ), @@ -1152,7 +1185,7 @@ void main() { await client.unpinMessage(message); - verify(() => mockDio.post('/messages/${message.id}', + verify(() => mockDio.put('/messages/${message.id}', data: anything)).called(1); }); }); diff --git a/packages/stream_chat_flutter/lib/src/message_actions_modal.dart b/packages/stream_chat_flutter/lib/src/message_actions_modal.dart index 6d42f9bd..1da4fcea 100644 --- a/packages/stream_chat_flutter/lib/src/message_actions_modal.dart +++ b/packages/stream_chat_flutter/lib/src/message_actions_modal.dart @@ -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 { showSendingIndicator: false, shape: widget.messageShape, attachmentShape: widget.attachmentShape, + showPinHighlight: false, textBuilder: widget.textBuilder, ), ), @@ -263,6 +272,8 @@ class _MessageActionsModalState extends State { _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 { } } + 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 { ); } + 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; diff --git a/packages/stream_chat_flutter/lib/src/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view.dart index 845c50ee..dfab3630 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view.dart @@ -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 pinPermissions; + @override _MessageListViewState createState() => _MessageListViewState(); } @@ -811,6 +815,10 @@ class _MessageListViewState extends State { ) { 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 { textBuilder: widget.textBuilder, usernameBuilder: widget.usernameBuilder, onLinkTap: widget.onLinkTap, + showPinButton: widget.pinPermissions.contains(currentUserMember.role), ); } @@ -950,6 +959,11 @@ class _MessageListViewState extends State { ? 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('MESSAGE-${message.id}'), @@ -1063,6 +1077,7 @@ class _MessageListViewState extends State { textBuilder: widget.textBuilder, usernameBuilder: widget.usernameBuilder, onLinkTap: widget.onLinkTap, + showPinButton: widget.pinPermissions.contains(currentUserMember.role), ); if (!message.isDeleted && diff --git a/packages/stream_chat_flutter/lib/src/message_reactions_modal.dart b/packages/stream_chat_flutter/lib/src/message_reactions_modal.dart index d331d726..9d78e3d9 100644 --- a/packages/stream_chat_flutter/lib/src/message_reactions_modal.dart +++ b/packages/stream_chat_flutter/lib/src/message_reactions_modal.dart @@ -162,6 +162,7 @@ class MessageReactionsModal extends StatelessWidget { showReactionPickerIndicator: showReactions && (message.status == MessageSendingStatus.sent), textBuilder: textBuilder, + showPinHighlight: false, ), ), if (message.latestReactions?.isNotEmpty == true) ...[ diff --git a/packages/stream_chat_flutter/lib/src/message_widget.dart b/packages/stream_chat_flutter/lib/src/message_widget.dart index d519d29b..f1b5428d 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget.dart @@ -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 attachmentBuilders; @@ -454,7 +462,12 @@ class _MessageWidgetState extends State 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 ? 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: [ - 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: [ + 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: [ - 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: [ + 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 !isFailedState && widget.onThreadTap != null, showFlagButton: widget.showFlagButton, + showPinButton: widget.showPinButton, customActions: widget.customActions, ), )); @@ -1125,8 +1156,38 @@ class _MessageWidgetState extends State ); } + 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; diff --git a/packages/stream_chat_flutter/lib/src/stream_svg_icon.dart b/packages/stream_chat_flutter/lib/src/stream_svg_icon.dart index 78dfba2c..ae344a89 100644 --- a/packages/stream_chat_flutter/lib/src/stream_svg_icon.dart +++ b/packages/stream_chat_flutter/lib/src/stream_svg_icon.dart @@ -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; diff --git a/packages/stream_chat_flutter/lib/svgs/icon_pin.svg b/packages/stream_chat_flutter/lib/svgs/icon_pin.svg new file mode 100644 index 00000000..0f494729 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/icon_pin.svg @@ -0,0 +1,3 @@ + + +