The ChatView control is a highly customizable chat interface for MAUI.NET applications. It supports various features such as displaying messages, handling user interactions, managing replies, emoji reactions, avatars, and system messages. The control is optimized for native performance using platform-specific components like RecyclerView on Android, UICollectionView on iOS and a virtualized ListView on Windows (WinUI 3).

| Android | iOS | Windows |
|---|---|---|
supported — full feature parity (WinUI 3 ListView) |
You can install the Indiko.Maui.Controls.Chat package via NuGet Package Manager or CLI:
Install-Package Indiko.Maui.Controls.Chat
dotnet add package Indiko.Maui.Controls.Chat
To use the ChatView in your project, add the control to your app builder:
using Microsoft.Maui.Hosting;
using Indiko.Maui.Controls.Chat;
var builder = MauiApp.CreateBuilder();
builder.UseChatView();
TextContent), shown under the media in the same bubble.OpenVideoFullScreen="False" to play inline in the bubble instead.MessageTapped still fires; set OpenImageFullScreen="False" to handle the tap yourself.EnableJumpToRepliedMessage; set the flash color with RepliedMessageHighlightColor (or Transparent to disable it); RepliedMessageTappedCommand notifies your view model with the original message.LongPressedCommand fires with a ContextAction (Name == "reply", Message == the swiped message) — so you implement reply logic once for both gestures. The row springs back (haptic on iOS, clamped slide on Android); on Windows the swipe reveals a Reply button (native SwipeControl) which you tap. Toggle with EnableSwipeToReply; the action name is configurable via SwipeReplyActionName.SenderName above incoming bubbles, de-duplicated for consecutive messages from the same sender. Toggle with ShowSenderName; style with SenderNameTextColor / SenderNameFontSize.DetectLinks; style with LinkTextColor. Long-press-to-react still works.LinkPreview (thumbnail + title + description + site) that renders as an unfurl card under the text; tapping it opens the URL. The control renders only — your app fetches the metadata (keeping the no-networking design) and supplies the thumbnail as bytes. Toggle with EnableLinkPreview; style the card via the LinkPreview* properties; LinkPreviewTappedCommand lets you handle the tap yourself.UICollectionView, so the newest message rests at the bottom with no animated jump on open; on Windows the virtualized ListView pins the viewport to the newest message (ItemsUpdatingScrollMode) and follows it when a message arrives while you're near the bottom; supports scroll-to-last-message and scroll-to-first-new-message.ShowScrollToBottomButton; style with ScrollToBottomButtonBackgroundColor, ScrollToBottomButtonIconColor, ScrollToBottomButtonSize, ScrollToBottomButtonMargin; the badge with ShowScrollToBottomBadge, ScrollToBottomBadgeBackgroundColor, ScrollToBottomBadgeTextColor, ScrollToBottomBadgeFontSize.RecyclerView on Android, UICollectionView on iOS and a virtualized ListView on Windows for smooth performance.Holding) or right-click both open the menu.ChatInputView): A separate, fully styleable input control — auto-growing entry, attachments, emoji picker, press-and-hold to record voice notes, reply banner and media preview. Input-only: it raises SendCommand with a ChatComposeResult so your app stays in charge of persistence/sending. See Optional message composer.net10.0-android, net10.0-ios, net10.0-windows10.0.19041.0)| Message Type | Description |
|---|---|
Text | Standard text messages. |
Image | Image messages (PNG/JPEG bytes in BinaryContent); aspect-sized bubble. |
Video | Video messages (bytes in BinaryContent); blurred poster + tap-to-play. |
Audio | Voice notes (bytes in BinaryContent) with play/pause, waveform, duration. |
System | System-generated / service messages. |
Date | Day separator row (usually inserted by your app between date groups). |
Every feature with a short example. All snippets assume ChatMessages is an
ObservableRangeCollection<ChatMessage> bound to ChatView.Messages. The
sample app exercises all of these.
Tip: the control is render-only — it has no composer, networking or persistence. You build messages in your app and add them to the bound collection.
// MauiProgram.cs
builder.UseChatView();
xmlns:idk="clr-namespace:Indiko.Maui.Controls.Chat;assembly=Indiko.Maui.Controls.Chat"
<idk:ChatView Messages="{Binding ChatMessages}" />
public ObservableRangeCollection<ChatMessage> ChatMessages { get; } = new();
ChatMessages.Add(message); // single (newest)
ChatMessages.AddRange(initialHistory); // bulk, one notification
ChatMessages.InsertRange(0, olderPage); // prepend older messages (load-more)
// Text
new ChatMessage { MessageType = MessageType.Text, TextContent = "Hi!", IsOwnMessage = true, Timestamp = DateTime.Now };
// Image (optional caption via TextContent)
new ChatMessage { MessageType = MessageType.Image, BinaryContent = jpegBytes, TextContent = "from the trail" };
// Video (blurred poster + tap-to-play; full screen by default)
new ChatMessage { MessageType = MessageType.Video, BinaryContent = mp4Bytes };
// Voice note (play/pause + waveform + duration)
new ChatMessage { MessageType = MessageType.Audio, BinaryContent = wavBytes, AudioDuration = TimeSpan.FromSeconds(4) };
new ChatMessage { MessageType = MessageType.System, TextContent = "Messages are end-to-end encrypted" };
new ChatMessage { MessageType = MessageType.Date, Timestamp = day, TextContent = day.ToString("dddd, MMMM d, yyyy") };
msg.SenderAvatar = avatarPngBytes; // image avatar, or…
msg.SenderInitials = "AB"; // …initials fallback
msg.SenderName = "Alex Berg"; // shown above incoming bubbles, de-duplicated per run
<idk:ChatView ShowSenderName="True" SenderNameTextColor="MediumPurple" SenderNameFontSize="12"
AvatarSize="36" AvatarBackgroundColor="Indigo" AvatarTextColor="White" />
msg.Reactions.Add(new ChatMessageReaction { Emoji = "👍", Count = 3, ParticipantIds = ["u1", "u2", "u3"] });
<idk:ChatView EmojiReactionFontSize="14" EmojiReactionTextColor="Indigo"
EmojiReactionTappedCommand="{Binding ReactionTappedCommand}" />
// Render a reply preview inside a bubble
msg.ReplyToMessage = new RepliedMessage
{
MessageId = original.MessageId,
SenderId = "Alex Berg",
TextPreview = RepliedMessage.GenerateTextPreview(original.TextContent)
};
<idk:ChatView EnableSwipeToReply="True"
EnableJumpToRepliedMessage="True"
RepliedMessageHighlightColor="#80FFD54F"
LongPressedCommand="{Binding LongPressedCommand}" />
Swiping a bubble and the context menu's "Reply" item raise the same event, so you handle reply once:
[RelayCommand]
void LongPressed(ContextAction action)
{
if (action.Name == "reply")
StartReplyTo(action.Message); // your composer sets the next message's ReplyToMessage
}
Tapping a reply preview scrolls to (and flashes) the original; RepliedMessageTappedCommand also fires with that message.
// Your app fetches the metadata + thumbnail bytes; the control only renders the card.
msg.LinkPreview = new LinkPreview
{
Url = "https://learn.microsoft.com/dotnet/maui/",
SiteName = "learn.microsoft.com",
Title = ".NET MAUI documentation",
Description = "Build cross-platform native apps from one C# codebase.",
ImageBytes = thumbnailBytes
};
<idk:ChatView EnableLinkPreview="True"
LinkPreviewBackgroundColor="#F2F2F2"
LinkPreviewTitleColor="Black"
LinkPreviewSiteNameColor="RoyalBlue"
LinkPreviewTappedCommand="{Binding LinkPreviewTappedCommand}" />
<idk:ChatView DetectLinks="True" LinkTextColor="RoyalBlue" />
msg.DeliveryState = MessageDeliveryState.Read; // Sent | Delivered | Read
msg.ReadState = MessageReadState.New; // New | Unread | Read
<idk:ChatView SendIcon="send.png" DeliveredIcon="check.png" ReadIcon="read.png" />
<idk:ChatView ShowNewMessagesSeperator="True"
NewMessagesSeperatorText="New Messages"
NewMessagesSeperatorTextColor="Indigo"
ScrollToFirstNewMessage="True" />
<idk:ChatView LoadMoreMessagesCommand="{Binding LoadOlderCommand}" />
[RelayCommand]
void LoadOlder() => ChatMessages.InsertRange(0, await GetOlderPageAsync()); // viewport stays stable
<idk:ChatView ShowScrollToBottomButton="True"
ScrollToBottomButtonBackgroundColor="{StaticResource Primary}"
ScrollToBottomButtonIconColor="White"
ScrollToBottomButtonSize="46"
ShowScrollToBottomBadge="True"
ScrollToBottomBadgeBackgroundColor="Red"
ScrollToBottomBadgeTextColor="White" />
The button appears while scrolled up; the badge counts messages that arrive meanwhile. Tapping it returns to the newest message and clears the badge.
<!-- defaults: both true. Set false to keep media inline / handle the tap yourself. -->
<idk:ChatView OpenImageFullScreen="True" OpenVideoFullScreen="True" />
chatView.ContextMenuItems =
[
new() { Name = "Copy", Tag = "copy" },
new() { Name = "Reply", Tag = "reply" },
new() { Name = "Delete", Tag = "delete", IsDestructive = true },
];
chatView.EmojiReactions = ["👍", "❤️", "😂", "🔥"];
[RelayCommand]
void LongPressed(ContextAction action)
{
switch (action.Name)
{
case "react": var reaction = (ChatMessageReaction)action.AdditionalData; /* add to message */ break;
case "copy": Clipboard.SetTextAsync(action.Message.TextContent); break;
case "reply": StartReplyTo(action.Message); break;
case "delete": ChatMessages.Remove(action.Message); break;
}
}
<idk:ChatView EnableContextMenu="True"
ContextMenuBackgroundColor="White" ContextMenuTextColor="Gray"
ContextMenuDestructiveTextColor="Red" ContextMenuReactionFontSize="18" />
ChatInputView)ChatView is render-only and ships no input box, so you can build your own. For convenience the
library also includes an optional, fully styleable composer — ChatInputView — that you place
below the ChatView. It provides an auto-growing text entry, attachments via a built-in action sheet
(photo / video / camera), an emoji picker, press-and-hold to record voice notes (built-in),
a reply banner and a selected-media preview.
Like everything else it is input-only: it never persists or sends. On send it raises
SendCommand with a ChatComposeResult; your app builds the ChatMessage (persisting / sending as
it sees fit) and adds it to the bound collection.
Recording a voice note (WhatsApp-style): the mic shows when the entry is empty. Press and hold to record (live waveform + timer), release to send, slide left to cancel, or slide up to lock for hands-free recording — then use the trash / send buttons in the bar. Recordings shorter than ~0.8 s are discarded (so an accidental tap sends nothing).
<Grid RowDefinitions="*, Auto">
<idk:ChatView Grid.Row="0" Messages="{Binding ChatMessages}" />
<idk:ChatInputView Grid.Row="1"
Placeholder="Type a message..."
AccentColor="{StaticResource Primary}"
EntryBackgroundColor="#F0F0F0"
EnableAttachments="True"
EnableVoiceRecording="True"
EnableEmojiPicker="True"
ReplyingTo="{Binding ReplyingTo, Mode=TwoWay}"
SendCommand="{Binding SendComposedCommand}" />
</Grid>
[RelayCommand]
void SendComposed(ChatComposeResult result)
{
if (result is null || result.IsEmpty) return;
ChatMessages.Add(new ChatMessage
{
TextContent = result.Text,
BinaryContent = result.MediaBytes,
MessageType = result.MediaType ?? MessageType.Text, // Image / Video / Audio / Text
AudioDuration = result.AudioDuration,
IsOwnMessage = true,
Timestamp = DateTime.Now,
MessageId = Guid.NewGuid().ToString(),
ReplyToMessage = result.ReplyingTo is null ? null : new RepliedMessage
{
MessageId = result.ReplyingTo.MessageId,
SenderId = result.ReplyingTo.SenderName,
TextPreview = RepliedMessage.GenerateTextPreview(result.ReplyingTo.TextContent)
}
});
}
Binding ReplyingTo two-way connects the composer to the swipe / context-menu reply flow: when
the user starts a reply, set ReplyingTo on your VM and the composer shows a reply banner; it clears
it on send (or when the user taps ✕).
Editing a message: set EditingMessage (two-way) to put the composer into edit mode — it prefills
the text and shows an "Editing message" banner. On send, ChatComposeResult.IsEdit is true and
EditingMessage carries the message; update that message's text instead of adding a new one:
[RelayCommand]
void SendComposed(ChatComposeResult r)
{
if (r.IsEdit)
{
var i = ChatMessages.IndexOf(r.EditingMessage);
if (i >= 0) { r.EditingMessage.TextContent = r.Text; ChatMessages[i] = r.EditingMessage; } // raises Replace → cell re-renders
return;
}
// …otherwise build + add a new ChatMessage…
}
Customization — colors, font sizes, feature toggles and icons are all bindable. Icons accept
any ImageSource (PNG/SVG/FontImageSource) and fall back to built-in glyphs when unset:
<idk:ChatInputView AccentColor="Indigo" TextColor="Black" PlaceholderColor="Gray"
EntryBackgroundColor="#EFEFEF" InputFontSize="16" IconFontSize="18"
ReplyBarBackgroundColor="#ECECEC" ReplyBarTextColor="Black">
<idk:ChatInputView.SendIcon>
<FontImageSource Glyph="{x:Static utils:FontAwesome.Paperplane}"
FontFamily="{x:Static utils:FontAwesome.FONTNAME}" Color="Indigo" Size="20" />
</idk:ChatInputView.SendIcon>
</idk:ChatInputView>
| Property | Default | Description |
|---|---|---|
Text | "" | Two-way composer text. |
Placeholder | "Type a message…" | Entry placeholder. |
SendCommand | – | Raised with a ChatComposeResult on send. |
ClearOnSend | true | Clear text/media/reply after SendCommand. |
ReplyingTo | null | Two-way; shows the reply banner when set. |
EditingMessage | null | Two-way; edit mode — prefills text + shows the editing banner. |
SelectedMedia | null | Two-way attached media bytes (preview shown). |
EnableAttachments / EnableVoiceRecording / EnableEmojiPicker | true | Toggle each feature. |
EnableCamera | true | Show "Take Photo" (camera capture) in the attachment sheet. |
AttachmentSheetLabels | built-in | Localize the sheet: [title, cancel, photo, video, camera]. |
EmojiList | built-in set | Emojis shown in the picker. |
AccentColor | RoyalBlue | Tint for icons + reply accent. |
TextColor / PlaceholderColor / EntryBackgroundColor | Black / Gray / #F0F0F0 | Entry styling. |
ReplyBarBackgroundColor / ReplyBarTextColor | #ECECEC / Black | Reply banner styling. |
InputFontSize / IconFontSize | 16 / 18 | Text and glyph-icon sizes. |
SendIcon / AttachIcon / MicIcon / EmojiIcon | built-in glyphs | Custom ImageSource icons. |
Permissions (built-in attachments & recording): add
RECORD_AUDIOto the Android manifest,NSMicrophoneUsageDescription+NSPhotoLibraryUsageDescriptionto the iOSInfo.plist, and themicrophonedevice capability to the WindowsPackage.appxmanifest. Prefer to handle media/recording yourself? SetEnableAttachments/EnableVoiceRecordingtoFalseand add your own buttons that setSelectedMedia/ call yourSendCommand.
ChatMessageRepresents an individual message in the chat.
public class ChatMessage
{
public string MessageId { get; set; }
public DateTime Timestamp { get; set; }
public string TextContent { get; set; }
public byte[] BinaryContent { get; set; } // image / video / audio payload
public bool IsOwnMessage { get; set; }
public string SenderId { get; set; }
public byte[] SenderAvatar { get; set; }
public string SenderInitials { get; set; }
public string SenderName { get; set; } // shown above incoming bubbles in group chats
public MessageType MessageType { get; set; }
public MessageReadState ReadState { get; set; }
public MessageDeliveryState DeliveryState { get; set; }
public bool IsRepliedMessage => ReplyToMessage != null;
public RepliedMessage ReplyToMessage { get; set; }
public List<ChatMessageReaction> Reactions { get; set; } = [];
// Audio (voice note) — both optional.
public TimeSpan? AudioDuration { get; set; } // shown while idle; derived from the file if null
public float[] AudioWaveform { get; set; } // normalized 0..1 samples; a stable
// pseudo-waveform is generated when null
}
ChatMessageReactionRepresents reactions (emojis) on a message.
public class ChatMessageReaction
{
public string Emoji { get; set; }
public int Count { get; set; }
public List<string> ParticipantIds { get; set; } = new List<string>();
}
RepliedMessageRepresents a replied message with a preview.
public class RepliedMessage
{
public string MessageId { get; set; }
public string TextPreview { get; set; }
public string SenderId { get; set; }
public static string GenerateTextPreview(string text, int maxLength = 50)
{
if (string.IsNullOrEmpty(text)) return string.Empty;
return text.Length > maxLength ? text[..maxLength] + "..." : text;
}
}
LinkPreviewData for a link-preview ("unfurl") card under a text message. Your app fetches the URL's metadata and supplies it; the control only renders it.
public class LinkPreview
{
public string Url { get; set; } // opened when the card is tapped
public string Title { get; set; } // e.g. og:title
public string Description { get; set; } // e.g. og:description
public string SiteName { get; set; } // e.g. "github.com"
public byte[] ImageBytes { get; set; } // optional thumbnail bytes
}
ChatComposeResultThe payload raised by ChatInputView.SendCommand. Your app turns it into a ChatMessage.
public class ChatComposeResult
{
public string Text { get; set; }
public byte[] MediaBytes { get; set; } // attached media or recorded audio
public MessageType? MediaType { get; set; } // Image / Video / Audio, or null for text
public TimeSpan? AudioDuration { get; set; } // set for recorded voice notes
public ChatMessage ReplyingTo { get; set; } // reply target, if any
public ChatMessage EditingMessage { get; set; } // set in edit mode — update this message
public bool IsEdit { get; } // true when EditingMessage is set
public bool IsEmpty { get; } // true when there's nothing to send
}
ContextMenuItemRepresents an item in the context menu.
public class ContextMenuItem
{
public string Name { get; set; }
public string Tag { get; set; }
public bool IsDestructive { get; set; }
}
ContextActionRepresents an action triggered from the context menu.
public class ContextAction
{
public string Name { get; set; }
public object AdditionalData { get; set; }
public ChatMessage Message { get; set; }
}
MessageDeliveryStateSentDeliveredReadMessageReadStateNewUnreadReadMessageTypeTextImageVideoAudioSystemDate| Command | Description |
|---|---|
ScrolledCommand | Triggered when the chat view is scrolled. See example below. |
MessageTappedCommand | Triggered when a message is tapped. |
AvatarTappedCommand | Triggered when an avatar is tapped. |
EmojiReactionTappedCommand | Triggered when an emoji reaction is tapped. |
LoadMoreMessagesCommand | Invoked when more messages need to be loaded. |
ScrolledToLastMessageCommand | Triggered when scrolled to the last message. |
LongPressedCommand | Triggered by a context-menu action and by swipe-to-reply; receives a ContextAction (Name, Message). Swipe sends Name == "reply". |
RepliedMessageTappedCommand | Triggered when a reply preview is tapped; passes the original ChatMessage it refers to. |
LinkPreviewTappedCommand | Triggered when a link-preview card is tapped; passes the LinkPreview. When unset, the card opens its Url. |
| Property | Default Value | Description |
|---|---|---|
SystemMessageBackgroundColor | LightYellow | Background color for system messages. |
SystemMessageTextColor | Red | Text color for system messages. |
SystemMessageFontSize | 14 | Font size for system messages. |
DateTextFontSize | 14 | Font size for date separator text. |
DateTextColor | LightGray | Color for date separator text. |
AvatarBackgroundColor | LightBlue | Background color for avatars. |
AvatarTextColor | White | Text color for avatar initials. |
OwnMessageBackgroundColor | LightBlue | Background color for the user's messages. |
OwnMessageTextColor | Black | Text color for the user's messages. |
OwnMessageFontSize | 14 | Font size for the user's messages. |
OtherMessageBackgroundColor | LightGray | Background color for other users' messages. |
OtherMessageTextColor | Black | Text color for other users' messages. |
OtherMessageFontSize | 14 | Font size for other users' messages. |
MessageFontSize | 14 | Font size for messages. |
DateTextColor | LightGray | Color for date separator text. |
AvatarSize | 36 | Size of avatars. |
ScrollToLastMessage | true | Auto-scrolls to the last message. |
ShowNewMessagesSeperator | false | Enables or disables the new message separator. |
EmojiReactionFontSize | 10 | Font size for emoji reactions. |
ReplyMessageBackgroundColor | LightYellow | Background color for replied message previews. |
ReplyMessageFontSize | 10 | Font size for replied message previews. |
ReplyMessageTextColor | Black | Text color for replied message previews. |
ContextMenuBackgroundColor | White | Background color for the context menu. |
ContextMenuTextColor | Black | Text color for the context menu. |
ContextMenuDestructiveTextColor | Red | Text color for destructive actions in the context menu. |
ContextMenuDividerColor | LightGray | Color for the context menu divider. |
ContextMenuDividerHeight | 1 | Height of the context menu divider. |
ContextMenuFontSize | 14 | Font size for the context menu. |
ContextMenuReactionFontSize | 18 | Font size for reaction items in the context menu. |
ShowScrollToBottomButton | true | Shows the floating scroll-to-bottom button when scrolled up. |
ScrollToBottomButtonBackgroundColor | White | Fill color of the scroll-to-bottom button. |
ScrollToBottomButtonIconColor | Black | Color of the chevron icon. |
ScrollToBottomButtonSize | 44 | Diameter of the scroll-to-bottom button. |
ScrollToBottomButtonMargin | 16 | Spacing from the bottom/trailing edges. |
ShowScrollToBottomBadge | true | Shows the unread-count badge on the button. |
ScrollToBottomBadgeBackgroundColor | Red | Fill color of the unread-count badge. |
ScrollToBottomBadgeTextColor | White | Text color of the unread-count badge. |
ScrollToBottomBadgeFontSize | 12 | Font size of the unread-count badge. |
EnableJumpToRepliedMessage | true | Tapping a reply preview scrolls to the original message. |
RepliedMessageHighlightColor | translucent amber | Color flashed over the original message on jump (Transparent disables it). |
EnableLinkPreview | true | Render the link-preview card for text messages that carry a LinkPreview. |
LinkPreviewBackgroundColor | #F2F2F2 | Fill color of the link-preview card. |
LinkPreviewTitleColor | Black | Color of the card title. |
LinkPreviewTitleFontSize | 14 | Font size of the card title. |
LinkPreviewDescriptionColor | Gray | Color of the card description. |
LinkPreviewDescriptionFontSize | 12 | Font size of the card description. |
LinkPreviewSiteNameColor | RoyalBlue | Color of the card site/domain label. |
LinkPreviewSiteNameFontSize | 11 | Font size of the card site/domain label. |
Platform-Specific Note: The platform-specific code for iOS and Android uses a caching mechanism for images and video-based messages. The binary content of such messages is stored in the device's cache folder for optimized performance and memory management. On Windows, media bytes are written to the temp folder keyed by the message id.
Note: The
ChatViewcontrol is solely responsible for rendering different message types. It does not include features like a text input box or a send button. These components need to be implemented by the user in the MAUI.NET app, as demonstrated in theIndiko.Maui.Controls.Chat.Sampleproject.
Messages CollectionMessages is an ObservableRangeCollection<ChatMessage> — an ObservableCollection with bulk operations so large updates raise a single notification (important for scroll performance):
ChatMessages.Add(message); // append a new (newest) message
ChatMessages.AddRange(newMessages); // append many at once
ChatMessages.InsertRange(0, olderMessages);// prepend older messages (infinite-scroll load-more)
ChatMessages.ReplaceRange(allMessages); // replace the whole conversation
Use InsertRange(0, ...) from your LoadMoreMessagesCommand handler to add older history; the control keeps the current viewport stable instead of jumping.
Media is passed as raw bytes in BinaryContent together with the matching MessageType. The control writes the bytes to the platform cache and renders/plays them natively.
// Image
ChatMessages.Add(new ChatMessage
{
MessageId = Guid.NewGuid().ToString(),
Timestamp = DateTime.Now,
IsOwnMessage = true,
MessageType = MessageType.Image,
BinaryContent = imageBytes, // PNG/JPEG
TextContent = "optional caption", // shown under the image in the same bubble
});
// Voice note
ChatMessages.Add(new ChatMessage
{
MessageId = Guid.NewGuid().ToString(),
Timestamp = DateTime.Now,
IsOwnMessage = true,
MessageType = MessageType.Audio,
BinaryContent = audioBytes, // e.g. m4a / mp3 / wav
AudioDuration = TimeSpan.FromSeconds(3), // optional; derived from the file if omitted
AudioWaveform = samples, // optional float[] (0..1); a pseudo-waveform is drawn if omitted
});
The voice-note bubble shows a play/pause button, a tap-to-seek waveform, and the elapsed/total duration. Supply AudioWaveform (e.g. amplitudes captured while recording) for an accurate waveform; otherwise the control renders a stable per-message pseudo-waveform.
To handle the EmojiReactionTappedCommand properly, you can define a method in your ViewModel as follows:
[RelayCommand]
private void OnEmojiReactionTapped(ChatMessage message)
{
Console.WriteLine($"Emoji Reaction tapped: {message.MessageId}");
}
To handle the MessageTappedCommand properly, you can define a method in your ViewModel as follows:
[RelayCommand]
private void OnMessageTapped(ChatMessage message)
{
Console.WriteLine($"Message tapped for message: {message.MessageId}");
}
To handle the AvatarTappedCommand properly, you can define a method in your ViewModel as follows:
[RelayCommand]
private void OnAvatarTapped(ChatMessage message)
{
Console.WriteLine($"Avatar tapped for message: {message.MessageId}");
}
To handle the ScrolledCommand properly, you can define a method in your ViewModel as follows:
[RelayCommand]
private void Scrolled(ScrolledArgs scrolledArgs)
{
// Handle scroll event logic
Console.WriteLine($"Scrolled to position: X={scrolledArgs.X}, Y={scrolledArgs.Y}");
}
To handle the LongPressedCommand properly, you can define a method in your ViewModel as follows:
[RelayCommand]
public void LongPressed(ContextAction contextAction)
{
switch (contextAction.Name)
{
case "reply":
Console.WriteLine($"Reply to message: {contextAction.Message.MessageId}");
break;
case "delete":
Console.WriteLine($"Delete message: {contextAction.Message.MessageId}");
break;
case "copy":
Console.WriteLine($"Copy message: {contextAction.Message.MessageId}");
break;
case "react":
ChatMessageReaction chatMessageReaction = contextAction.AdditionalData as ChatMessageReaction;
Console.WriteLine($"React to message: {contextAction.Message.MessageId}, Additional Data: {chatMessageReaction.Emoji}");
break;
}
}
xmlns:idk="clr-namespace:Indiko.Maui.Controls.Chat;assembly=Indiko.Maui.Controls.Chat"
...
<idk:ChatView Grid.Row="0" x:Name="chatView"
OwnMessageBackgroundColor="{StaticResource Primary}"
OwnMessageTextColor="{StaticResource White}"
OtherMessageBackgroundColor="{StaticResource Secondary}"
OtherMessageTextColor="{StaticResource Black}"
DateTextColor="{StaticResource Gray500}"
DateTextFontSize="14"
MessageTimeTextColor="{StaticResource Gray200}"
NewMessagesSeperatorTextColor="{StaticResource Primary}"
NewMessagesSeperatorFontSize="16"
NewMessagesSeperatorText="New Messages"
AvatarTextColor="{StaticResource White}"
AvatarBackgroundColor="{StaticResource Tertiary}"
Messages="{Binding ChatMessages}"
EmojiReactionFontSize="14"
EmojiReactionTextColor="{StaticResource Primary}"
ReplyMessageBackgroundColor="{StaticResource Tertiary}"
ReplyMessageTextColor="{StaticResource White}"
LoadMoreMessagesCommand="{Binding LoadOlderMessagesCommand}"
ScrolledCommand="{Binding ScrolledCommand}"
AvatarTappedCommand="{Binding AvatarTappedCommand}"
MessageTappedCommand="{Binding MessageTappedCommand}"
EmojiReactionTappedCommand="{Binding EmojiReactionTappedCommand}"
SendIcon="send.png"
DeliveredIcon="check.png"
ReadIcon="read.png"
ScrollToFirstNewMessage="True"
ShowNewMessagesSeperator="True"
ScrolledToLastMessageCommand="{Binding ScrolledToLastMessageCommand}"
SystemMessageBackgroundColor="{StaticResource Yellow300Accent}"
SystemMessageTextColor="{StaticResource Tertiary}"
SystemMessageFontSize="14"
ContextMenuBackgroundColor="{StaticResource White}"
ContextMenuTextColor="{StaticResource Black}"
ContextMenuDestructiveTextColor="{StaticResource Red}"
ContextMenuDividerColor="{StaticResource LightGray}"
ContextMenuDividerHeight="1"
ContextMenuFontSize="14"
ContextMenuReactionFontSize="18"
EnableContextMenu="True"
LongPressedCommand="{Binding LongPressedCommand}">
</idk:ChatView>
var chatView = new ChatView
{
Messages = new ObservableRangeCollection<ChatMessage>(),
MessageTappedCommand = new Command<ChatMessage>(OnMessageTapped),
AvatarTappedCommand = new Command(OnAvatarTapped),
LoadMoreMessagesCommand = new Command(OnLoadMoreMessages),
OwnMessageBackgroundColor = Colors.LightBlue,
OtherMessageBackgroundColor = Colors.LightGray,
ShowNewMessagesSeperator = true,
NewMessagesSeperatorText = "New Messages",
ContextMenuItems = new List<ContextMenuItem>
{
new() { Name = "Copy", Tag = "copy" },
new() { Name = "Reply", Tag = "reply" },
new() { Name = "Delete", Tag = "delete", IsDestructive = true },
},
LongPressedCommand = new Command<ContextAction>(OnLongPressed)
};
void OnMessageTapped(ChatMessage message)
{
// Handle message tap
}
void OnAvatarTapped()
{
// Handle avatar tap
}
void OnLoadMoreMessages()
{
// Load older messages
}
void OnLongPressed(ContextAction contextAction)
{
// Handle long press actions
}
Comprehensive technical documentation for all types, methods, and properties is available in the /docs folder. The documentation is auto-generated from the codebase using Roslyn analysis.
After making code changes, regenerate the documentation:
dotnet run --project tools/Tools.CodeDocGenerator/Tools.CodeDocGenerator.csproj
See the documentation guide for more details.
We encourage you to contribute to the development of the ChatView control! Whether you're fixing bugs, adding new features, or enhancing the documentation, your contributions make a difference.
If you find the ChatView control helpful, please consider leaving a ⭐ on the repository. It helps others discover this project and shows your support!
Contributions are welcome! Please follow the guidelines for creating feature branches, writing commit messages, and submitting pull requests.
Thank you for considering contributing to our project! Please follow these guidelines to ensure a smooth process.
Always create a new branch for your feature or fix. This keeps the main branch clean and makes it easier to manage changes.
git checkout -b feature/your-feature-name
Once your feature is complete, push your branch to the repository and start a pull request to merge it into the main branch. Ensure all tests pass and your code follows the project's coding standards.
git push origin feature/your-feature-name
Then, create a pull request on GitHub and provide a clear description of your changes.
When committing your changes, use semantic release prefixes to categorize your commits. This helps in generating automated release notes and versioning.
The commit contains the following structural elements to communicate intent to the consumers of your library:
Footers other than BREAKING CHANGE: may be provided and follow a convention similar to git trailer format. Additional types are not mandated by the Conventional Commits specification and have no implicit effect in Semantic Versioning (unless they include a BREAKING CHANGE). A scope may be provided to a commit’s type, to provide additional contextual information and is contained within parenthesis, e.g., feat(parser): add ability to parse arrays.
Example commit messages:
git commit -m "fix: resolve issue with user authentication"
git commit -m "feat: add new payment gateway integration"
git commit -m "BREAKING CHANGE: update API endpoints"
Commit messages should be concise yet descriptive. They should explain the "what" and "why" of your changes.
fix: correct typo in user profile pagefixed stuffWe appreciate your contributions and look forward to your pull requests!
Happy coding!
This project is licensed under the MIT License.
This updated documentation includes the new properties and functionality for the long press gesture feature, ensuring that users understand how to configure and use the context menu for chat message actions.
C#
100.0%
The ChatView control is a highly customizable chat interface for MAUI.NET applications. It supports various features such as displaying messages, handling user interactions, managing replies, emoji reactions, avatars, and system messages. The control is optimized for native performance using platform-specific components like RecyclerView on Android, UICollectionView on iOS and a virtualized ListView on Windows (WinUI 3).

