Merge branch 'develop' into feat/queryAround
This commit is contained in:
@@ -33,7 +33,7 @@ jobs:
|
||||
flutter-version: ${{ env.flutter_version }}
|
||||
|
||||
- name: "Install Tools"
|
||||
run: flutter pub global activate melos 1.0.0-dev.6
|
||||
run: flutter pub global activate melos 1.0.0-dev.10
|
||||
- name: "Bootstrap Workspace"
|
||||
run: melos bootstrap
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
flutter-version: ${{ env.flutter_version }}
|
||||
- name: "Install Tools"
|
||||
run: |
|
||||
flutter pub global activate melos 1.0.0-dev.6
|
||||
flutter pub global activate melos 1.0.0-dev.10
|
||||
- name: "Bootstrap Workspace"
|
||||
run: melos bootstrap
|
||||
- name: "Dart Analyze"
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
|
||||
- Extra properties added to `PaginationParams` to aid in fetching messages.
|
||||
|
||||
🐞 Fixed
|
||||
|
||||
- `closeConnection()` now uses `normalClosure` status when closing websocket.
|
||||
|
||||
## 3.2.0
|
||||
|
||||
🐞 Fixed
|
||||
@@ -726,4 +730,4 @@
|
||||
|
||||
## 0.0.2
|
||||
|
||||
- first beta version
|
||||
- first beta version
|
||||
@@ -121,7 +121,8 @@ class WebSocket with TimerHelper {
|
||||
_logger?.info('Closing connection with $baseUrl');
|
||||
if (_webSocketChannel != null) {
|
||||
_unsubscribeFromWebSocketChannel();
|
||||
_webSocketChannel?.sink.close(status.goingAway);
|
||||
_webSocketChannel?.sink
|
||||
.close(_manuallyClosed ? status.normalClosure : status.goingAway);
|
||||
_webSocketChannel = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
## Upcoming
|
||||
## Upcoming
|
||||
|
||||
✅ Added
|
||||
|
||||
- `MessageListView` now allows more better control over spacing after messages using `spacingWidgetBuilder`.
|
||||
- `StreamChannel` can now fetch messages around a message ID with the `queryAroundMessage` call.
|
||||
|
||||
🐞 Fixed
|
||||
|
||||
- [[#766]]`AttachmentActionsModal` now has customisation options for actions.
|
||||
- Fixed `MessageWidget` null errors associated with `channel.memberCount`.
|
||||
- Fixed adding attachments on web.
|
||||
|
||||
## 3.2.0
|
||||
|
||||
- Updated Dart SDK constraints to `>=2.14.0 <3.0.0`
|
||||
@@ -866,4 +873,4 @@ The property showVideoFullScreen was added mainly because of this issue brianega
|
||||
|
||||
## 0.0.1
|
||||
|
||||
- First release
|
||||
- First release
|
||||
@@ -24,6 +24,11 @@ class AttachmentActionsModal extends StatelessWidget {
|
||||
this.onShowMessage,
|
||||
this.imageDownloader,
|
||||
this.fileDownloader,
|
||||
this.showReply = true,
|
||||
this.showShowInChat = true,
|
||||
this.showSave = true,
|
||||
this.showDelete = true,
|
||||
this.customActions = const [],
|
||||
}) : super(key: key);
|
||||
|
||||
/// The message containing the attachments
|
||||
@@ -41,6 +46,49 @@ class AttachmentActionsModal extends StatelessWidget {
|
||||
/// Callback to provide download files
|
||||
final AttachmentDownloader? fileDownloader;
|
||||
|
||||
/// Show reply option
|
||||
final bool showReply;
|
||||
|
||||
/// Show show in chat option
|
||||
final bool showShowInChat;
|
||||
|
||||
/// Show save option
|
||||
final bool showSave;
|
||||
|
||||
/// Show delete option
|
||||
final bool showDelete;
|
||||
|
||||
/// List of custom actions
|
||||
final List<AttachmentAction> customActions;
|
||||
|
||||
/// Creates a copy of [MessageWidget] with specified attributes overridden.
|
||||
AttachmentActionsModal copyWith({
|
||||
Key? key,
|
||||
int? currentIndex,
|
||||
Message? message,
|
||||
VoidCallback? onShowMessage,
|
||||
AttachmentDownloader? imageDownloader,
|
||||
AttachmentDownloader? fileDownloader,
|
||||
bool? showReply,
|
||||
bool? showShowInChat,
|
||||
bool? showSave,
|
||||
bool? showDelete,
|
||||
List<AttachmentAction>? customActions,
|
||||
}) =>
|
||||
AttachmentActionsModal(
|
||||
key: key ?? this.key,
|
||||
currentIndex: currentIndex ?? this.currentIndex,
|
||||
message: message ?? this.message,
|
||||
onShowMessage: onShowMessage ?? this.onShowMessage,
|
||||
imageDownloader: imageDownloader ?? this.imageDownloader,
|
||||
fileDownloader: fileDownloader ?? this.fileDownloader,
|
||||
showReply: showReply ?? this.showReply,
|
||||
showShowInChat: showShowInChat ?? this.showShowInChat,
|
||||
showSave: showSave ?? this.showSave,
|
||||
showDelete: showDelete ?? this.showDelete,
|
||||
customActions: customActions ?? this.customActions,
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
@@ -67,82 +115,86 @@ class AttachmentActionsModal extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildButton(
|
||||
context,
|
||||
context.translations.replyLabel,
|
||||
StreamSvgIcon.iconCurveLineLeftUp(
|
||||
size: 24,
|
||||
color: theme.colorTheme.textLowEmphasis,
|
||||
if (showReply)
|
||||
_buildButton(
|
||||
context,
|
||||
context.translations.replyLabel,
|
||||
StreamSvgIcon.iconCurveLineLeftUp(
|
||||
size: 24,
|
||||
color: theme.colorTheme.textLowEmphasis,
|
||||
),
|
||||
() {
|
||||
Navigator.pop(context, ReturnActionType.reply);
|
||||
},
|
||||
),
|
||||
() {
|
||||
Navigator.pop(context, ReturnActionType.reply);
|
||||
},
|
||||
),
|
||||
_buildButton(
|
||||
context,
|
||||
context.translations.showInChatLabel,
|
||||
StreamSvgIcon.eye(
|
||||
size: 24,
|
||||
color: theme.colorTheme.textHighEmphasis,
|
||||
if (showShowInChat)
|
||||
_buildButton(
|
||||
context,
|
||||
context.translations.showInChatLabel,
|
||||
StreamSvgIcon.eye(
|
||||
size: 24,
|
||||
color: theme.colorTheme.textHighEmphasis,
|
||||
),
|
||||
onShowMessage,
|
||||
),
|
||||
onShowMessage,
|
||||
),
|
||||
_buildButton(
|
||||
context,
|
||||
message.attachments[currentIndex].type == 'video'
|
||||
? context.translations.saveVideoLabel
|
||||
: context.translations.saveImageLabel,
|
||||
StreamSvgIcon.iconSave(
|
||||
size: 24,
|
||||
color: theme.colorTheme.textLowEmphasis,
|
||||
if (showSave)
|
||||
_buildButton(
|
||||
context,
|
||||
message.attachments[currentIndex].type == 'video'
|
||||
? context.translations.saveVideoLabel
|
||||
: context.translations.saveImageLabel,
|
||||
StreamSvgIcon.iconSave(
|
||||
size: 24,
|
||||
color: theme.colorTheme.textLowEmphasis,
|
||||
),
|
||||
() {
|
||||
final attachment = message.attachments[currentIndex];
|
||||
final isImage = attachment.type == 'image';
|
||||
final Future<String?> Function(
|
||||
Attachment, {
|
||||
void Function(int, int) progressCallback,
|
||||
}) saveFile = fileDownloader ?? _downloadAttachment;
|
||||
final Future<String?> Function(
|
||||
Attachment, {
|
||||
void Function(int, int) progressCallback,
|
||||
}) saveImage = imageDownloader ?? _downloadAttachment;
|
||||
final downloader = isImage ? saveImage : saveFile;
|
||||
|
||||
final progressNotifier =
|
||||
ValueNotifier<_DownloadProgress?>(
|
||||
_DownloadProgress.initial(),
|
||||
);
|
||||
|
||||
downloader(
|
||||
attachment,
|
||||
progressCallback: (received, total) {
|
||||
progressNotifier.value = _DownloadProgress(
|
||||
total,
|
||||
received,
|
||||
);
|
||||
},
|
||||
).catchError((e, stk) {
|
||||
progressNotifier.value = null;
|
||||
});
|
||||
|
||||
// Closing attachment actions modal before opening
|
||||
// attachment download dialog
|
||||
Navigator.pop(context);
|
||||
|
||||
showDialog(
|
||||
barrierDismissible: false,
|
||||
context: context,
|
||||
barrierColor: theme.colorTheme.overlay,
|
||||
builder: (context) => _buildDownloadProgressDialog(
|
||||
context,
|
||||
progressNotifier,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
() {
|
||||
final attachment = message.attachments[currentIndex];
|
||||
final isImage = attachment.type == 'image';
|
||||
final Future<String?> Function(
|
||||
Attachment, {
|
||||
void Function(int, int) progressCallback,
|
||||
}) saveFile = fileDownloader ?? _downloadAttachment;
|
||||
final Future<String?> Function(
|
||||
Attachment, {
|
||||
void Function(int, int) progressCallback,
|
||||
}) saveImage = imageDownloader ?? _downloadAttachment;
|
||||
final downloader = isImage ? saveImage : saveFile;
|
||||
|
||||
final progressNotifier =
|
||||
ValueNotifier<_DownloadProgress?>(
|
||||
_DownloadProgress.initial(),
|
||||
);
|
||||
|
||||
downloader(
|
||||
attachment,
|
||||
progressCallback: (received, total) {
|
||||
progressNotifier.value = _DownloadProgress(
|
||||
total,
|
||||
received,
|
||||
);
|
||||
},
|
||||
).catchError((e, stk) {
|
||||
progressNotifier.value = null;
|
||||
});
|
||||
|
||||
// Closing attachment actions modal before opening
|
||||
// attachment download dialog
|
||||
Navigator.pop(context);
|
||||
|
||||
showDialog(
|
||||
barrierDismissible: false,
|
||||
context: context,
|
||||
barrierColor: theme.colorTheme.overlay,
|
||||
builder: (context) => _buildDownloadProgressDialog(
|
||||
context,
|
||||
progressNotifier,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (StreamChat.of(context).currentUser?.id ==
|
||||
message.user?.id)
|
||||
message.user?.id &&
|
||||
showDelete)
|
||||
_buildButton(
|
||||
context,
|
||||
context.translations.deleteLabel.capitalize(),
|
||||
@@ -171,6 +223,16 @@ class AttachmentActionsModal extends StatelessWidget {
|
||||
},
|
||||
color: theme.colorTheme.accentError,
|
||||
),
|
||||
...customActions
|
||||
.map(
|
||||
(e) => _buildButton(
|
||||
context,
|
||||
e.actionTitle,
|
||||
e.icon,
|
||||
e.onTap,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
]
|
||||
.map<Widget>((e) => Align(
|
||||
alignment: Alignment.centerRight,
|
||||
@@ -193,7 +255,7 @@ class AttachmentActionsModal extends StatelessWidget {
|
||||
Widget _buildButton(
|
||||
context,
|
||||
String title,
|
||||
StreamSvgIcon icon,
|
||||
Widget icon,
|
||||
VoidCallback? onTap, {
|
||||
Color? color,
|
||||
Key? key,
|
||||
@@ -331,3 +393,22 @@ class _DownloadProgress {
|
||||
|
||||
int get toPercentage => (received * 100) ~/ total;
|
||||
}
|
||||
|
||||
/// Class for custom attachment action
|
||||
class AttachmentAction {
|
||||
/// Constructor for custom attachment action
|
||||
AttachmentAction({
|
||||
required this.actionTitle,
|
||||
required this.icon,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
/// Title for the attachment action
|
||||
String actionTitle;
|
||||
|
||||
/// Icon for the attachment action
|
||||
Widget icon;
|
||||
|
||||
/// Callback for when the action is tapped
|
||||
VoidCallback onTap;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:characters/characters.dart';
|
||||
import 'package:diacritic/diacritic.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/src/emoji/emoji.dart';
|
||||
import 'package:stream_chat_flutter/src/localization/translations.dart';
|
||||
@@ -46,7 +47,7 @@ extension IterableX<T> on Iterable<T> {
|
||||
extension PlatformFileX on PlatformFile {
|
||||
/// Converts the [PlatformFile] into [AttachmentFile]
|
||||
AttachmentFile get toAttachmentFile => AttachmentFile(
|
||||
path: path,
|
||||
path: kIsWeb ? null : path,
|
||||
name: name,
|
||||
bytes: bytes,
|
||||
size: size,
|
||||
|
||||
@@ -33,6 +33,7 @@ class FullScreenMedia extends StatefulWidget {
|
||||
this.startIndex = 0,
|
||||
String? userName,
|
||||
this.onShowMessage,
|
||||
this.attachmentActionsModalBuilder,
|
||||
}) : userName = userName ?? '',
|
||||
super(key: key);
|
||||
|
||||
@@ -51,6 +52,11 @@ class FullScreenMedia extends StatefulWidget {
|
||||
/// Callback for when show message is tapped
|
||||
final ShowMessageCallback? onShowMessage;
|
||||
|
||||
/// Widget builder for attachment actions modal
|
||||
/// [defaultActionsModal] is the default [AttachmentActionsModal] config
|
||||
/// Use [defaultActionsModal.copyWith] to easily customize it
|
||||
final AttachmentActionsBuilder? attachmentActionsModalBuilder;
|
||||
|
||||
@override
|
||||
_FullScreenMediaState createState() => _FullScreenMediaState();
|
||||
}
|
||||
@@ -196,6 +202,8 @@ class _FullScreenMediaState extends State<FullScreenMedia>
|
||||
StreamChannel.of(context).channel,
|
||||
);
|
||||
},
|
||||
attachmentActionsModalBuilder:
|
||||
widget.attachmentActionsModalBuilder,
|
||||
),
|
||||
if (!widget.message.isEphemeral)
|
||||
GalleryFooter(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
@@ -6,6 +6,15 @@ import 'package:stream_chat_flutter/src/stream_svg_icon.dart';
|
||||
import 'package:stream_chat_flutter/src/theme/themes.dart';
|
||||
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
|
||||
|
||||
/// Widget builder for attachment actions modal
|
||||
/// [defaultActionsModal] is the default [AttachmentActionsModal] config
|
||||
/// Use [defaultActionsModal.copyWith] to easily customize it
|
||||
typedef AttachmentActionsBuilder = Widget Function(
|
||||
BuildContext context,
|
||||
Attachment attachment,
|
||||
AttachmentActionsModal defaultActionsModal,
|
||||
);
|
||||
|
||||
/// Header/AppBar widget for media display screen
|
||||
class GalleryHeader extends StatelessWidget implements PreferredSizeWidget {
|
||||
/// Creates a channel header
|
||||
@@ -21,6 +30,7 @@ class GalleryHeader extends StatelessWidget implements PreferredSizeWidget {
|
||||
this.userName = '',
|
||||
this.sentAt = '',
|
||||
this.backgroundColor,
|
||||
this.attachmentActionsModalBuilder,
|
||||
}) : preferredSize = const Size.fromHeight(kToolbarHeight),
|
||||
super(key: key);
|
||||
|
||||
@@ -55,6 +65,11 @@ class GalleryHeader extends StatelessWidget implements PreferredSizeWidget {
|
||||
/// The background color of this [GalleryHeader].
|
||||
final Color? backgroundColor;
|
||||
|
||||
/// Widget builder for attachment actions modal
|
||||
/// [defaultActionsModal] is the default [AttachmentActionsModal] config
|
||||
/// Use [defaultActionsModal.copyWith] to easily customize it
|
||||
final AttachmentActionsBuilder? attachmentActionsModalBuilder;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final galleryHeaderThemeData = GalleryHeaderTheme.of(context);
|
||||
@@ -123,17 +138,26 @@ class GalleryHeader extends StatelessWidget implements PreferredSizeWidget {
|
||||
final galleryHeaderThemeData =
|
||||
StreamChatTheme.of(context).galleryHeaderTheme;
|
||||
|
||||
final defaultModal = AttachmentActionsModal(
|
||||
message: message,
|
||||
currentIndex: currentIndex,
|
||||
onShowMessage: onShowMessage,
|
||||
);
|
||||
|
||||
final effectiveModal = attachmentActionsModalBuilder?.call(
|
||||
context,
|
||||
message.attachments[currentIndex],
|
||||
defaultModal,
|
||||
) ??
|
||||
defaultModal;
|
||||
|
||||
final result = await showDialog(
|
||||
useRootNavigator: false,
|
||||
context: context,
|
||||
barrierColor: galleryHeaderThemeData.bottomSheetBarrierColor,
|
||||
builder: (context) => StreamChannel(
|
||||
channel: channel,
|
||||
child: AttachmentActionsModal(
|
||||
message: message,
|
||||
currentIndex: currentIndex,
|
||||
onShowMessage: onShowMessage,
|
||||
),
|
||||
child: effectiveModal,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -58,6 +58,46 @@ typedef OnMessageTap = void Function(Message);
|
||||
/// Callback on reply tapped
|
||||
typedef ReplyTapCallback = void Function(Message);
|
||||
|
||||
/// Spacing Types (These are properties of a message to help inform the decision
|
||||
/// of how much space / which widget to build after it)
|
||||
enum SpacingType {
|
||||
/// Message is a thread
|
||||
thread,
|
||||
|
||||
/// There is a >1s time diff between current and last message
|
||||
timeDiff,
|
||||
|
||||
/// Next message is by a different user
|
||||
otherUser,
|
||||
|
||||
/// Message is deleted
|
||||
deleted,
|
||||
|
||||
/// No other conditions are valid, default spacing (This will likely be the
|
||||
/// only rule in the list provided)
|
||||
defaultSpacing,
|
||||
}
|
||||
|
||||
/// Builder for building certain spacing after widgets.
|
||||
/// This spacing can be in form of any widgets you like.
|
||||
/// A List of [SpacingType] is provided to help inform the decision of
|
||||
/// what to build after the message.
|
||||
///
|
||||
/// As an example:
|
||||
/// MessageListView(
|
||||
/// spacingWidgetBuilder: (context, list) {
|
||||
/// if(list.contains(SpacingType.defaultSpacing)) {
|
||||
/// return SizedBox(height: 2.0,);
|
||||
/// } else {
|
||||
/// return SizedBox(height: 8.0,);
|
||||
/// }
|
||||
/// },
|
||||
/// ),
|
||||
typedef SpacingWidgetBuilder = Widget Function(
|
||||
BuildContext context,
|
||||
List<SpacingType> spacingTypes,
|
||||
);
|
||||
|
||||
/// Class for message details
|
||||
// ignore: prefer-match-file-name
|
||||
class MessageDetails {
|
||||
@@ -171,6 +211,7 @@ class MessageListView extends StatefulWidget {
|
||||
this.reverse = true,
|
||||
this.paginationLimit = 20,
|
||||
this.paginationLoadingIndicatorBuilder,
|
||||
this.spacingWidgetBuilder,
|
||||
}) : super(key: key);
|
||||
|
||||
/// Function used to build a custom message widget
|
||||
@@ -289,6 +330,12 @@ class MessageListView extends StatefulWidget {
|
||||
/// Builder used to build the loading indicator shown while paginating.
|
||||
final WidgetBuilder? paginationLoadingIndicatorBuilder;
|
||||
|
||||
/// This allows a user to customise the space after a message
|
||||
/// A List of [SpacingType] is provided to provide more data about the
|
||||
/// type of message (thread, difference in time between current and last
|
||||
/// message, default spacing, etc)
|
||||
final SpacingWidgetBuilder? spacingWidgetBuilder;
|
||||
|
||||
@override
|
||||
_MessageListViewState createState() => _MessageListViewState();
|
||||
}
|
||||
@@ -564,17 +611,38 @@ class _MessageListViewState extends State<MessageListView> {
|
||||
Units.MINUTE,
|
||||
);
|
||||
|
||||
final spacingRules = <SpacingType>[];
|
||||
|
||||
final isNextUserSame =
|
||||
message.user!.id == nextMessage.user?.id;
|
||||
final isThread = message.replyCount! > 0;
|
||||
final isDeleted = message.isDeleted;
|
||||
if (timeDiff >= 1 ||
|
||||
!isNextUserSame ||
|
||||
isThread ||
|
||||
isDeleted) {
|
||||
return const SizedBox(height: 8);
|
||||
final hasTimeDiff = timeDiff >= 1;
|
||||
|
||||
if (hasTimeDiff) {
|
||||
spacingRules.add(SpacingType.timeDiff);
|
||||
}
|
||||
return const SizedBox(height: 2);
|
||||
|
||||
if (!isNextUserSame) {
|
||||
spacingRules.add(SpacingType.otherUser);
|
||||
}
|
||||
|
||||
if (isThread) {
|
||||
spacingRules.add(SpacingType.thread);
|
||||
}
|
||||
|
||||
if (isDeleted) {
|
||||
spacingRules.add(SpacingType.deleted);
|
||||
}
|
||||
|
||||
if (spacingRules.isNotEmpty) {
|
||||
return widget.spacingWidgetBuilder
|
||||
?.call(context, spacingRules) ??
|
||||
const SizedBox(height: 8);
|
||||
}
|
||||
return widget.spacingWidgetBuilder
|
||||
?.call(context, [SpacingType.defaultSpacing]) ??
|
||||
const SizedBox(height: 2);
|
||||
},
|
||||
itemBuilder: (context, i) {
|
||||
if (i == itemCount - 1) {
|
||||
|
||||
@@ -1230,6 +1230,7 @@ class _MessageWidgetState extends State<MessageWidget>
|
||||
Widget _buildSendingIndicator() {
|
||||
final style = widget.messageTheme.createdAtStyle;
|
||||
final message = widget.message;
|
||||
final memberCount = StreamChannel.of(context).channel.memberCount ?? 0;
|
||||
|
||||
if (hasNonUrlAttachments &&
|
||||
(message.status == MessageSendingStatus.sending ||
|
||||
@@ -1260,7 +1261,7 @@ class _MessageWidgetState extends State<MessageWidget>
|
||||
if (isMessageRead) {
|
||||
child = Row(
|
||||
children: [
|
||||
if (StreamChannel.of(context).channel.memberCount! > 2)
|
||||
if (memberCount > 2)
|
||||
Text(
|
||||
widget.readList!.length.toString(),
|
||||
style: style.copyWith(
|
||||
|
||||
@@ -2,6 +2,7 @@ export 'package:jiffy/jiffy.dart';
|
||||
export 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
|
||||
|
||||
export 'src/attachment/attachment.dart';
|
||||
export 'src/attachment_actions_modal.dart';
|
||||
export 'src/back_button.dart';
|
||||
export 'src/channel_avatar.dart';
|
||||
export 'src/channel_header.dart';
|
||||
|
||||
Reference in New Issue
Block a user