From 255c37401b8a16669abd774fae45863a923460cd Mon Sep 17 00:00:00 2001 From: bientavu Date: Mon, 24 Aug 2026 15:20:04 +0200 Subject: [PATCH 01/11] feat(messages): store admin messages and serve them to clients --- Jellyfin/backend/Api/ControllerExtensions.cs | 7 + Jellyfin/backend/Api/MoonfinController.cs | 31 ++- .../backend/Api/MoonfinMessagesController.cs | 202 ++++++++++++++++ Jellyfin/backend/PluginConfiguration.cs | 197 ++++++++++++++++ .../ServerMessageTests.cs | 220 ++++++++++++++++++ 5 files changed, 654 insertions(+), 3 deletions(-) create mode 100644 Jellyfin/backend/Api/MoonfinMessagesController.cs create mode 100644 Jellyfin/tests/Moonfin.Server.Tests/ServerMessageTests.cs diff --git a/Jellyfin/backend/Api/ControllerExtensions.cs b/Jellyfin/backend/Api/ControllerExtensions.cs index 8aeb043..e5305b8 100644 --- a/Jellyfin/backend/Api/ControllerExtensions.cs +++ b/Jellyfin/backend/Api/ControllerExtensions.cs @@ -13,6 +13,13 @@ public static class ControllerExtensions return Guid.TryParse(userIdClaim, out var userId) ? userId : null; } + /// + /// True when the caller is a server admin. This is the same role the "RequiresElevation" + /// policy checks, so it matches what Jellyfin itself allows. + /// + public static bool IsAdminFromClaims(this ControllerBase controller) => + controller.User.IsInRole("Administrator"); + public static string? GetDeviceIdFromClaims(this ControllerBase controller) { var deviceId = controller.User.FindFirst("Jellyfin-DeviceId")?.Value; diff --git a/Jellyfin/backend/Api/MoonfinController.cs b/Jellyfin/backend/Api/MoonfinController.cs index 3f6ce7b..d4cde47 100644 --- a/Jellyfin/backend/Api/MoonfinController.cs +++ b/Jellyfin/backend/Api/MoonfinController.cs @@ -83,6 +83,7 @@ public ActionResult Ping() : null, MdblistAvailable = !string.IsNullOrWhiteSpace(config?.MdblistApiKey), TmdbAvailable = !string.IsNullOrWhiteSpace(config?.TmdbApiKey), + MessagesSupported = true, DefaultSettings = config?.DefaultUserSettings }); } @@ -506,13 +507,30 @@ public ActionResult BroadcastMessage([FromBody] MoonfinBroadcastRequest request) var deliveries = _settingsService.BroadcastMessage(message); + // Also save it, so users who were not connected still find it in the app. Older + // clients keep reading the "adminMessage" event above and ignore this. + var stored = new ServerMessage + { + Id = Guid.NewGuid().ToString("N"), + Body = message, + Severity = ServerMessage.SeverityInfo, + Delivery = ServerMessage.DeliveryPopup, + Audience = ServerMessage.AudienceAll, + CreatedUtc = DateTime.UtcNow, + CreatedByUserId = this.GetUserIdFromClaims()?.ToString("N") + }; + + config.Messages.Add(stored); + ServerMessage.Prune(config.Messages); + MoonfinPlugin.Instance?.SaveConfiguration(); + _settingsService.BroadcastSystemEvent("messagesChanged"); + // SSE only reaches clients that are open right now, so push covers - // backgrounded and killed apps. The route is left blank because the - // client ignores blank routes and just opens the app on a tap. + // backgrounded and killed apps. The route opens the messages window on tap. var pushTargets = 0; foreach (var userId in _notificationStore.GetUsersWithDevices()) { - _pushDelivery.QueueToUser(userId, "Message from your server", message, route: ""); + _pushDelivery.QueueToUser(userId, "Message from your server", message, route: "messages"); pushTargets++; } @@ -1525,6 +1543,13 @@ public class MoonfinPingResponse [JsonPropertyName("tmdbAvailable")] public bool? TmdbAvailable { get; set; } + /// + /// True when this plugin has the admin messages endpoints. Older plugins leave it out, so + /// the app reads a missing value as false and hides the button. + /// + [JsonPropertyName("messagesSupported")] + public bool? MessagesSupported { get; set; } + [JsonPropertyName("defaultSettings")] public MoonfinSettingsProfile? DefaultSettings { get; set; } diff --git a/Jellyfin/backend/Api/MoonfinMessagesController.cs b/Jellyfin/backend/Api/MoonfinMessagesController.cs new file mode 100644 index 0000000..787a87c --- /dev/null +++ b/Jellyfin/backend/Api/MoonfinMessagesController.cs @@ -0,0 +1,202 @@ +using System.Net.Mime; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moonfin.Server.Services; + +namespace Moonfin.Server.Api; + +/// +/// API controller for admin messages shown to users in the Moonfin app. +/// +[ApiController] +[Route("Moonfin")] +[Produces(MediaTypeNames.Application.Json)] +public class MoonfinMessagesController : ControllerBase +{ + private readonly MoonfinSettingsService _settingsService; + private readonly NotificationStore _notificationStore; + private readonly PushDeliveryService _pushDelivery; + + public MoonfinMessagesController( + MoonfinSettingsService settingsService, + NotificationStore notificationStore, + PushDeliveryService pushDelivery) + { + _settingsService = settingsService; + _notificationStore = notificationStore; + _pushDelivery = pushDelivery; + } + + /// + /// Returns the messages the calling user should see right now. + /// + [HttpGet("Messages")] + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + public ActionResult GetMessages() + { + var config = MoonfinPlugin.Instance?.Configuration; + if (config?.EnableSettingsSync != true) + { + return StatusCode(StatusCodes.Status503ServiceUnavailable, new { error = "Settings sync is disabled" }); + } + + var userId = this.GetUserIdFromClaims(); + if (userId == null) + { + return Unauthorized(new { error = "A valid user token is required" }); + } + + // Filtering happens here, not on the client. Sending everything and hiding it in the + // app would let any user read messages meant for someone else. + var isAdmin = this.IsAdminFromClaims(); + var now = DateTime.UtcNow; + var items = config.Messages + .Where(m => m.IsVisibleTo(userId.Value, isAdmin, now)) + .OrderByDescending(m => m.Pinned) + .ThenByDescending(m => m.CreatedUtc) + .ToList(); + + return Ok(new { items }); + } + + /// + /// Returns every message, including scheduled and expired ones, for the admin panel. + /// + [HttpGet("Admin/Messages")] + [Authorize(Policy = "RequiresElevation")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult GetAdminMessages() + { + var messages = MoonfinPlugin.Instance?.Configuration.Messages ?? new List(); + + return Ok(new + { + items = messages + .OrderByDescending(m => m.Pinned) + .ThenByDescending(m => m.CreatedUtc) + .ToList() + }); + } + + /// + /// Creates a message, or replaces one when the ID already exists. + /// + [HttpPost("Admin/Messages")] + [Authorize(Policy = "RequiresElevation")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public ActionResult SaveMessage([FromBody] ServerMessage message) + { + var plugin = MoonfinPlugin.Instance; + if (plugin == null) + { + return StatusCode(StatusCodes.Status503ServiceUnavailable, new { error = "Plugin is not ready" }); + } + + if (message == null) + { + return BadRequest(new { error = "A message body is required" }); + } + + message.Sanitize(); + + if (message.Title.Length == 0 && message.Body.Length == 0) + { + return BadRequest(new { error = "A title or a body is required" }); + } + + var messages = plugin.Configuration.Messages; + var existing = messages.FirstOrDefault(m => + string.Equals(m.Id, message.Id, StringComparison.OrdinalIgnoreCase)); + + if (existing != null) + { + message.CreatedUtc = existing.CreatedUtc; + message.CreatedByUserId = existing.CreatedByUserId; + messages.Remove(existing); + } + else + { + message.Id = Guid.NewGuid().ToString("N"); + message.CreatedUtc = DateTime.UtcNow; + message.CreatedByUserId = this.GetUserIdFromClaims()?.ToString("N"); + } + + messages.Add(message); + ServerMessage.Prune(messages); + plugin.SaveConfiguration(); + + _settingsService.BroadcastSystemEvent("messagesChanged"); + + // Only push for messages meant to interrupt. An inbox message can wait until the user + // opens the app. + if (existing == null && message.Delivery != ServerMessage.DeliveryInbox) + { + SendPush(message); + } + + return Ok(new { success = true, item = message }); + } + + /// + /// Deletes one message by ID. + /// + [HttpDelete("Admin/Messages/{messageId}")] + [Authorize(Policy = "RequiresElevation")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult DeleteMessage([FromRoute] string messageId) + { + var plugin = MoonfinPlugin.Instance; + if (plugin == null) + { + return StatusCode(StatusCodes.Status503ServiceUnavailable, new { error = "Plugin is not ready" }); + } + + var removed = plugin.Configuration.Messages + .RemoveAll(m => string.Equals(m.Id, messageId, StringComparison.OrdinalIgnoreCase)); + + if (removed == 0) + { + return NotFound(new { error = "Message not found" }); + } + + plugin.SaveConfiguration(); + _settingsService.BroadcastSystemEvent("messagesChanged"); + + return Ok(new { success = true }); + } + + /// + /// Queues a push so the message also reaches users who do not have the app open. + /// The route tells the app to open the messages window on tap. + /// + private void SendPush(ServerMessage message) + { + // Admin-only messages get no push: we cannot tell who is an admin without the user + // manager, and admins still see them next time they open the app. + if (message.Audience == ServerMessage.AudienceAdmins) + { + return; + } + + var title = message.Title.Length > 0 ? message.Title : "Message from your server"; + var body = message.Body; + + foreach (var userId in _notificationStore.GetUsersWithDevices()) + { + if (message.Audience == ServerMessage.AudienceUsers && + !message.TargetUserIds.Any(id => + Guid.TryParse(id, out var target) && target == userId)) + { + continue; + } + + _pushDelivery.QueueToUser(userId, title, body, route: "messages"); + } + } +} diff --git a/Jellyfin/backend/PluginConfiguration.cs b/Jellyfin/backend/PluginConfiguration.cs index 4d88d1e..5ce4828 100644 --- a/Jellyfin/backend/PluginConfiguration.cs +++ b/Jellyfin/backend/PluginConfiguration.cs @@ -205,6 +205,12 @@ public class PluginConfiguration : BasePluginConfiguration /// public List UploadedThemes { get; set; } = new(); + /// + /// Admin messages shown to users in the app. They are only a few KB of text, so they live + /// in the config instead of the data folder. + /// + public List Messages { get; set; } = new(); + // --------------------------------------------------------------------- // Retro games (EmulatorJS) configuration // --------------------------------------------------------------------- @@ -371,3 +377,194 @@ public class UploadedThemeEntry public string? UploadedByUserId { get; set; } public string ChecksumSha256 { get; set; } = string.Empty; } + +/// +/// One message the admin wrote for users to read in the app. +/// +public class ServerMessage +{ + /// Highest number of messages kept. Older ones are dropped on save. + public const int MaxStored = 50; + + /// Body length cap, so a huge paste cannot break the app layout. + public const int MaxBodyLength = 2000; + + public const string SeverityInfo = "info"; + public const string SeverityWarning = "warning"; + public const string SeverityCritical = "critical"; + + /// Shows in the list only. + public const string DeliveryInbox = "inbox"; + + /// Shows a small toast when it arrives. + public const string DeliveryToast = "toast"; + + /// Opens the message window once, until the user reads it. + public const string DeliveryPopup = "popup"; + + public const string AudienceAll = "all"; + public const string AudienceUsers = "users"; + public const string AudienceAdmins = "admins"; + + public string Id { get; set; } = string.Empty; + public string Title { get; set; } = string.Empty; + public string Body { get; set; } = string.Empty; + public string Severity { get; set; } = SeverityInfo; + public string Delivery { get; set; } = DeliveryInbox; + public bool Pinned { get; set; } + public string? ActionLabel { get; set; } + public string? ActionUrl { get; set; } + + /// When the message starts showing. Null means right away. + public DateTime? StartUtc { get; set; } + + /// When the message stops showing. Null means never. + public DateTime? EndUtc { get; set; } + + public string Audience { get; set; } = AudienceAll; + + /// User IDs to show this to. Only used when is "users". + public List TargetUserIds { get; set; } = new(); + + public DateTime CreatedUtc { get; set; } + public string? CreatedByUserId { get; set; } + + /// + /// True when this message should show right now, for this user. + /// + public bool IsVisibleTo(Guid userId, bool isAdmin, DateTime nowUtc) + { + if (StartUtc.HasValue && nowUtc < StartUtc.Value) + { + return false; + } + + if (EndUtc.HasValue && nowUtc >= EndUtc.Value) + { + return false; + } + + return Audience switch + { + AudienceAdmins => isAdmin, + AudienceUsers => TargetUserIds.Any(id => + Guid.TryParse(id, out var target) && target == userId), + _ => true + }; + } + + /// + /// Cleans admin input before it is saved. Bad values fall back to the default instead of + /// being rejected, except the action URL which is dropped when it is not http or https. + /// + public void Sanitize() + { + Title = (Title ?? string.Empty).Trim(); + Body = (Body ?? string.Empty).Trim(); + + if (Body.Length > MaxBodyLength) + { + Body = Body.Substring(0, MaxBodyLength); + } + + Severity = Severity switch + { + SeverityWarning => SeverityWarning, + SeverityCritical => SeverityCritical, + _ => SeverityInfo + }; + + Delivery = Delivery switch + { + DeliveryToast => DeliveryToast, + DeliveryPopup => DeliveryPopup, + _ => DeliveryInbox + }; + + Audience = Audience switch + { + AudienceUsers => AudienceUsers, + AudienceAdmins => AudienceAdmins, + _ => AudienceAll + }; + + if (Audience != AudienceUsers) + { + TargetUserIds = new List(); + } + else + { + TargetUserIds = TargetUserIds + .Where(id => Guid.TryParse(id, out _)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + ActionLabel = string.IsNullOrWhiteSpace(ActionLabel) ? null : ActionLabel.Trim(); + ActionUrl = SanitizeActionUrl(ActionUrl); + + // A link with no label is useless to the user, and a label with no link does nothing. + if (ActionUrl == null) + { + ActionLabel = null; + } + else if (ActionLabel == null) + { + ActionUrl = null; + } + + // An end date before the start date would hide the message forever. + if (StartUtc.HasValue && EndUtc.HasValue && EndUtc.Value <= StartUtc.Value) + { + EndUtc = null; + } + } + + /// + /// Keeps only real http and https links. Anything else is dropped, since this URL gets + /// opened on the user's device. + /// + private static string? SanitizeActionUrl(string? rawUrl) + { + if (string.IsNullOrWhiteSpace(rawUrl)) + { + return null; + } + + var value = rawUrl.Trim().Trim('"', '\'').Trim(); + + if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || + (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) + { + return null; + } + + return uri.ToString(); + } + + /// + /// Drops expired messages, then the oldest ones if the list is still too long. Keeps the + /// config file from growing forever without needing a scheduled task. + /// + public static void Prune(List messages) + { + var now = DateTime.UtcNow; + messages.RemoveAll(m => m.EndUtc.HasValue && m.EndUtc.Value < now); + + if (messages.Count <= MaxStored) + { + return; + } + + var extra = messages + .OrderBy(m => m.Pinned) + .ThenBy(m => m.CreatedUtc) + .Take(messages.Count - MaxStored) + .ToList(); + + foreach (var message in extra) + { + messages.Remove(message); + } + } +} diff --git a/Jellyfin/tests/Moonfin.Server.Tests/ServerMessageTests.cs b/Jellyfin/tests/Moonfin.Server.Tests/ServerMessageTests.cs new file mode 100644 index 0000000..5a2f190 --- /dev/null +++ b/Jellyfin/tests/Moonfin.Server.Tests/ServerMessageTests.cs @@ -0,0 +1,220 @@ +using Moonfin.Server; +using Moonfin.Server.Api; +using Xunit; + +namespace Moonfin.Server.Tests; + +public class ServerMessageTests +{ + private static readonly DateTime Now = new(2026, 8, 24, 12, 0, 0, DateTimeKind.Utc); + private static readonly Guid Alice = Guid.Parse("11111111-1111-1111-1111-111111111111"); + private static readonly Guid Bob = Guid.Parse("22222222-2222-2222-2222-222222222222"); + + private static ServerMessage Message() => new() + { + Id = "m1", + Title = "Hello", + Body = "Body", + CreatedUtc = Now + }; + + [Fact] + public void IsVisibleTo_ShowsPlainMessageToEveryone() + { + var message = Message(); + + Assert.True(message.IsVisibleTo(Alice, isAdmin: false, Now)); + Assert.True(message.IsVisibleTo(Bob, isAdmin: true, Now)); + } + + [Fact] + public void IsVisibleTo_HidesMessageBeforeItStarts() + { + var message = Message(); + message.StartUtc = Now.AddHours(1); + + Assert.False(message.IsVisibleTo(Alice, isAdmin: false, Now)); + Assert.True(message.IsVisibleTo(Alice, isAdmin: false, Now.AddHours(2))); + } + + [Fact] + public void IsVisibleTo_HidesMessageOnceItEnds() + { + var message = Message(); + message.EndUtc = Now.AddHours(1); + + Assert.True(message.IsVisibleTo(Alice, isAdmin: false, Now)); + Assert.False(message.IsVisibleTo(Alice, isAdmin: false, Now.AddHours(1))); + Assert.False(message.IsVisibleTo(Alice, isAdmin: false, Now.AddHours(2))); + } + + [Fact] + public void IsVisibleTo_AdminsOnlyMessageStaysHiddenFromUsers() + { + var message = Message(); + message.Audience = ServerMessage.AudienceAdmins; + + Assert.False(message.IsVisibleTo(Alice, isAdmin: false, Now)); + Assert.True(message.IsVisibleTo(Alice, isAdmin: true, Now)); + } + + [Fact] + public void IsVisibleTo_TargetedMessageReachesOnlyItsTargets() + { + var message = Message(); + message.Audience = ServerMessage.AudienceUsers; + message.TargetUserIds = new List { Alice.ToString() }; + + Assert.True(message.IsVisibleTo(Alice, isAdmin: false, Now)); + Assert.False(message.IsVisibleTo(Bob, isAdmin: false, Now)); + + // Being an admin does not grant access to someone else's message. + Assert.False(message.IsVisibleTo(Bob, isAdmin: true, Now)); + } + + [Fact] + public void Sanitize_FallsBackToDefaultsOnUnknownValues() + { + var message = Message(); + message.Severity = "catastrophic"; + message.Delivery = "carrier-pigeon"; + message.Audience = "nobody"; + + message.Sanitize(); + + Assert.Equal(ServerMessage.SeverityInfo, message.Severity); + Assert.Equal(ServerMessage.DeliveryInbox, message.Delivery); + Assert.Equal(ServerMessage.AudienceAll, message.Audience); + } + + [Theory] + [InlineData("javascript:alert(1)")] + [InlineData("file:///etc/passwd")] + [InlineData("moonfin://open")] + [InlineData("not a url")] + public void Sanitize_DropsLinksThatAreNotHttp(string url) + { + var message = Message(); + message.ActionLabel = "Open"; + message.ActionUrl = url; + + message.Sanitize(); + + Assert.Null(message.ActionUrl); + Assert.Null(message.ActionLabel); + } + + [Fact] + public void Sanitize_KeepsHttpAndHttpsLinks() + { + var message = Message(); + message.ActionLabel = "Open"; + message.ActionUrl = "https://example.com/news"; + + message.Sanitize(); + + Assert.Equal("https://example.com/news", message.ActionUrl); + Assert.Equal("Open", message.ActionLabel); + } + + [Fact] + public void Sanitize_DropsLabelWithoutLinkAndLinkWithoutLabel() + { + var labelOnly = Message(); + labelOnly.ActionLabel = "Open"; + labelOnly.Sanitize(); + Assert.Null(labelOnly.ActionLabel); + + var linkOnly = Message(); + linkOnly.ActionUrl = "https://example.com"; + linkOnly.Sanitize(); + Assert.Null(linkOnly.ActionUrl); + } + + [Fact] + public void Sanitize_CutsBodyToTheCap() + { + var message = Message(); + message.Body = new string('x', ServerMessage.MaxBodyLength + 500); + + message.Sanitize(); + + Assert.Equal(ServerMessage.MaxBodyLength, message.Body.Length); + } + + [Fact] + public void Sanitize_ClearsTargetsWhenAudienceIsNotUsers() + { + var message = Message(); + message.Audience = ServerMessage.AudienceAll; + message.TargetUserIds = new List { Alice.ToString() }; + + message.Sanitize(); + + Assert.Empty(message.TargetUserIds); + } + + [Fact] + public void Sanitize_DropsTargetsThatAreNotUserIds() + { + var message = Message(); + message.Audience = ServerMessage.AudienceUsers; + message.TargetUserIds = new List { Alice.ToString(), "everyone" }; + + message.Sanitize(); + + Assert.Equal(new[] { Alice.ToString() }, message.TargetUserIds); + } + + [Fact] + public void Sanitize_ClearsEndDateThatWouldHideTheMessageForever() + { + var message = Message(); + message.StartUtc = Now; + message.EndUtc = Now.AddHours(-1); + + message.Sanitize(); + + Assert.Null(message.EndUtc); + } + + [Fact] + public void PruneMessages_RemovesExpiredMessages() + { + var fresh = Message(); + var expired = Message(); + expired.Id = "old"; + expired.EndUtc = DateTime.UtcNow.AddDays(-1); + + var messages = new List { fresh, expired }; + ServerMessage.Prune(messages); + + Assert.Equal(new[] { "m1" }, messages.Select(m => m.Id)); + } + + [Fact] + public void PruneMessages_DropsOldestFirstAndKeepsPinned() + { + var messages = new List(); + + var pinned = Message(); + pinned.Id = "pinned"; + pinned.Pinned = true; + pinned.CreatedUtc = Now.AddYears(-5); + messages.Add(pinned); + + for (var i = 0; i < ServerMessage.MaxStored + 5; i++) + { + var message = Message(); + message.Id = $"m{i}"; + message.CreatedUtc = Now.AddMinutes(i); + messages.Add(message); + } + + ServerMessage.Prune(messages); + + Assert.Equal(ServerMessage.MaxStored, messages.Count); + Assert.Contains(messages, m => m.Id == "pinned"); + Assert.DoesNotContain(messages, m => m.Id == "m0"); + } +} From a3a6a3183c1b4cc0a8d285064b6f7e9928cc38e7 Mon Sep 17 00:00:00 2001 From: bientavu Date: Mon, 24 Aug 2026 15:28:09 +0200 Subject: [PATCH 02/11] feat(messages): add the messages tab to the config page --- Jellyfin/backend/Pages/configPage.html | 536 ++++++++++++++++++++++++- 1 file changed, 534 insertions(+), 2 deletions(-) diff --git a/Jellyfin/backend/Pages/configPage.html b/Jellyfin/backend/Pages/configPage.html index af1c50c..56a7049 100644 --- a/Jellyfin/backend/Pages/configPage.html +++ b/Jellyfin/backend/Pages/configPage.html @@ -374,6 +374,10 @@

