[backend/core] Add mute support (ISH-169)

This commit is contained in:
Laura Hausmann 2024-03-14 14:58:07 +01:00
parent a2075d4c63
commit f41be007d7
No known key found for this signature in database
GPG key ID: D044E84C5BE01605
5 changed files with 187 additions and 97 deletions

View file

@ -197,23 +197,7 @@ public class AccountController(
followee.PrecomputedIsFollowedBy = true;
}
var res = new RelationshipEntity
{
Id = followee.Id,
Following = followee.PrecomputedIsFollowedBy ?? false,
FollowedBy = followee.PrecomputedIsFollowing ?? false,
Blocking = followee.PrecomputedIsBlockedBy ?? false,
BlockedBy = followee.PrecomputedIsBlocking ?? false,
Requested = followee.PrecomputedIsRequestedBy ?? false,
RequestedBy = followee.PrecomputedIsRequested ?? false,
Muting = followee.PrecomputedIsMutedBy ?? false,
Endorsed = false, //FIXME
Note = "", //FIXME
Notifying = false, //FIXME
DomainBlocking = false, //FIXME
MutingNotifications = false, //FIXME
ShowingReblogs = true //FIXME
};
var res = RenderRelationship(followee);
return Ok(res);
}
@ -236,23 +220,54 @@ public class AccountController(
await userSvc.UnfollowUserAsync(user, followee);
var res = new RelationshipEntity
{
Id = followee.Id,
Following = followee.PrecomputedIsFollowedBy ?? false,
FollowedBy = followee.PrecomputedIsFollowing ?? false,
Blocking = followee.PrecomputedIsBlockedBy ?? false,
BlockedBy = followee.PrecomputedIsBlocking ?? false,
Requested = followee.PrecomputedIsRequestedBy ?? false,
RequestedBy = followee.PrecomputedIsRequested ?? false,
Muting = followee.PrecomputedIsMutedBy ?? false,
Endorsed = false, //FIXME
Note = "", //FIXME
Notifying = false, //FIXME
DomainBlocking = false, //FIXME
MutingNotifications = false, //FIXME
ShowingReblogs = true //FIXME
};
var res = RenderRelationship(followee);
return Ok(res);
}
[HttpPost("{id}/mute")]
[Authorize("write:mutes")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(RelationshipEntity))]
public async Task<IActionResult> MuteUser(string id, [FromHybrid] AccountSchemas.AccountMuteRequest request)
{
var user = HttpContext.GetUserOrFail();
if (user.Id == id)
throw GracefulException.BadRequest("You cannot mute yourself");
var mutee = await db.Users
.Where(p => p.Id == id)
.IncludeCommonProperties()
.PrecomputeRelationshipData(user)
.FirstOrDefaultAsync() ??
throw GracefulException.RecordNotFound();
//TODO: handle notifications parameter
await userSvc.MuteUserAsync(user, mutee, DateTime.UtcNow + TimeSpan.FromSeconds(request.Duration));
var res = RenderRelationship(mutee);
return Ok(res);
}
[HttpPost("{id}/unmute")]
[Authorize("write:mutes")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(RelationshipEntity))]
public async Task<IActionResult> UnmuteUser(string id)
{
var user = HttpContext.GetUserOrFail();
if (user.Id == id)
throw GracefulException.BadRequest("You cannot unmute yourself");
var mutee = await db.Users
.Where(p => p.Id == id)
.IncludeCommonProperties()
.PrecomputeRelationshipData(user)
.FirstOrDefaultAsync() ??
throw GracefulException.RecordNotFound();
await userSvc.UnmuteUserAsync(user, mutee);
var res = RenderRelationship(mutee);
return Ok(res);
}
@ -270,23 +285,7 @@ public class AccountController(
.PrecomputeRelationshipData(user)
.ToListAsync();
var res = users.Select(u => new RelationshipEntity
{
Id = u.Id,
Following = u.PrecomputedIsFollowedBy ?? false,
FollowedBy = u.PrecomputedIsFollowing ?? false,
Blocking = u.PrecomputedIsBlockedBy ?? false,
BlockedBy = u.PrecomputedIsBlocking ?? false,
Requested = u.PrecomputedIsRequestedBy ?? false,
RequestedBy = u.PrecomputedIsRequested ?? false,
Muting = u.PrecomputedIsMutedBy ?? false,
Endorsed = false, //FIXME
Note = "", //FIXME
Notifying = false, //FIXME
DomainBlocking = false, //FIXME
MutingNotifications = false, //FIXME
ShowingReblogs = true //FIXME
});
var res = users.Select(RenderRelationship);
return Ok(res);
}
@ -447,23 +446,7 @@ public class AccountController(
var relationship = await db.Users.Where(p => id == p.Id)
.IncludeCommonProperties()
.PrecomputeRelationshipData(user)
.Select(u => new RelationshipEntity
{
Id = u.Id,
Following = u.PrecomputedIsFollowedBy ?? false,
FollowedBy = u.PrecomputedIsFollowing ?? false,
Blocking = u.PrecomputedIsBlockedBy ?? false,
BlockedBy = u.PrecomputedIsBlocking ?? false,
Requested = u.PrecomputedIsRequestedBy ?? false,
RequestedBy = u.PrecomputedIsRequested ?? false,
Muting = u.PrecomputedIsMutedBy ?? false,
Endorsed = false, //FIXME
Note = "", //FIXME
Notifying = false, //FIXME
DomainBlocking = false, //FIXME
MutingNotifications = false, //FIXME
ShowingReblogs = true //FIXME
})
.Select(u => RenderRelationship(u))
.FirstOrDefaultAsync();
if (relationship == null)
@ -490,23 +473,7 @@ public class AccountController(
var relationship = await db.Users.Where(p => id == p.Id)
.IncludeCommonProperties()
.PrecomputeRelationshipData(user)
.Select(u => new RelationshipEntity
{
Id = u.Id,
Following = u.PrecomputedIsFollowedBy ?? false,
FollowedBy = u.PrecomputedIsFollowing ?? false,
Blocking = u.PrecomputedIsBlockedBy ?? false,
BlockedBy = u.PrecomputedIsBlocking ?? false,
Requested = u.PrecomputedIsRequestedBy ?? false,
RequestedBy = u.PrecomputedIsRequested ?? false,
Muting = u.PrecomputedIsMutedBy ?? false,
Endorsed = false, //FIXME
Note = "", //FIXME
Notifying = false, //FIXME
DomainBlocking = false, //FIXME
MutingNotifications = false, //FIXME
ShowingReblogs = true //FIXME
})
.Select(u => RenderRelationship(u))
.FirstOrDefaultAsync();
if (relationship == null)
@ -524,4 +491,25 @@ public class AccountController(
var res = await userRenderer.RenderAsync(user);
return Ok(res);
}
private static RelationshipEntity RenderRelationship(User u)
{
return new RelationshipEntity
{
Id = u.Id,
Following = u.PrecomputedIsFollowedBy ?? false,
FollowedBy = u.PrecomputedIsFollowing ?? false,
Blocking = u.PrecomputedIsBlockedBy ?? false,
BlockedBy = u.PrecomputedIsBlocking ?? false,
Requested = u.PrecomputedIsRequestedBy ?? false,
RequestedBy = u.PrecomputedIsRequested ?? false,
Muting = u.PrecomputedIsMutedBy ?? false,
Endorsed = false, //FIXME
Note = "", //FIXME
Notifying = false, //FIXME
DomainBlocking = false, //FIXME
MutingNotifications = false, //FIXME
ShowingReblogs = true //FIXME
};
}
}

View file

@ -62,4 +62,13 @@ public abstract class AccountSchemas
[B(Name = "sensitive")]
public bool? Sensitive { get; set; }
}
public class AccountMuteRequest
{
[J("notifications")]
[B(Name = "notifications")]
public bool Notifications { get; set; } = true;
[J("duration")] [B(Name = "duration")] public long Duration { get; set; } = 0;
}
}