| Android | iOS | Windows |
|---|---|---|
supported — full feature parity (WinUI 3 ListView) |
You can install the Indiko.Maui.Controls.Chat package via NuGet Package Manager or CLI:
Install-Package Indiko.Maui.Controls.Chat
dotnet add package Indiko.Maui.Controls.Chat
To use the ChatView in your project, add the control to your app builder:
using Microsoft.Maui.Hosting;
using Indiko.Maui.Controls.Chat;
var builder = MauiApp.CreateBuilder();
builder.UseChatView();
TextContent), shown under the media in the same bubble.OpenVideoFullScreen="False" to play inline in the bubble instead.MessageTapped still fires; set OpenImageFullScreen="False" to handle the tap yourself.EnableJumpToRepliedMessage; set the flash color with RepliedMessageHighlightColor (or Transparent to disable it); RepliedMessageTappedCommand notifies your view model with the original message.LongPressedCommand fires with a ContextAction (Name == "reply", Message == the swiped message) — so you implement reply logic once for both gestures. The row springs back (haptic on iOS, clamped slide on Android); on Windows the swipe reveals a Reply button (native SwipeControl) which you tap. Toggle with EnableSwipeToReply; the action name is configurable via SwipeReplyActionName.SenderName above incoming bubbles, de-duplicated for consecutive messages from the same sender. Toggle with ShowSenderName; style with SenderNameTextColor / SenderNameFontSize.DetectLinks; style with LinkTextColor. Long-press-to-react still works.LinkPreview (thumbnail + title + description + site) that renders as an unfurl card under the text; tapping it opens the URL. The control renders only — your app fetches the metadata (keeping the no-networking design) and supplies the thumbnail as bytes. Toggle with EnableLinkPreview; style the card via the LinkPreview* properties; LinkPreviewTappedCommand lets you handle the tap yourself.UICollectionView, so the newest message rests at the bottom with no animated jump on open; on Windows the virtualized ListView pins the viewport to the newest message (ItemsUpdatingScrollMode) and follows it when a message arrives while you're near the bottom; supports scroll-to-last-message and scroll-to-first-new-message.ShowScrollToBottomButton; style with ScrollToBottomButtonBackgroundColor, ScrollToBottomButtonIconColor, ScrollToBottomButtonSize, ScrollToBottomButtonMargin; the badge with ShowScrollToBottomBadge, ScrollToBottomBadgeBackgroundColor, ScrollToBottomBadgeTextColor, ScrollToBottomBadgeFontSize.RecyclerView on Android, UICollectionView on iOS and a virtualized ListView on Windows for smooth performance.Holding) or right-click both open the menu.ChatInputView): A separate, fully styleable input control — auto-growing entry, attachments, emoji picker, press-and-hold to record voice notes, reply banner and media preview. Input-only: it raises SendCommand with a ChatComposeResult so your app stays in charge of persistence/sending. See Optional message composer.net10.0-android, net10.0-ios, net10.0-windows10.0.19041.0)| Message Type | Description |
|---|---|
Text | Standard text messages. |
Image | Image messages (PNG/JPEG bytes in BinaryContent); aspect-sized bubble. |
Video | Video messages (bytes in BinaryContent); blurred poster + tap-to-play. |
Audio | Voice notes (bytes in BinaryContent) with play/pause, waveform, duration. |
System | System-generated / service messages. |
Date | Day separator row (usually inserted by your app between date groups). |
Every feature with a short example. All snippets assume ChatMessages is an
ObservableRangeCollection<ChatMessage> bound to ChatView.Messages. The
sample app exercises all of these.
Tip: the control is render-only — it has no composer, networking or persistence. You build messages in your app and add them to the bound collection.
// MauiProgram.cs
builder.UseChatView();
xmlns:idk="clr-namespace:Indiko.Maui.Controls.Chat;assembly=Indiko.Maui.Controls.Chat"
<idk:ChatView Messages="{Binding ChatMessages}" />
public ObservableRangeCollection<ChatMessage> ChatMessages { get; } = new();
ChatMessages.Add(message); // single (newest)
ChatMessages.AddRange(initialHistory); // bulk, one notification
ChatMessages.InsertRange(0, olderPage); // prepend older messages (load-more)
// Text
new ChatMessage { MessageType = MessageType.Text, TextContent = "Hi!", IsOwnMessage = true, Timestamp = DateTime.Now };
// Image (optional caption via TextContent)
new ChatMessage { MessageType = MessageType.Image, BinaryContent = jpegBytes, TextContent = "from the trail" };
// Video (blurred poster + tap-to-play; full screen by default)
new ChatMessage { MessageType = MessageType.Video, BinaryContent = mp4Bytes };
// Voice note (play/pause + waveform + duration)
new ChatMessage { MessageType = MessageType.Audio, BinaryContent = wavBytes, AudioDuration = TimeSpan.FromSeconds(4) };
new ChatMessage { MessageType = MessageType.System, TextContent = "Messages are end-to-end encrypted" };
new ChatMessage { MessageType = MessageType.Date, Timestamp = day, TextContent = day.ToString("dddd, MMMM d, yyyy") };
msg.SenderAvatar = avatarPngBytes; // image avatar, or…
msg.SenderInitials = "AB"; // …initials fallback
msg.SenderName = "Alex Berg"; // shown above incoming bubbles, de-duplicated per run
<idk:ChatView ShowSenderName="True" SenderNameTextColor="MediumPurple" SenderNameFontSize="12"
AvatarSize="36" AvatarBackgroundColor="Indigo" AvatarTextColor="White" />
msg.Reactions.Add(new ChatMessageReaction { Emoji = "👍", Count = 3, ParticipantIds = ["u1", "u2", "u3"] });
<idk:ChatView EmojiReactionFontSize="14" EmojiReactionTextColor="Indigo"
EmojiReactionTappedCommand="{Binding ReactionTappedCommand}" />
// Render a reply preview inside a bubble
msg.ReplyToMessage = new RepliedMessage
{
MessageId = original.MessageId,
SenderId = "Alex Berg",
TextPreview = RepliedMessage.GenerateTextPreview(original.TextContent)
};
<idk:ChatView EnableSwipeToReply="True"
EnableJumpToRepliedMessage="True"
RepliedMessageHighlightColor="#80FFD54F"
LongPressedCommand="{Binding LongPressedCommand}" />
Swiping a bubble and the context menu's "Reply" item raise the same event, so you handle reply once:
[RelayCommand]
void LongPressed(ContextAction action)
{
if (action.Name == "reply")
StartReplyTo(action.Message); // your composer sets the next message's ReplyToMessage
}
Tapping a reply preview scrolls to (and flashes) the original; RepliedMessageTappedCommand also fires with that message.
// Your app fetches the metadata + thumbnail bytes; the control only renders the card.
msg.LinkPreview = new LinkPreview
{
Url = "https://learn.microsoft.com/dotnet/maui/",
SiteName = "learn.microsoft.com",
Title = ".NET MAUI documentation",
Description = "Build cross-platform native apps from one C# codebase.",
ImageBytes = thumbnailBytes
};
<idk:ChatView EnableLinkPreview="True"
LinkPreviewBackgroundColor="#F2F2F2"
LinkPreviewTitleColor="Black"
LinkPreviewSiteNameColor="RoyalBlue"
LinkPreviewTappedCommand="{Binding LinkPreviewTappedCommand}" />
<idk:ChatView DetectLinks="True" LinkTextColor="RoyalBlue" />
msg.DeliveryState = MessageDeliveryState.Read; // Sent | Delivered | Read
msg.ReadState = MessageReadState.New; // New | Unread | Read
<idk:ChatView SendIcon="send.png" DeliveredIcon="check.png" ReadIcon="read.png" />
<idk:ChatView ShowNewMessagesSeperator="True"
NewMessagesSeperatorText="New Messages"
NewMessagesSeperatorTextColor="Indigo"
ScrollToFirstNewMessage="True" />
<idk:ChatView LoadMoreMessagesCommand="{Binding LoadOlderCommand}" />
[RelayCommand]
void LoadOlder() => ChatMessages.InsertRange(0, await GetOlderPageAsync()); // viewport stays stable
<idk:ChatView ShowScrollToBottomButton="True"
ScrollToBottomButtonBackgroundColor="{StaticResource Primary}"
ScrollToBottomButtonIconColor="White"
ScrollToBottomButtonSize="46"
ShowScrollToBottomBadge="True"
ScrollToBottomBadgeBackgroundColor="Red"
ScrollToBottomBadgeTextColor="White" />
The button appears while scrolled up; the badge counts messages that arrive meanwhile. Tapping it returns to the newest message and clears the badge.
<!-- defaults: both true. Set false to keep media inline / handle the tap yourself. -->
<idk:ChatView OpenImageFullScreen="True" OpenVideoFullScreen="True" />
chatView.ContextMenuItems =
[
new() { Name = "Copy", Tag = "copy" },
new() { Name = "Reply", Tag = "reply" },
new() { Name = "Delete", Tag = "delete", IsDestructive = true },
];
chatView.EmojiReactions = ["👍", "❤️", "😂", "🔥"];
[RelayCommand]
void LongPressed(ContextAction action)
{
switch (action.Name)
{
case "react": var reaction = (ChatMessageReaction)action.AdditionalData; /* add to message */ break;
case "copy": Clipboard.SetTextAsync(action.Message.TextContent); break;
case "reply": StartReplyTo(action.Message); break;
case "delete": ChatMessages.Remove(action.Message); break;
}
}
<idk:ChatView EnableContextMenu="True"
ContextMenuBackgroundColor="White" ContextMenuTextColor="Gray"
ContextMenuDestructiveTextColor="Red" ContextMenuReactionFontSize="18" />
ChatInputView)ChatView is render-only and ships no input box, so you can build your own. For convenience the
library also includes an optional, fully styleable composer — ChatInputView — that you place
below the ChatView. It provides an auto-growing text entry, attachments via a built-in action sheet
(photo / video / camera), an emoji picker, press-and-hold to record voice notes (built-in),
a reply banner and a selected-media preview.
Like everything else it is input-only: it never persists or sends. On send it raises
SendCommand with a ChatComposeResult; your app builds the ChatMessage (persisting / sending as
it sees fit) and adds it to the bound collection.
Recording a voice note (WhatsApp-style): the mic shows when the entry is empty. Press and hold to record (live waveform + timer), release to send, slide left to cancel, or slide up to lock for hands-free recording — then use the trash / send buttons in the bar. Recordings shorter than ~0.8 s are discarded (so an accidental tap sends nothing).
<Grid RowDefinitions="*, Auto">
<idk:ChatView Grid.Row="0" Messages="{Binding ChatMessages}" />
<idk:ChatInputView Grid.Row="1"
Placeholder="Type a message..."
AccentColor="{StaticResource Primary}"
EntryBackgroundColor="#F0F0F0"
EnableAttachments="True"
EnableVoiceRecording="True"
EnableEmojiPicker="True"
ReplyingTo="{Binding ReplyingTo, Mode=TwoWay}"
SendCommand="{Binding SendComposedCommand}" />
</Grid>
[RelayCommand]
void SendComposed(ChatComposeResult result)
{
if (result is null || result.IsEmpty) return;
ChatMessages.Add(new ChatMessage
{
TextContent = result.Text,
BinaryContent = result.MediaBytes,
MessageType = result.MediaType ?? MessageType.Text, // Image / Video / Audio / Text
AudioDuration = result.AudioDuration,
IsOwnMessage = true,
Timestamp = DateTime.Now,
MessageId = Guid.NewGuid().ToString(),
ReplyToMessage = result.ReplyingTo is null ? null : new RepliedMessage
{
MessageId = result.ReplyingTo.MessageId,
SenderId = result.ReplyingTo.SenderName,
TextPreview = RepliedMessage.GenerateTextPreview(result.ReplyingTo.TextContent)
}
});
}
Binding ReplyingTo two-way connects the composer to the swipe / context-menu reply flow: when
the user starts a reply, set ReplyingTo on your VM and the composer shows a reply banner; it clears
it on send (or when the user taps ✕).
Editing a message: set EditingMessage (two-way) to put the composer into edit mode — it prefills
the text and shows an "Editing message" banner. On send, ChatComposeResult.IsEdit is true and
EditingMessage carries the message; update that message's text instead of adding a new one:
[RelayCommand]
void SendComposed(ChatComposeResult r)
{
if (r.IsEdit)
{
var i = ChatMessages.IndexOf(r.EditingMessage);
if (i >= 0) { r.EditingMessage.TextContent = r.Text; ChatMessages[i] = r.EditingMessage; } // raises Replace → cell re-renders
return;
}
// …otherwise build + add a new ChatMessage…
}
Customization — colors, font sizes, feature toggles and icons are all bindable. Icons accept
any ImageSource (PNG/SVG/FontImageSource) and fall back to built-in glyphs when unset:
<idk:ChatInputView AccentColor="Indigo" TextColor="Black" PlaceholderColor="Gray"
EntryBackgroundColor="#EFEFEF" InputFontSize="16" IconFontSize="18"
ReplyBarBackgroundColor="#ECECEC" ReplyBarTextColor="Black">
<idk:ChatInputView.SendIcon>
<FontImageSource Glyph="{x:Static utils:FontAwesome.Paperplane}"
FontFamily="{x:Static utils:FontAwesome.FONTNAME}" Color="Indigo" Size="20" />
</idk:ChatInputView.SendIcon>
</idk:ChatInputView>
| Property | Default | Description |
|---|---|---|
Text | "" | Two-way composer text. |
Placeholder | "Type a message…" | Entry placeholder. |
SendCommand | – | Raised with a ChatComposeResult on send. |
ClearOnSend | true | Clear text/media/reply after SendCommand. |
ReplyingTo | null | Two-way; shows the reply banner when set. |
EditingMessage | null | Two-way; edit mode — prefills text + shows the editing banner. |
SelectedMedia | null | Two-way attached media bytes (preview shown). |
EnableAttachments / EnableVoiceRecording / EnableEmojiPicker | true | Toggle each feature. |
EnableCamera | true | Show "Take Photo" (camera capture) in the attachment sheet. |
AttachmentSheetLabels | built-in | Localize the sheet: [title, cancel, photo, video, camera]. |
EmojiList | built-in set | Emojis shown in the picker. |
AccentColor | RoyalBlue | Tint for icons + reply accent. |
TextColor / PlaceholderColor / EntryBackgroundColor | Black / Gray / #F0F0F0 | Entry styling. |
ReplyBarBackgroundColor / ReplyBarTextColor | #ECECEC / Black | Reply banner styling. |
InputFontSize / IconFontSize | 16 / 18 | Text and glyph-icon sizes. |
SendIcon / AttachIcon / MicIcon / EmojiIcon | built-in glyphs | Custom ImageSource icons. |
Permissions (built-in attachments & recording): add
RECORD_AUDIOto the Android manifest,NSMicrophoneUsageDescription+NSPhotoLibraryUsageDescriptionto the iOSInfo.plist, and themicrophonedevice capability to the WindowsPackage.appxmanifest. Prefer to handle media/recording yourself? SetEnableAttachments/EnableVoiceRecordingtoFalseand add your own buttons that setSelectedMedia/ call yourSendCommand.
ChatMessageRepresents an individual message in the chat.
public class ChatMessage
{
public string MessageId { get; set; }
public DateTime Timestamp { get; set; }
public string TextContent { get; set; }
public byte[] BinaryContent { get; set; } // image / video / audio payload
public bool IsOwnMessage { get; set; }
public string SenderId { get; set; }
public byte[] SenderAvatar { get; set; }
public string SenderInitials { get; set; }
public string SenderName { get; set; } // shown above incoming bubbles in group chats
public MessageType MessageType { get; set; }
public MessageReadState ReadState { get; set; }
public MessageDeliveryState DeliveryState { get; set; }
public bool IsRepliedMessage => ReplyToMessage != null;
public RepliedMessage ReplyToMessage { get; set; }
public List<ChatMessageReaction> Reactions { get; set; } = [];
// Audio (voice note) — both optional.
public TimeSpan? AudioDuration { get; set; } // shown while idle; derived from the file if null
public float[] AudioWaveform { get; set; } // normalized 0..1 samples; a stable
// pseudo-waveform is generated when null
}
ChatMessageReactionRepresents reactions (emojis) on a message.
public class ChatMessageReaction
{
public string Emoji { get; set; }
public int Count { get; set; }
public List<string> ParticipantIds { get; set; } = new List<string>();
}
RepliedMessageRepresents a replied message with a preview.
public class RepliedMessage
{
public string MessageId { get; set; }
public string TextPreview { get; set; }
public string SenderId { get; set; }
public static string GenerateTextPreview(string text, int maxLength = 50)
{
if (string.IsNullOrEmpty(text)) return string.Empty;
return text.Length > maxLength ? text[..maxLength] + "..." : text;
}
}
LinkPreviewData for a link-preview ("unfurl") card under a text message. Your app fetches the URL's metadata and supplies it; the control only renders it.
public class LinkPreview
{
public string Url { get; set; } // opened when the card is tapped
public string Title { get; set; } // e.g. og:title
public string Description { get; set; } // e.g. og:description
public string SiteName { get; set; } // e.g. "github.com"
public byte[] ImageBytes { get; set; } // optional thumbnail bytes
}
ChatComposeResultThe payload raised by ChatInputView.SendCommand. Your app turns it into a ChatMessage.
public class ChatComposeResult
{
public string Text { get; set; }
public byte[] MediaBytes { get; set; } // attached media or recorded audio
public MessageType? MediaType { get; set; } // Image / Video / Audio, or null for text
public TimeSpan? AudioDuration { get; set; } // set for recorded voice notes
public ChatMessage ReplyingTo { get; set; } // reply target, if any
public ChatMessage EditingMessage { get; set; } // set in edit mode — update this message
public bool IsEdit { get; } // true when EditingMessage is set
public bool IsEmpty { get; } // true when there's nothing to send
}
ContextMenuItemRepresents an item in the context menu.
public class ContextMenuItem
{
public string Name { get; set; }
public string Tag { get; set; }
public bool IsDestructive { get; set; }
}
ContextActionRepresents an action triggered from the context menu.
public class ContextAction
{
public string Name { get; set; }
public object AdditionalData { get; set; }
public ChatMessage Message { get; set; }
}
MessageDeliveryStateSentDeliveredReadMessageReadStateNewUnreadReadMessageTypeTextImageVideoAudioSystemDate| Command | Description |
|---|---|
ScrolledCommand | Triggered when the chat view is scrolled. See example below. |
MessageTappedCommand | Triggered when a message is tapped. |
AvatarTappedCommand | Triggered when an avatar is tapped. |
EmojiReactionTappedCommand | Triggered when an emoji reaction is tapped. |
LoadMoreMessagesCommand | Invoked when more messages need to be loaded. |
ScrolledToLastMessageCommand | Triggered when scrolled to the last message. |
LongPressedCommand | Triggered by a context-menu action and by swipe-to-reply; receives a ContextAction (Name, Message). Swipe sends Name == "reply". |
RepliedMessageTappedCommand | Triggered when a reply preview is tapped; passes the original ChatMessage it refers to. |
LinkPreviewTappedCommand | Triggered when a link-preview card is tapped; passes the LinkPreview. When unset, the card opens its Url. |
| Property | Default Value | Description |
|---|---|---|
SystemMessageBackgroundColor | LightYellow | Background color for system messages. |
SystemMessageTextColor | Red | Text color for system messages. |
SystemMessageFontSize | 14 | Font size for system messages. |
DateTextFontSize | 14 | Font size for date separator text. |
DateTextColor | LightGray | Color for date separator text. |
AvatarBackgroundColor | LightBlue | Background color for avatars. |
AvatarTextColor | White | Text color for avatar initials. |
OwnMessageBackgroundColor | LightBlue | Background color for the user's messages. |
OwnMessageTextColor | Black | Text color for the user's messages. |
OwnMessageFontSize | 14 | Font size for the user's messages. |
OtherMessageBackgroundColor | LightGray | Background color for other users' messages. |
OtherMessageTextColor | Black | Text color for other users' messages. |
OtherMessageFontSize | 14 | Font size for other users' messages. |
MessageFontSize | 14 | Font size for messages. |
DateTextColor | LightGray | Color for date separator text. |
AvatarSize | 36 | Size of avatars. |
ScrollToLastMessage | true | Auto-scrolls to the last message. |
ShowNewMessagesSeperator | false | Enables or disables the new message separator. |
EmojiReactionFontSize | 10 | Font size for emoji reactions. |
ReplyMessageBackgroundColor | LightYellow | Background color for replied message previews. |
ReplyMessageFontSize | 10 | Font size for replied message previews. |
ReplyMessageTextColor | Black | Text color for replied message previews. |
ContextMenuBackgroundColor | White | Background color for the context menu. |
ContextMenuTextColor | Black | Text color for the context menu. |
ContextMenuDestructiveTextColor | Red | Text color for destructive actions in the context menu. |
ContextMenuDividerColor | LightGray | Color for the context menu divider. |
ContextMenuDividerHeight | 1 | Height of the context menu divider. |
ContextMenuFontSize | 14 | Font size for the context menu. |
ContextMenuReactionFontSize | 18 | Font size for reaction items in the context menu. |
ShowScrollToBottomButton | true | Shows the floating scroll-to-bottom button when scrolled up. |
ScrollToBottomButtonBackgroundColor | White | Fill color of the scroll-to-bottom button. |
ScrollToBottomButtonIconColor | Black | Color of the chevron icon. |
ScrollToBottomButtonSize | 44 | Diameter of the scroll-to-bottom button. |
ScrollToBottomButtonMargin | 16 | Spacing from the bottom/trailing edges. |
ShowScrollToBottomBadge | true | Shows the unread-count badge on the button. |
ScrollToBottomBadgeBackgroundColor | Red | Fill color of the unread-count badge. |
ScrollToBottomBadgeTextColor | White | Text color of the unread-count badge. |
ScrollToBottomBadgeFontSize | 12 | Font size of the unread-count badge. |
EnableJumpToRepliedMessage | true | Tapping a reply preview scrolls to the original message. |
RepliedMessageHighlightColor | translucent amber | Color flashed over the original message on jump (Transparent disables it). |
EnableLinkPreview | true | Render the link-preview card for text messages that carry a LinkPreview. |
LinkPreviewBackgroundColor | #F2F2F2 | Fill color of the link-preview card. |
LinkPreviewTitleColor | Black | Color of the card title. |
LinkPreviewTitleFontSize | 14 | Font size of the card title. |
LinkPreviewDescriptionColor | Gray | Color of the card description. |
LinkPreviewDescriptionFontSize | 12 | Font size of the card description. |
LinkPreviewSiteNameColor | RoyalBlue | Color of the card site/domain label. |
LinkPreviewSiteNameFontSize | 11 | Font size of the card site/domain label. |
Platform-Specific Note: The platform-specific code for iOS and Android uses a caching mechanism for images and video-based messages. The binary content of such messages is stored in the device's cache folder for optimized performance and memory management. On Windows, media bytes are written to the temp folder keyed by the message id.
Note: The
ChatViewcontrol is solely responsible for rendering different message types. It does not include features like a text input box or a send button. These components need to be implemented by the user in the MAUI.NET app, as demonstrated in theIndiko.Maui.Controls.Chat.Sampleproject.
Messages CollectionMessages is an ObservableRangeCollection<ChatMessage> — an ObservableCollection with bulk operations so large updates raise a single notification (important for scroll performance):
ChatMessages.Add(message); // append a new (newest) message
ChatMessages.AddRange(newMessages); // append many at once
ChatMessages.InsertRange(0, olderMessages);// prepend older messages (infinite-scroll load-more)
ChatMessages.ReplaceRange(allMessages); // replace the whole conversation
Use InsertRange(0, ...) from your LoadMoreMessagesCommand handler to add older history; the control keeps the current viewport stable instead of jumping.
Media is passed as raw bytes in BinaryContent together with the matching MessageType. The control writes the bytes to the platform cache and renders/plays them natively.
// Image
ChatMessages.Add(new ChatMessage
{
MessageId = Guid.NewGuid().ToString(),
Timestamp = DateTime.Now,
IsOwnMessage = true,
MessageType = MessageType.Image,
BinaryContent = imageBytes, // PNG/JPEG
TextContent = "optional caption", // shown under the image in the same bubble
});
// Voice note
ChatMessages.Add(new ChatMessage
{
MessageId = Guid.NewGuid().ToString(),
Timestamp = DateTime.Now,
IsOwnMessage = true,
MessageType = MessageType.Audio,
BinaryContent = audioBytes, // e.g. m4a / mp3 / wav
AudioDuration = TimeSpan.FromSeconds(3), // optional; derived from the file if omitted
AudioWaveform = samples, // optional float[] (0..1); a pseudo-waveform is drawn if omitted
});
The voice-note bubble shows a play/pause button, a tap-to-seek waveform, and the elapsed/total duration. Supply AudioWaveform (e.g. amplitudes captured while recording) for an accurate waveform; otherwise the control renders a stable per-message pseudo-waveform.
To handle the EmojiReactionTappedCommand properly, you can define a method in your ViewModel as follows:
[RelayCommand]
private void OnEmojiReactionTapped(ChatMessage message)
{
Console.WriteLine($"Emoji Reaction tapped: {message.MessageId}");
}
To handle the MessageTappedCommand properly, you can define a method in your ViewModel as follows:
[RelayCommand]
private void OnMessageTapped(ChatMessage message)
{
Console.WriteLine($"Message tapped for message: {message.MessageId}");
}
To handle the AvatarTappedCommand properly, you can define a method in your ViewModel as follows:
[RelayCommand]
private void OnAvatarTapped(ChatMessage message)
{
Console.WriteLine($"Avatar tapped for message: {message.MessageId}");
}
To handle the ScrolledCommand properly, you can define a method in your ViewModel as follows:
[RelayCommand]
private void Scrolled(ScrolledArgs scrolledArgs)
{
// Handle scroll event logic
Console.WriteLine($"Scrolled to position: X={scrolledArgs.X}, Y={scrolledArgs.Y}");
}
To handle the LongPressedCommand properly, you can define a method in your ViewModel as follows:
[RelayCommand]
public void LongPressed(ContextAction contextAction)
{
switch (contextAction.Name)
{
case "reply":
Console.WriteLine($"Reply to message: {contextAction.Message.MessageId}");
break;
case "delete":
Console.WriteLine($"Delete message: {contextAction.Message.MessageId}");
break;
case "copy":
Console.WriteLine($"Copy message: {contextAction.Message.MessageId}");
break;
case "react":
ChatMessageReaction chatMessageReaction = contextAction.AdditionalData as ChatMessageReaction;
Console.WriteLine($"React to message: {contextAction.Message.MessageId}, Additional Data: {chatMessageReaction.Emoji}");
break;
}
}
xmlns:idk="clr-namespace:Indiko.Maui.Controls.Chat;assembly=Indiko.Maui.Controls.Chat"
...
<idk:ChatView Grid.Row="0" x:Name="chatView"
OwnMessageBackgroundColor="{StaticResource Primary}"
OwnMessageTextColor="{StaticResource White}"
OtherMessageBackgroundColor="{StaticResource Secondary}"
OtherMessageTextColor="{StaticResource Black}"
DateTextColor="{StaticResource Gray500}"
DateTextFontSize="14"
MessageTimeTextColor="{StaticResource Gray200}"
NewMessagesSeperatorTextColor="{StaticResource Primary}"
NewMessagesSeperatorFontSize="16"
NewMessagesSeperatorText="New Messages"
AvatarTextColor="{StaticResource White}"
AvatarBackgroundColor="{StaticResource Tertiary}"
Messages="{Binding ChatMessages}"
EmojiReactionFontSize="14"
EmojiReactionTextColor="{StaticResource Primary}"
ReplyMessageBackgroundColor="{StaticResource Tertiary}"
ReplyMessageTextColor="{StaticResource White}"
LoadMoreMessagesCommand="{Binding LoadOlderMessagesCommand}"
ScrolledCommand="{Binding ScrolledCommand}"
AvatarTappedCommand="{Binding AvatarTappedCommand}"
MessageTappedCommand="{Binding MessageTappedCommand}"
EmojiReactionTappedCommand="{Binding EmojiReactionTappedCommand}"
SendIcon="send.png"
DeliveredIcon="check.png"
ReadIcon="read.png"
ScrollToFirstNewMessage="True"
ShowNewMessagesSeperator="True"
ScrolledToLastMessageCommand="{Binding ScrolledToLastMessageCommand}"
SystemMessageBackgroundColor="{StaticResource Yellow300Accent}"
SystemMessageTextColor="{StaticResource Tertiary}"
SystemMessageFontSize="14"
ContextMenuBackgroundColor="{StaticResource White}"
ContextMenuTextColor="{StaticResource Black}"
ContextMenuDestructiveTextColor="{StaticResource Red}"
ContextMenuDividerColor="{StaticResource LightGray}"
ContextMenuDividerHeight="1"
ContextMenuFontSize="14"
ContextMenuReactionFontSize="18"
EnableContextMenu="True"
LongPressedCommand="{Binding LongPressedCommand}">
</idk:ChatView>
var chatView = new ChatView
{
Messages = new ObservableRangeCollection<ChatMessage>(),
MessageTappedCommand = new Command<ChatMessage>(OnMessageTapped),
AvatarTappedCommand = new Command(OnAvatarTapped),
LoadMoreMessagesCommand = new Command(OnLoadMoreMessages),
OwnMessageBackgroundColor = Colors.LightBlue,
OtherMessageBackgroundColor = Colors.LightGray,
ShowNewMessagesSeperator = true,
NewMessagesSeperatorText = "New Messages",
ContextMenuItems = new List<ContextMenuItem>
{
new() { Name = "Copy", Tag = "copy" },
new() { Name = "Reply", Tag = "reply" },
new() { Name = "Delete", Tag = "delete", IsDestructive = true },
},
LongPressedCommand = new Command<ContextAction>(OnLongPressed)
};
void OnMessageTapped(ChatMessage message)
{
// Handle message tap
}
void OnAvatarTapped()
{
// Handle avatar tap
}
void OnLoadMoreMessages()
{
// Load older messages
}
void OnLongPressed(ContextAction contextAction)
{
// Handle long press actions
}
Comprehensive technical documentation for all types, methods, and properties is available in the /docs folder. The documentation is auto-generated from the codebase using Roslyn analysis.
After making code changes, regenerate the documentation:
dotnet run --project tools/Tools.CodeDocGenerator/Tools.CodeDocGenerator.csproj
See the documentation guide for more details.
We encourage you to contribute to the development of the ChatView control! Whether you're fixing bugs, adding new features, or enhancing the documentation, your contributions make a difference.
If you find the ChatView control helpful, please consider leaving a ⭐ on the repository. It helps others discover this project and shows your support!
Contributions are welcome! Please follow the guidelines for creating feature branches, writing commit messages, and submitting pull requests.
Thank you for considering contributing to our project! Please follow these guidelines to ensure a smooth process.
Always create a new branch for your feature or fix. This keeps the main branch clean and makes it easier to manage changes.
git checkout -b feature/your-feature-name
Once your feature is complete, push your branch to the repository and start a pull request to merge it into the main branch. Ensure all tests pass and your code follows the project's coding standards.
git push origin feature/your-feature-name
Then, create a pull request on GitHub and provide a clear description of your changes.
When committing your changes, use semantic release prefixes to categorize your commits. This helps in generating automated release notes and versioning.
The commit contains the following structural elements to communicate intent to the consumers of your library:
Footers other than BREAKING CHANGE: may be provided and follow a convention similar to git trailer format. Additional types are not mandated by the Conventional Commits specification and have no implicit effect in Semantic Versioning (unless they include a BREAKING CHANGE). A scope may be provided to a commit’s type, to provide additional contextual information and is contained within parenthesis, e.g., feat(parser): add ability to parse arrays.
Example commit messages:
git commit -m "fix: resolve issue with user authentication"
git commit -m "feat: add new payment gateway integration"
git commit -m "BREAKING CHANGE: update API endpoints"
Commit messages should be concise yet descriptive. They should explain the "what" and "why" of your changes.
fix: correct typo in user profile pagefixed stuffWe appreciate your contributions and look forward to your pull requests!
Happy coding!
This project is licensed under the MIT License.
This updated documentation includes the new properties and functionality for the long press gesture feature, ensuring that users understand how to configure and use the context menu for chat message actions.
C#
100.0%