Moonfin Settings

ThemesUpload & catalog + + + + + + +
+

Saved Messages

+
+ Up to 50 messages are kept. Past that, the oldest unpinned ones are dropped. +
+
+
+
@@ -1744,8 +1859,9 @@

Apply Defaults To Users

Broadcast Message

- Send a live message to all currently connected Moonfin clients. - This message is not saved and only appears for users with an active connection. + Send a message to all connected Moonfin clients right now. It is also saved to + the Messages tab, so users who were offline still see it later. + For anything you want to write properly, use the Messages tab instead.
@@ -3582,6 +3698,374 @@

Active Downloads

}); } + var moonfinEditingMessageId = null; + + var moonfinSeverityColors = { + info: '#52b54b', + warning: '#f0ad4e', + critical: '#d9534f' + }; + + var moonfinDeliveryLabels = { + inbox: 'Quiet', + toast: 'Corner popup', + popup: 'Opens the window' + }; + + function setMessageResult(text, color) { + var result = document.querySelector('#MessageSaveResult'); + if (!result) { + return; + } + + if (!text) { + result.style.display = 'none'; + result.textContent = ''; + return; + } + + result.style.display = ''; + result.style.color = color || 'rgba(255,255,255,0.75)'; + result.textContent = text; + } + + // The date inputs work in the admin's own time zone, but the server stores UTC. + function messageDateToInput(utcValue) { + if (!utcValue) { + return ''; + } + + var date = new Date(utcValue); + if (isNaN(date.getTime())) { + return ''; + } + + var pad = function(n) { return (n < 10 ? '0' : '') + n; }; + return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate()) + + 'T' + pad(date.getHours()) + ':' + pad(date.getMinutes()); + } + + function messageDateFromInput(value) { + if (!value) { + return null; + } + + var date = new Date(value); + return isNaN(date.getTime()) ? null : date.toISOString(); + } + + function formatMessageDate(utcValue) { + if (!utcValue) { + return ''; + } + + var date = new Date(utcValue); + return isNaN(date.getTime()) ? '' : date.toLocaleString(); + } + + function toggleMessageTargets() { + var audience = document.querySelector('#MessageAudience'); + var row = document.querySelector('#MessageTargetsRow'); + if (!audience || !row) { + return; + } + + row.style.display = audience.value === 'users' ? '' : 'none'; + } + + function loadMessageTargetUsers() { + var select = document.querySelector('#MessageTargets'); + if (!select || !ApiClient.getUsers) { + return Promise.resolve(); + } + + return ApiClient.getUsers().then(function(users) { + select.innerHTML = ''; + (users || []).forEach(function(user) { + var option = document.createElement('option'); + option.value = user.Id; + option.textContent = user.Name; + select.appendChild(option); + }); + }); + } + + function resetMessageForm() { + moonfinEditingMessageId = null; + + document.querySelector('#MessageTitle').value = ''; + document.querySelector('#MessageBody').value = ''; + document.querySelector('#MessageSeverity').value = 'info'; + document.querySelector('#MessageDelivery').value = 'inbox'; + document.querySelector('#MessageAudience').value = 'all'; + document.querySelector('#MessageStart').value = ''; + document.querySelector('#MessageEnd').value = ''; + document.querySelector('#MessageActionLabel').value = ''; + document.querySelector('#MessageActionUrl').value = ''; + document.querySelector('#MessagePinned').checked = false; + + var targets = document.querySelector('#MessageTargets'); + if (targets) { + Array.prototype.forEach.call(targets.options, function(option) { + option.selected = false; + }); + } + + var saveBtn = document.querySelector('#MessageSaveBtn'); + if (saveBtn) { + saveBtn.querySelector('span').textContent = 'Save Message'; + } + + var resetBtn = document.querySelector('#MessageResetBtn'); + if (resetBtn) { + resetBtn.style.display = 'none'; + } + + toggleMessageTargets(); + setMessageResult('', ''); + } + + function editMessage(item) { + moonfinEditingMessageId = item.id || item.Id || null; + + document.querySelector('#MessageTitle').value = item.title || item.Title || ''; + document.querySelector('#MessageBody').value = item.body || item.Body || ''; + document.querySelector('#MessageSeverity').value = item.severity || item.Severity || 'info'; + document.querySelector('#MessageDelivery').value = item.delivery || item.Delivery || 'inbox'; + document.querySelector('#MessageAudience').value = item.audience || item.Audience || 'all'; + document.querySelector('#MessageStart').value = messageDateToInput(item.startUtc || item.StartUtc); + document.querySelector('#MessageEnd').value = messageDateToInput(item.endUtc || item.EndUtc); + document.querySelector('#MessageActionLabel').value = item.actionLabel || item.ActionLabel || ''; + document.querySelector('#MessageActionUrl').value = item.actionUrl || item.ActionUrl || ''; + document.querySelector('#MessagePinned').checked = !!(item.pinned || item.Pinned); + + var selected = item.targetUserIds || item.TargetUserIds || []; + var targets = document.querySelector('#MessageTargets'); + if (targets) { + Array.prototype.forEach.call(targets.options, function(option) { + option.selected = selected.some(function(id) { + return String(id).toLowerCase() === String(option.value).toLowerCase(); + }); + }); + } + + var saveBtn = document.querySelector('#MessageSaveBtn'); + if (saveBtn) { + saveBtn.querySelector('span').textContent = 'Update Message'; + } + + var resetBtn = document.querySelector('#MessageResetBtn'); + if (resetBtn) { + resetBtn.style.display = ''; + } + + toggleMessageTargets(); + setMessageResult('', ''); + + var titleInput = document.querySelector('#MessageTitle'); + if (titleInput) { + titleInput.focus(); + } + } + + function renderMessagesList(items) { + var container = document.querySelector('#MessagesList'); + if (!container) { + return; + } + + if (!items || items.length === 0) { + container.innerHTML = '
No messages yet.
'; + return; + } + + var now = new Date(); + var html = ''; + + items.forEach(function(item) { + var id = item.id || item.Id || ''; + var title = item.title || item.Title || ''; + var body = item.body || item.Body || ''; + var severity = item.severity || item.Severity || 'info'; + var delivery = item.delivery || item.Delivery || 'inbox'; + var audience = item.audience || item.Audience || 'all'; + var pinned = !!(item.pinned || item.Pinned); + var start = item.startUtc || item.StartUtc; + var end = item.endUtc || item.EndUtc; + var targets = item.targetUserIds || item.TargetUserIds || []; + var color = moonfinSeverityColors[severity] || moonfinSeverityColors.info; + + var state = 'Showing now'; + if (start && new Date(start) > now) { + state = 'Starts ' + formatMessageDate(start); + } else if (end && new Date(end) <= now) { + state = 'Expired'; + } else if (end) { + state = 'Until ' + formatMessageDate(end); + } + + var who = audience === 'admins' + ? 'Admins only' + : audience === 'users' + ? targets.length + (targets.length === 1 ? ' user' : ' users') + : 'Everyone'; + + html += '
' + + '
' + + '
' + + '' + esc(title || '(no title)') + '' + + (pinned ? 'PINNED' : '') + + '
' + + '
' + esc(body.length > 160 ? body.slice(0, 160) + '…' : body) + '
' + + '
' + + esc(state) + ' • ' + esc(who) + ' • ' + esc(moonfinDeliveryLabels[delivery] || delivery) + + '
' + + '
' + + '' + + '' + + '
'; + }); + + container.innerHTML = html; + } + + function loadMessagesList() { + var container = document.querySelector('#MessagesList'); + if (!container) { + return; + } + + var serverUrl = ApiClient.serverAddress ? ApiClient.serverAddress() : ''; + container.innerHTML = '
Loading messages...
'; + + fetch(serverUrl + '/Moonfin/Admin/Messages', { + method: 'GET', + headers: getMoonfinAuthHeaders() + }) + .then(parseJsonResponse) + .then(function(payload) { + var items = payload.items || payload.Items || []; + window.moonfinMessagesCache = items; + renderMessagesList(items); + }) + .catch(function(error) { + container.innerHTML = '
' + + esc((error && error.message) ? error.message : 'Failed to load messages.') + '
'; + }); + } + + function saveMessage() { + var saveBtn = document.querySelector('#MessageSaveBtn'); + var title = (document.querySelector('#MessageTitle').value || '').trim(); + var body = (document.querySelector('#MessageBody').value || '').trim(); + + if (!title && !body) { + setMessageResult('Enter a title or a message.', '#d9534f'); + return; + } + + var audience = document.querySelector('#MessageAudience').value; + var targetSelect = document.querySelector('#MessageTargets'); + var targetUserIds = []; + if (audience === 'users' && targetSelect) { + targetUserIds = Array.prototype.filter.call(targetSelect.options, function(option) { + return option.selected; + }).map(function(option) { + return option.value; + }); + + if (targetUserIds.length === 0) { + setMessageResult('Pick at least one user, or change who sees it.', '#d9534f'); + return; + } + } + + var payload = { + id: moonfinEditingMessageId || '', + title: title, + body: body, + severity: document.querySelector('#MessageSeverity').value, + delivery: document.querySelector('#MessageDelivery').value, + audience: audience, + targetUserIds: targetUserIds, + pinned: document.querySelector('#MessagePinned').checked, + actionLabel: (document.querySelector('#MessageActionLabel').value || '').trim(), + actionUrl: (document.querySelector('#MessageActionUrl').value || '').trim(), + startUtc: messageDateFromInput(document.querySelector('#MessageStart').value), + endUtc: messageDateFromInput(document.querySelector('#MessageEnd').value) + }; + + var serverUrl = ApiClient.serverAddress ? ApiClient.serverAddress() : ''; + var wasEditing = !!moonfinEditingMessageId; + + saveBtn.disabled = true; + setMessageResult('', ''); + Dashboard.showLoadingMsg(); + + fetch(serverUrl + '/Moonfin/Admin/Messages', { + method: 'POST', + headers: getMoonfinAuthHeaders(), + body: JSON.stringify(payload) + }) + .then(parseJsonResponse) + .then(function(response) { + var saved = response.item || response.Item || {}; + var warnings = []; + + if (payload.actionUrl && !(saved.actionUrl || saved.ActionUrl)) { + warnings.push('the link was dropped, it must start with http:// or https://'); + } + if (payload.endUtc && !(saved.endUtc || saved.EndUtc)) { + warnings.push('the end date was dropped, it was before the start date'); + } + + var message = wasEditing ? 'Message updated.' : 'Message saved.'; + if (warnings.length) { + message += ' Note: ' + warnings.join(', ') + '.'; + } + + // resetMessageForm clears the result line, so set it afterwards. + resetMessageForm(); + setMessageResult(message, warnings.length ? '#f0ad4e' : '#52b54b'); + loadMessagesList(); + }) + .catch(function(error) { + setMessageResult((error && error.message) ? error.message : 'Save failed.', '#d9534f'); + }) + .finally(function() { + saveBtn.disabled = false; + Dashboard.hideLoadingMsg(); + }); + } + + function deleteMessage(messageId) { + if (!messageId) { + return; + } + + if (!window.confirm('Delete this message?')) { + return; + } + + var serverUrl = ApiClient.serverAddress ? ApiClient.serverAddress() : ''; + + fetch(serverUrl + '/Moonfin/Admin/Messages/' + encodeURIComponent(messageId), { + method: 'DELETE', + headers: getMoonfinAuthHeaders() + }) + .then(parseJsonResponse) + .then(function() { + if (moonfinEditingMessageId === messageId) { + resetMessageForm(); + } + setMessageResult('Message deleted.', '#52b54b'); + loadMessagesList(); + }) + .catch(function(error) { + setMessageResult((error && error.message) ? error.message : 'Delete failed.', '#d9534f'); + }); + } + function ensureMoonfinAdminRuntimeStyles() { var source = document.getElementById('MoonfinAdminRuntimeStyleSource'); if (!source || !source.textContent) { @@ -3731,6 +4215,54 @@

