diff --git a/Iceshrimp.Backend/Controllers/Mastodon/ConversationsController.cs b/Iceshrimp.Backend/Controllers/Mastodon/ConversationsController.cs index d65c2a76..ea71fb09 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/ConversationsController.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/ConversationsController.cs @@ -41,6 +41,7 @@ public class ConversationsController( var conversations = await db.Conversations(user) .IncludeCommonProperties() .FilterHiddenConversations(user, db) + .FilterMutedThreads(user, db) .Paginate(p => p.ThreadId ?? p.Id, pq, ControllerContext) .Select(p => new Conversation { diff --git a/Iceshrimp.Backend/Controllers/Mastodon/NotificationController.cs b/Iceshrimp.Backend/Controllers/Mastodon/NotificationController.cs index 960b6899..ac5ba1c0 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/NotificationController.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/NotificationController.cs @@ -50,6 +50,7 @@ public class NotificationController(DatabaseContext db, NotificationRenderer not .FilterByGetNotificationsRequest(request) .EnsureNoteVisibilityFor(p => p.Note, user) .FilterHiddenNotifications(user, db) + .FilterMutedThreads(user, db) .Paginate(p => p.MastoId, query, ControllerContext) .PrecomputeNoteVisibilities(user) .RenderAllForMastodonAsync(notificationRenderer, user); diff --git a/Iceshrimp.Backend/Controllers/Mastodon/Streaming/Channels/DirectChannel.cs b/Iceshrimp.Backend/Controllers/Mastodon/Streaming/Channels/DirectChannel.cs index 76ce545f..0f408168 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/Streaming/Channels/DirectChannel.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/Streaming/Channels/DirectChannel.cs @@ -73,10 +73,10 @@ public class DirectChannel(WebSocketConnection connection) : IChannel return rendered; } - private async Task RenderConversation(Note note, NoteWithVisibilities wrapped) + private async Task RenderConversation( + Note note, NoteWithVisibilities wrapped, AsyncServiceScope scope + ) { - await using var scope = connection.ScopeFactory.CreateAsyncScope(); - var db = scope.ServiceProvider.GetRequiredService(); var renderer = scope.ServiceProvider.GetRequiredService(); var userRenderer = scope.ServiceProvider.GetRequiredService(); @@ -106,11 +106,14 @@ public class DirectChannel(WebSocketConnection connection) : IChannel if (connection.IsFiltered(note)) return; if (note.CreatedAt < DateTime.UtcNow - TimeSpan.FromMinutes(5)) return; + await using var scope = connection.ScopeFactory.CreateAsyncScope(); + if (await connection.IsMutedThread(note, scope)) return; + var message = new StreamingUpdateMessage { Stream = [Name], Event = "conversation", - Payload = JsonSerializer.Serialize(await RenderConversation(note, wrapped)) + Payload = JsonSerializer.Serialize(await RenderConversation(note, wrapped, scope)) }; await connection.SendMessageAsync(JsonSerializer.Serialize(message)); @@ -129,11 +132,12 @@ public class DirectChannel(WebSocketConnection connection) : IChannel if (wrapped == null) return; if (connection.IsFiltered(note)) return; + await using var scope = connection.ScopeFactory.CreateAsyncScope(); var message = new StreamingUpdateMessage { Stream = [Name], Event = "conversation", - Payload = JsonSerializer.Serialize(await RenderConversation(note, wrapped)) + Payload = JsonSerializer.Serialize(await RenderConversation(note, wrapped, scope)) }; await connection.SendMessageAsync(JsonSerializer.Serialize(message)); diff --git a/Iceshrimp.Backend/Controllers/Mastodon/Streaming/Channels/HashtagChannel.cs b/Iceshrimp.Backend/Controllers/Mastodon/Streaming/Channels/HashtagChannel.cs index d5ed6829..ceb17d44 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/Streaming/Channels/HashtagChannel.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/Streaming/Channels/HashtagChannel.cs @@ -106,6 +106,7 @@ public class HashtagChannel(WebSocketConnection connection, bool local) : IChann if (wrapped == null) return; if (connection.IsFiltered(note)) return; await using var scope = connection.ScopeFactory.CreateAsyncScope(); + if (await connection.IsMutedThread(note, scope)) return; var renderer = scope.ServiceProvider.GetRequiredService(); var data = new NoteRenderer.NoteRendererDto { Filters = connection.Filters.ToList() }; diff --git a/Iceshrimp.Backend/Controllers/Mastodon/Streaming/Channels/ListChannel.cs b/Iceshrimp.Backend/Controllers/Mastodon/Streaming/Channels/ListChannel.cs index e4578e46..3b37ae11 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/Streaming/Channels/ListChannel.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/Streaming/Channels/ListChannel.cs @@ -129,6 +129,7 @@ public class ListChannel(WebSocketConnection connection) : IChannel if (wrapped == null) return; if (connection.IsFiltered(note)) return; await using var scope = connection.ScopeFactory.CreateAsyncScope(); + if (await connection.IsMutedThread(note, scope)) return; var renderer = scope.ServiceProvider.GetRequiredService(); var data = new NoteRenderer.NoteRendererDto { Filters = connection.Filters.ToList() }; diff --git a/Iceshrimp.Backend/Controllers/Mastodon/Streaming/Channels/PublicChannel.cs b/Iceshrimp.Backend/Controllers/Mastodon/Streaming/Channels/PublicChannel.cs index ebe04dbd..e03f3451 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/Streaming/Channels/PublicChannel.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/Streaming/Channels/PublicChannel.cs @@ -89,6 +89,7 @@ public class PublicChannel( if (wrapped == null) return; if (connection.IsFiltered(note)) return; await using var scope = connection.ScopeFactory.CreateAsyncScope(); + if (await connection.IsMutedThread(note, scope)) return; var renderer = scope.ServiceProvider.GetRequiredService(); var data = new NoteRenderer.NoteRendererDto { Filters = connection.Filters.ToList() }; diff --git a/Iceshrimp.Backend/Controllers/Mastodon/Streaming/Channels/UserChannel.cs b/Iceshrimp.Backend/Controllers/Mastodon/Streaming/Channels/UserChannel.cs index 07d92228..6854cba1 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/Streaming/Channels/UserChannel.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/Streaming/Channels/UserChannel.cs @@ -102,6 +102,7 @@ public class UserChannel(WebSocketConnection connection, bool notificationsOnly) if (connection.IsFiltered(note)) return; if (note.CreatedAt < DateTime.UtcNow - TimeSpan.FromMinutes(5)) return; await using var scope = connection.ScopeFactory.CreateAsyncScope(); + if (await connection.IsMutedThread(note, scope)) return; var renderer = scope.ServiceProvider.GetRequiredService(); var intermediate = await renderer.RenderAsync(note, connection.Token.User); @@ -172,7 +173,9 @@ public class UserChannel(WebSocketConnection connection, bool notificationsOnly) { if (!IsApplicable(notification)) return; if (IsFiltered(notification)) return; + await using var scope = connection.ScopeFactory.CreateAsyncScope(); + if (notification.Note != null && await connection.IsMutedThread(notification.Note, scope)) return; var renderer = scope.ServiceProvider.GetRequiredService(); diff --git a/Iceshrimp.Backend/Controllers/Mastodon/Streaming/WebSocketConnection.cs b/Iceshrimp.Backend/Controllers/Mastodon/Streaming/WebSocketConnection.cs index 4c1e316c..3170cb28 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/Streaming/WebSocketConnection.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/Streaming/WebSocketConnection.cs @@ -366,6 +366,14 @@ public sealed class WebSocketConnection( (IsFiltered(note.Renote.Renote.User) || IsFilteredMentions(note.Renote.Renote.Mentions))); + public async Task IsMutedThread(Note note, AsyncServiceScope scope) + { + if (note.Reply == null) return false; + if (note.User.Id == Token.UserId) return false; + await using var db = scope.ServiceProvider.GetRequiredService(); + return await db.NoteThreadMutings.AnyAsync(p => p.UserId == Token.UserId && p.ThreadId == note.ThreadId); + } + public async Task CloseAsync(WebSocketCloseStatus status) { Dispose(); diff --git a/Iceshrimp.Backend/Controllers/Mastodon/TimelineController.cs b/Iceshrimp.Backend/Controllers/Mastodon/TimelineController.cs index 21de80da..8291ac32 100644 --- a/Iceshrimp.Backend/Controllers/Mastodon/TimelineController.cs +++ b/Iceshrimp.Backend/Controllers/Mastodon/TimelineController.cs @@ -38,6 +38,7 @@ public class TimelineController(DatabaseContext db, NoteRenderer noteRenderer, C .FilterByFollowingAndOwn(user, db, heuristic) .EnsureVisibleFor(user) .FilterHidden(user, db, filterHiddenListMembers: true) + .FilterMutedThreads(user, db) .Paginate(query, ControllerContext) .PrecomputeVisibilities(user) .RenderAllForMastodonAsync(noteRenderer, user, Filter.FilterContext.Home); @@ -56,6 +57,7 @@ public class TimelineController(DatabaseContext db, NoteRenderer noteRenderer, C .HasVisibility(Note.NoteVisibility.Public) .FilterByPublicTimelineRequest(request) .FilterHidden(user, db) + .FilterMutedThreads(user, db) .Paginate(query, ControllerContext) .PrecomputeVisibilities(user) .RenderAllForMastodonAsync(noteRenderer, user, Filter.FilterContext.Public); @@ -74,6 +76,7 @@ public class TimelineController(DatabaseContext db, NoteRenderer noteRenderer, C .Where(p => p.Tags.Contains(hashtag.ToLowerInvariant())) .FilterByHashtagTimelineRequest(request) .FilterHidden(user, db) + .FilterMutedThreads(user, db) .Paginate(query, ControllerContext) .PrecomputeVisibilities(user) .RenderAllForMastodonAsync(noteRenderer, user, Filter.FilterContext.Public); @@ -93,6 +96,7 @@ public class TimelineController(DatabaseContext db, NoteRenderer noteRenderer, C .Where(p => db.UserListMembers.Any(l => l.UserListId == id && l.UserId == p.UserId)) .EnsureVisibleFor(user) .FilterHidden(user, db) + .FilterMutedThreads(user, db) .Paginate(query, ControllerContext) .PrecomputeVisibilities(user) .RenderAllForMastodonAsync(noteRenderer, user, Filter.FilterContext.Lists); diff --git a/Iceshrimp.Backend/Controllers/Web/NoteController.cs b/Iceshrimp.Backend/Controllers/Web/NoteController.cs index dd8857a3..64e1dd75 100644 --- a/Iceshrimp.Backend/Controllers/Web/NoteController.cs +++ b/Iceshrimp.Backend/Controllers/Web/NoteController.cs @@ -336,6 +336,50 @@ public class NoteController( }; } + [HttpPost("{id}/mute")] + [Authenticate] + [Authorize] + [EnableRateLimiting("strict")] + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task MuteNoteThread(string id) + { + var user = HttpContext.GetUserOrFail(); + var target = await db.Notes.Where(p => p.Id == id) + .EnsureVisibleFor(user) + .Select(p => p.ThreadId ?? p.Id) + .FirstOrDefaultAsync() ?? + throw GracefulException.NotFound("Note not found"); + + var mute = new NoteThreadMuting + { + Id = IdHelpers.GenerateSlowflakeId(), + CreatedAt = DateTime.UtcNow, + ThreadId = target, + UserId = user.Id + }; + + await db.NoteThreadMutings.Upsert(mute).On(p => new { p.UserId, p.ThreadId }).NoUpdate().RunAsync(); + } + + [HttpPost("{id}/unmute")] + [Authenticate] + [Authorize] + [EnableRateLimiting("strict")] + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task UnmuteNoteThread(string id) + { + var user = HttpContext.GetUserOrFail(); + var target = await db.Notes.Where(p => p.Id == id) + .EnsureVisibleFor(user) + .Select(p => p.ThreadId ?? p.Id) + .FirstOrDefaultAsync() ?? + throw GracefulException.NotFound("Note not found"); + + await db.NoteThreadMutings.Where(p => p.User == user && p.ThreadId == target).ExecuteDeleteAsync(); + } + [HttpPost] [Authenticate] [Authorize] diff --git a/Iceshrimp.Backend/Controllers/Web/TimelineController.cs b/Iceshrimp.Backend/Controllers/Web/TimelineController.cs index 29ae0beb..81782b35 100644 --- a/Iceshrimp.Backend/Controllers/Web/TimelineController.cs +++ b/Iceshrimp.Backend/Controllers/Web/TimelineController.cs @@ -34,6 +34,7 @@ public class TimelineController(DatabaseContext db, NoteRenderer noteRenderer, C .FilterByFollowingAndOwn(user, db, heuristic) .EnsureVisibleFor(user) .FilterHidden(user, db, filterHiddenListMembers: true) + .FilterMutedThreads(user, db) .Paginate(pq, ControllerContext) .PrecomputeVisibilities(user) .ToListAsync(); diff --git a/Iceshrimp.Backend/Core/Extensions/QueryableExtensions.cs b/Iceshrimp.Backend/Core/Extensions/QueryableExtensions.cs index 1959c714..f965c3ec 100644 --- a/Iceshrimp.Backend/Core/Extensions/QueryableExtensions.cs +++ b/Iceshrimp.Backend/Core/Extensions/QueryableExtensions.cs @@ -351,6 +351,19 @@ public static class QueryableExtensions return query.Where(p => p.VisibleUserIds.IsDisjoint(hidden)); } + public static IQueryable FilterMutedThreads(this IQueryable query, User user, DatabaseContext db) + { + return query.Where(p => !db.NoteThreadMutings.Any(m => m.User == user && m.ThreadId == (p.ThreadId ?? p.Id))); + } + + public static IQueryable FilterMutedThreads( + this IQueryable query, User user, DatabaseContext db + ) + { + return query.Where(p => p.Note == null || + !db.NoteThreadMutings.Any(m => m.User == user && m.ThreadId == (p.Note.ThreadId ?? p.Note.Id))); + } + private static (IQueryable hidden, IQueryable? mentionsHidden) FilterHiddenInternal( User? user, DatabaseContext db, @@ -388,8 +401,8 @@ public static class QueryableExtensions if (except != null) { - hidden = hidden.Except(new[] { except }); - mentionsHidden = mentionsHidden?.Except(new[] { except }); + hidden = hidden.Except([except]); + mentionsHidden = mentionsHidden?.Except([except]); } return (hidden, mentionsHidden); @@ -404,16 +417,14 @@ public static class QueryableExtensions return note => !hidden.Contains(note.UserId) && !hidden.Contains(note.RenoteUserId) && !hidden.Contains(note.ReplyUserId) && - (note.Renote == null || - !hidden.Contains(note.Renote.RenoteUserId)) && + (note.Renote == null || !hidden.Contains(note.Renote.RenoteUserId)) && note.Mentions.IsDisjoint(mentionsHidden); } return note => !hidden.Contains(note.UserId) && !hidden.Contains(note.RenoteUserId) && !hidden.Contains(note.ReplyUserId) && - (note.Renote == null || - !hidden.Contains(note.Renote.RenoteUserId)); + (note.Renote == null || !hidden.Contains(note.Renote.RenoteUserId)); } public static IQueryable FilterHidden( diff --git a/Iceshrimp.Backend/SignalR/Helpers/StreamingConnectionAggregate.cs b/Iceshrimp.Backend/SignalR/Helpers/StreamingConnectionAggregate.cs index ac5d9241..b4e6329c 100644 --- a/Iceshrimp.Backend/SignalR/Helpers/StreamingConnectionAggregate.cs +++ b/Iceshrimp.Backend/SignalR/Helpers/StreamingConnectionAggregate.cs @@ -70,7 +70,9 @@ public sealed class StreamingConnectionAggregate : IDisposable { if (notification.NotifieeId != _userId) return; if (notification.Notifier != null && IsFiltered(notification.Notifier)) return; + await using var scope = GetTempScope(); + if (notification.Note != null && await IsMutedThread(notification.Note, scope)) return; var renderer = scope.ServiceProvider.GetRequiredService(); var rendered = await renderer.RenderOne(notification, _user); @@ -91,6 +93,13 @@ public sealed class StreamingConnectionAggregate : IDisposable var recipients = FindRecipients(data.note); if (recipients.connectionIds.Count == 0) return; + if (data.note.Reply != null) + { + await using var scope = _scopeFactory.CreateAsyncScope(); + if (await IsMutedThread(data.note, scope)) + return; + } + var rendered = EnforceRenoteReplyVisibility(await data.rendered(), wrapped); await _hub.Clients.Clients(recipients.connectionIds).NotePublished(recipients.timelines, rendered); } @@ -144,6 +153,14 @@ public sealed class StreamingConnectionAggregate : IDisposable return res is not { Note.IsPureRenote: true, Renote: null } ? res : null; } + private async Task IsMutedThread(Note note, AsyncServiceScope scope) + { + if (note.Reply == null) return false; + if (note.User.Id == _userId) return false; + await using var db = scope.ServiceProvider.GetRequiredService(); + return await db.NoteThreadMutings.AnyAsync(p => p.UserId == _userId && p.ThreadId == note.ThreadId); + } + [SuppressMessage("ReSharper", "SuggestBaseTypeForParameter")] private bool IsFiltered(Note note) => IsFiltered(note.User) || _blocking.Intersects(note.Mentions) || _muting.Intersects(note.Mentions);