feat: Add notification sound functionality and embed support

- Introduced NotificationConfig to manage notification settings, including sound file.
- Added Notification.mp3 asset for notifications.
- Implemented NotificationSoundService to handle playing notification sounds.
- Enhanced ChatRenderer and MainWindow to support emoji rendering.
- Updated MessageDto to allow multiple embeds per message.
- Modified LinkEmbedService to fetch multiple embeds from message content.
- Added data migration for legacy embed JSON format to new array format.
- Adjusted embed handling in ChatService and IrcMessageFormatter to accommodate multiple embeds.
- Updated HubConstants for new embed dimensions and limits.
This commit is contained in:
HueByte
2026-02-19 21:52:18 +01:00
parent 7167b40013
commit b508a5854a
14 changed files with 746 additions and 156 deletions
+9 -9
View File
@@ -175,15 +175,15 @@ public class ChatService : IChatService
}
}
// Attempt to fetch link embed for URLs in the message
EmbedDto? embed = null;
// Attempt to fetch link embeds for URLs in the message
List<EmbedDto>? embeds = null;
try
{
embed = await _embedService.TryGetEmbedAsync(content);
embeds = await _embedService.TryGetEmbedsAsync(content);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to fetch link embed for message in '{Channel}'", channelName);
_logger.LogWarning(ex, "Failed to fetch link embeds for message in '{Channel}'", channelName);
}
var message = new Message
@@ -195,7 +195,7 @@ public class ChatService : IChatService
ChannelId = channel.Id,
SenderUserId = userId,
SenderUsername = username,
EmbedJson = embed is not null ? JsonSerializer.Serialize(embed) : null,
EmbedJson = embeds is not null ? JsonSerializer.Serialize(embeds) : null,
};
db.Messages.Add(message);
@@ -211,7 +211,7 @@ public class ChatService : IChatService
null,
null,
message.SentAt,
embed);
embeds);
await BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, messageDto));
@@ -418,10 +418,10 @@ public class ChatService : IChatService
return raw.Select(x =>
{
EmbedDto? embed = null;
List<EmbedDto>? embeds = null;
if (x.m.EmbedJson is not null)
{
try { embed = JsonSerializer.Deserialize<EmbedDto>(x.m.EmbedJson); }
try { embeds = JsonSerializer.Deserialize<List<EmbedDto>>(x.m.EmbedJson); }
catch { /* ignore malformed JSON */ }
}
@@ -435,7 +435,7 @@ public class ChatService : IChatService
x.m.AttachmentUrl,
x.m.AttachmentFileName,
x.m.SentAt,
embed);
embeds);
}).ToList();
}
}
+102 -91
View File
@@ -24,108 +24,119 @@ public partial class LinkEmbedService
}
/// <summary>
/// Detect the first URL in message content and attempt to fetch OG embed data.
/// Returns null if no URL found, fetch fails, or no useful OG data.
/// Detect all URLs in message content and attempt to fetch OG embed data for each.
/// Returns null if no URLs found or all fetches fail.
/// Never throws — all errors are caught internally.
/// </summary>
public async Task<EmbedDto?> TryGetEmbedAsync(string content)
public async Task<List<EmbedDto>?> TryGetEmbedsAsync(string content)
{
try
{
var url = ExtractFirstUrl(content);
if (url is null)
return null;
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
return null;
if (uri.Scheme is not ("http" or "https"))
return null;
if (IsPrivateHost(uri))
return null;
using var cts = new CancellationTokenSource(
TimeSpan.FromSeconds(HubConstants.EmbedFetchTimeoutSeconds));
var client = _httpClientFactory.CreateClient("OgFetch");
using var request = new HttpRequestMessage(HttpMethod.Get, uri);
using var response = await client.SendAsync(request,
HttpCompletionOption.ResponseHeadersRead, cts.Token);
if (!response.IsSuccessStatusCode)
return null;
var contentType = response.Content.Headers.ContentType?.MediaType;
if (contentType is null || !contentType.StartsWith("text/html", StringComparison.OrdinalIgnoreCase))
return null;
var html = await ReadLimitedAsync(response, HubConstants.EmbedMaxHtmlBytes, cts.Token);
if (string.IsNullOrWhiteSpace(html))
return null;
var ogTags = ParseOgTags(html);
// Try og:title, fallback to <title> tag
var title = ogTags.GetValueOrDefault("title");
if (string.IsNullOrWhiteSpace(title))
{
var titleMatch = TitleTagRegex().Match(html);
if (titleMatch.Success)
title = WebUtility.HtmlDecode(titleMatch.Groups[1].Value.Trim());
}
// If no title at all, nothing useful to show
if (string.IsNullOrWhiteSpace(title))
return null;
var siteName = ogTags.GetValueOrDefault("site_name");
var description = ogTags.GetValueOrDefault("description");
// Truncate description
if (description is not null && description.Length > HubConstants.EmbedMaxDescriptionLength)
description = description[..(HubConstants.EmbedMaxDescriptionLength - 3)] + "...";
// HTML decode text fields
title = WebUtility.HtmlDecode(title);
siteName = siteName is not null ? WebUtility.HtmlDecode(siteName) : null;
description = description is not null ? WebUtility.HtmlDecode(description) : null;
// Attempt to fetch OG image thumbnail
string? imageAscii = null;
var imageUrl = ogTags.GetValueOrDefault("image");
if (!string.IsNullOrWhiteSpace(imageUrl))
{
imageAscii = await FetchImageThumbnailAsync(imageUrl, uri, cts.Token);
}
return new EmbedDto(siteName, title, description, imageAscii, url);
}
catch (OperationCanceledException)
{
_logger.LogDebug("Embed fetch timed out for message content");
var urls = ExtractUrls(content);
if (urls.Count == 0)
return null;
}
catch (Exception ex)
var embeds = new List<EmbedDto>();
foreach (var url in urls)
{
_logger.LogDebug(ex, "Failed to fetch embed");
return null;
try
{
var embed = await FetchEmbedForUrlAsync(url);
if (embed is not null)
embeds.Add(embed);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to fetch embed for {Url}", url);
}
}
return embeds.Count > 0 ? embeds : null;
}
private static string? ExtractFirstUrl(string content)
private async Task<EmbedDto?> FetchEmbedForUrlAsync(string url)
{
var match = UrlRegex().Match(content);
if (!match.Success)
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
return null;
var url = match.Value;
if (uri.Scheme is not ("http" or "https"))
return null;
// Strip trailing punctuation that's likely not part of the URL
url = url.TrimEnd('.', ',', '!', '?', ')', ']', ';', ':');
if (IsPrivateHost(uri))
return null;
return url;
using var cts = new CancellationTokenSource(
TimeSpan.FromSeconds(HubConstants.EmbedFetchTimeoutSeconds));
var client = _httpClientFactory.CreateClient("OgFetch");
using var request = new HttpRequestMessage(HttpMethod.Get, uri);
using var response = await client.SendAsync(request,
HttpCompletionOption.ResponseHeadersRead, cts.Token);
if (!response.IsSuccessStatusCode)
return null;
var contentType = response.Content.Headers.ContentType?.MediaType;
if (contentType is null || !contentType.StartsWith("text/html", StringComparison.OrdinalIgnoreCase))
return null;
var html = await ReadLimitedAsync(response, HubConstants.EmbedMaxHtmlBytes, cts.Token);
if (string.IsNullOrWhiteSpace(html))
return null;
var ogTags = ParseOgTags(html);
// Try og:title, fallback to <title> tag
var title = ogTags.GetValueOrDefault("title");
if (string.IsNullOrWhiteSpace(title))
{
var titleMatch = TitleTagRegex().Match(html);
if (titleMatch.Success)
title = WebUtility.HtmlDecode(titleMatch.Groups[1].Value.Trim());
}
// If no title at all, nothing useful to show
if (string.IsNullOrWhiteSpace(title))
return null;
var siteName = ogTags.GetValueOrDefault("site_name");
var description = ogTags.GetValueOrDefault("description");
// Truncate very long descriptions but keep a generous limit
if (description is not null && description.Length > HubConstants.EmbedMaxDescriptionLength)
description = description[..(HubConstants.EmbedMaxDescriptionLength - 3)] + "...";
// HTML decode text fields
title = WebUtility.HtmlDecode(title);
siteName = siteName is not null ? WebUtility.HtmlDecode(siteName) : null;
description = description is not null ? WebUtility.HtmlDecode(description) : null;
// Attempt to fetch OG image as small icon
string? imageAscii = null;
var imageUrl = ogTags.GetValueOrDefault("image");
if (!string.IsNullOrWhiteSpace(imageUrl))
{
imageAscii = await FetchImageThumbnailAsync(imageUrl, uri, cts.Token);
}
return new EmbedDto(siteName, title, description, imageAscii, url);
}
private static List<string> ExtractUrls(string content)
{
var urls = new List<string>();
foreach (Match match in UrlRegex().Matches(content))
{
var url = match.Value.TrimEnd('.', ',', '!', '?', ')', ']', ';', ':');
if (!urls.Contains(url))
urls.Add(url);
if (urls.Count >= HubConstants.EmbedMaxUrlsPerMessage)
break;
}
return urls;
}
private static bool IsPrivateHost(Uri uri)
@@ -220,8 +231,8 @@ public partial class LinkEmbedService
memoryStream.Position = 0;
return _asciiService.ConvertToAscii(memoryStream,
HubConstants.EmbedThumbnailWidth,
HubConstants.EmbedThumbnailHeight);
HubConstants.EmbedIconWidth,
HubConstants.EmbedIconHeight);
}
catch (Exception ex)
{
@@ -1,5 +1,6 @@
using System.Text.RegularExpressions;
using EchoHub.Core.Constants;
using EchoHub.Core.DTOs;
using EchoHub.Server.Data;
using Microsoft.EntityFrameworkCore;
@@ -16,6 +17,7 @@ public static partial class DataMigrationService
await EnsureDefaultChannelsPublicAsync(db, logger);
await MigrateAnsiMessagesAsync(db, logger);
await MigrateEmbedJsonToArrayAsync(db, logger);
}
/// <summary>
@@ -91,4 +93,47 @@ public static partial class DataMigrationService
[GeneratedRegex(@"\x1b\[(?:(0)|(?:(38;2|48;2);(\d{1,3});(\d{1,3});(\d{1,3})))m")]
private static partial Regex AnsiColorRegex();
/// <summary>
/// Migrate old single-object EmbedJson ("{...}") to array format ("[{...}]").
/// </summary>
private static async Task MigrateEmbedJsonToArrayAsync(EchoHubDbContext db, ILogger logger)
{
var messages = await db.Messages
.Where(m => m.EmbedJson != null)
.ToListAsync();
var toMigrate = messages
.Where(m => m.EmbedJson!.TrimStart().StartsWith('{'))
.ToList();
if (toMigrate.Count == 0)
return;
logger.LogInformation("Found {Count} messages with legacy single-embed JSON. Migrating to array format...", toMigrate.Count);
var modified = 0;
foreach (var message in toMigrate)
{
try
{
var single = System.Text.Json.JsonSerializer.Deserialize<EmbedDto>(message.EmbedJson!);
if (single is not null)
{
message.EmbedJson = System.Text.Json.JsonSerializer.Serialize(new[] { single });
modified++;
}
}
catch
{
// Skip malformed JSON
}
}
if (modified > 0)
{
await db.SaveChangesAsync();
logger.LogInformation("Migrated {Count} embed records from single-object to array format.", modified);
}
}
}