Active Downloads

setThemeUploadResult('', ''); loadAdminThemesList(); + var messageAudience = document.querySelector('#MessageAudience'); + if (messageAudience && !messageAudience.dataset.bound) { + messageAudience.dataset.bound = 'true'; + messageAudience.addEventListener('change', toggleMessageTargets); + } + + var messageSaveBtn = document.querySelector('#MessageSaveBtn'); + if (messageSaveBtn && !messageSaveBtn.dataset.bound) { + messageSaveBtn.dataset.bound = 'true'; + messageSaveBtn.addEventListener('click', saveMessage); + } + + var messageResetBtn = document.querySelector('#MessageResetBtn'); + if (messageResetBtn && !messageResetBtn.dataset.bound) { + messageResetBtn.dataset.bound = 'true'; + messageResetBtn.addEventListener('click', resetMessageForm); + } + + var messagesListContainer = document.querySelector('#MessagesList'); + if (messagesListContainer && !messagesListContainer.dataset.bound) { + messagesListContainer.dataset.bound = 'true'; + messagesListContainer.addEventListener('click', function(event) { + var editButton = event.target.closest('.moonfinMessageEditBtn'); + if (editButton) { + var editId = editButton.getAttribute('data-message-id') || ''; + var cached = window.moonfinMessagesCache || []; + var found = cached.filter(function(item) { + return (item.id || item.Id) === editId; + })[0]; + if (found) { + editMessage(found); + } + return; + } + + var deleteButton = event.target.closest('.moonfinMessageDeleteBtn'); + if (deleteButton) { + deleteMessage(deleteButton.getAttribute('data-message-id') || ''); + } + }); + } + + // The user list must be there before the form can preselect targets on edit. + loadMessageTargetUsers().then(function() { + resetMessageForm(); + loadMessagesList(); + }); + Dashboard.showLoadingMsg(); ApiClient.getPluginConfiguration(MoonfinConfig.pluginUniqueId).then(function(config) { document.querySelector('#EnableSettingsSync').checked = config.EnableSettingsSync; From 7a0a32584cbc9156b83bc199c1c94ad43594e252 Mon Sep 17 00:00:00 2001 From: bientavu Date: Mon, 24 Aug 2026 15:28:21 +0200 Subject: [PATCH 03/11] feat(messages): mirror admin messages on the Emby plugin --- Emby/Emby.Plugins.Moonfin/Api/AuthHelpers.cs | 7 + .../Api/MessagesRequests.cs | 27 ++ .../Api/MessagesService.cs | 154 ++++++++ .../Api/SettingsService.cs | 29 +- .../Pages/configPage.html | 120 +++++- Emby/Emby.Plugins.Moonfin/Pages/moonfin.js | 352 ++++++++++++++++++ .../PluginConfiguration.cs | 178 +++++++++ 7 files changed, 862 insertions(+), 5 deletions(-) create mode 100644 Emby/Emby.Plugins.Moonfin/Api/MessagesRequests.cs create mode 100644 Emby/Emby.Plugins.Moonfin/Api/MessagesService.cs diff --git a/Emby/Emby.Plugins.Moonfin/Api/AuthHelpers.cs b/Emby/Emby.Plugins.Moonfin/Api/AuthHelpers.cs index aa9dc5e..0dd1d68 100644 --- a/Emby/Emby.Plugins.Moonfin/Api/AuthHelpers.cs +++ b/Emby/Emby.Plugins.Moonfin/Api/AuthHelpers.cs @@ -23,6 +23,13 @@ internal static class AuthHelpers catch { return null; } } + /// True when the caller is a server admin, the same check the "Admin" role uses. + public static bool IsCurrentUserAdmin(IRequest request, IAuthorizationContext authContext) + { + try { return GetCurrentUser(request, authContext)?.Policy?.IsAdministrator == true; } + catch { return false; } + } + /// Returns all server user GUIDs, or null if the user manager is unavailable. public static IReadOnlyCollection? GetAllServerUserIds() { diff --git a/Emby/Emby.Plugins.Moonfin/Api/MessagesRequests.cs b/Emby/Emby.Plugins.Moonfin/Api/MessagesRequests.cs new file mode 100644 index 0000000..2eb2b3c --- /dev/null +++ b/Emby/Emby.Plugins.Moonfin/Api/MessagesRequests.cs @@ -0,0 +1,27 @@ +using MediaBrowser.Controller.Net; +using MediaBrowser.Model.Services; + +namespace Emby.Plugins.Moonfin.Api +{ + [Route("/Moonfin/Messages", "GET")] + [Authenticated] + public class GetMessagesRequest : IReturn { } + + [Route("/Moonfin/Admin/Messages", "GET")] + [Authenticated(Roles = "Admin")] + public class GetAdminMessagesRequest : IReturn { } + + [Route("/Moonfin/Admin/Messages", "POST")] + [Authenticated(Roles = "Admin")] + public class SaveMessageRequest : IReturn, IRequiresRequestStream + { + public System.IO.Stream RequestStream { get; set; } = null!; + } + + [Route("/Moonfin/Admin/Messages/{MessageId}", "DELETE")] + [Authenticated(Roles = "Admin")] + public class DeleteMessageRequest : IReturn + { + public string? MessageId { get; set; } + } +} diff --git a/Emby/Emby.Plugins.Moonfin/Api/MessagesService.cs b/Emby/Emby.Plugins.Moonfin/Api/MessagesService.cs new file mode 100644 index 0000000..c65d52b --- /dev/null +++ b/Emby/Emby.Plugins.Moonfin/Api/MessagesService.cs @@ -0,0 +1,154 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using MediaBrowser.Common; +using MediaBrowser.Controller.Net; +using MediaBrowser.Model.Services; + +namespace Emby.Plugins.Moonfin.Api +{ + public class MessagesService : IService, IRequiresRequest, IHasResultFactory + { + private readonly IAuthorizationContext _authContext; + + public IRequest Request { get; set; } = null!; + public IHttpResultFactory ResultFactory { get; set; } = null!; + + public MessagesService(IApplicationHost appHost) + { + _authContext = appHost.Resolve(); + ResultFactory = appHost.Resolve(); + } + + private object Json(object? body) => MoonfinJson.Result(Request, ResultFactory, body); + private object Json(int statusCode, object? body) { Request.Response.StatusCode = statusCode; return Json(body); } + + public object Get(GetMessagesRequest request) + { + var config = Plugin.Instance?.Configuration; + if (config?.EnableSettingsSync != true) + return Json(503, new { error = "Settings sync is disabled" }); + + var userId = AuthHelpers.GetCurrentUserId(Request, _authContext); + if (userId == null) return Json(401, new { error = "A valid user token is required" }); + + // Filtering happens here, not on the client. Sending everything and hiding it in + // the app would let any user read messages meant for someone else. + var isAdmin = AuthHelpers.IsCurrentUserAdmin(Request, _authContext); + var now = DateTime.UtcNow; + var items = config.Messages + .Where(m => m.IsVisibleTo(userId.Value, isAdmin, now)) + .OrderByDescending(m => m.Pinned) + .ThenByDescending(m => m.CreatedUtc) + .ToList(); + + return Json(new { items }); + } + + public object Get(GetAdminMessagesRequest request) + { + var messages = Plugin.Instance?.Configuration?.Messages ?? new List(); + + return Json(new + { + items = messages + .OrderByDescending(m => m.Pinned) + .ThenByDescending(m => m.CreatedUtc) + .ToList() + }); + } + + public async Task Post(SaveMessageRequest request) + { + var plugin = Plugin.Instance; + var config = plugin?.Configuration; + if (plugin == null || config == null) return Json(503, new { error = "Plugin is not ready" }); + + var message = await MoonfinJson.ReadBodyAsync(request.RequestStream).ConfigureAwait(false); + if (message == null) return Json(400, new { error = "A message body is required" }); + + message.Sanitize(); + + if (message.Title.Length == 0 && message.Body.Length == 0) + return Json(400, new { error = "A title or a body is required" }); + + var messages = config.Messages; + var existing = messages.FirstOrDefault(m => + string.Equals(m.Id, message.Id, StringComparison.OrdinalIgnoreCase)); + + if (existing != null) + { + message.CreatedUtc = existing.CreatedUtc; + message.CreatedByUserId = existing.CreatedByUserId; + messages.Remove(existing); + } + else + { + message.Id = Guid.NewGuid().ToString("N"); + message.CreatedUtc = DateTime.UtcNow; + message.CreatedByUserId = AuthHelpers.GetCurrentUserId(Request, _authContext)?.ToString("N"); + } + + messages.Add(message); + ServerMessage.Prune(messages); + plugin.UpdateConfiguration(config); + + Plugin.Instance?.SettingsService?.BroadcastSystemEvent("messagesChanged"); + + // Only push for messages meant to interrupt. An inbox message can wait until the + // user opens the app. + if (existing == null && message.Delivery != ServerMessage.DeliveryInbox) + SendPush(message); + + return Json(new { success = true, item = message }); + } + + public object Delete(DeleteMessageRequest request) + { + var plugin = Plugin.Instance; + var config = plugin?.Configuration; + if (plugin == null || config == null) return Json(503, new { error = "Plugin is not ready" }); + + var messageId = request.MessageId ?? string.Empty; + var removed = config.Messages + .RemoveAll(m => string.Equals(m.Id, messageId, StringComparison.OrdinalIgnoreCase)); + + if (removed == 0) return Json(404, new { error = "Message not found" }); + + plugin.UpdateConfiguration(config); + Plugin.Instance?.SettingsService?.BroadcastSystemEvent("messagesChanged"); + + return Json(new { success = true }); + } + + /// + /// Queues a push so the message also reaches users who do not have the app open. + /// The route tells the app to open the messages window on tap. + /// + private static void SendPush(ServerMessage message) + { + // Admin-only messages get no push, since the notification store does not know who + // is an admin. Admins still see them next time they open the app. + if (message.Audience == ServerMessage.AudienceAdmins) return; + + var store = Plugin.Instance?.NotificationStore; + var pushDelivery = Plugin.Instance?.PushDelivery; + if (store == null || pushDelivery == null) return; + + var title = message.Title.Length > 0 ? message.Title : "Message from your server"; + + foreach (var userId in store.GetUsersWithDevices()) + { + if (message.Audience == ServerMessage.AudienceUsers && + !message.TargetUserIds.Any(id => + Guid.TryParse(id, out var target) && target == userId)) + { + continue; + } + + pushDelivery.QueueToUser(userId, title, message.Body, route: "messages"); + } + } + } +} diff --git a/Emby/Emby.Plugins.Moonfin/Api/SettingsService.cs b/Emby/Emby.Plugins.Moonfin/Api/SettingsService.cs index f966634..08e2c15 100644 --- a/Emby/Emby.Plugins.Moonfin/Api/SettingsService.cs +++ b/Emby/Emby.Plugins.Moonfin/Api/SettingsService.cs @@ -81,6 +81,9 @@ public object Get(GetPingRequest request) jellyseerrUrl = seerrUrl, mdblistAvailable = !string.IsNullOrWhiteSpace(config?.MdblistApiKey), tmdbAvailable = !string.IsNullOrWhiteSpace(config?.TmdbApiKey), + // Older plugins leave this out, which the app reads as false and hides the + // messages button. + messagesSupported = true, defaultSettings = config?.DefaultUserSettings }); } @@ -341,9 +344,29 @@ public async Task Post(BroadcastRequest request) var deliveries = Settings.BroadcastMessage(message); + // Also save it, so users who were not connected still find it in the app. Older + // clients keep reading the "adminMessage" event above and ignore this. + var config = Plugin.Instance?.Configuration; + if (Plugin.Instance != null && config != null) + { + config.Messages.Add(new ServerMessage + { + Id = Guid.NewGuid().ToString("N"), + Body = message, + Severity = ServerMessage.SeverityInfo, + Delivery = ServerMessage.DeliveryPopup, + Audience = ServerMessage.AudienceAll, + CreatedUtc = DateTime.UtcNow, + CreatedByUserId = AuthHelpers.GetCurrentUserId(Request, _authContext)?.ToString("N") + }); + ServerMessage.Prune(config.Messages); + Plugin.Instance.UpdateConfiguration(config); + Settings.BroadcastSystemEvent("messagesChanged"); + } + // The websocket only reaches clients that are open right now, so push - // covers backgrounded and killed apps. The route is left blank because - // the client ignores blank routes and just opens the app on a tap. + // covers backgrounded and killed apps. The route opens the messages window + // on tap. var pushTargets = 0; var store = Plugin.Instance?.NotificationStore; var pushDelivery = Plugin.Instance?.PushDelivery; @@ -351,7 +374,7 @@ public async Task Post(BroadcastRequest request) { foreach (var userId in store.GetUsersWithDevices()) { - pushDelivery.QueueToUser(userId, "Message from your server", message, route: ""); + pushDelivery.QueueToUser(userId, "Message from your server", message, route: "messages"); pushTargets++; } } diff --git a/Emby/Emby.Plugins.Moonfin/Pages/configPage.html b/Emby/Emby.Plugins.Moonfin/Pages/configPage.html index c28c2ea..c7644b0 100644 --- a/Emby/Emby.Plugins.Moonfin/Pages/configPage.html +++ b/Emby/Emby.Plugins.Moonfin/Pages/configPage.html @@ -567,6 +567,10 @@