View file

@ -24,16 +24,20 @@ public abstract class BackgroundTaskQueue
CancellationToken token
)
{
if (job is DriveFileDeleteJob driveFileDeleteJob)
switch (job)
{
if (driveFileDeleteJob.Expire)
case DriveFileDeleteJob { Expire: true } driveFileDeleteJob:
await ProcessDriveFileExpire(driveFileDeleteJob, scope, token);
else
break;
case DriveFileDeleteJob driveFileDeleteJob:
await ProcessDriveFileDelete(driveFileDeleteJob, scope, token);
}
else if (job is PollExpiryJob pollExpiryJob)
{
await ProcessPollExpiry(pollExpiryJob, scope, token);
break;
case PollExpiryJob pollExpiryJob:
await ProcessPollExpiry(pollExpiryJob, scope, token);
break;
case MuteExpiryJob muteExpiryJob:
await ProcessMuteExpiry(muteExpiryJob, scope, token);
break;
}
}
@ -171,11 +175,26 @@ public abstract class BackgroundTaskQueue
await deliverSvc.DeliverToAsync(activity, note.User, voters.ToArray());
}
}
private static async Task ProcessMuteExpiry(
MuteExpiryJob job,
IServiceProvider scope,
CancellationToken token
)
{
var db = scope.GetRequiredService<DatabaseContext>();
var muting = await db.Mutings.FirstOrDefaultAsync(p => p.Id == job.MuteId, token);
if (muting is not { ExpiresAt: not null }) return;
if (muting.ExpiresAt > DateTime.UtcNow + TimeSpan.FromSeconds(30)) return;
db.Remove(muting);
await db.SaveChangesAsync(token);
}
}
[ProtoContract]
[ProtoInclude(100, typeof(DriveFileDeleteJob))]
[ProtoInclude(101, typeof(PollExpiryJob))]
[ProtoInclude(102, typeof(MuteExpiryJob))]
public class BackgroundTaskJob : Job;
[ProtoContract]
@ -189,4 +208,10 @@ public class DriveFileDeleteJob : BackgroundTaskJob
public class PollExpiryJob : BackgroundTaskJob
{
[ProtoMember(1)] public required string NoteId;
}
[ProtoContract]
public class MuteExpiryJob : BackgroundTaskJob
{
[ProtoMember(1)] public required string MuteId;
}

