feat: emoji picker, GIF search dialog and AI chat entry in the chat UI
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b612c3345b
commit
66b5f4e751
8 files changed
+442
-5
No files matched your search
@@ -0,0 +1,41 @@
|
||||
namespace FamilyPlanner.Web.Client.Components.Chat;
|
||||
|
||||
/// <summary>Kleine eingebaute Emoji-Auswahl nach Kategorien (ohne externe Pakete oder Dienste, ADR-0022).</summary>
|
||||
public static class EmojiCatalog
|
||||
{
|
||||
public sealed record Category(string Key, string Title, string Icon, IReadOnlyList<string> Emojis);
|
||||
|
||||
public static IReadOnlyList<Category> Categories { get; } =
|
||||
[
|
||||
new("smileys", "Gesichter", "😀",
|
||||
[
|
||||
"😀", "😃", "😄", "😁", "😆", "😅", "😂", "🤣", "🙂", "🙃", "😉", "😊", "😇", "🥰", "😍", "🤩", "😘", "😋", "😛", "😜",
|
||||
"🤪", "🤗", "🤔", "🤭", "😐", "😏", "😴", "😎", "🤓", "😕", "😮", "😲", "😳", "🥺", "😢", "😭", "😤", "😡", "🥳", "🤒",
|
||||
]),
|
||||
new("gestures", "Hände & Menschen", "👍",
|
||||
[
|
||||
"👍", "👎", "👏", "🙌", "🙏", "🤝", "👋", "✌️", "🤞", "👌", "🤟", "💪", "☝️", "👀", "🧠", "❤️", "🧡", "💛", "💚", "💙",
|
||||
"💜", "🖤", "💔", "💕", "💖", "👶", "🧒", "👦", "👧", "👨", "👩", "🧑", "👴", "👵",
|
||||
]),
|
||||
new("animals", "Tiere & Natur", "🐶",
|
||||
[
|
||||
"🐶", "🐱", "🐭", "🐹", "🐰", "🦊", "🐻", "🐼", "🐨", "🐯", "🦁", "🐮", "🐷", "🐸", "🐵", "🐔", "🐧", "🐦", "🦉", "🦋",
|
||||
"🐝", "🐞", "🐢", "🐠", "🐬", "🌳", "🌲", "🌴", "🌸", "🌻", "🌈", "☀️", "🌙", "⭐", "⛅", "🌧️", "❄️", "🔥", "💧",
|
||||
]),
|
||||
new("food", "Essen & Trinken", "🍕",
|
||||
[
|
||||
"🍎", "🍐", "🍊", "🍋", "🍌", "🍉", "🍇", "🍓", "🍒", "🍑", "🥕", "🌽", "🥦", "🥔", "🍞", "🧀", "🥚", "🍳", "🥞", "🍗",
|
||||
"🍔", "🍟", "🍕", "🌭", "🌮", "🍝", "🍜", "🍣", "🍦", "🍩", "🍪", "🎂", "🍰", "🍫", "🍿", "☕", "🍵", "🥛", "🧃",
|
||||
]),
|
||||
new("activities", "Spiel & Freizeit", "⚽",
|
||||
[
|
||||
"⚽", "🏀", "🏈", "⚾", "🎾", "🏐", "🏓", "🎱", "🥅", "⛳", "🏊", "🚴", "🏃", "🎮", "🎲", "🧩", "🎯", "🎨", "🎸", "🎹",
|
||||
"🎤", "🎧", "📚", "✏️", "🎬", "🎈", "🎉", "🎁", "🏆", "🥇", "🪁", "🛝",
|
||||
]),
|
||||
new("travel", "Orte & Dinge", "🚗",
|
||||
[
|
||||
"🚗", "🚕", "🚌", "🚲", "🛴", "🚂", "✈️", "🚀", "🚢", "🏠", "🏫", "🏥", "🏖️", "⛺", "🎡", "🗺️", "📱", "💻", "📷", "💡",
|
||||
"🔑", "🧸", "🛒", "🧺", "🧹", "⏰", "📅", "📌", "✅", "❌", "❓", "❗", "💯", "🎵", "✨", "💤",
|
||||
]),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<div class="fp-emoji-picker" role="dialog" aria-label="Emoji auswählen">
|
||||
<div class="fp-emoji-tabs" role="tablist" aria-label="Kategorien">
|
||||
@foreach (var category in EmojiCatalog.Categories)
|
||||
{
|
||||
<button type="button" role="tab" class="@($"fp-emoji-tab {(category.Key == _active ? "is-active" : string.Empty)}")"
|
||||
aria-selected="@(category.Key == _active ? "true" : "false")" aria-label="@category.Title" title="@category.Title"
|
||||
data-category="@category.Key" @onclick="() => _active = category.Key">@category.Icon</button>
|
||||
}
|
||||
</div>
|
||||
<div class="fp-emoji-grid" role="tabpanel">
|
||||
@foreach (var emoji in Active.Emojis)
|
||||
{
|
||||
<button type="button" class="fp-emoji-item" data-emoji="@emoji" aria-label="@emoji" @onclick="() => OnPick.InvokeAsync(emoji)">@emoji</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private string _active = EmojiCatalog.Categories[0].Key;
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<string> OnPick { get; set; }
|
||||
|
||||
private EmojiCatalog.Category Active => EmojiCatalog.Categories.First(c => c.Key == _active);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
@using FamilyPlanner.Domain.Chat
|
||||
@using FamilyPlanner.Shared.Chat
|
||||
@inject IChatApi Api
|
||||
|
||||
<MudDialog>
|
||||
<DialogContent>
|
||||
<form @onsubmit="SearchAsync" @onsubmit:preventDefault="true" class="d-flex gap-2 align-center">
|
||||
<MudTextField T="string" @bind-Value="_query" Immediate="true" Variant="Variant.Outlined" Margin="Margin.Dense" Label="Suchbegriff"
|
||||
MaxLength="@ChatRules.GifQueryMaxLength" AutoFocus="true" InputId="gif-query" Class="flex-grow-1" />
|
||||
<MudButton ButtonType="ButtonType.Submit" Variant="Variant.Filled" Color="Color.Primary" Disabled="@(_busy || _query.Trim().Length < 2)"
|
||||
Class="fp-gif-search">Suchen</MudButton>
|
||||
</form>
|
||||
<MudText Typo="Typo.caption" Class="mt-1">Nur dein Suchbegriff geht über unseren Server an Giphy – keine Namen, Konten oder Adressen. Es werden nur kinderfreundliche GIFs gezeigt.</MudText>
|
||||
|
||||
@if (_error is not null)
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning" Dense="true" Class="mt-2 fp-gif-error">@_error</MudAlert>
|
||||
}
|
||||
else if (_busy)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" Class="mt-2" />
|
||||
}
|
||||
else if (_results is { Count: 0 })
|
||||
{
|
||||
<MudText Class="mt-2 fp-gif-empty">Nichts gefunden – versuch einen anderen Begriff.</MudText>
|
||||
}
|
||||
|
||||
@if (_results is { Count: > 0 })
|
||||
{
|
||||
<div class="fp-gif-grid mt-2">
|
||||
@foreach (var gif in _results)
|
||||
{
|
||||
<button type="button" class="fp-gif-item" data-gif-id="@gif.Id" aria-label="@gif.Title" title="@gif.Title" @onclick="() => Select(gif)">
|
||||
<img src="@gif.PreviewUrl" alt="@gif.Title" loading="lazy" />
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="() => Dialog?.Cancel()">Abbrechen</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
private IMudDialogInstance? Dialog { get; set; }
|
||||
|
||||
private string _query = string.Empty;
|
||||
private IReadOnlyList<GifResultDto>? _results;
|
||||
private string? _error;
|
||||
private bool _busy;
|
||||
|
||||
private async Task SearchAsync()
|
||||
{
|
||||
var query = _query.Trim();
|
||||
if (_busy || query.Length < 2)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_busy = true;
|
||||
_error = null;
|
||||
try
|
||||
{
|
||||
var result = await Api.SearchGifsAsync(query);
|
||||
if (result.Succeeded && result.Value is { } value)
|
||||
{
|
||||
_results = value.Results;
|
||||
}
|
||||
else
|
||||
{
|
||||
_results = null;
|
||||
_error = result.Problem?.Message ?? "Die GIF-Suche ist gerade nicht erreichbar.";
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void Select(GifResultDto gif) => Dialog?.Close(DialogResult.Ok(gif.Id));
|
||||
}
|
||||
@@ -22,6 +22,11 @@
|
||||
<div class="d-flex align-center gap-2 mb-2">
|
||||
<MudText Typo="Typo.h4" HtmlTag="h1">Chat</MudText>
|
||||
<MudSpacer />
|
||||
@if (Store.Features.AiChatAvailable && Store.Channels.All(c => c.Kind != ChatChannelKind.Ai))
|
||||
{
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" StartIcon="@Icons.Material.Filled.SmartToy" OnClick="StartAiAsync"
|
||||
Class="fp-chat-new-ai">KI-Chat</MudButton>
|
||||
}
|
||||
@if (CanDirect)
|
||||
{
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="StartDirectAsync"
|
||||
@@ -57,6 +62,10 @@
|
||||
{
|
||||
<MudAvatar Size="Size.Medium" Color="Color.Primary"><MudIcon Icon="@Icons.Material.Filled.Groups" /></MudAvatar>
|
||||
}
|
||||
else if (channel.Kind == ChatChannelKind.Ai)
|
||||
{
|
||||
<MudAvatar Size="Size.Medium" Color="Color.Secondary"><MudIcon Icon="@Icons.Material.Filled.SmartToy" /></MudAvatar>
|
||||
}
|
||||
else
|
||||
{
|
||||
var partner = Person(channel.PartnerId);
|
||||
@@ -152,15 +161,31 @@
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="fp-chat-typing" role="status" aria-live="polite">@TypingText(channel.Id)</div>
|
||||
<div class="fp-chat-typing" role="status" aria-live="polite">@(_sending && channel.Kind == ChatChannelKind.Ai ? "KI denkt nach …" : TypingText(channel.Id))</div>
|
||||
|
||||
@if (channel.CanSend)
|
||||
{
|
||||
@if (_emojiOpen)
|
||||
{
|
||||
<EmojiPicker OnPick="InsertEmojiAsync" />
|
||||
}
|
||||
|
||||
<form class="fp-chat-composer" @onsubmit="SendAsync" @onsubmit:preventDefault="true">
|
||||
<label class="fp-chat-attach" title="Bild senden">
|
||||
<InputFile OnChange="SendImageAsync" accept="image/png,image/jpeg,image/webp" aria-label="Bild senden" disabled="@_sending" />
|
||||
<MudIcon Icon="@Icons.Material.Filled.AddPhotoAlternate" />
|
||||
</label>
|
||||
@if (channel.Kind != ChatChannelKind.Ai)
|
||||
{
|
||||
<label class="fp-chat-attach" title="Bild senden">
|
||||
<InputFile OnChange="SendImageAsync" accept="image/png,image/jpeg,image/webp" aria-label="Bild senden" disabled="@_sending" />
|
||||
<MudIcon Icon="@Icons.Material.Filled.AddPhotoAlternate" />
|
||||
</label>
|
||||
}
|
||||
<button type="button" class="fp-chat-tool fp-chat-emoji" title="Emoji einfügen" aria-label="Emoji einfügen" aria-expanded="@(_emojiOpen ? "true" : "false")"
|
||||
disabled="@_sending" @onclick="() => _emojiOpen = !_emojiOpen">
|
||||
<MudIcon Icon="@Icons.Material.Filled.InsertEmoticon" />
|
||||
</button>
|
||||
@if (channel.Kind != ChatChannelKind.Ai && Store.Features.GifSearchAvailable)
|
||||
{
|
||||
<button type="button" class="fp-chat-tool fp-chat-gif" title="GIF suchen" aria-label="GIF suchen" disabled="@_sending" @onclick="SendGifAsync">GIF</button>
|
||||
}
|
||||
<MudTextField T="string" Value="_draft" ValueChanged="OnDraftChangedAsync" Immediate="true" Variant="Variant.Outlined" Margin="Margin.Dense"
|
||||
Placeholder="Nachricht schreiben …" MaxLength="@ChatMessage.TextMaxLength" Disabled="_sending" Class="fp-chat-input"
|
||||
InputId="chat-input" aria-label="Nachricht" AutoFocus="true" />
|
||||
@@ -188,6 +213,7 @@
|
||||
private Guid? _openedChannel;
|
||||
private string _draft = string.Empty;
|
||||
private bool _sending;
|
||||
private bool _emojiOpen;
|
||||
private bool _newConversation;
|
||||
private Guid? _lastId;
|
||||
private Guid? _firstId;
|
||||
@@ -229,6 +255,7 @@
|
||||
|
||||
_openedChannel = ChannelId;
|
||||
_draft = string.Empty;
|
||||
_emojiOpen = false;
|
||||
_lastId = _firstId = null;
|
||||
if (ChannelId is { } id)
|
||||
{
|
||||
@@ -386,6 +413,72 @@
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Fügt das Emoji an der Schreibmarke ein (sonst am Ende) und hält die Längenbegrenzung ein.</summary>
|
||||
private async Task InsertEmojiAsync(string emoji)
|
||||
{
|
||||
if (_draft.Length + emoji.Length > ChatMessage.TextMaxLength)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var inserted = false;
|
||||
try
|
||||
{
|
||||
inserted = await JS.InvokeAsync<bool>("fpChat.insertAtCursor", "chat-input", emoji, ChatMessage.TextMaxLength);
|
||||
}
|
||||
catch (Exception ex) when (ex is JSException or JSDisconnectedException or InvalidOperationException)
|
||||
{
|
||||
// Ohne Schreibmarke: unten anhängen.
|
||||
}
|
||||
|
||||
if (!inserted)
|
||||
{
|
||||
await OnDraftChangedAsync(_draft + emoji);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task StartAiAsync()
|
||||
{
|
||||
if (await Store.StartAiAsync() is { } channel)
|
||||
{
|
||||
Navigation.NavigateTo($"chat/{channel.Id}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendGifAsync()
|
||||
{
|
||||
if (_sending)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var dialog = await DialogService.ShowAsync<GifSearchDialog>("GIF suchen", new DialogParameters(), new DialogOptions { MaxWidth = MaxWidth.Medium, FullWidth = true });
|
||||
var result = await dialog.Result;
|
||||
if (result is not { Canceled: false, Data: string gifId })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_sending = true;
|
||||
try
|
||||
{
|
||||
var error = await Store.SendGifAsync(gifId, _draft);
|
||||
if (error is null)
|
||||
{
|
||||
_draft = string.Empty;
|
||||
_newConversation = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add(error, Severity.Error);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_sending = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadOlderAsync()
|
||||
{
|
||||
await TryJsAsync("fpChat.capture", ListId);
|
||||
|
||||
@@ -11,6 +11,16 @@ public interface IChatApi
|
||||
Task<ApiResult<ChatChannelDto>> StartDirectAsync(Guid userId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Die neuesten <paramref name="take"/> Nachrichten bzw. die davor liegende Seite (<paramref name="before"/>).</summary>
|
||||
/// <summary>Welche Zusatzfunktionen der Chat anbietet (GIF-Suche, KI-Chat).</summary>
|
||||
Task<ApiResult<ChatFeaturesDto>> GetFeaturesAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>Der eigene KI-Kanal (wird beim ersten Aufruf angelegt).</summary>
|
||||
Task<ApiResult<ChatChannelDto>> StartAiAsync(CancellationToken ct = default);
|
||||
|
||||
Task<ApiResult<GifSearchResultDto>> SearchGifsAsync(string query, CancellationToken ct = default);
|
||||
|
||||
Task<ApiResult<ChatMessageDto>> SendGifAsync(Guid channelId, SendGifRequest request, CancellationToken ct = default);
|
||||
|
||||
Task<ApiResult<ChatMessagePageDto>> GetMessagesAsync(Guid channelId, Guid? before, int take, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Wert ist <c>null</c>, wenn die Nachricht inzwischen gelöscht wurde (204).</summary>
|
||||
@@ -37,6 +47,18 @@ public sealed class ChatApi(HttpClient http) : IChatApi
|
||||
public Task<ApiResult<ChatChannelDto>> StartDirectAsync(Guid userId, CancellationToken ct = default) =>
|
||||
ApiCall.SendAsync<ChatChannelDto>(() => http.PostAsJsonAsync($"{Base}/direct", new StartDirectChatRequest(userId), ct), ct);
|
||||
|
||||
public Task<ApiResult<ChatFeaturesDto>> GetFeaturesAsync(CancellationToken ct = default) =>
|
||||
ApiCall.SendAsync<ChatFeaturesDto>(() => http.GetAsync($"{Base}/features", ct), ct);
|
||||
|
||||
public Task<ApiResult<ChatChannelDto>> StartAiAsync(CancellationToken ct = default) =>
|
||||
ApiCall.SendAsync<ChatChannelDto>(() => http.PostAsync($"{Base}/ai", null, ct), ct);
|
||||
|
||||
public Task<ApiResult<GifSearchResultDto>> SearchGifsAsync(string query, CancellationToken ct = default) =>
|
||||
ApiCall.SendAsync<GifSearchResultDto>(() => http.GetAsync($"{Base}/gifs?q={Uri.EscapeDataString(query)}", ct), ct);
|
||||
|
||||
public Task<ApiResult<ChatMessageDto>> SendGifAsync(Guid channelId, SendGifRequest request, CancellationToken ct = default) =>
|
||||
ApiCall.SendAsync<ChatMessageDto>(() => http.PostAsJsonAsync($"{Base}/channels/{channelId}/gifs", request, ct), ct);
|
||||
|
||||
public Task<ApiResult<ChatMessagePageDto>> GetMessagesAsync(Guid channelId, Guid? before, int take, CancellationToken ct = default) =>
|
||||
ApiCall.SendAsync<ChatMessagePageDto>(
|
||||
() => http.GetAsync($"{Base}/channels/{channelId}/messages?take={take}{(before is { } id ? $"&before={id}" : string.Empty)}", ct), ct);
|
||||
|
||||
@@ -11,6 +11,9 @@ namespace FamilyPlanner.Web.Client.Services;
|
||||
public sealed class ChatStore : IAsyncDisposable
|
||||
{
|
||||
/// <summary>So lange gilt ein „tippt …“-Signal ohne Erneuerung.</summary>
|
||||
/// <summary>Titel der Api-Antwort, wenn die KI nicht antworten konnte (die Nachricht selbst ist dann gespeichert).</summary>
|
||||
public const string AiChatProblemTitle = "KI-Chat";
|
||||
|
||||
public static readonly TimeSpan TypingLifetime = TimeSpan.FromSeconds(4);
|
||||
|
||||
private static readonly TimeSpan TypingSendInterval = TimeSpan.FromSeconds(3);
|
||||
@@ -43,6 +46,9 @@ public sealed class ChatStore : IAsyncDisposable
|
||||
|
||||
public IReadOnlyList<ChatChannelDto> Channels => _channels;
|
||||
|
||||
/// <summary>Zusatzfunktionen (GIF-Suche, KI-Chat); bis zur Antwort des Servers alles aus.</summary>
|
||||
public ChatFeaturesDto Features { get; private set; } = new(false, false, false);
|
||||
|
||||
public int TotalUnread => _channels.Sum(c => c.UnreadCount);
|
||||
|
||||
public bool IsLoading { get; private set; }
|
||||
@@ -100,6 +106,12 @@ public sealed class ChatStore : IAsyncDisposable
|
||||
_live.Connected += OnConnectedAsync;
|
||||
await _live.EnsureStartedAsync();
|
||||
await RefreshChannelsAsync();
|
||||
var features = await _api.GetFeaturesAsync();
|
||||
if (features.Succeeded && features.Value is { } value)
|
||||
{
|
||||
Features = value;
|
||||
RaiseChanged();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Kanäle ----------
|
||||
@@ -148,6 +160,25 @@ public sealed class ChatStore : IAsyncDisposable
|
||||
return channel;
|
||||
}
|
||||
|
||||
/// <summary>Öffnet bzw. legt den eigenen KI-Kanal an.</summary>
|
||||
public async Task<ChatChannelDto?> StartAiAsync()
|
||||
{
|
||||
var result = await _api.StartAiAsync();
|
||||
if (!result.Succeeded || result.Value is not { } channel)
|
||||
{
|
||||
Notice?.Invoke(result.Problem?.Message ?? "Der KI-Chat konnte nicht geöffnet werden.");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (_channels.All(c => c.Id != channel.Id))
|
||||
{
|
||||
_channels = [.. _channels, channel];
|
||||
RaiseChanged();
|
||||
}
|
||||
|
||||
return channel;
|
||||
}
|
||||
|
||||
// ---------- Unterhaltung ----------
|
||||
|
||||
public async Task OpenAsync(Guid channelId)
|
||||
@@ -239,6 +270,13 @@ public sealed class ChatStore : IAsyncDisposable
|
||||
}
|
||||
|
||||
var result = await _api.SendAsync(channelId, new SendChatMessageRequest(Guid.CreateVersion7(), text));
|
||||
if (result.Problem is { Title: AiChatProblemTitle } aiProblem)
|
||||
{
|
||||
// Die Frage ist gespeichert (und über die Live-Verbindung schon im Verlauf); nur die KI konnte nicht antworten.
|
||||
Notice?.Invoke(aiProblem.Message);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
return result.Problem?.Message ?? "Die Nachricht konnte nicht gesendet werden.";
|
||||
@@ -273,6 +311,28 @@ public sealed class ChatStore : IAsyncDisposable
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Sendet ein GIF aus der Suche (der Server lädt es selbst; es geht nur die Kennung hin).</summary>
|
||||
public async Task<string?> SendGifAsync(string gifId, string? caption)
|
||||
{
|
||||
if (SelectedChannelId is not { } channelId)
|
||||
{
|
||||
return "Bitte zuerst eine Unterhaltung öffnen.";
|
||||
}
|
||||
|
||||
var result = await _api.SendGifAsync(channelId, new SendGifRequest(Guid.CreateVersion7(), gifId, caption));
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
return result.Problem?.Message ?? "Das GIF konnte nicht gesendet werden.";
|
||||
}
|
||||
|
||||
if (result.Value is { } message)
|
||||
{
|
||||
AddOwnMessage(message);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<string?> DeleteAsync(ChatMessageDto message)
|
||||
{
|
||||
var result = await _api.DeleteAsync(message.Id);
|
||||
|
||||
@@ -1444,6 +1444,96 @@ button.fp-meal-dish {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fp-chat-tool {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 44px;
|
||||
height: 44px;
|
||||
padding: 0 8px;
|
||||
border: 0;
|
||||
border-radius: 22px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fp-chat-tool:hover,
|
||||
.fp-chat-tool:focus-visible {
|
||||
background: var(--mud-palette-action-default-hover);
|
||||
}
|
||||
|
||||
.fp-chat-tool:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.fp-emoji-picker {
|
||||
border-top: 1px solid var(--mud-palette-lines-default);
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.fp-emoji-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.fp-emoji-tab,
|
||||
.fp-emoji-item {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
font-size: 1.4rem;
|
||||
line-height: 1;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.fp-emoji-tab.is-active {
|
||||
background: var(--mud-palette-action-default-hover);
|
||||
}
|
||||
|
||||
.fp-emoji-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
max-height: 168px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.fp-emoji-item:hover,
|
||||
.fp-emoji-item:focus-visible,
|
||||
.fp-emoji-tab:hover,
|
||||
.fp-emoji-tab:focus-visible {
|
||||
background: var(--mud-palette-action-default-hover);
|
||||
}
|
||||
|
||||
.fp-gif-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.fp-gif-item {
|
||||
padding: 0;
|
||||
border: 1px solid var(--mud-palette-lines-default);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.fp-gif-item img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.fp-nav-unread {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,28 @@
|
||||
}
|
||||
},
|
||||
|
||||
// Emoji an der Schreibmarke einfügen und Blazor per input-Ereignis informieren. false = nicht möglich (Aufrufer hängt unten an).
|
||||
insertAtCursor(id, text, maxLength) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el || typeof el.selectionStart !== 'number') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const start = el.selectionStart;
|
||||
const end = el.selectionEnd;
|
||||
const next = el.value.slice(0, start) + text + el.value.slice(end);
|
||||
if (maxLength && next.length > maxLength) {
|
||||
return true; // zu lang: nichts einfügen, aber nicht zusätzlich anhängen
|
||||
}
|
||||
|
||||
el.value = next;
|
||||
const caret = start + text.length;
|
||||
el.setSelectionRange(caret, caret);
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
el.focus();
|
||||
return true;
|
||||
},
|
||||
|
||||
isVisible() {
|
||||
return document.visibilityState === 'visible';
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user