diff --git a/lib/api.dart b/lib/api.dart index 88db5468..436357bf 100644 --- a/lib/api.dart +++ b/lib/api.dart @@ -27,21 +27,22 @@ export 'package:mineral/src/api/common/commands/command_helper.dart'; export 'package:mineral/src/api/common/commands/command_option.dart'; export 'package:mineral/src/api/common/commands/command_option_type.dart'; export 'package:mineral/src/api/common/commands/command_type.dart'; +export 'package:mineral/src/api/common/components/action_row.dart'; +export 'package:mineral/src/api/common/components/attached_file.dart'; +// Commons +export 'package:mineral/src/api/common/components/builder/message_builder.dart'; +export 'package:mineral/src/api/common/components/builder/modal_builder.dart'; +export 'package:mineral/src/api/common/components/button.dart'; export 'package:mineral/src/api/common/components/component.dart'; -export 'package:mineral/src/api/common/components/component_type.dart'; +export 'package:mineral/src/api/common/components/media_gallery.dart'; export 'package:mineral/src/api/common/components/media_item.dart'; -// Commons -export 'package:mineral/src/api/common/components/message/message_builder.dart'; -export 'package:mineral/src/api/common/components/message/message_button.dart'; -export 'package:mineral/src/api/common/components/message/message_file.dart'; -export 'package:mineral/src/api/common/components/message/message_gallery.dart'; -export 'package:mineral/src/api/common/components/message/message_row_builder.dart'; -export 'package:mineral/src/api/common/components/message/message_section.dart'; -export 'package:mineral/src/api/common/components/message/message_separator.dart'; -export 'package:mineral/src/api/common/components/message/message_thumbnail.dart'; -export 'package:mineral/src/api/common/components/modal/modal_builder.dart'; -export 'package:mineral/src/api/common/components/modal/modal_text_input.dart'; -export 'package:mineral/src/api/common/components/shared/select_menu.dart'; +export 'package:mineral/src/api/common/components/message_component.dart'; +export 'package:mineral/src/api/common/components/message_thumbnail.dart'; +export 'package:mineral/src/api/common/components/modal_component.dart'; +export 'package:mineral/src/api/common/components/section.dart'; +export 'package:mineral/src/api/common/components/select_menu.dart'; +export 'package:mineral/src/api/common/components/separator.dart'; +export 'package:mineral/src/api/common/components/text_input.dart'; export 'package:mineral/src/api/common/embed/message_embed.dart'; export 'package:mineral/src/api/common/embed/message_embed_assets.dart'; export 'package:mineral/src/api/common/embed/message_embed_author.dart'; diff --git a/lib/events.dart b/lib/events.dart index 8c02e5d8..bcd76234 100644 --- a/lib/events.dart +++ b/lib/events.dart @@ -11,12 +11,12 @@ export 'package:mineral/src/domains/events/contracts/private/private_channel_cre export 'package:mineral/src/domains/events/contracts/private/private_channel_delete_event.dart'; export 'package:mineral/src/domains/events/contracts/private/private_channel_pins_update_event.dart'; export 'package:mineral/src/domains/events/contracts/private/private_channel_update_event.dart'; -export 'package:mineral/src/domains/events/contracts/private/private_modal_submit_event.dart'; export 'package:mineral/src/domains/events/contracts/private/private_message_create_event.dart'; -export 'package:mineral/src/domains/events/contracts/private/private_text_select_event.dart'; -export 'package:mineral/src/domains/events/contracts/private/private_user_select_event.dart'; export 'package:mineral/src/domains/events/contracts/private/private_message_reaction_add_event.dart'; export 'package:mineral/src/domains/events/contracts/private/private_message_reaction_remove_event.dart'; +export 'package:mineral/src/domains/events/contracts/private/private_modal_submit_event.dart'; +export 'package:mineral/src/domains/events/contracts/private/private_text_select_event.dart'; +export 'package:mineral/src/domains/events/contracts/private/private_user_select_event.dart'; export 'package:mineral/src/domains/events/contracts/server/server_ban_add_event.dart'; export 'package:mineral/src/domains/events/contracts/server/server_ban_remove_event.dart'; export 'package:mineral/src/domains/events/contracts/server/server_button_click_event.dart'; @@ -27,7 +27,6 @@ export 'package:mineral/src/domains/events/contracts/server/server_channel_selec export 'package:mineral/src/domains/events/contracts/server/server_channel_update_event.dart'; export 'package:mineral/src/domains/events/contracts/server/server_create_event.dart'; export 'package:mineral/src/domains/events/contracts/server/server_delete_event.dart'; -export 'package:mineral/src/domains/events/contracts/server/server_modal_submit_event.dart'; export 'package:mineral/src/domains/events/contracts/server/server_emojis_update_event.dart'; export 'package:mineral/src/domains/events/contracts/server/server_member_add_event.dart'; export 'package:mineral/src/domains/events/contracts/server/server_member_remove_event.dart'; @@ -36,6 +35,7 @@ export 'package:mineral/src/domains/events/contracts/server/server_member_update export 'package:mineral/src/domains/events/contracts/server/server_message_create_event.dart'; export 'package:mineral/src/domains/events/contracts/server/server_message_reaction_add_event.dart'; export 'package:mineral/src/domains/events/contracts/server/server_message_reaction_remove_event.dart'; +export 'package:mineral/src/domains/events/contracts/server/server_modal_submit_event.dart'; export 'package:mineral/src/domains/events/contracts/server/server_presence_update_event.dart'; export 'package:mineral/src/domains/events/contracts/server/server_role_select_event.dart'; export 'package:mineral/src/domains/events/contracts/server/server_roles_create_event.dart'; diff --git a/lib/src/api/common/components/message/message_row_builder.dart b/lib/src/api/common/components/action_row.dart similarity index 71% rename from lib/src/api/common/components/message/message_row_builder.dart rename to lib/src/api/common/components/action_row.dart index 9840b323..25b13d76 100644 --- a/lib/src/api/common/components/message/message_row_builder.dart +++ b/lib/src/api/common/components/action_row.dart @@ -1,9 +1,9 @@ import 'package:mineral/api.dart'; -final class MessageRowBuilder implements Component { +final class ActionRow implements MessageComponent { final List components; - MessageRowBuilder({this.components = const []}); + ActionRow({this.components = const []}); @override Map toJson() { diff --git a/lib/src/api/common/components/attached_file.dart b/lib/src/api/common/components/attached_file.dart new file mode 100644 index 00000000..2b673c2e --- /dev/null +++ b/lib/src/api/common/components/attached_file.dart @@ -0,0 +1,133 @@ +import 'dart:io'; + +import 'package:http/http.dart' as http; +import 'package:mineral/api.dart'; + +final class AttachedFile implements MessageComponent { + ComponentType get type => ComponentType.file; + + MediaItem item; + + AttachedFile._(this.item); + + /// Creates an [AttachedFile] from a local file. + /// + /// Example: + /// ```dart + /// final file = File('assets/image.png'); + /// final attachedFile = AttachedFile.fromFile(file, 'image.png', spoiler: false); + /// ``` + factory AttachedFile.fromFile( + File file, + String name, { + bool? spoiler, + String? proxyUrl, + int? height, + int? width, + String? contentType, + String? description, + }) { + final mediaItem = MediaItem.fromFile( + file, + name, + spoiler: spoiler, + proxyUrl: proxyUrl, + height: height, + width: width, + contentType: contentType, + description: description, + ); + return AttachedFile._(mediaItem); + } + + /// Creates an [AttachedFile] from a [MediaItem]. + /// + /// If the [MediaItem] was created using [MediaItem.fromNetwork] and doesn't + /// have bytes yet, this method will fetch the file from the network URL. + /// + /// Example: + /// ```dart + /// final mediaItem = MediaItem.fromNetwork('https://example.com/image.png'); + /// final attachedFile = await AttachedFile.fromMediaItem(mediaItem); + /// ``` + static Future fromMediaItem(MediaItem mediaItem) async { + // If bytes are already present (e.g., from MediaItem.fromFile), use them directly + if (mediaItem.bytes != null) { + return AttachedFile._(mediaItem); + } + + // If no bytes, fetch from the network URL + final uri = Uri.parse(mediaItem.url); + final response = await http.get(uri); + + // Extract filename from the URL or use a default + final name = + uri.pathSegments.isNotEmpty ? uri.pathSegments.last : 'file.txt'; + + // Create a new MediaItem with the fetched bytes and attachment:// URL + final media = MediaItem.fromNetwork( + 'attachment://$name', + spoiler: mediaItem.spoiler, + proxyUrl: mediaItem.proxyUrl, + height: mediaItem.height, + width: mediaItem.width, + contentType: mediaItem.contentType, + description: mediaItem.description, + )..bytes = response.bodyBytes; + + return AttachedFile._(media); + } + + /// Creates an [AttachedFile] by fetching a file from a network URL. + /// + /// This method downloads the file from the specified [url] and prepares it + /// for attachment to a Discord message. The file is automatically converted + /// to use Discord's `attachment://` protocol with the provided [name]. + /// + /// Example: + /// ```dart + /// final attachedFile = await AttachedFile.fromNetwork( + /// 'https://example.com/data/data.json', + /// 'data.json', + /// spoiler: true, + /// ); + /// + /// final builder = MessageBuilder() + /// ..addText('Check out these datas!') + /// ..addFile(attachedFile); + /// ``` + static Future fromNetwork( + String url, + String name, { + bool? spoiler, + String? proxyUrl, + int? height, + int? width, + String? contentType, + String? description, + }) async { + // Fetch bytes from the network URL + final uri = Uri.parse(url); + final response = await http.get(uri); + + // Create a MediaItem with the fetched bytes + final media = MediaItem.fromNetwork( + 'attachment://$name', + spoiler: spoiler, + proxyUrl: proxyUrl, + height: height, + width: width, + contentType: contentType, + description: description, + ) + ..bytes = response.bodyBytes + ..spoiler = spoiler; + + return AttachedFile._(media); + } + + @override + Map toJson() { + return {'type': type.value, ...item.toJson()}; + } +} diff --git a/lib/src/api/common/components/builder/message_builder.dart b/lib/src/api/common/components/builder/message_builder.dart new file mode 100644 index 00000000..9087e620 --- /dev/null +++ b/lib/src/api/common/components/builder/message_builder.dart @@ -0,0 +1,539 @@ +import 'package:mineral/api.dart'; +import 'package:mineral/src/api/common/components/container.dart'; +import 'package:mineral/src/api/common/components/text_display.dart'; + +/// A builder for constructing Discord messages using message components v2. +/// +/// The [MessageBuilder] provides a fluent API for composing messages with [TextDisplay], +/// interactive elements [Button], [SelectMenu], media [AttachedFile], [MediaGallery], +/// and layout components [Section], [Container], [Separator] using Discord's +/// message components v2 specification. +/// +/// ## Usage +/// +/// Create a builder and add components using the cascade operator or method chaining: +/// +/// ```dart +/// final builder = MessageBuilder() +/// ..addText('# Welcome to our server!') +/// ..addSeparator() +/// ..addText('Please read the rules before posting.') +/// ..addButtons([ +/// MessageButton.primary('accept_rules', label: 'Accept Rules'), +/// MessageButton.secondary('learn_more', label: 'Learn More'), +/// ]); +/// +/// builder.addText("-# PS: Dont forget to have fun!"); +/// +/// // Send the message +/// await channel.send(builder); +/// ``` +/// +/// ## Features +/// +/// - **Text & Formatting**: Add markdown-formatted text and separators +/// - **Interactive Components**: Buttons and select menus for user interaction +/// - **Media**: Attach files and create image galleries +/// - **Layout**: Organize content with sections and containers +/// - **Composition**: Combine multiple builders or copy existing ones +/// +/// ## Examples +/// +/// ### Simple text message +/// +/// ```dart +/// final message = MessageBuilder.text('Hello, world!'); +/// ``` +/// +/// ### Message with buttons +/// +/// ```dart +/// final builder = MessageBuilder() +/// ..addText('Choose an option:') +/// ..addButtons([ +/// MessageButton.primary('option_1', label: 'Option 1'), +/// MessageButton.secondary('option_2', label: 'Option 2'), +/// MessageButton.danger('cancel', label: 'Cancel'), +/// ]); +/// ``` +/// +/// ### Message with file attachments +/// +/// ```dart +/// // Using MediaItem +/// final mediaFromFile = MediaItem.fromFile(File('assets/logo.png'), 'logo.png'); +/// final mediaFromNetwork = MediaItem.fromNetwork('https://example.com/image.jpg'); +/// +/// final builder = MessageBuilder() +/// ..addText('Check out these images:') +/// ..addFile(await AttachedFile.fromMediaItem(mediaFromFile)) +/// ..addFile(await AttachedFile.fromMediaItem(mediaFromNetwork)); +/// +/// // Or use the convenience factory +/// final builder2 = MessageBuilder() +/// ..addText('Another way:') +/// ..addFile(AttachedFile.fromFile(File('assets/logo.png'), 'logo.png')) +/// ..addFile(await AttachedFile.fromNetwork('https://example.com/image.jpg', 'image.jpg)); +/// ``` +/// +/// ### Using containers for visual grouping +/// +/// ```dart +/// final container = MessageBuilder() +/// ..addText('This is inside a container') +/// ..addText('With multiple lines') +/// ..addText('And even interactive components') +/// ..addButton(MessageButton.primary('click_me', label: 'Click Me!')); +/// +/// final builder = MessageBuilder() +/// ..addText('Main message') +/// ..addContainer( +/// builder: container, +/// color: Color.blue, +/// spoiler: false, +/// ); +/// ``` +final class MessageBuilder { + final List _components = []; + + /// Creates an empty [MessageBuilder]. + MessageBuilder(); + + /// Creates a [MessageBuilder] with initial text content. + /// + /// This factory constructor is a convenience for creating a builder with + /// a single text component. + /// + /// Example: + /// ```dart + /// final builder = MessageBuilder.text('Hello, world!'); + /// + /// // Equivalent to: + /// final builder = MessageBuilder()..addText('Hello, world!'); + /// ``` + factory MessageBuilder.text(String text) { + return MessageBuilder()..addText(text); + } + + /// Adds a single button to the message. + /// + /// Each button is automatically wrapped in an [ActionRow]. + /// + /// Example: + /// ```dart + /// builder.addButton( + /// MessageButton.primary('confirm', label: 'Confirm'), + /// ); + /// ``` + /// + /// See also: + /// - [addButtons] to add multiple buttons in a single row + /// - [Button] for button types and options + void addButton(Button button) { + final row = ActionRow(components: [button]); + _components.add(row); + } + + /// Adds multiple buttons in a single row. + /// + /// Discord supports up to 5 buttons per action row. If you need more buttons, + /// call this method multiple times to create separate rows. + /// + /// Example: + /// ```dart + /// builder.addButtons([ + /// MessageButton.primary('yes', label: 'Yes'), + /// MessageButton.secondary('no', label: 'No'), + /// MessageButton.danger('cancel', label: 'Cancel'), + /// ]); + /// ``` + /// + /// Throws an [ArgumentError] if more than 5 buttons are provided. + /// + /// See also: + /// - [addButton] to add a single button + void addButtons(List