View file

@ -13,6 +13,7 @@ using Iceshrimp.Backend.Core.Helpers.LibMfm.Conversion;
using Iceshrimp.Backend.Core.Helpers.LibMfm.Parsing;
using Iceshrimp.Backend.Core.Helpers.LibMfm.Types;
using Iceshrimp.Backend.Core.Middleware;
using Iceshrimp.Backend.Core.Queues;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
@ -32,7 +33,8 @@ public class UserService(
NotificationService notificationSvc,
EmojiService emojiSvc,
ActivityPub.MentionsResolver mentionsResolver,
ActivityPub.UserRenderer userRenderer
ActivityPub.UserRenderer userRenderer,
QueueService queueSvc
)
{
private static readonly AsyncKeyedLocker<string> KeyedLocker = new(o =>
@ -187,8 +189,8 @@ public class UserService(
{
var bgDb = provider.GetRequiredService<DatabaseContext>();
var bgInstanceSvc = provider.GetRequiredService<InstanceService>();
var dbInstance = await bgInstanceSvc.GetUpdatedInstanceMetadataAsync(user);
var dbInstance = await bgInstanceSvc.GetUpdatedInstanceMetadataAsync(user);
await bgDb.Instances.Where(p => p.Id == dbInstance.Id)
.ExecuteUpdateAsync(p => p.SetProperty(i => i.UsersCount, i => i.UsersCount + 1));
});
@ -303,7 +305,7 @@ public class UserService(
{
if (user.Host != null) throw new Exception("This method is only valid for local users");
if (user.UserProfile == null) throw new Exception("user.UserProfile must not be null at this stage");
user.Tags = ResolveHashtags(user.UserProfile.Description);
db.Update(user);
@ -811,7 +813,7 @@ public class UserService(
tags.AddRange(extracted);
if (tags.Count == 0) return [];
tags = tags.Distinct().ToList();
_ = followupTaskSvc.ExecuteTask("UpdateHashtagsTable", async provider =>
@ -826,4 +828,49 @@ public class UserService(
return tags;
}
public async Task MuteUserAsync(User muter, User mutee, DateTime? expiration)
{
mutee.PrecomputedIsMutedBy = true;
var muting = await db.Mutings.FirstOrDefaultAsync(p => p.Muter == muter && p.Mutee == mutee);
if (muting != null)
{
if (muting.ExpiresAt == expiration) return;
muting.ExpiresAt = expiration;
await db.SaveChangesAsync();
if (expiration == null) return;
var job = new MuteExpiryJob { MuteId = muting.Id };
await queueSvc.BackgroundTaskQueue.ScheduleAsync(job, expiration.Value);
return;
}
muting = new Muting
{
Id = IdHelpers.GenerateSlowflakeId(),
CreatedAt = DateTime.UtcNow,
Mutee = mutee,
Muter = muter,
ExpiresAt = expiration
};
await db.AddAsync(muting);
await db.SaveChangesAsync();
if (expiration != null)
{
var job = new MuteExpiryJob { MuteId = muting.Id };
await queueSvc.BackgroundTaskQueue.ScheduleAsync(job, expiration.Value);
}
}
public async Task UnmuteUserAsync(User muter, User mutee)
{
if (!mutee.PrecomputedIsMutedBy ?? false)
return;
await db.Mutings.Where(p => p.Muter == muter && p.Mutee == mutee).ExecuteDeleteAsync();
mutee.PrecomputedIsMutedBy = false;
}
}

View file

@ -0,0 +1,21 @@
using System.Diagnostics.CodeAnalysis;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Services;
using Microsoft.EntityFrameworkCore;
namespace Iceshrimp.Backend.Core.Tasks;
[SuppressMessage("ReSharper", "UnusedType.Global", Justification = "Instantiated at runtime by CronService")]
public class MuteExpiryTask : ICronTask
{
public async Task Invoke(IServiceProvider provider)
{
var db = provider.GetRequiredService<DatabaseContext>();
await db.Mutings.Where(p => p.ExpiresAt != null && p.ExpiresAt < DateTime.UtcNow - TimeSpan.FromMinutes(5))
.ExecuteDeleteAsync();
}
// Midnight
public TimeSpan Trigger => TimeSpan.Zero;
public CronTaskType Type => CronTaskType.Daily;
}