Moonfin Settings

ThemesUpload & catalog + + + + + + +
+

Saved Messages

+
+ Up to 50 messages are kept. Past that, the oldest unpinned ones are dropped. +
+
+
+
@@ -1889,8 +2004,9 @@

Push Defaults To Existing Users

Broadcast Message

- Send a live message to all currently connected Moonfin clients. - This message is not saved and only appears for users with an active connection. + Send a message to all connected Moonfin clients right now. It is also saved to + the Messages tab, so users who were offline still see it later. + For anything you want to write properly, use the Messages tab instead.
diff --git a/Emby/Emby.Plugins.Moonfin/Pages/moonfin.js b/Emby/Emby.Plugins.Moonfin/Pages/moonfin.js index 7da78e9..4bdbe1e 100644 --- a/Emby/Emby.Plugins.Moonfin/Pages/moonfin.js +++ b/Emby/Emby.Plugins.Moonfin/Pages/moonfin.js @@ -2068,6 +2068,323 @@ define(['baseView', 'loading', 'emby-input', 'emby-button', 'emby-checkbox', 'em }); } + var moonfinSeverityColors = { + info: '#52b54b', + warning: '#f0ad4e', + critical: '#d9534f' + }; + + var moonfinDeliveryLabels = { + inbox: 'Quiet', + toast: 'Corner popup', + popup: 'Opens the window' + }; + + function setMessageResult(view, text, color) { + var result = view.querySelector('#MessageSaveResult'); + if (!result) return; + + if (!text) { + result.style.display = 'none'; + result.textContent = ''; + return; + } + + result.style.display = ''; + result.style.color = color || ''; + result.textContent = text; + } + + // The date inputs work in the admin's own time zone, but the server stores UTC. + function messageDateToInput(utcValue) { + if (!utcValue) return ''; + + var date = new Date(utcValue); + if (isNaN(date.getTime())) return ''; + + var pad = function (n) { return (n < 10 ? '0' : '') + n; }; + return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate()) + + 'T' + pad(date.getHours()) + ':' + pad(date.getMinutes()); + } + + function messageDateFromInput(value) { + if (!value) return null; + var date = new Date(value); + return isNaN(date.getTime()) ? null : date.toISOString(); + } + + function formatMessageDate(utcValue) { + if (!utcValue) return ''; + var date = new Date(utcValue); + return isNaN(date.getTime()) ? '' : date.toLocaleString(); + } + + function toggleMessageTargets(view) { + var audience = view.querySelector('#MessageAudience'); + var row = view.querySelector('#MessageTargetsRow'); + if (!audience || !row) return; + row.style.display = audience.value === 'users' ? '' : 'none'; + } + + function loadMessageTargetUsers(view) { + var select = view.querySelector('#MessageTargets'); + if (!select || !ApiClient.getUsers) return Promise.resolve(); + + return ApiClient.getUsers().then(function (users) { + select.innerHTML = ''; + (users || []).forEach(function (user) { + var option = document.createElement('option'); + option.value = user.Id; + option.textContent = user.Name; + select.appendChild(option); + }); + }).catch(function () {}); + } + + function resetMessageForm(view) { + view.__moonfinEditingMessageId = null; + + view.querySelector('#MessageTitle').value = ''; + view.querySelector('#MessageBody').value = ''; + view.querySelector('#MessageSeverity').value = 'info'; + view.querySelector('#MessageDelivery').value = 'inbox'; + view.querySelector('#MessageAudience').value = 'all'; + view.querySelector('#MessageStart').value = ''; + view.querySelector('#MessageEnd').value = ''; + view.querySelector('#MessageActionLabel').value = ''; + view.querySelector('#MessageActionUrl').value = ''; + view.querySelector('#MessagePinned').checked = false; + + var targets = view.querySelector('#MessageTargets'); + if (targets) { + Array.prototype.forEach.call(targets.options, function (option) { option.selected = false; }); + } + + var saveBtn = view.querySelector('#MessageSaveBtn'); + if (saveBtn) saveBtn.querySelector('span').textContent = 'Save Message'; + + var resetBtn = view.querySelector('#MessageResetBtn'); + if (resetBtn) resetBtn.style.display = 'none'; + + toggleMessageTargets(view); + setMessageResult(view, '', ''); + } + + function editMessage(view, item) { + view.__moonfinEditingMessageId = item.Id || item.id || null; + + view.querySelector('#MessageTitle').value = item.Title || item.title || ''; + view.querySelector('#MessageBody').value = item.Body || item.body || ''; + view.querySelector('#MessageSeverity').value = item.Severity || item.severity || 'info'; + view.querySelector('#MessageDelivery').value = item.Delivery || item.delivery || 'inbox'; + view.querySelector('#MessageAudience').value = item.Audience || item.audience || 'all'; + view.querySelector('#MessageStart').value = messageDateToInput(item.StartUtc || item.startUtc); + view.querySelector('#MessageEnd').value = messageDateToInput(item.EndUtc || item.endUtc); + view.querySelector('#MessageActionLabel').value = item.ActionLabel || item.actionLabel || ''; + view.querySelector('#MessageActionUrl').value = item.ActionUrl || item.actionUrl || ''; + view.querySelector('#MessagePinned').checked = !!(item.Pinned || item.pinned); + + var selected = item.TargetUserIds || item.targetUserIds || []; + var targets = view.querySelector('#MessageTargets'); + if (targets) { + Array.prototype.forEach.call(targets.options, function (option) { + option.selected = selected.some(function (id) { + return String(id).toLowerCase() === String(option.value).toLowerCase(); + }); + }); + } + + var saveBtn = view.querySelector('#MessageSaveBtn'); + if (saveBtn) saveBtn.querySelector('span').textContent = 'Update Message'; + + var resetBtn = view.querySelector('#MessageResetBtn'); + if (resetBtn) resetBtn.style.display = ''; + + toggleMessageTargets(view); + setMessageResult(view, '', ''); + + var titleInput = view.querySelector('#MessageTitle'); + if (titleInput) titleInput.focus(); + } + + function renderMessagesList(view, items) { + var container = view.querySelector('#MessagesList'); + if (!container) return; + + if (!items || items.length === 0) { + container.innerHTML = '
No messages yet.
'; + return; + } + + var now = new Date(); + var html = ''; + + items.forEach(function (item) { + var id = item.Id || item.id || ''; + var title = item.Title || item.title || ''; + var body = item.Body || item.body || ''; + var severity = item.Severity || item.severity || 'info'; + var delivery = item.Delivery || item.delivery || 'inbox'; + var audience = item.Audience || item.audience || 'all'; + var pinned = !!(item.Pinned || item.pinned); + var start = item.StartUtc || item.startUtc; + var end = item.EndUtc || item.endUtc; + var targets = item.TargetUserIds || item.targetUserIds || []; + var color = moonfinSeverityColors[severity] || moonfinSeverityColors.info; + + var state = 'Showing now'; + if (start && new Date(start) > now) { + state = 'Starts ' + formatMessageDate(start); + } else if (end && new Date(end) <= now) { + state = 'Expired'; + } else if (end) { + state = 'Until ' + formatMessageDate(end); + } + + var who = audience === 'admins' + ? 'Admins only' + : audience === 'users' + ? targets.length + (targets.length === 1 ? ' user' : ' users') + : 'Everyone'; + + html += '
' + + '
' + + '
' + + '' + esc(title || '(no title)') + '' + + (pinned ? 'PINNED' : '') + + '
' + + '
' + esc(body.length > 160 ? body.slice(0, 160) + '…' : body) + '
' + + '
' + + esc(state) + ' • ' + esc(who) + ' • ' + esc(moonfinDeliveryLabels[delivery] || delivery) + + '
' + + '
' + + '' + + '' + + '
'; + }); + + container.innerHTML = html; + } + + function loadMessagesList(view) { + var container = view.querySelector('#MessagesList'); + if (!container) return; + + var serverUrl = ApiClient.serverAddress ? ApiClient.serverAddress() : ''; + container.innerHTML = '
Loading messages...
'; + + fetch(serverUrl + '/Moonfin/Admin/Messages', { method: 'GET', headers: moonfinAuthHeaders() }) + .then(parseJsonResponse) + .then(function (payload) { + var items = payload.items || payload.Items || []; + view.__moonfinMessagesCache = items; + renderMessagesList(view, items); + }) + .catch(function (error) { + container.innerHTML = '
' + + esc((error && error.message) ? error.message : 'Failed to load messages.') + '
'; + }); + } + + function saveMessage(view) { + var saveBtn = view.querySelector('#MessageSaveBtn'); + var title = (view.querySelector('#MessageTitle').value || '').trim(); + var body = (view.querySelector('#MessageBody').value || '').trim(); + + if (!title && !body) { + setMessageResult(view, 'Enter a title or a message.', '#d9534f'); + return; + } + + var audience = view.querySelector('#MessageAudience').value; + var targetSelect = view.querySelector('#MessageTargets'); + var targetUserIds = []; + if (audience === 'users' && targetSelect) { + targetUserIds = Array.prototype.filter.call(targetSelect.options, function (option) { + return option.selected; + }).map(function (option) { return option.value; }); + + if (targetUserIds.length === 0) { + setMessageResult(view, 'Pick at least one user, or change who sees it.', '#d9534f'); + return; + } + } + + var payload = { + Id: view.__moonfinEditingMessageId || '', + Title: title, + Body: body, + Severity: view.querySelector('#MessageSeverity').value, + Delivery: view.querySelector('#MessageDelivery').value, + Audience: audience, + TargetUserIds: targetUserIds, + Pinned: view.querySelector('#MessagePinned').checked, + ActionLabel: (view.querySelector('#MessageActionLabel').value || '').trim(), + ActionUrl: (view.querySelector('#MessageActionUrl').value || '').trim(), + StartUtc: messageDateFromInput(view.querySelector('#MessageStart').value), + EndUtc: messageDateFromInput(view.querySelector('#MessageEnd').value) + }; + + var serverUrl = ApiClient.serverAddress ? ApiClient.serverAddress() : ''; + var wasEditing = !!view.__moonfinEditingMessageId; + + if (saveBtn) saveBtn.disabled = true; + setMessageResult(view, '', ''); + + fetch(serverUrl + '/Moonfin/Admin/Messages', { + method: 'POST', + headers: moonfinAuthHeaders(), + body: JSON.stringify(payload) + }) + .then(parseJsonResponse) + .then(function (response) { + var saved = response.item || response.Item || {}; + var warnings = []; + + if (payload.ActionUrl && !(saved.ActionUrl || saved.actionUrl)) { + warnings.push('the link was dropped, it must start with http:// or https://'); + } + if (payload.EndUtc && !(saved.EndUtc || saved.endUtc)) { + warnings.push('the end date was dropped, it was before the start date'); + } + + var message = wasEditing ? 'Message updated.' : 'Message saved.'; + if (warnings.length) message += ' Note: ' + warnings.join(', ') + '.'; + + // resetMessageForm clears the result line, so set it afterwards. + resetMessageForm(view); + setMessageResult(view, message, warnings.length ? '#f0ad4e' : '#52b54b'); + loadMessagesList(view); + }) + .catch(function (error) { + setMessageResult(view, (error && error.message) ? error.message : 'Save failed.', '#d9534f'); + }) + .finally(function () { + if (saveBtn) saveBtn.disabled = false; + }); + } + + function deleteMessage(view, messageId) { + if (!messageId) return; + if (!window.confirm('Delete this message?')) return; + + var serverUrl = ApiClient.serverAddress ? ApiClient.serverAddress() : ''; + + fetch(serverUrl + '/Moonfin/Admin/Messages/' + encodeURIComponent(messageId), { + method: 'DELETE', + headers: moonfinAuthHeaders() + }) + .then(parseJsonResponse) + .then(function () { + if (view.__moonfinEditingMessageId === messageId) resetMessageForm(view); + setMessageResult(view, 'Message deleted.', '#52b54b'); + loadMessagesList(view); + }) + .catch(function (error) { + setMessageResult(view, (error && error.message) ? error.message : 'Delete failed.', '#d9534f'); + }); + } + function initializeDefaultsSubtabs(view) { var subnav = view.querySelector('.defaultsSubnavBar'); if (!subnav || subnav.dataset.bound) return; @@ -2144,6 +2461,36 @@ define(['baseView', 'loading', 'emby-input', 'emby-button', 'emby-checkbox', 'em var broadcastBtn = view.querySelector('#BroadcastMessageBtn'); if (broadcastBtn) broadcastBtn.addEventListener('click', function () { broadcast(view); }); + var messageAudience = view.querySelector('#MessageAudience'); + if (messageAudience) { + messageAudience.addEventListener('change', function () { toggleMessageTargets(view); }); + } + + var messageSaveBtn = view.querySelector('#MessageSaveBtn'); + if (messageSaveBtn) messageSaveBtn.addEventListener('click', function () { saveMessage(view); }); + + var messageResetBtn = view.querySelector('#MessageResetBtn'); + if (messageResetBtn) messageResetBtn.addEventListener('click', function () { resetMessageForm(view); }); + + var messagesListContainer = view.querySelector('#MessagesList'); + if (messagesListContainer) { + messagesListContainer.addEventListener('click', function (event) { + var editButton = event.target.closest('.moonfinMessageEditBtn'); + if (editButton) { + var editId = editButton.getAttribute('data-message-id') || ''; + var cached = view.__moonfinMessagesCache || []; + var found = cached.filter(function (item) { + return (item.Id || item.id) === editId; + })[0]; + if (found) editMessage(view, found); + return; + } + + var deleteButton = event.target.closest('.moonfinMessageDeleteBtn'); + if (deleteButton) deleteMessage(view, deleteButton.getAttribute('data-message-id') || ''); + }); + } + var mdblistTestBtn = view.querySelector('#MdblistTestKeyBtn'); if (mdblistTestBtn) mdblistTestBtn.addEventListener('click', function () { testMdblistKey(view); }); @@ -2164,6 +2511,11 @@ define(['baseView', 'loading', 'emby-input', 'emby-button', 'emby-checkbox', 'em clearSelectedThemeFile(view); setThemeUploadResult(view, '', ''); loadAdminThemesList(view); + // The user list must be there before the form can preselect targets on edit. + loadMessageTargetUsers(view).then(function () { + resetMessageForm(view); + loadMessagesList(view); + }); loadConfig(view); }; diff --git a/Emby/Emby.Plugins.Moonfin/PluginConfiguration.cs b/Emby/Emby.Plugins.Moonfin/PluginConfiguration.cs index 0a9efe7..3ee8929 100644 --- a/Emby/Emby.Plugins.Moonfin/PluginConfiguration.cs +++ b/Emby/Emby.Plugins.Moonfin/PluginConfiguration.cs @@ -130,6 +130,12 @@ public bool EnsureWebhookSecret() /// Metadata index for uploaded custom themes stored in the plugin data folder. public List UploadedThemes { get; set; } = new List(); + /// + /// Admin messages shown to users in the app. They are only a few KB of text, so they + /// live in the config instead of the data folder. + /// + public List Messages { get; set; } = new List(); + // Retro games (EmulatorJS) configuration. public bool GamesEnabled { get; set; } = false; public List GameLibraryIds { get; set; } = new List(); @@ -224,4 +230,176 @@ public class UploadedThemeEntry public string? UploadedByUserId { get; set; } public string ChecksumSha256 { get; set; } = string.Empty; } + + /// + /// One message the admin wrote for users to read in the app. + /// + public class ServerMessage + { + /// Highest number of messages kept. Older ones are dropped on save. + public const int MaxStored = 50; + + /// Body length cap, so a huge paste cannot break the app layout. + public const int MaxBodyLength = 2000; + + public const string SeverityInfo = "info"; + public const string SeverityWarning = "warning"; + public const string SeverityCritical = "critical"; + + /// Shows in the list only. + public const string DeliveryInbox = "inbox"; + + /// Shows a small toast when it arrives. + public const string DeliveryToast = "toast"; + + /// Opens the message window once, until the user reads it. + public const string DeliveryPopup = "popup"; + + public const string AudienceAll = "all"; + public const string AudienceUsers = "users"; + public const string AudienceAdmins = "admins"; + + public string Id { get; set; } = string.Empty; + public string Title { get; set; } = string.Empty; + public string Body { get; set; } = string.Empty; + public string Severity { get; set; } = SeverityInfo; + public string Delivery { get; set; } = DeliveryInbox; + public bool Pinned { get; set; } + public string? ActionLabel { get; set; } + public string? ActionUrl { get; set; } + + /// When the message starts showing. Null means right away. + public DateTime? StartUtc { get; set; } + + /// When the message stops showing. Null means never. + public DateTime? EndUtc { get; set; } + + public string Audience { get; set; } = AudienceAll; + + /// User IDs to show this to. Only used when Audience is "users". + public List TargetUserIds { get; set; } = new List(); + + public DateTime CreatedUtc { get; set; } + public string? CreatedByUserId { get; set; } + + /// + /// True when this message should show right now, for this user. + /// + public bool IsVisibleTo(Guid userId, bool isAdmin, DateTime nowUtc) + { + if (StartUtc.HasValue && nowUtc < StartUtc.Value) + return false; + + if (EndUtc.HasValue && nowUtc >= EndUtc.Value) + return false; + + return Audience switch + { + AudienceAdmins => isAdmin, + AudienceUsers => TargetUserIds.Any(id => + Guid.TryParse(id, out var target) && target == userId), + _ => true + }; + } + + /// + /// Cleans admin input before it is saved. Bad values fall back to the default instead + /// of being rejected, except the action URL which is dropped when it is not http or + /// https. + /// + public void Sanitize() + { + Title = (Title ?? string.Empty).Trim(); + Body = (Body ?? string.Empty).Trim(); + + if (Body.Length > MaxBodyLength) + Body = Body.Substring(0, MaxBodyLength); + + Severity = Severity switch + { + SeverityWarning => SeverityWarning, + SeverityCritical => SeverityCritical, + _ => SeverityInfo + }; + + Delivery = Delivery switch + { + DeliveryToast => DeliveryToast, + DeliveryPopup => DeliveryPopup, + _ => DeliveryInbox + }; + + Audience = Audience switch + { + AudienceUsers => AudienceUsers, + AudienceAdmins => AudienceAdmins, + _ => AudienceAll + }; + + if (Audience != AudienceUsers) + { + TargetUserIds = new List(); + } + else + { + TargetUserIds = TargetUserIds + .Where(id => Guid.TryParse(id, out _)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + ActionLabel = string.IsNullOrWhiteSpace(ActionLabel) ? null : ActionLabel.Trim(); + ActionUrl = SanitizeActionUrl(ActionUrl); + + // A link with no label is useless to the user, and a label with no link does nothing. + if (ActionUrl == null) + ActionLabel = null; + else if (ActionLabel == null) + ActionUrl = null; + + // An end date before the start date would hide the message forever. + if (StartUtc.HasValue && EndUtc.HasValue && EndUtc.Value <= StartUtc.Value) + EndUtc = null; + } + + /// + /// Keeps only real http and https links. Anything else is dropped, since this URL gets + /// opened on the user's device. + /// + private static string? SanitizeActionUrl(string? rawUrl) + { + if (string.IsNullOrWhiteSpace(rawUrl)) + return null; + + var value = rawUrl.Trim().Trim('"', '\'').Trim(); + + if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || + (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) + return null; + + return uri.ToString(); + } + + /// + /// Drops expired messages, then the oldest ones if the list is still too long. Keeps + /// the config file from growing forever without needing a scheduled task. + /// + public static void Prune(List messages) + { + var now = DateTime.UtcNow; + messages.RemoveAll(m => m.EndUtc.HasValue && m.EndUtc.Value < now); + + if (messages.Count <= MaxStored) + return; + + var extra = messages + .OrderBy(m => m.Pinned) + .ThenBy(m => m.CreatedUtc) + .Take(messages.Count - MaxStored) + .ToList(); + + foreach (var message in extra) + messages.Remove(message); + } + } } From ca1c18d84345b463ab6b76980c93457de67e4de8 Mon Sep 17 00:00:00 2001 From: bientavu Date: Mon, 24 Aug 2026 16:43:09 +0200 Subject: [PATCH 04/11] feat(messages): pick a message colour and write markdown in the editor --- Jellyfin/backend/Api/MoonfinController.cs | 2 +- Jellyfin/backend/Pages/configPage.html | 41 +++++++++++-------- Jellyfin/backend/PluginConfiguration.cs | 20 +++++---- .../ServerMessageTests.cs | 20 ++++++++- 4 files changed, 56 insertions(+), 27 deletions(-) diff --git a/Jellyfin/backend/Api/MoonfinController.cs b/Jellyfin/backend/Api/MoonfinController.cs index d4cde47..8980378 100644 --- a/Jellyfin/backend/Api/MoonfinController.cs +++ b/Jellyfin/backend/Api/MoonfinController.cs @@ -513,7 +513,7 @@ public ActionResult BroadcastMessage([FromBody] MoonfinBroadcastRequest request) { Id = Guid.NewGuid().ToString("N"), Body = message, - Severity = ServerMessage.SeverityInfo, + Color = ServerMessage.ColorWhite, Delivery = ServerMessage.DeliveryPopup, Audience = ServerMessage.AudienceAll, CreatedUtc = DateTime.UtcNow, diff --git a/Jellyfin/backend/Pages/configPage.html b/Jellyfin/backend/Pages/configPage.html index 56a7049..d239839 100644 --- a/Jellyfin/backend/Pages/configPage.html +++ b/Jellyfin/backend/Pages/configPage.html @@ -1717,17 +1717,24 @@

Messages

- + +
+ Formatting uses Markdown. Only CommonMark is supported: + syntax reference. + Images are not shown, even though that page lists them. +
- - + + + + + -
Sets the colour of the message and of the button in the app menu.
+
Colours the message in the app. The menu button stays neutral.
@@ -3700,10 +3707,12 @@

Active Downloads

var moonfinEditingMessageId = null; - var moonfinSeverityColors = { - info: '#52b54b', - warning: '#f0ad4e', - critical: '#d9534f' + var moonfinMessageColors = { + white: '#e8eaed', + green: '#4caf6d', + blue: '#4a9ee0', + yellow: '#e0b040', + red: '#e05260' }; var moonfinDeliveryLabels = { @@ -3795,7 +3804,7 @@

Active Downloads

document.querySelector('#MessageTitle').value = ''; document.querySelector('#MessageBody').value = ''; - document.querySelector('#MessageSeverity').value = 'info'; + document.querySelector('#MessageColor').value = 'white'; document.querySelector('#MessageDelivery').value = 'inbox'; document.querySelector('#MessageAudience').value = 'all'; document.querySelector('#MessageStart').value = ''; @@ -3830,7 +3839,7 @@

Active Downloads

document.querySelector('#MessageTitle').value = item.title || item.Title || ''; document.querySelector('#MessageBody').value = item.body || item.Body || ''; - document.querySelector('#MessageSeverity').value = item.severity || item.Severity || 'info'; + document.querySelector('#MessageColor').value = item.color || item.Color || 'white'; document.querySelector('#MessageDelivery').value = item.delivery || item.Delivery || 'inbox'; document.querySelector('#MessageAudience').value = item.audience || item.Audience || 'all'; document.querySelector('#MessageStart').value = messageDateToInput(item.startUtc || item.StartUtc); @@ -3886,14 +3895,14 @@

Active Downloads

var id = item.id || item.Id || ''; var title = item.title || item.Title || ''; var body = item.body || item.Body || ''; - var severity = item.severity || item.Severity || 'info'; + var pick = item.color || item.Color || 'white'; var delivery = item.delivery || item.Delivery || 'inbox'; var audience = item.audience || item.Audience || 'all'; var pinned = !!(item.pinned || item.Pinned); var start = item.startUtc || item.StartUtc; var end = item.endUtc || item.EndUtc; var targets = item.targetUserIds || item.TargetUserIds || []; - var color = moonfinSeverityColors[severity] || moonfinSeverityColors.info; + var color = moonfinMessageColors[pick] || moonfinMessageColors.white; var state = 'Showing now'; if (start && new Date(start) > now) { @@ -3984,7 +3993,7 @@

Active Downloads

id: moonfinEditingMessageId || '', title: title, body: body, - severity: document.querySelector('#MessageSeverity').value, + color: document.querySelector('#MessageColor').value, delivery: document.querySelector('#MessageDelivery').value, audience: audience, targetUserIds: targetUserIds, diff --git a/Jellyfin/backend/PluginConfiguration.cs b/Jellyfin/backend/PluginConfiguration.cs index 5ce4828..eba522f 100644 --- a/Jellyfin/backend/PluginConfiguration.cs +++ b/Jellyfin/backend/PluginConfiguration.cs @@ -389,9 +389,11 @@ public class ServerMessage /// Body length cap, so a huge paste cannot break the app layout. public const int MaxBodyLength = 2000; - public const string SeverityInfo = "info"; - public const string SeverityWarning = "warning"; - public const string SeverityCritical = "critical"; + public const string ColorGreen = "green"; + public const string ColorRed = "red"; + public const string ColorYellow = "yellow"; + public const string ColorBlue = "blue"; + public const string ColorWhite = "white"; /// Shows in the list only. public const string DeliveryInbox = "inbox"; @@ -409,7 +411,7 @@ public class ServerMessage public string Id { get; set; } = string.Empty; public string Title { get; set; } = string.Empty; public string Body { get; set; } = string.Empty; - public string Severity { get; set; } = SeverityInfo; + public string Color { get; set; } = ColorWhite; public string Delivery { get; set; } = DeliveryInbox; public bool Pinned { get; set; } public string? ActionLabel { get; set; } @@ -467,11 +469,13 @@ public void Sanitize() Body = Body.Substring(0, MaxBodyLength); } - Severity = Severity switch + Color = Color switch { - SeverityWarning => SeverityWarning, - SeverityCritical => SeverityCritical, - _ => SeverityInfo + ColorGreen => ColorGreen, + ColorRed => ColorRed, + ColorYellow => ColorYellow, + ColorBlue => ColorBlue, + _ => ColorWhite }; Delivery = Delivery switch diff --git a/Jellyfin/tests/Moonfin.Server.Tests/ServerMessageTests.cs b/Jellyfin/tests/Moonfin.Server.Tests/ServerMessageTests.cs index 5a2f190..44ffda8 100644 --- a/Jellyfin/tests/Moonfin.Server.Tests/ServerMessageTests.cs +++ b/Jellyfin/tests/Moonfin.Server.Tests/ServerMessageTests.cs @@ -76,17 +76,33 @@ public void IsVisibleTo_TargetedMessageReachesOnlyItsTargets() public void Sanitize_FallsBackToDefaultsOnUnknownValues() { var message = Message(); - message.Severity = "catastrophic"; + message.Color = "chartreuse"; message.Delivery = "carrier-pigeon"; message.Audience = "nobody"; message.Sanitize(); - Assert.Equal(ServerMessage.SeverityInfo, message.Severity); + Assert.Equal(ServerMessage.ColorWhite, message.Color); Assert.Equal(ServerMessage.DeliveryInbox, message.Delivery); Assert.Equal(ServerMessage.AudienceAll, message.Audience); } + [Theory] + [InlineData(ServerMessage.ColorGreen)] + [InlineData(ServerMessage.ColorRed)] + [InlineData(ServerMessage.ColorYellow)] + [InlineData(ServerMessage.ColorBlue)] + [InlineData(ServerMessage.ColorWhite)] + public void Sanitize_KeepsEveryColourTheAdminCanPick(string colour) + { + var message = Message(); + message.Color = colour; + + message.Sanitize(); + + Assert.Equal(colour, message.Color); + } + [Theory] [InlineData("javascript:alert(1)")] [InlineData("file:///etc/passwd")] From 5cef2184232dcf30edbdab86b72c06870f580d1f Mon Sep 17 00:00:00 2001 From: bientavu Date: Mon, 24 Aug 2026 16:43:30 +0200 Subject: [PATCH 05/11] feat(messages): mirror the colour picker and editor on the Emby plugin --- .../Api/SettingsService.cs | 2 +- .../Pages/configPage.html | 21 ++++++++++++------- Emby/Emby.Plugins.Moonfin/Pages/moonfin.js | 20 ++++++++++-------- .../PluginConfiguration.cs | 20 +++++++++++------- 4 files changed, 38 insertions(+), 25 deletions(-) diff --git a/Emby/Emby.Plugins.Moonfin/Api/SettingsService.cs b/Emby/Emby.Plugins.Moonfin/Api/SettingsService.cs index 08e2c15..af3b4d7 100644 --- a/Emby/Emby.Plugins.Moonfin/Api/SettingsService.cs +++ b/Emby/Emby.Plugins.Moonfin/Api/SettingsService.cs @@ -353,7 +353,7 @@ public async Task Post(BroadcastRequest request) { Id = Guid.NewGuid().ToString("N"), Body = message, - Severity = ServerMessage.SeverityInfo, + Color = ServerMessage.ColorWhite, Delivery = ServerMessage.DeliveryPopup, Audience = ServerMessage.AudienceAll, CreatedUtc = DateTime.UtcNow, diff --git a/Emby/Emby.Plugins.Moonfin/Pages/configPage.html b/Emby/Emby.Plugins.Moonfin/Pages/configPage.html index c7644b0..0498144 100644 --- a/Emby/Emby.Plugins.Moonfin/Pages/configPage.html +++ b/Emby/Emby.Plugins.Moonfin/Pages/configPage.html @@ -1888,17 +1888,24 @@

Messages

- + +
+ Formatting uses Markdown. Only CommonMark is supported: + syntax reference. + Images are not shown, even though that page lists them. +
- - + + + + + -
Sets the colour of the message and of the button in the app menu.
+
Colours the message in the app. The menu button stays neutral.
diff --git a/Emby/Emby.Plugins.Moonfin/Pages/moonfin.js b/Emby/Emby.Plugins.Moonfin/Pages/moonfin.js index 4bdbe1e..cd593ba 100644 --- a/Emby/Emby.Plugins.Moonfin/Pages/moonfin.js +++ b/Emby/Emby.Plugins.Moonfin/Pages/moonfin.js @@ -2068,10 +2068,12 @@ define(['baseView', 'loading', 'emby-input', 'emby-button', 'emby-checkbox', 'em }); } - var moonfinSeverityColors = { - info: '#52b54b', - warning: '#f0ad4e', - critical: '#d9534f' + var moonfinMessageColors = { + white: '#e8eaed', + green: '#4caf6d', + blue: '#4a9ee0', + yellow: '#e0b040', + red: '#e05260' }; var moonfinDeliveryLabels = { @@ -2146,7 +2148,7 @@ define(['baseView', 'loading', 'emby-input', 'emby-button', 'emby-checkbox', 'em view.querySelector('#MessageTitle').value = ''; view.querySelector('#MessageBody').value = ''; - view.querySelector('#MessageSeverity').value = 'info'; + view.querySelector('#MessageColor').value = 'white'; view.querySelector('#MessageDelivery').value = 'inbox'; view.querySelector('#MessageAudience').value = 'all'; view.querySelector('#MessageStart').value = ''; @@ -2175,7 +2177,7 @@ define(['baseView', 'loading', 'emby-input', 'emby-button', 'emby-checkbox', 'em view.querySelector('#MessageTitle').value = item.Title || item.title || ''; view.querySelector('#MessageBody').value = item.Body || item.body || ''; - view.querySelector('#MessageSeverity').value = item.Severity || item.severity || 'info'; + view.querySelector('#MessageColor').value = item.Color || item.color || 'white'; view.querySelector('#MessageDelivery').value = item.Delivery || item.delivery || 'inbox'; view.querySelector('#MessageAudience').value = item.Audience || item.audience || 'all'; view.querySelector('#MessageStart').value = messageDateToInput(item.StartUtc || item.startUtc); @@ -2223,14 +2225,14 @@ define(['baseView', 'loading', 'emby-input', 'emby-button', 'emby-checkbox', 'em var id = item.Id || item.id || ''; var title = item.Title || item.title || ''; var body = item.Body || item.body || ''; - var severity = item.Severity || item.severity || 'info'; + var pick = item.Color || item.color || 'white'; var delivery = item.Delivery || item.delivery || 'inbox'; var audience = item.Audience || item.audience || 'all'; var pinned = !!(item.Pinned || item.pinned); var start = item.StartUtc || item.startUtc; var end = item.EndUtc || item.endUtc; var targets = item.TargetUserIds || item.targetUserIds || []; - var color = moonfinSeverityColors[severity] || moonfinSeverityColors.info; + var color = moonfinMessageColors[pick] || moonfinMessageColors.white; var state = 'Showing now'; if (start && new Date(start) > now) { @@ -2314,7 +2316,7 @@ define(['baseView', 'loading', 'emby-input', 'emby-button', 'emby-checkbox', 'em Id: view.__moonfinEditingMessageId || '', Title: title, Body: body, - Severity: view.querySelector('#MessageSeverity').value, + Color: view.querySelector('#MessageColor').value, Delivery: view.querySelector('#MessageDelivery').value, Audience: audience, TargetUserIds: targetUserIds, diff --git a/Emby/Emby.Plugins.Moonfin/PluginConfiguration.cs b/Emby/Emby.Plugins.Moonfin/PluginConfiguration.cs index 3ee8929..b0550d3 100644 --- a/Emby/Emby.Plugins.Moonfin/PluginConfiguration.cs +++ b/Emby/Emby.Plugins.Moonfin/PluginConfiguration.cs @@ -242,9 +242,11 @@ public class ServerMessage /// Body length cap, so a huge paste cannot break the app layout. public const int MaxBodyLength = 2000; - public const string SeverityInfo = "info"; - public const string SeverityWarning = "warning"; - public const string SeverityCritical = "critical"; + public const string ColorGreen = "green"; + public const string ColorRed = "red"; + public const string ColorYellow = "yellow"; + public const string ColorBlue = "blue"; + public const string ColorWhite = "white"; /// Shows in the list only. public const string DeliveryInbox = "inbox"; @@ -262,7 +264,7 @@ public class ServerMessage public string Id { get; set; } = string.Empty; public string Title { get; set; } = string.Empty; public string Body { get; set; } = string.Empty; - public string Severity { get; set; } = SeverityInfo; + public string Color { get; set; } = ColorWhite; public string Delivery { get; set; } = DeliveryInbox; public bool Pinned { get; set; } public string? ActionLabel { get; set; } @@ -315,11 +317,13 @@ public void Sanitize() if (Body.Length > MaxBodyLength) Body = Body.Substring(0, MaxBodyLength); - Severity = Severity switch + Color = Color switch { - SeverityWarning => SeverityWarning, - SeverityCritical => SeverityCritical, - _ => SeverityInfo + ColorGreen => ColorGreen, + ColorRed => ColorRed, + ColorYellow => ColorYellow, + ColorBlue => ColorBlue, + _ => ColorWhite }; Delivery = Delivery switch From 55f73da7d15d578527f87806a6abc1f8f2c3345c Mon Sep 17 00:00:00 2001 From: bientavu Date: Mon, 24 Aug 2026 17:57:44 +0200 Subject: [PATCH 06/11] fix(messages): put the message label above the text box --- Emby/Emby.Plugins.Moonfin/Pages/configPage.html | 2 +- Jellyfin/backend/Pages/configPage.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Emby/Emby.Plugins.Moonfin/Pages/configPage.html b/Emby/Emby.Plugins.Moonfin/Pages/configPage.html index 0498144..e46f6fd 100644 --- a/Emby/Emby.Plugins.Moonfin/Pages/configPage.html +++ b/Emby/Emby.Plugins.Moonfin/Pages/configPage.html @@ -1887,7 +1887,7 @@

Messages

- +
Formatting uses Markdown. Only CommonMark is supported: diff --git a/Jellyfin/backend/Pages/configPage.html b/Jellyfin/backend/Pages/configPage.html index d239839..57b26ff 100644 --- a/Jellyfin/backend/Pages/configPage.html +++ b/Jellyfin/backend/Pages/configPage.html @@ -1716,7 +1716,7 @@

Messages

- +
Formatting uses Markdown. Only CommonMark is supported: From 8d98baaeea620f9896ae1eb32210e36079fea9bc Mon Sep 17 00:00:00 2001 From: bientavu Date: Mon, 24 Aug 2026 18:28:38 +0200 Subject: [PATCH 07/11] feat(messages): two delivery modes and admin-controlled order --- .../backend/Api/MoonfinMessagesController.cs | 65 ++++++++++++-- Jellyfin/backend/Pages/configPage.html | 84 ++++++++++++++----- Jellyfin/backend/PluginConfiguration.cs | 16 +--- .../ServerMessageTests.cs | 11 +-- 4 files changed, 128 insertions(+), 48 deletions(-) diff --git a/Jellyfin/backend/Api/MoonfinMessagesController.cs b/Jellyfin/backend/Api/MoonfinMessagesController.cs index 787a87c..a218aa9 100644 --- a/Jellyfin/backend/Api/MoonfinMessagesController.cs +++ b/Jellyfin/backend/Api/MoonfinMessagesController.cs @@ -56,8 +56,6 @@ public ActionResult GetMessages() var now = DateTime.UtcNow; var items = config.Messages .Where(m => m.IsVisibleTo(userId.Value, isAdmin, now)) - .OrderByDescending(m => m.Pinned) - .ThenByDescending(m => m.CreatedUtc) .ToList(); return Ok(new { items }); @@ -76,8 +74,6 @@ public ActionResult GetAdminMessages() return Ok(new { items = messages - .OrderByDescending(m => m.Pinned) - .ThenByDescending(m => m.CreatedUtc) .ToList() }); } @@ -117,16 +113,18 @@ public ActionResult SaveMessage([FromBody] ServerMessage message) { message.CreatedUtc = existing.CreatedUtc; message.CreatedByUserId = existing.CreatedByUserId; - messages.Remove(existing); + // Replaced in place, so editing a message does not move it in the list. + messages[messages.IndexOf(existing)] = message; } else { message.Id = Guid.NewGuid().ToString("N"); message.CreatedUtc = DateTime.UtcNow; message.CreatedByUserId = this.GetUserIdFromClaims()?.ToString("N"); + // Newest first by default. The admin can move it with the arrows. + messages.Insert(0, message); } - messages.Add(message); ServerMessage.Prune(messages); plugin.SaveConfiguration(); @@ -142,6 +140,52 @@ public ActionResult SaveMessage([FromBody] ServerMessage message) return Ok(new { success = true, item = message }); } + /// + /// Reorders the stored messages to match the IDs given. The app shows them in this + /// order, so this is what the up and down arrows in the config page drive. + /// + [HttpPost("Admin/Messages/Order")] + [Authorize(Policy = "RequiresElevation")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public ActionResult ReorderMessages([FromBody] MoonfinMessageOrderRequest request) + { + var plugin = MoonfinPlugin.Instance; + if (plugin == null) + { + return StatusCode(StatusCodes.Status503ServiceUnavailable, new { error = "Plugin is not ready" }); + } + + var ids = request?.Ids; + if (ids == null || ids.Count == 0) + { + return BadRequest(new { error = "ids is required" }); + } + + var messages = plugin.Configuration.Messages; + var reordered = new List(messages.Count); + + foreach (var id in ids) + { + var match = messages.FirstOrDefault(m => + string.Equals(m.Id, id, StringComparison.OrdinalIgnoreCase)); + if (match != null && !reordered.Contains(match)) + { + reordered.Add(match); + } + } + + // Anything the caller left out keeps its place at the end, so a stale list from + // the config page cannot delete messages. + reordered.AddRange(messages.Where(m => !reordered.Contains(m))); + + plugin.Configuration.Messages = reordered; + plugin.SaveConfiguration(); + _settingsService.BroadcastSystemEvent("messagesChanged"); + + return Ok(new { success = true }); + } + /// /// Deletes one message by ID. /// @@ -200,3 +244,12 @@ private void SendPush(ServerMessage message) } } } + +/// +/// Body for the message reorder endpoint. +/// +public class MoonfinMessageOrderRequest +{ + /// Message IDs in the order they should be shown. + public List Ids { get; set; } = new(); +} diff --git a/Jellyfin/backend/Pages/configPage.html b/Jellyfin/backend/Pages/configPage.html index 57b26ff..84a75b4 100644 --- a/Jellyfin/backend/Pages/configPage.html +++ b/Jellyfin/backend/Pages/configPage.html @@ -1738,14 +1738,15 @@

Messages

- +
- The last two also send a phone notification to users who turned them on. + Notify adds the message to the unread count on the menu button. Open shows it + the next time the user opens the app, and also sends a phone notification to + users who turned them on.
@@ -1792,12 +1793,6 @@

Messages

-
- -
' + + '' + + '
' + '' + '' + ''; @@ -3963,6 +3957,43 @@

Active Downloads

}); } + // Moves one message up or down and saves the whole order, so the app shows + // them in the same sequence as this list. + function moveMessage(messageId, delta) { + var items = (window.moonfinMessagesCache || []).slice(); + var from = -1; + for (var i = 0; i < items.length; i++) { + if ((items[i].id || items[i].Id) === messageId) { from = i; break; } + } + + var to = from + delta; + if (from < 0 || to < 0 || to >= items.length) return; + + var moved = items.splice(from, 1)[0]; + items.splice(to, 0, moved); + + // Redrawn straight away so the arrows feel instant, then saved. + window.moonfinMessagesCache = items; + renderMessagesList(items); + + var serverUrl = ApiClient.serverAddress ? ApiClient.serverAddress() : ''; + fetch(serverUrl + '/Moonfin/Admin/Messages/Order', { + method: 'POST', + headers: getMoonfinAuthHeaders(), + body: JSON.stringify({ + ids: items.map(function (item) { return item.id || item.Id; }) + }) + }) + .then(parseJsonResponse) + .catch(function (error) { + setMessageResult( + (error && error.message) ? error.message : 'Could not save the new order.', + '#d9534f' + ); + loadMessagesList(); + }); + } + function saveMessage() { var saveBtn = document.querySelector('#MessageSaveBtn'); var title = (document.querySelector('#MessageTitle').value || '').trim(); @@ -3997,7 +4028,6 @@

Active Downloads

delivery: document.querySelector('#MessageDelivery').value, audience: audience, targetUserIds: targetUserIds, - pinned: document.querySelector('#MessagePinned').checked, actionLabel: (document.querySelector('#MessageActionLabel').value || '').trim(), actionUrl: (document.querySelector('#MessageActionUrl').value || '').trim(), startUtc: messageDateFromInput(document.querySelector('#MessageStart').value), @@ -4246,6 +4276,18 @@

Active Downloads

if (messagesListContainer && !messagesListContainer.dataset.bound) { messagesListContainer.dataset.bound = 'true'; messagesListContainer.addEventListener('click', function(event) { + var upButton = event.target.closest('.moonfinMessageUpBtn'); + if (upButton) { + moveMessage(upButton.getAttribute('data-message-id') || '', -1); + return; + } + + var downButton = event.target.closest('.moonfinMessageDownBtn'); + if (downButton) { + moveMessage(downButton.getAttribute('data-message-id') || '', 1); + return; + } + var editButton = event.target.closest('.moonfinMessageEditBtn'); if (editButton) { var editId = editButton.getAttribute('data-message-id') || ''; diff --git a/Jellyfin/backend/PluginConfiguration.cs b/Jellyfin/backend/PluginConfiguration.cs index eba522f..d824bba 100644 --- a/Jellyfin/backend/PluginConfiguration.cs +++ b/Jellyfin/backend/PluginConfiguration.cs @@ -395,12 +395,9 @@ public class ServerMessage public const string ColorBlue = "blue"; public const string ColorWhite = "white"; - /// Shows in the list only. + /// Only marks the menu button as unread. public const string DeliveryInbox = "inbox"; - /// Shows a small toast when it arrives. - public const string DeliveryToast = "toast"; - /// Opens the message window once, until the user reads it. public const string DeliveryPopup = "popup"; @@ -413,7 +410,6 @@ public class ServerMessage public string Body { get; set; } = string.Empty; public string Color { get; set; } = ColorWhite; public string Delivery { get; set; } = DeliveryInbox; - public bool Pinned { get; set; } public string? ActionLabel { get; set; } public string? ActionUrl { get; set; } @@ -478,12 +474,7 @@ public void Sanitize() _ => ColorWhite }; - Delivery = Delivery switch - { - DeliveryToast => DeliveryToast, - DeliveryPopup => DeliveryPopup, - _ => DeliveryInbox - }; + Delivery = Delivery == DeliveryPopup ? DeliveryPopup : DeliveryInbox; Audience = Audience switch { @@ -561,8 +552,7 @@ public static void Prune(List messages) } var extra = messages - .OrderBy(m => m.Pinned) - .ThenBy(m => m.CreatedUtc) + .OrderBy(m => m.CreatedUtc) .Take(messages.Count - MaxStored) .ToList(); diff --git a/Jellyfin/tests/Moonfin.Server.Tests/ServerMessageTests.cs b/Jellyfin/tests/Moonfin.Server.Tests/ServerMessageTests.cs index 44ffda8..a2fa287 100644 --- a/Jellyfin/tests/Moonfin.Server.Tests/ServerMessageTests.cs +++ b/Jellyfin/tests/Moonfin.Server.Tests/ServerMessageTests.cs @@ -209,16 +209,10 @@ public void PruneMessages_RemovesExpiredMessages() } [Fact] - public void PruneMessages_DropsOldestFirstAndKeepsPinned() + public void PruneMessages_DropsTheOldestFirst() { var messages = new List(); - var pinned = Message(); - pinned.Id = "pinned"; - pinned.Pinned = true; - pinned.CreatedUtc = Now.AddYears(-5); - messages.Add(pinned); - for (var i = 0; i < ServerMessage.MaxStored + 5; i++) { var message = Message(); @@ -230,7 +224,8 @@ public void PruneMessages_DropsOldestFirstAndKeepsPinned() ServerMessage.Prune(messages); Assert.Equal(ServerMessage.MaxStored, messages.Count); - Assert.Contains(messages, m => m.Id == "pinned"); Assert.DoesNotContain(messages, m => m.Id == "m0"); + Assert.DoesNotContain(messages, m => m.Id == "m4"); + Assert.Contains(messages, m => m.Id == "m5"); } } From d5b5e92ec119050c9c9949c071f401dbd1535ea6 Mon Sep 17 00:00:00 2001 From: bientavu Date: Mon, 24 Aug 2026 18:28:54 +0200 Subject: [PATCH 08/11] feat(messages): mirror the delivery modes and ordering on Emby --- .../Api/MessagesRequests.cs | 16 +++++ .../Api/MessagesService.cs | 46 +++++++++++-- .../Pages/configPage.html | 19 ++---- Emby/Emby.Plugins.Moonfin/Pages/moonfin.js | 66 ++++++++++++++++--- .../PluginConfiguration.cs | 16 +---- 5 files changed, 123 insertions(+), 40 deletions(-) diff --git a/Emby/Emby.Plugins.Moonfin/Api/MessagesRequests.cs b/Emby/Emby.Plugins.Moonfin/Api/MessagesRequests.cs index 2eb2b3c..e8d2f26 100644 --- a/Emby/Emby.Plugins.Moonfin/Api/MessagesRequests.cs +++ b/Emby/Emby.Plugins.Moonfin/Api/MessagesRequests.cs @@ -18,6 +18,13 @@ public class SaveMessageRequest : IReturn, IRequiresRequestStream public System.IO.Stream RequestStream { get; set; } = null!; } + [Route("/Moonfin/Admin/Messages/Order", "POST")] + [Authenticated(Roles = "Admin")] + public class ReorderMessagesRequest : IReturn, IRequiresRequestStream + { + public System.IO.Stream RequestStream { get; set; } = null!; + } + [Route("/Moonfin/Admin/Messages/{MessageId}", "DELETE")] [Authenticated(Roles = "Admin")] public class DeleteMessageRequest : IReturn @@ -25,3 +32,12 @@ public class DeleteMessageRequest : IReturn public string? MessageId { get; set; } } } + +namespace Emby.Plugins.Moonfin.Api +{ + /// Body for the message reorder endpoint. + public class MessageOrderBody + { + public System.Collections.Generic.List? Ids { get; set; } + } +} diff --git a/Emby/Emby.Plugins.Moonfin/Api/MessagesService.cs b/Emby/Emby.Plugins.Moonfin/Api/MessagesService.cs index c65d52b..f8b2729 100644 --- a/Emby/Emby.Plugins.Moonfin/Api/MessagesService.cs +++ b/Emby/Emby.Plugins.Moonfin/Api/MessagesService.cs @@ -39,8 +39,6 @@ public object Get(GetMessagesRequest request) var now = DateTime.UtcNow; var items = config.Messages .Where(m => m.IsVisibleTo(userId.Value, isAdmin, now)) - .OrderByDescending(m => m.Pinned) - .ThenByDescending(m => m.CreatedUtc) .ToList(); return Json(new { items }); @@ -53,8 +51,6 @@ public object Get(GetAdminMessagesRequest request) return Json(new { items = messages - .OrderByDescending(m => m.Pinned) - .ThenByDescending(m => m.CreatedUtc) .ToList() }); } @@ -81,16 +77,18 @@ public async Task Post(SaveMessageRequest request) { message.CreatedUtc = existing.CreatedUtc; message.CreatedByUserId = existing.CreatedByUserId; - messages.Remove(existing); + // Replaced in place, so editing a message does not move it in the list. + messages[messages.IndexOf(existing)] = message; } else { message.Id = Guid.NewGuid().ToString("N"); message.CreatedUtc = DateTime.UtcNow; message.CreatedByUserId = AuthHelpers.GetCurrentUserId(Request, _authContext)?.ToString("N"); + // Newest first by default. The admin can move it with the arrows. + messages.Insert(0, message); } - messages.Add(message); ServerMessage.Prune(messages); plugin.UpdateConfiguration(config); @@ -104,6 +102,42 @@ public async Task Post(SaveMessageRequest request) return Json(new { success = true, item = message }); } + /// + /// Reorders the stored messages to match the IDs given. The app shows them in this + /// order, so this is what the up and down arrows in the config page drive. + /// + public async Task Post(ReorderMessagesRequest request) + { + var plugin = Plugin.Instance; + var config = plugin?.Configuration; + if (plugin == null || config == null) return Json(503, new { error = "Plugin is not ready" }); + + var body = await MoonfinJson.ReadBodyAsync(request.RequestStream).ConfigureAwait(false); + var ids = body?.Ids; + if (ids == null || ids.Count == 0) return Json(400, new { error = "ids is required" }); + + var messages = config.Messages; + var reordered = new List(messages.Count); + + foreach (var id in ids) + { + var match = messages.FirstOrDefault(m => + string.Equals(m.Id, id, StringComparison.OrdinalIgnoreCase)); + if (match != null && !reordered.Contains(match)) + reordered.Add(match); + } + + // Anything the caller left out keeps its place at the end, so a stale list from + // the config page cannot delete messages. + reordered.AddRange(messages.Where(m => !reordered.Contains(m))); + + config.Messages = reordered; + plugin.UpdateConfiguration(config); + Plugin.Instance?.SettingsService?.BroadcastSystemEvent("messagesChanged"); + + return Json(new { success = true }); + } + public object Delete(DeleteMessageRequest request) { var plugin = Plugin.Instance; diff --git a/Emby/Emby.Plugins.Moonfin/Pages/configPage.html b/Emby/Emby.Plugins.Moonfin/Pages/configPage.html index e46f6fd..adbba88 100644 --- a/Emby/Emby.Plugins.Moonfin/Pages/configPage.html +++ b/Emby/Emby.Plugins.Moonfin/Pages/configPage.html @@ -1909,14 +1909,15 @@

Messages

- +
- The last two also send a phone notification to users who turned them on. + Notify adds the message to the unread count on the menu button. Open shows it + the next time the user opens the app, and also sends a phone notification to + users who turned them on.
@@ -1963,12 +1964,6 @@

Messages

-
- -
' + + '' + + '
' + '' + '' + ''; @@ -2288,6 +2287,44 @@ define(['baseView', 'loading', 'emby-input', 'emby-button', 'emby-checkbox', 'em }); } + // Moves one message up or down and saves the whole order, so the app shows + // them in the same sequence as this list. + function moveMessage(view, messageId, delta) { + var items = (view.__moonfinMessagesCache || []).slice(); + var from = -1; + for (var i = 0; i < items.length; i++) { + if ((items[i].Id || items[i].id) === messageId) { from = i; break; } + } + + var to = from + delta; + if (from < 0 || to < 0 || to >= items.length) return; + + var moved = items.splice(from, 1)[0]; + items.splice(to, 0, moved); + + // Redrawn straight away so the arrows feel instant, then saved. + view.__moonfinMessagesCache = items; + renderMessagesList(view, items); + + var serverUrl = ApiClient.serverAddress ? ApiClient.serverAddress() : ''; + fetch(serverUrl + '/Moonfin/Admin/Messages/Order', { + method: 'POST', + headers: moonfinAuthHeaders(), + body: JSON.stringify({ + Ids: items.map(function (item) { return item.Id || item.id; }) + }) + }) + .then(parseJsonResponse) + .catch(function (error) { + setMessageResult( + view, + (error && error.message) ? error.message : 'Could not save the new order.', + '#d9534f' + ); + loadMessagesList(view); + }); + } + function saveMessage(view) { var saveBtn = view.querySelector('#MessageSaveBtn'); var title = (view.querySelector('#MessageTitle').value || '').trim(); @@ -2320,7 +2357,6 @@ define(['baseView', 'loading', 'emby-input', 'emby-button', 'emby-checkbox', 'em Delivery: view.querySelector('#MessageDelivery').value, Audience: audience, TargetUserIds: targetUserIds, - Pinned: view.querySelector('#MessagePinned').checked, ActionLabel: (view.querySelector('#MessageActionLabel').value || '').trim(), ActionUrl: (view.querySelector('#MessageActionUrl').value || '').trim(), StartUtc: messageDateFromInput(view.querySelector('#MessageStart').value), @@ -2477,6 +2513,18 @@ define(['baseView', 'loading', 'emby-input', 'emby-button', 'emby-checkbox', 'em var messagesListContainer = view.querySelector('#MessagesList'); if (messagesListContainer) { messagesListContainer.addEventListener('click', function (event) { + var upButton = event.target.closest('.moonfinMessageUpBtn'); + if (upButton) { + moveMessage(view, upButton.getAttribute('data-message-id') || '', -1); + return; + } + + var downButton = event.target.closest('.moonfinMessageDownBtn'); + if (downButton) { + moveMessage(view, downButton.getAttribute('data-message-id') || '', 1); + return; + } + var editButton = event.target.closest('.moonfinMessageEditBtn'); if (editButton) { var editId = editButton.getAttribute('data-message-id') || ''; diff --git a/Emby/Emby.Plugins.Moonfin/PluginConfiguration.cs b/Emby/Emby.Plugins.Moonfin/PluginConfiguration.cs index b0550d3..a6cae37 100644 --- a/Emby/Emby.Plugins.Moonfin/PluginConfiguration.cs +++ b/Emby/Emby.Plugins.Moonfin/PluginConfiguration.cs @@ -248,12 +248,9 @@ public class ServerMessage public const string ColorBlue = "blue"; public const string ColorWhite = "white"; - /// Shows in the list only. + /// Only marks the menu button as unread. public const string DeliveryInbox = "inbox"; - /// Shows a small toast when it arrives. - public const string DeliveryToast = "toast"; - /// Opens the message window once, until the user reads it. public const string DeliveryPopup = "popup"; @@ -266,7 +263,6 @@ public class ServerMessage public string Body { get; set; } = string.Empty; public string Color { get; set; } = ColorWhite; public string Delivery { get; set; } = DeliveryInbox; - public bool Pinned { get; set; } public string? ActionLabel { get; set; } public string? ActionUrl { get; set; } @@ -326,12 +322,7 @@ public void Sanitize() _ => ColorWhite }; - Delivery = Delivery switch - { - DeliveryToast => DeliveryToast, - DeliveryPopup => DeliveryPopup, - _ => DeliveryInbox - }; + Delivery = Delivery == DeliveryPopup ? DeliveryPopup : DeliveryInbox; Audience = Audience switch { @@ -397,8 +388,7 @@ public static void Prune(List messages) return; var extra = messages - .OrderBy(m => m.Pinned) - .ThenBy(m => m.CreatedUtc) + .OrderBy(m => m.CreatedUtc) .Take(messages.Count - MaxStored) .ToList(); From bf7127cb0a445d7937b420b165a7536c75bc42b2 Mon Sep 17 00:00:00 2001 From: bientavu Date: Mon, 24 Aug 2026 18:39:12 +0200 Subject: [PATCH 09/11] feat(messages): add admin default setting for show server messages button --- .../Models/MoonfinSettingsProfile.cs | 1 + Emby/Emby.Plugins.Moonfin/Pages/configPage.html | 8 ++++++++ Emby/Emby.Plugins.Moonfin/Pages/moonfin.js | 2 ++ Jellyfin/backend/Models/MoonfinSettingsProfile.cs | 3 +++ Jellyfin/backend/Pages/configPage.html | 10 ++++++++++ 5 files changed, 24 insertions(+) diff --git a/Emby/Emby.Plugins.Moonfin/Models/MoonfinSettingsProfile.cs b/Emby/Emby.Plugins.Moonfin/Models/MoonfinSettingsProfile.cs index 8b95032..9da1a2d 100644 --- a/Emby/Emby.Plugins.Moonfin/Models/MoonfinSettingsProfile.cs +++ b/Emby/Emby.Plugins.Moonfin/Models/MoonfinSettingsProfile.cs @@ -300,6 +300,7 @@ public class MoonfinSettingsProfile [JsonPropertyName("use24HourClock")] public bool? Use24HourClock { get; set; } [JsonPropertyName("homeRowInfoOverlay")] public bool? HomeRowInfoOverlay { get; set; } [JsonPropertyName("showSeerrButton")] public bool? ShowSeerrButton { get; set; } + [JsonPropertyName("showServerMessagesButton")] public bool? ShowServerMessagesButton { get; set; } [JsonPropertyName("crashReportsEnabled")] public bool? CrashReportsEnabled { get; set; } [JsonPropertyName("diagnosticLoggingEnabled")] public bool? DiagnosticLoggingEnabled { get; set; } [JsonPropertyName("updateNotificationsEnabled")] public bool? UpdateNotificationsEnabled { get; set; } diff --git a/Emby/Emby.Plugins.Moonfin/Pages/configPage.html b/Emby/Emby.Plugins.Moonfin/Pages/configPage.html index adbba88..ce8ccd2 100644 --- a/Emby/Emby.Plugins.Moonfin/Pages/configPage.html +++ b/Emby/Emby.Plugins.Moonfin/Pages/configPage.html @@ -1169,6 +1169,14 @@

Nav Button +
+ + +
diff --git a/Emby/Emby.Plugins.Moonfin/Pages/moonfin.js b/Emby/Emby.Plugins.Moonfin/Pages/moonfin.js index bd582eb..ca464c4 100644 --- a/Emby/Emby.Plugins.Moonfin/Pages/moonfin.js +++ b/Emby/Emby.Plugins.Moonfin/Pages/moonfin.js @@ -1702,6 +1702,7 @@ define(['baseView', 'loading', 'emby-input', 'emby-button', 'emby-checkbox', 'em setNullableBoolSelect(view, '#DefaultNavbarAlwaysExpanded', defaults.navbarAlwaysExpanded); setNullableBoolSelect(view, '#DefaultEnableFolderView', defaults.enableFolderView); setNullableBoolSelect(view, '#DefaultShowSeerrButton', defaults.showSeerrButton); + setNullableBoolSelect(view, '#DefaultShowServerMessagesButton', defaults.showServerMessagesButton); setSelectValue(view, '#DefaultMediaBarSourceType', defaults.mediaBarSourceType, 'Configured source'); loadAdminGenrePicker(view, defaults.mediaBarExcludedGenres || []); @@ -1868,6 +1869,7 @@ define(['baseView', 'loading', 'emby-input', 'emby-button', 'emby-checkbox', 'em d.navbarAlwaysExpanded = getNullableBoolSelect(view, '#DefaultNavbarAlwaysExpanded'); d.enableFolderView = getNullableBoolSelect(view, '#DefaultEnableFolderView'); d.showSeerrButton = getNullableBoolSelect(view, '#DefaultShowSeerrButton'); + d.showServerMessagesButton = getNullableBoolSelect(view, '#DefaultShowServerMessagesButton'); d.mediaBarSourceType = view.querySelector('#DefaultMediaBarSourceType').value || null; var genreIds = Array.prototype.slice.call(view.querySelectorAll('.adminGenreCb:checked')).map(function (cb) { return cb.dataset.id; }); diff --git a/Jellyfin/backend/Models/MoonfinSettingsProfile.cs b/Jellyfin/backend/Models/MoonfinSettingsProfile.cs index b78ee09..10c3949 100644 --- a/Jellyfin/backend/Models/MoonfinSettingsProfile.cs +++ b/Jellyfin/backend/Models/MoonfinSettingsProfile.cs @@ -848,6 +848,9 @@ public class MoonfinSettingsProfile [JsonPropertyName("crashReportsEnabled")] public bool? CrashReportsEnabled { get; set; } + [JsonPropertyName("showServerMessagesButton")] + public bool? ShowServerMessagesButton { get; set; } + [JsonPropertyName("diagnosticLoggingEnabled")] public bool? DiagnosticLoggingEnabled { get; set; } diff --git a/Jellyfin/backend/Pages/configPage.html b/Jellyfin/backend/Pages/configPage.html index 84a75b4..87fcae2 100644 --- a/Jellyfin/backend/Pages/configPage.html +++ b/Jellyfin/backend/Pages/configPage.html @@ -1020,6 +1020,14 @@

Nav Button +
+ + +
@@ -4377,6 +4385,7 @@

Active Downloads

setNullableBoolSelect('#DefaultNavbarAlwaysExpanded', defaults.navbarAlwaysExpanded); setNullableBoolSelect('#DefaultEnableFolderView', defaults.enableFolderView); setNullableBoolSelect('#DefaultShowSeerrButton', defaults.showSeerrButton); + setNullableBoolSelect('#DefaultShowServerMessagesButton', defaults.showServerMessagesButton); setSelectValue('#DefaultMediaBarSourceType', defaults.mediaBarSourceType, 'Configured source'); loadAdminGenrePicker(defaults.mediaBarExcludedGenres || []); @@ -4758,6 +4767,7 @@

Active Downloads

config.DefaultUserSettings.navbarAlwaysExpanded = getNullableBoolSelect('#DefaultNavbarAlwaysExpanded'); config.DefaultUserSettings.enableFolderView = getNullableBoolSelect('#DefaultEnableFolderView'); config.DefaultUserSettings.showSeerrButton = getNullableBoolSelect('#DefaultShowSeerrButton'); + config.DefaultUserSettings.showServerMessagesButton = getNullableBoolSelect('#DefaultShowServerMessagesButton'); config.DefaultUserSettings.mediaBarSourceType = document.querySelector('#DefaultMediaBarSourceType').value || null; var genreIds = []; From d2f308fadb864d5942e315389289cd9a669b6e9f Mon Sep 17 00:00:00 2001 From: bientavu Date: Tue, 25 Aug 2026 11:29:50 +0200 Subject: [PATCH 10/11] docs(messages): clarify open delivery mode text for push notifications --- Emby/Emby.Plugins.Moonfin/Pages/configPage.html | 6 +++--- Jellyfin/backend/Pages/configPage.html | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Emby/Emby.Plugins.Moonfin/Pages/configPage.html b/Emby/Emby.Plugins.Moonfin/Pages/configPage.html index ce8ccd2..09596af 100644 --- a/Emby/Emby.Plugins.Moonfin/Pages/configPage.html +++ b/Emby/Emby.Plugins.Moonfin/Pages/configPage.html @@ -1923,9 +1923,9 @@

Messages

- Notify adds the message to the unread count on the menu button. Open shows it - the next time the user opens the app, and also sends a phone notification to - users who turned them on. + Notify adds the message to the unread count on the menu button. Open makes the + message window come up on its own, once. Open also sends a phone notification, if + this server has push set up.
diff --git a/Jellyfin/backend/Pages/configPage.html b/Jellyfin/backend/Pages/configPage.html index 87fcae2..bc04f76 100644 --- a/Jellyfin/backend/Pages/configPage.html +++ b/Jellyfin/backend/Pages/configPage.html @@ -1752,9 +1752,9 @@

Messages

- Notify adds the message to the unread count on the menu button. Open shows it - the next time the user opens the app, and also sends a phone notification to - users who turned them on. + Notify adds the message to the unread count on the menu button. Open makes the + message window come up on its own, once. Open also sends a phone notification, if + this server has push set up.
From 491172cee16d34dd1d82ef85dbb2d0f412a08f23 Mon Sep 17 00:00:00 2001 From: RadicalMuffinMan <103554043+RadicalMuffinMan@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:52:02 -0400 Subject: [PATCH 11/11] capped the title at the length the config page already enforces and kept the escaping in an action link instead of decoding it on save --- .../PluginConfiguration.cs | 9 +++++++- Jellyfin/backend/PluginConfiguration.cs | 11 ++++++++- .../ServerMessageTests.cs | 23 +++++++++++++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/Emby/Emby.Plugins.Moonfin/PluginConfiguration.cs b/Emby/Emby.Plugins.Moonfin/PluginConfiguration.cs index a6cae37..e24c60e 100644 --- a/Emby/Emby.Plugins.Moonfin/PluginConfiguration.cs +++ b/Emby/Emby.Plugins.Moonfin/PluginConfiguration.cs @@ -242,6 +242,9 @@ public class ServerMessage /// Body length cap, so a huge paste cannot break the app layout. public const int MaxBodyLength = 2000; + /// Title length cap, the same limit the config page puts on its field. + public const int MaxTitleLength = 120; + public const string ColorGreen = "green"; public const string ColorRed = "red"; public const string ColorYellow = "yellow"; @@ -313,6 +316,9 @@ public void Sanitize() if (Body.Length > MaxBodyLength) Body = Body.Substring(0, MaxBodyLength); + if (Title.Length > MaxTitleLength) + Title = Title.Substring(0, MaxTitleLength); + Color = Color switch { ColorGreen => ColorGreen, @@ -372,7 +378,8 @@ public void Sanitize() (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) return null; - return uri.ToString(); + // AbsoluteUri keeps the escaping the admin typed, where ToString would decode it. + return uri.AbsoluteUri; } /// diff --git a/Jellyfin/backend/PluginConfiguration.cs b/Jellyfin/backend/PluginConfiguration.cs index d824bba..2c07b94 100644 --- a/Jellyfin/backend/PluginConfiguration.cs +++ b/Jellyfin/backend/PluginConfiguration.cs @@ -389,6 +389,9 @@ public class ServerMessage /// Body length cap, so a huge paste cannot break the app layout. public const int MaxBodyLength = 2000; + /// Title length cap, the same limit the config page puts on its field. + public const int MaxTitleLength = 120; + public const string ColorGreen = "green"; public const string ColorRed = "red"; public const string ColorYellow = "yellow"; @@ -465,6 +468,11 @@ public void Sanitize() Body = Body.Substring(0, MaxBodyLength); } + if (Title.Length > MaxTitleLength) + { + Title = Title.Substring(0, MaxTitleLength); + } + Color = Color switch { ColorGreen => ColorGreen, @@ -534,7 +542,8 @@ public void Sanitize() return null; } - return uri.ToString(); + // AbsoluteUri keeps the escaping the admin typed, where ToString would decode it. + return uri.AbsoluteUri; } /// diff --git a/Jellyfin/tests/Moonfin.Server.Tests/ServerMessageTests.cs b/Jellyfin/tests/Moonfin.Server.Tests/ServerMessageTests.cs index a2fa287..29b3022 100644 --- a/Jellyfin/tests/Moonfin.Server.Tests/ServerMessageTests.cs +++ b/Jellyfin/tests/Moonfin.Server.Tests/ServerMessageTests.cs @@ -158,6 +158,29 @@ public void Sanitize_CutsBodyToTheCap() Assert.Equal(ServerMessage.MaxBodyLength, message.Body.Length); } + [Fact] + public void Sanitize_CutsTitleToTheCap() + { + var message = Message(); + message.Title = new string('x', ServerMessage.MaxTitleLength + 50); + + message.Sanitize(); + + Assert.Equal(ServerMessage.MaxTitleLength, message.Title.Length); + } + + [Fact] + public void Sanitize_KeepsTheEscapingInALink() + { + var message = Message(); + message.ActionLabel = "Open"; + message.ActionUrl = "https://example.com/a%20b?q=caf%C3%A9"; + + message.Sanitize(); + + Assert.Equal("https://example.com/a%20b?q=caf%C3%A9", message.ActionUrl); + } + [Fact] public void Sanitize_ClearsTargetsWhenAudienceIsNotUsers() {