diff --git a/Core/Resgrid.Config/ChatConfig.cs b/Core/Resgrid.Config/ChatConfig.cs index b64138ff..6e643aa5 100644 --- a/Core/Resgrid.Config/ChatConfig.cs +++ b/Core/Resgrid.Config/ChatConfig.cs @@ -24,17 +24,24 @@ public static class ChatConfig /// Novu workflow triggered for realtime chat message push notifications. public static string NovuChatWorkflowId = "user-chat-message"; - /// GIF search provider: "giphy" or "tenor". Empty disables GIF search. + /// + /// GIF search provider: "giphy" is the only supported value (Tenor stopped accepting new API + /// clients in January 2026 and was removed). Empty disables GIF search. + /// public static string GifProvider = ""; public static string GiphyApiKey = ""; - public static string TenorApiKey = ""; + + /// + /// Giphy content-rating cap for GIF search/trending results. Allowed values: "g", "pg", + /// "pg-13" — anything else (including "r") falls back to the workplace-safe default "g". + /// + public static string GifRating = "g"; /// Allowed CDN hosts for GIF message metadata urls (https only); anything else is dropped server-side. public static string[] GifCdnHosts = new[] { "giphy.com", "i.giphy.com", "media.giphy.com", - "media0.giphy.com", "media1.giphy.com", "media2.giphy.com", "media3.giphy.com", "media4.giphy.com", - "tenor.com", "media.tenor.com", "c.tenor.com" + "media0.giphy.com", "media1.giphy.com", "media2.giphy.com", "media3.giphy.com", "media4.giphy.com" }; public static int MaxMessageLength = 4000; diff --git a/Core/Resgrid.Model/Providers/IGifProvider.cs b/Core/Resgrid.Model/Providers/IGifProvider.cs index 7fa7ff48..d32ae29e 100644 --- a/Core/Resgrid.Model/Providers/IGifProvider.cs +++ b/Core/Resgrid.Model/Providers/IGifProvider.cs @@ -17,8 +17,8 @@ public class GifSearchResult } /// - /// Server-side GIF search proxy (Giphy or Tenor per ChatConfig.GifProvider) so provider API keys - /// never ship to clients. + /// Server-side GIF search proxy (Giphy, per ChatConfig.GifProvider) so provider API keys never + /// ship to clients. Results are capped to a workplace-safe content rating (ChatConfig.GifRating). /// public interface IGifProvider { diff --git a/Core/Resgrid.Services/ChatMessageService.cs b/Core/Resgrid.Services/ChatMessageService.cs index 9a716e06..90a49b9d 100644 --- a/Core/Resgrid.Services/ChatMessageService.cs +++ b/Core/Resgrid.Services/ChatMessageService.cs @@ -640,7 +640,9 @@ private async Task ProvisionAcksAsync(ChatChannel channel, ChatMessage message, RequiredOn = message.SentOn }), cancellationToken); - PublishEvent(channel, ChatEventKinds.AckRequired, new { message.ChatMessageId, message.ChatChannelId, message.MessageSeq, RequiredCount = requiredUserIds.Count }); + // SenderUserId lets clients skip the "acknowledge" banner for the sender's own message + // (the sender has no ack row — see requiredUserIds above). + PublishEvent(channel, ChatEventKinds.AckRequired, new { message.ChatMessageId, message.ChatChannelId, message.MessageSeq, RequiredCount = requiredUserIds.Count, message.SenderUserId }); } private async Task SaveEditHistoryAsync(ChatMessage message, ChatMessageEditType editType, string byUserId, CancellationToken cancellationToken) diff --git a/Providers/Resgrid.Providers.Messaging/GifProvider.cs b/Providers/Resgrid.Providers.Messaging/GifProvider.cs index 6769e85b..9fa83958 100644 --- a/Providers/Resgrid.Providers.Messaging/GifProvider.cs +++ b/Providers/Resgrid.Providers.Messaging/GifProvider.cs @@ -12,9 +12,9 @@ namespace Resgrid.Providers.Messaging { /// - /// GIF search proxy for chat. Talks to Giphy or Tenor (per ChatConfig.GifProvider) server-side so - /// the API key never reaches clients. Failures return empty result sets — GIF search is never a - /// hard dependency. + /// GIF search proxy for chat. Talks to Giphy server-side so the API key never reaches clients. + /// Results are capped to a workplace-safe content rating (ChatConfig.GifRating, "g" by default). + /// Failures return empty result sets — GIF search is never a hard dependency. /// public class GifProvider : IGifProvider { @@ -26,6 +26,9 @@ public class GifProvider : IGifProvider private static readonly TimeSpan SearchCacheDuration = TimeSpan.FromSeconds(60); private const int MaxOffset = 5000; + // Giphy content ratings this proxy will ever request; "r" is deliberately not allowed. + private static readonly string[] AllowedRatings = { "g", "pg", "pg-13" }; + private readonly ICacheProvider _cacheProvider; public GifProvider(ICacheProvider cacheProvider) @@ -40,9 +43,6 @@ public bool IsConfigured if (string.Equals(ChatConfig.GifProvider, "giphy", StringComparison.OrdinalIgnoreCase)) return !string.IsNullOrWhiteSpace(ChatConfig.GiphyApiKey); - if (string.Equals(ChatConfig.GifProvider, "tenor", StringComparison.OrdinalIgnoreCase)) - return !string.IsNullOrWhiteSpace(ChatConfig.TenorApiKey); - return false; } } @@ -54,16 +54,13 @@ public async Task> SearchAsync(string query, int limit, in // Short per-query cache: identical searches are common (picker re-open, scroll re-fetch) // and each uncached call burns provider API quota. - var cacheKey = $"gifsearch:{ChatConfig.GifProvider?.ToLowerInvariant()}:{query.Trim().ToLowerInvariant()}:{Clamp(limit)}:{ClampOffset(offset)}"; + var cacheKey = $"gifsearch:giphy:{Rating()}:{query.Trim().ToLowerInvariant()}:{Clamp(limit)}:{ClampOffset(offset)}"; async Task> search() { try { - if (string.Equals(ChatConfig.GifProvider, "tenor", StringComparison.OrdinalIgnoreCase)) - return await TenorRequestAsync($"https://tenor.googleapis.com/v2/search?q={Uri.EscapeDataString(query)}&key={ChatConfig.TenorApiKey}&limit={Clamp(limit)}&pos={ClampOffset(offset)}"); - - return await GiphyRequestAsync($"https://api.giphy.com/v1/gifs/search?api_key={ChatConfig.GiphyApiKey}&q={Uri.EscapeDataString(query)}&limit={Clamp(limit)}&offset={ClampOffset(offset)}&rating=pg-13"); + return await GiphyRequestAsync($"https://api.giphy.com/v1/gifs/search?api_key={ChatConfig.GiphyApiKey}&q={Uri.EscapeDataString(query)}&limit={Clamp(limit)}&offset={ClampOffset(offset)}&rating={Rating()}"); } catch (Exception ex) { @@ -85,10 +82,7 @@ public async Task> TrendingAsync(int limit) try { - if (string.Equals(ChatConfig.GifProvider, "tenor", StringComparison.OrdinalIgnoreCase)) - return await TenorRequestAsync($"https://tenor.googleapis.com/v2/featured?key={ChatConfig.TenorApiKey}&limit={Clamp(limit)}"); - - return await GiphyRequestAsync($"https://api.giphy.com/v1/gifs/trending?api_key={ChatConfig.GiphyApiKey}&limit={Clamp(limit)}&rating=pg-13"); + return await GiphyRequestAsync($"https://api.giphy.com/v1/gifs/trending?api_key={ChatConfig.GiphyApiKey}&limit={Clamp(limit)}&rating={Rating()}"); } catch (Exception ex) { @@ -97,6 +91,13 @@ public async Task> TrendingAsync(int limit) } } + /// Sanitized content rating: only g/pg/pg-13 ever reach the provider; default "g". + private static string Rating() + { + var rating = ChatConfig.GifRating?.Trim().ToLowerInvariant(); + return AllowedRatings.Contains(rating) ? rating : "g"; + } + // Belt-and-suspenders scrub for key=/api_key= query params. A bounded match timeout caps regex work // on pathological input (ReDoS guard); on timeout the literal-key replacements have already removed // the real secrets, so falling through without the query-param scrub is safe. @@ -114,9 +115,6 @@ private static void LogSanitizedException(Exception ex) if (!string.IsNullOrWhiteSpace(ChatConfig.GiphyApiKey)) text = text.Replace(ChatConfig.GiphyApiKey, "***"); - if (!string.IsNullOrWhiteSpace(ChatConfig.TenorApiKey)) - text = text.Replace(ChatConfig.TenorApiKey, "***"); - try { text = KeyQueryParamRegex.Replace(text, "$1=***"); @@ -148,32 +146,6 @@ private static async Task> GiphyRequestAsync(string url) .ToList(); } - private static async Task> TenorRequestAsync(string url) - { - var json = await _httpClient.GetStringAsync(url); - var payload = JObject.Parse(json); - - return (payload["results"] as JArray ?? new JArray()) - .Select(item => - { - var gif = item.SelectToken("media_formats.gif") ?? item.SelectToken("media_formats.tinygif"); - var preview = item.SelectToken("media_formats.tinygif") ?? gif; - var dims = gif?["dims"] as JArray; - - return new GifSearchResult - { - Id = (string)item["id"], - Title = (string)item["title"] ?? (string)item["content_description"], - PreviewUrl = (string)preview?["url"], - GifUrl = (string)gif?["url"], - Width = dims != null && dims.Count > 0 ? ParseInt(dims[0]) : 0, - Height = dims != null && dims.Count > 1 ? ParseInt(dims[1]) : 0 - }; - }) - .Where(r => !string.IsNullOrWhiteSpace(r.GifUrl) && IsAllowedCdnUrl(r.GifUrl) && IsAllowedCdnUrl(r.PreviewUrl)) - .ToList(); - } - private static bool IsAllowedCdnUrl(string url) { if (string.IsNullOrWhiteSpace(url)) diff --git a/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs b/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs index a95d0948..2b2684d9 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs @@ -52,6 +52,7 @@ public class ChatController : V4AuthenticatedApiControllerbase private readonly ICacheProvider _cacheProvider; private readonly IEventAggregator _eventAggregator; private readonly IQueueService _queueService; + private readonly IUserProfileService _userProfileService; public ChatController( IChatChannelService chatChannelService, @@ -66,7 +67,8 @@ public ChatController( IAuthorizationService authorizationService, ICacheProvider cacheProvider, IEventAggregator eventAggregator, - IQueueService queueService) + IQueueService queueService, + IUserProfileService userProfileService) { _chatChannelService = chatChannelService; _chatPermissionService = chatPermissionService; @@ -81,6 +83,7 @@ public ChatController( _cacheProvider = cacheProvider; _eventAggregator = eventAggregator; _queueService = queueService; + _userProfileService = userProfileService; } #endregion Members and Constructors @@ -770,6 +773,17 @@ public async Task> SendMessage(string channe if (await IsRateLimitedAsync("send", ChatConfig.SendRateLimitPerWindow)) return RateLimitedResult(); + // Assistant conversations are plain text only: no threads, no attachments/GIFs, no urgent + // priority. Enforced here so every client (web and mobile) gets the same behavior. + var channel = await _chatChannelService.GetChannelByIdAsync(channelId); + var isChatbotChannel = channel != null && channel.ChannelType == (int)ChatChannelType.Chatbot; + if (isChatbotChannel && ((ChatMessageType)input.MessageType != ChatMessageType.Text || !String.IsNullOrWhiteSpace(input.ThreadRootMessageId))) + return BadRequest("Assistant conversations only support plain text messages."); + + // Urgent (ack-required) priority is a channel-level broadcast concept; thread replies + // always send as normal priority no matter what the client asked for. + var isThreadReply = !String.IsNullOrWhiteSpace(input.ThreadRootMessageId); + var request = new ChatMessageSendRequest { ChatChannelId = channelId, @@ -778,7 +792,7 @@ public async Task> SendMessage(string channe AsIncidentCommander = input.AsIncidentCommander, Body = input.Body, MessageType = (ChatMessageType)input.MessageType, - Priority = (ChatMessagePriority)input.Priority, + Priority = isChatbotChannel || isThreadReply ? ChatMessagePriority.Normal : (ChatMessagePriority)input.Priority, ThreadRootMessageId = input.ThreadRootMessageId, AlsoSendToChannel = input.AlsoSendToChannel, ClientMessageId = input.ClientMessageId, @@ -823,8 +837,7 @@ public async Task> SendMessage(string channe // The assistant channel is also reachable from the regular chat page, so sends that arrive // through this generic endpoint must still feed the chatbot pipeline (the dedicated // ChatbotController.SendChatMessage does the same for the assistant panel). - var channel = await _chatChannelService.GetChannelByIdAsync(channelId); - if (channel != null && channel.ChannelType == (int)ChatChannelType.Chatbot && !String.IsNullOrWhiteSpace(message.Body)) + if (isChatbotChannel && !String.IsNullOrWhiteSpace(message.Body)) { var queued = await _queueService.EnqueueChatbotMessageAsync(new Resgrid.Model.Queue.ChatbotMessageQueueItem { @@ -916,6 +929,9 @@ public async Task> DeleteMessage(string messageId if (!await ChatEnabledAsync()) return NotFound(); + if (await IsChatbotMessageChannelAsync(messageId)) + return BadRequest("Messages can't be deleted in assistant conversations."); + var result = new ChatActionResult(); result.Success = await _chatMessageService.DeleteMessageAsync(messageId, UserId, false, null, cancellationToken); result.Status = result.Success ? ResponseHelper.Deleted : ResponseHelper.Failure; @@ -957,6 +973,9 @@ public async Task> AddReaction(string messageId, if (accessCheck != null) return accessCheck; + if (await IsChatbotMessageChannelAsync(messageId)) + return BadRequest("Reactions aren't available in assistant conversations."); + var result = new ChatActionResult(); result.Success = await _chatMessageService.AddReactionAsync(messageId, UserId, null, input.Emoji, cancellationToken); result.Status = result.Success ? ResponseHelper.Created : ResponseHelper.Failure; @@ -988,6 +1007,9 @@ public async Task> RemoveReaction(string messageI if (accessCheck != null) return accessCheck; + if (await IsChatbotMessageChannelAsync(messageId)) + return BadRequest("Reactions aren't available in assistant conversations."); + var result = new ChatActionResult(); result.Success = await _chatMessageService.RemoveReactionAsync(messageId, UserId, null, emoji, cancellationToken); result.Status = result.Success ? ResponseHelper.Deleted : ResponseHelper.Failure; @@ -1055,9 +1077,18 @@ public async Task> GetAcks(string messageId) if (acks != null && acks.Any()) { + var profiles = await _userProfileService.GetSelectedUserProfilesAsync(acks.Select(a => a.UserId).Distinct().ToList()); + var namesByUserId = (profiles ?? new List()) + .Where(p => p?.UserId != null) + .ToDictionary(p => p.UserId, p => p.FullName?.AsFirstNameLastName, StringComparer.OrdinalIgnoreCase); + foreach (var ack in acks) { - result.Data.Add(ConvertAckResultData(ack)); + var data = ConvertAckResultData(ack); + if (ack.UserId != null && namesByUserId.TryGetValue(ack.UserId, out var name)) + data.DisplayName = name; + + result.Data.Add(data); } result.PageSize = result.Data.Count; @@ -1254,6 +1285,9 @@ public async Task> UploadAttachment(s if (channel == null || channel.DepartmentId != DepartmentId) return NotFound(); + if (channel.ChannelType == (int)ChatChannelType.Chatbot) + return BadRequest("Attachments aren't available in assistant conversations."); + if (!await _chatPermissionService.CanPostAsync(channel, UserId, null)) return Unauthorized(); @@ -1553,6 +1587,20 @@ private async Task IsRateLimitedAsync(string action, int limitPerWindow) return StatusCode(StatusCodes.Status429TooManyRequests, result); } + /// + /// True when the message lives in an assistant (chatbot) conversation, where reactions, + /// threads and deletes are not available. + /// + private async Task IsChatbotMessageChannelAsync(string messageId) + { + var message = await _chatMessageService.GetMessageByIdAsync(messageId); + if (message == null) + return false; + + var channel = await _chatChannelService.GetChannelByIdAsync(message.ChatChannelId); + return channel != null && channel.ChannelType == (int)ChatChannelType.Chatbot; + } + /// /// Verifies the message exists in this department and the user can access its channel. /// Returns null when access is allowed, otherwise the error result to return. diff --git a/Web/Resgrid.Web.Services/Controllers/v4/ChatbotController.cs b/Web/Resgrid.Web.Services/Controllers/v4/ChatbotController.cs index 741c9b6c..1010c9b8 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/ChatbotController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/ChatbotController.cs @@ -447,6 +447,14 @@ public async Task> NewChatSession() if (session != null) await _chatbotSessionManager.EndSessionAsync(session.SessionId); + // Visible confirmation in the conversation (fans out over SignalR to every client); + // without it the reset is silent and looks like the button did nothing. + var channel = await _chatChannelService.EnsureChatbotChannelAsync(DepartmentId, UserId); + if (channel != null) + await _chatMessageService.SendBotMessageAsync(channel.ChatChannelId, + DepartmentId.ToString(System.Globalization.CultureInfo.InvariantCulture), + "Starting a new conversation — your previous context has been cleared.", "Resgrid Assistant"); + var result = new ChatbotSessionResetResult { Success = true, diff --git a/Web/Resgrid.Web.Services/Models/v4/Chat/ChatApiModels.cs b/Web/Resgrid.Web.Services/Models/v4/Chat/ChatApiModels.cs index fc3fbf22..1871cbe9 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Chat/ChatApiModels.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Chat/ChatApiModels.cs @@ -741,6 +741,11 @@ public class ChatAckResultData /// When the user acknowledged (null = still pending) /// public DateTime? AcknowledgedOn { get; set; } + + /// + /// Display name of the user the acknowledgment is required from (populated by GetAcks) + /// + public string DisplayName { get; set; } } /// diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index 79363420..73c5b523 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -734,6 +734,12 @@ Reason and optional note for the flag ChatActionResult indicating whether the flag was recorded + + + True when the message lives in an assistant (chatbot) conversation, where reactions, + threads and deletes are not available. + + Verifies the message exists in this department and the user can access its channel. @@ -7663,6 +7669,11 @@ When the user acknowledged (null = still pending) + + + Display name of the user the acknowledgment is required from (populated by GetAcks) + + A user report ("flag") of a chat message diff --git a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/ConversationView.tsx b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/ConversationView.tsx index 81630011..dc273aef 100644 --- a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/ConversationView.tsx +++ b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/ConversationView.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react'; -import { ChatMessageType, getCurrentDisplayName, type ChatChannelDto, type ChatMessageDto } from './types'; +import { ChatChannelType, ChatMessageType, getCurrentDisplayName, type ChatChannelDto, type ChatMessageDto } from './types'; import { chatHub } from './chatHub'; import { useChatStore, shallowArrayEqual } from './useChatStore'; import { chatStore, setHighlightMessage, type TypingEntry } from './chatStore'; @@ -55,6 +55,11 @@ function SkeletonRows() { export default function ConversationView(props: ConversationViewProps) { const { channel, currentUserId, canModerate, variant } = props; const channelId = channel.ChatChannelId; + // Assistant conversations are restricted regardless of where they're opened (footer drawer uses + // variant='bot'; the chat page renders the same channel with the default variant): text only — + // no emoji picker, GIFs, images, urgent priority, reactions, threads or deletes. Pin, flag and + // editing your own messages stay available. + const isBot = variant === 'bot' || channel.ChannelType === ChatChannelType.Chatbot; const allMessages = useChatStore((state) => state.messagesByChannel[channelId] ?? EMPTY_MESSAGES, shallowArrayEqual); const hasMore = useChatStore((state) => state.hasMoreByChannel[channelId] ?? false); @@ -305,13 +310,17 @@ export default function ConversationView(props: ConversationViewProps) { canModerate={canModerate} variant={variant} highlighted={message.ChatMessageId === highlightMessageId} - onReact={handleReact} - onOpenThread={variant === 'bot' ? undefined : props.onOpenThread} + showAckStatus={message.Priority === 1 && (message.SenderUserId === currentUserId || !!canModerate)} + onReact={isBot ? undefined : handleReact} + onOpenThread={isBot ? undefined : props.onOpenThread} onSaveEdit={handleSaveEdit} - onDelete={handleDelete} + onDelete={isBot ? undefined : handleDelete} onPin={canModerate ? handlePin : undefined} - onFlag={variant === 'bot' ? undefined : props.onFlag} + onFlag={props.onFlag} onOpenImage={handleOpenImage} + // Deliberately variant, not isBot: the footer drawer (variant='bot') sends via + // sendOverride, which handleRetry would bypass. On the chat page the assistant + // channel retries through the generic send, which feeds the bot pipeline. onRetrySend={variant === 'bot' ? undefined : handleRetry} onDiscardFailed={handleDiscard} /> @@ -331,10 +340,11 @@ export default function ConversationView(props: ConversationViewProps) { {lightboxUrl && } diff --git a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/atoms/AckStatus.tsx b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/atoms/AckStatus.tsx new file mode 100644 index 00000000..2e7c9049 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/atoms/AckStatus.tsx @@ -0,0 +1,79 @@ +import { useEffect, useState } from 'react'; +import { getAcks, type ChatAckDto } from '../chatApi'; +import { useChatStore } from '../useChatStore'; + +interface AckStatusProps { + messageId: string; +} + +function formatAckTime(value: string): string { + const date = new Date(value); + return date.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); +} + +/** + * Acknowledgment roll-up under an urgent message: "n/total acknowledged" with an expandable + * list of who acknowledged (and when) and who is still pending. Rendered only for the sender + * or a moderator — GetAcks returns 401 for anyone else. Live-refreshes off the per-message + * ack revision the hub bumps on each chatReceiptUpdated ack event. + */ +export default function AckStatus({ messageId }: AckStatusProps) { + const revision = useChatStore((state) => state.ackRevisionByMessage[messageId] ?? 0); + const [acks, setAcks] = useState(null); + const [open, setOpen] = useState(false); + + useEffect(() => { + let active = true; + getAcks(messageId) + .then((result) => { + if (active) { + setAcks(result); + } + }) + .catch(() => { + if (active) { + setAcks(null); + } + }); + return () => { + active = false; + }; + }, [messageId, revision]); + + if (!acks || acks.length === 0) { + return null; + } + + const acked = acks.filter((ack) => !!ack.AcknowledgedOn); + const pending = acks.filter((ack) => !ack.AcknowledgedOn); + const allAcked = pending.length === 0; + + return ( +
+ + {open && ( +
+ {acked.map((ack) => ( +
+ {ack.DisplayName || ack.UserId} + {ack.AcknowledgedOn ? formatAckTime(ack.AcknowledgedOn) : ''} +
+ ))} + {pending.map((ack) => ( +
+ {ack.DisplayName || ack.UserId} + pending +
+ ))} +
+ )} +
+ ); +} diff --git a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/atoms/Composer.tsx b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/atoms/Composer.tsx index 1d075897..796a5809 100644 --- a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/atoms/Composer.tsx +++ b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/atoms/Composer.tsx @@ -19,6 +19,7 @@ interface ComposerProps { allowGifs?: boolean; allowImages?: boolean; allowUrgent?: boolean; + allowEmoji?: boolean; placeholder?: string; disabled?: boolean; } @@ -36,6 +37,7 @@ export default function Composer({ allowGifs = true, allowImages = true, allowUrgent = true, + allowEmoji = true, placeholder = 'Write a message…', disabled = false, }: ComposerProps) { @@ -241,15 +243,17 @@ export default function Composer({
- + {allowEmoji && ( + + )} {allowGifs && (
)} + {isUrgent && !isDeleted && !isFailed && props.showAckStatus && } + {!isDeleted && !isFailed && ( props.onReact(message, emoji, mine)} + onToggle={(emoji, mine) => props.onReact?.(message, emoji, mine)} /> )} @@ -239,34 +244,36 @@ function MessageBubble(props: MessageBubbleProps) { {!isDeleted && !editing && !isFailed && (
-
- - {showReactions && ( -
- {QUICK_REACTIONS.map((emoji) => ( - - ))} -
- )} -
+ {props.onReact && ( +
+ + {showReactions && ( +
+ {QUICK_REACTIONS.map((emoji) => ( + + ))} +
+ )} +
+ )} {props.onOpenThread && (