[backend/federation] Initial poll support (ISH-62)

This commit is contained in:
Laura Hausmann 2024-03-04 23:28:33 +01:00
parent 8c20be0d95
commit 9de208b49b
No known key found for this signature in database
GPG key ID: D044E84C5BE01605
19 changed files with 570 additions and 15 deletions

View file

@ -0,0 +1,123 @@
using System.Net.Mime;
using Iceshrimp.Backend.Controllers.Mastodon.Attributes;
using Iceshrimp.Backend.Controllers.Mastodon.Renderers;
using Iceshrimp.Backend.Controllers.Mastodon.Schemas;
using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Helpers;
using Iceshrimp.Backend.Core.Middleware;
using Iceshrimp.Backend.Core.Services;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
namespace Iceshrimp.Backend.Controllers.Mastodon;
[MastodonApiController]
[Route("/api/v1/polls/{id}")]
[Authenticate]
[EnableCors("mastodon")]
[EnableRateLimiting("sliding")]
[Produces(MediaTypeNames.Application.Json)]
public class PollController(DatabaseContext db, PollRenderer pollRenderer, PollService pollSvc) : ControllerBase
{
[HttpGet("")]
[Authenticate("read:statuses")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PollEntity))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))]
public async Task<IActionResult> GetPoll(string id)
{
var user = HttpContext.GetUserOrFail();
var note = await db.Notes.Where(p => p.Id == id).EnsureVisibleFor(user).FirstOrDefaultAsync() ??
throw GracefulException.RecordNotFound();
var poll = await db.Polls.Where(p => p.Note == note).FirstOrDefaultAsync() ??
throw GracefulException.RecordNotFound();
var res = await pollRenderer.RenderAsync(poll, user);
return Ok(res);
}
[HttpPost("votes")]
[Authenticate("read:statuses")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PollEntity))]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(MastodonErrorResponse))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))]
public async Task<IActionResult> VotePoll(string id, [FromHybrid] PollSchemas.PollVoteRequest request)
{
var user = HttpContext.GetUserOrFail();
var note = await db.Notes.Where(p => p.Id == id)
.IncludeCommonProperties()
.EnsureVisibleFor(user)
.FirstOrDefaultAsync() ??
throw GracefulException.RecordNotFound();
var poll = await db.Polls.Where(p => p.Note == note).FirstOrDefaultAsync() ??
throw GracefulException.RecordNotFound();
if (poll.ExpiresAt < DateTime.UtcNow)
throw GracefulException.BadRequest("This poll is expired");
var existingVotes = await db.PollVotes.Where(p => p.User == user && p.Note == note).ToListAsync();
List<int> incr = [];
List<PollVote> votes = [];
if (!poll.Multiple)
{
if (existingVotes.Count != 0)
throw GracefulException.BadRequest("You already voted on this poll");
if (request.Choices is not [var choice])
throw GracefulException.BadRequest("You may only vote for one option");
if (choice >= poll.Choices.Count)
throw GracefulException.BadRequest($"This poll only has {poll.Choices.Count} options");
var vote = new PollVote
{
Id = IdHelpers.GenerateSlowflakeId(),
CreatedAt = DateTime.UtcNow,
User = user,
Note = note,
Choice = choice
};
await db.AddAsync(vote);
incr.Add(choice);
votes.Add(vote);
}
else
{
foreach (var choice in request.Choices.Except(existingVotes.Select(p => p.Choice)))
{
if (choice >= poll.Choices.Count)
throw GracefulException.BadRequest($"This poll only has {poll.Choices.Count} options");
var vote = new PollVote
{
Id = IdHelpers.GenerateSlowflakeId(),
CreatedAt = DateTime.UtcNow,
User = user,
Note = note,
Choice = choice
};
await db.AddAsync(vote);
incr.Add(choice);
votes.Add(vote);
}
}
await db.SaveChangesAsync();
foreach (var c in incr)
{
await db.Database
.ExecuteSqlAsync($"""UPDATE "poll" SET "votes"[{c + 1}] = "votes"[{c + 1}] + 1 WHERE "noteId" = {note.Id}""");
}
foreach (var vote in votes)
await pollSvc.RegisterPollVote(vote, poll, note);
await db.ReloadEntityAsync(poll);
var res = await pollRenderer.RenderAsync(poll, user);
return Ok(res);
}
}

View file

@ -12,6 +12,7 @@ namespace Iceshrimp.Backend.Controllers.Mastodon.Renderers;
public class NoteRenderer(
IOptions<Config.InstanceSection> config,
UserRenderer userRenderer,
PollRenderer pollRenderer,
MfmConverter mfmConverter,
DatabaseContext db
)
@ -69,6 +70,10 @@ public class NoteRenderer(
var account = data?.Accounts?.FirstOrDefault(p => p.Id == note.UserId) ??
await userRenderer.RenderAsync(note.User);
var poll = note.HasPoll
? (data?.Polls ?? await GetPolls([note], user)).FirstOrDefault(p => p.Id == note.Id)
: null;
var res = new StatusEntity
{
Id = note.Id,
@ -97,7 +102,8 @@ public class NoteRenderer(
Mentions = mentions,
IsPinned = pinned,
Attachments = attachments,
Emojis = noteEmoji
Emojis = noteEmoji,
Poll = poll
};
return res;
@ -170,6 +176,14 @@ public class NoteRenderer(
.ToListAsync();
}
private async Task<List<PollEntity>> GetPolls(IEnumerable<Note> notes, User? user)
{
var polls = await db.Polls.Where(p => notes.Contains(p.Note))
.ToListAsync();
return await pollRenderer.RenderManyAsync(polls, user).ToListAsync();
}
private async Task<List<EmojiEntity>> GetEmoji(IEnumerable<Note> notes)
{
var ids = notes.SelectMany(p => p.Emojis).ToList();
@ -203,6 +217,7 @@ public class NoteRenderer(
Accounts = accounts ?? await GetAccounts(noteList.Select(p => p.User)),
Mentions = await GetMentions(noteList),
Attachments = await GetAttachments(noteList),
Polls = await GetPolls(noteList, user),
LikedNotes = await GetLikedNotes(noteList, user),
BookmarkedNotes = await GetBookmarkedNotes(noteList, user),
PinnedNotes = await GetPinnedNotes(noteList, user),
@ -218,6 +233,7 @@ public class NoteRenderer(
public List<AccountEntity>? Accounts;
public List<MentionEntity>? Mentions;
public List<AttachmentEntity>? Attachments;
public List<PollEntity>? Polls;
public List<string>? LikedNotes;
public List<string>? BookmarkedNotes;
public List<string>? PinnedNotes;

View file

@ -0,0 +1,76 @@
using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions;
using Microsoft.EntityFrameworkCore;
namespace Iceshrimp.Backend.Controllers.Mastodon.Renderers;
public class PollRenderer(DatabaseContext db)
{
public async Task<PollEntity> RenderAsync(Poll poll, User? user, PollRendererDto? data = null)
{
var voted = (data?.Voted ?? await GetVoted([poll], user)).Contains(poll.NoteId);
var ownVotes = (data?.OwnVotes ?? await GetOwnVotes([poll], user)).Where(p => p.Key == poll.NoteId)
.Select(p => p.Value)
.DefaultIfEmpty([])
.First();
var res = new PollEntity
{
Id = poll.NoteId,
Expired = poll.ExpiresAt < DateTime.UtcNow,
Multiple = poll.Multiple,
ExpiresAt = poll.ExpiresAt?.ToStringIso8601Like(),
VotesCount = poll.Votes.Sum(),
VotersCount = !poll.Multiple ? poll.Votes.Sum() : 0, //TODO
Voted = voted,
OwnVotes = ownVotes,
Options = poll.Choices
.Select(p => new PollOptionEntity
{
Title = p, VotesCount = poll.Votes[poll.Choices.IndexOf(p)]
})
.ToList()
};
return res;
}
private async Task<List<string>> GetVoted(IEnumerable<Poll> polls, User? user)
{
if (user == null) return [];
return await db.PollVotes.Where(p => polls.Select(i => i.NoteId).Any(i => i == p.NoteId) && p.User == user)
.Select(p => p.NoteId)
.Distinct()
.ToListAsync();
}
private async Task<Dictionary<string, int[]>> GetOwnVotes(IEnumerable<Poll> polls, User? user)
{
if (user == null) return [];
return await db.PollVotes
.Where(p => polls.Select(i => i.NoteId).Any(i => i == p.NoteId) && p.User == user)
.GroupBy(p => p.NoteId)
.ToDictionaryAsync(p => p.Key, p => p.Select(i => i.Choice).ToArray());
}
public async Task<IEnumerable<PollEntity>> RenderManyAsync(IEnumerable<Poll> polls, User? user)
{
var pollList = polls.ToList();
var data = new PollRendererDto
{
OwnVotes = await GetOwnVotes(pollList, user), Voted = await GetVoted(pollList, user)
};
return await pollList.Select(p => RenderAsync(p, user, data)).AwaitAllAsync();
}
public class PollRendererDto
{
public Dictionary<string, int[]>? OwnVotes;
public List<string>? Voted;
}
}

View file

@ -0,0 +1,25 @@
using Iceshrimp.Backend.Core.Database;
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
namespace Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
public class PollEntity : IEntity
{
[J("id")] public required string Id { get; set; }
[J("expires_at")] public required string? ExpiresAt { get; set; }
[J("expired")] public required bool Expired { get; set; }
[J("multiple")] public required bool Multiple { get; set; }
[J("votes_count")] public required int VotesCount { get; set; }
[J("voters_count")] public required int? VotersCount { get; set; }
[J("voted")] public required bool Voted { get; set; }
[J("own_votes")] public required int[] OwnVotes { get; set; }
[J("options")] public required List<PollOptionEntity> Options { get; set; }
[J("emojis")] public List<EmojiEntity> Emoji => []; //TODO
}
public class PollOptionEntity
{
[J("title")] public required string Title { get; set; }
[J("votes_count")] public required int VotesCount { get; set; }
}

View file

@ -38,17 +38,18 @@ public class StatusEntity : IEntity
[J("pinned")]
[JI(Condition = JsonIgnoreCondition.WhenWritingNull)]
public required bool? IsPinned { get; set; }
[J("poll")] public required PollEntity? Poll { get; set; }
[J("mentions")] public required List<MentionEntity> Mentions { get; set; }
[J("media_attachments")] public required List<AttachmentEntity> Attachments { get; set; }
[J("emojis")] public required List<EmojiEntity> Emojis { get; set; }
[J("tags")] public object[] Tags => []; //FIXME
[J("reactions")] public object[] Reactions => []; //FIXME
[J("filtered")] public object[] Filtered => []; //FIXME
[J("card")] public object? Card => null; //FIXME
[J("poll")] public object? Poll => null; //FIXME
[J("application")] public object? Application => null; //FIXME
[J("tags")] public object[] Tags => []; //FIXME
[J("reactions")] public object[] Reactions => []; //FIXME
[J("filtered")] public object[] Filtered => []; //FIXME
[J("card")] public object? Card => null; //FIXME
[J("application")] public object? Application => null; //FIXME
[J("language")] public string? Language => null; //FIXME

View file

@ -0,0 +1,16 @@
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
using JR = System.Text.Json.Serialization.JsonRequiredAttribute;
using B = Microsoft.AspNetCore.Mvc.BindPropertyAttribute;
namespace Iceshrimp.Backend.Controllers.Mastodon.Schemas;
public class PollSchemas
{
public class PollVoteRequest
{
[B(Name = "choices")]
[J("choices")]
[JR]
public required List<int> Choices { get; set; }
}
}

View file

@ -52,6 +52,8 @@ public static class ServiceExtensions
.AddScoped<ErrorHandlerMiddleware>()
.AddScoped<Controllers.Mastodon.Renderers.UserRenderer>()
.AddScoped<Controllers.Mastodon.Renderers.NoteRenderer>()
.AddScoped<PollRenderer>()
.AddScoped<PollService>()
.AddScoped<NoteRenderer>()
.AddScoped<UserRenderer>()
.AddScoped<NotificationRenderer>()

View file

@ -13,4 +13,9 @@ public static class TaskExtensions
// ignored
}
}
public static async Task<List<T>> ToListAsync<T>(this Task<IEnumerable<T>> task)
{
return (await task).ToList();
}
}

View file

@ -162,4 +162,13 @@ public class ActivityRenderer(
return RenderAnnounce(note, actor, to, cc, renoteUri);
}
public ASNote RenderVote(PollVote vote, Poll poll, Note note) => new()
{
Id = GenerateActivityId(),
AttributedTo = [userRenderer.RenderLite(vote.User)],
To = [new ASObjectBase(note.User.Uri ?? note.User.GetPublicUri(config.Value))],
InReplyTo = new ASObjectBase(note.Uri ?? note.GetPublicUri(config.Value)),
Name = poll.Choices[vote.Choice]
};
}

View file

@ -19,12 +19,9 @@ public class UserRenderer(IOptions<Config.InstanceSection> config, DatabaseConte
/// <returns>ASActor with only the Id field populated</returns>
public ASActor RenderLite(User user)
{
if (user.Host != null)
{
return new ASActor { Id = user.Uri ?? throw new GracefulException("Remote user must have an URI") };
}
return new ASActor { Id = user.GetPublicUri(config.Value) };
return user.Host != null
? new ASActor { Id = user.Uri ?? throw new GracefulException("Remote user must have an URI") }
: new ASActor { Id = user.GetPublicUri(config.Value) };
}
public async Task<ASActor> RenderAsync(User user)

View file

@ -0,0 +1,21 @@
using Iceshrimp.Backend.Core.Configuration;
using J = Newtonsoft.Json.JsonPropertyAttribute;
using JC = Newtonsoft.Json.JsonConverterAttribute;
using VC = Iceshrimp.Backend.Core.Federation.ActivityStreams.Types.ValueObjectConverter;
namespace Iceshrimp.Backend.Core.Federation.ActivityStreams.Types;
public class ASCollectionBase : ASObjectBase
{
[J("@type")]
[JC(typeof(StringListSingleConverter))]
public string Type => ObjectType;
[J($"{Constants.ActivityStreamsNs}#totalItems")]
[JC(typeof(VC))]
public ulong? TotalItems { get; set; }
public const string ObjectType = $"{Constants.ActivityStreamsNs}#Collection";
}
public sealed class ASCollectionBaseConverter : ASSerializer.ListSingleObjectConverter<ASCollectionBase>;

View file

@ -42,6 +42,10 @@ public class ASNote : ASObject
[J($"{Constants.ActivityStreamsNs}#summary")]
[JC(typeof(VC))]
public string? Summary { get; set; }
[J($"{Constants.ActivityStreamsNs}#name")]
[JC(typeof(VC))]
public string? Name { get; set; }
[J($"{Constants.ActivityStreamsNs}#published")]
[JC(typeof(VC))]

View file

@ -41,6 +41,7 @@ public class ASObject : ASObjectBase
ASActor.Types.Organization => token.ToObject<ASActor>(),
ASActor.Types.Application => token.ToObject<ASActor>(),
ASNote.Types.Note => token.ToObject<ASNote>(),
ASQuestion.Types.Question => token.ToObject<ASQuestion>(),
ASCollection.ObjectType => token.ToObject<ASCollection>(),
ASCollectionPage.ObjectType => token.ToObject<ASCollectionPage>(),
ASOrderedCollection.ObjectType => token.ToObject<ASOrderedCollection>(),

View file

@ -0,0 +1,58 @@
using Iceshrimp.Backend.Core.Configuration;
using J = Newtonsoft.Json.JsonPropertyAttribute;
using JC = Newtonsoft.Json.JsonConverterAttribute;
using VC = Iceshrimp.Backend.Core.Federation.ActivityStreams.Types.ValueObjectConverter;
namespace Iceshrimp.Backend.Core.Federation.ActivityStreams.Types;
public class ASQuestion : ASNote
{
public ASQuestion() => Type = Types.Question;
private List<ASQuestionOption>? _anyOf;
private List<ASQuestionOption>? _oneOf;
[J($"{Constants.ActivityStreamsNs}#oneOf")]
public List<ASQuestionOption>? OneOf
{
get => _oneOf;
set => _oneOf = value?[..Math.Min(10, value.Count)];
}
[J($"{Constants.ActivityStreamsNs}#anyOf")]
public List<ASQuestionOption>? AnyOf
{
get => _anyOf;
set => _anyOf = value?[..Math.Min(10, value.Count)];
}
[J($"{Constants.ActivityStreamsNs}#endTime")]
[JC(typeof(VC))]
public DateTime? EndTime { get; set; }
[J($"{Constants.ActivityStreamsNs}#closed")]
[JC(typeof(VC))]
public DateTime? Closed { get; set; }
public class ASQuestionOption : ASObjectBase
{
[J("@type")]
[JC(typeof(StringListSingleConverter))]
public string Type => ASNote.Types.Note;
[J($"{Constants.ActivityStreamsNs}#name")]
[JC(typeof(VC))]
public string? Name { get; set; }
[J($"{Constants.ActivityStreamsNs}#replies")]
[JC(typeof(ASCollectionBaseConverter))]
public ASCollectionBase? Replies { get; set; }
}
public new static class Types
{
private const string Ns = Constants.ActivityStreamsNs;
public const string Question = $"{Ns}#Question";
}
}

View file

@ -1,5 +1,6 @@
using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
@ -29,6 +30,10 @@ public abstract class BackgroundTaskQueue
else
await ProcessDriveFileDelete(driveFileDeleteJob, scope, token);
}
else if (job is PollExpiryJob pollExpiryJob)
{
await ProcessPollExpiry(pollExpiryJob, scope, token);
}
}
private static async Task ProcessDriveFileDelete(
@ -125,10 +130,28 @@ public abstract class BackgroundTaskQueue
}
}
}
private static async Task ProcessPollExpiry(
PollExpiryJob job,
IServiceProvider scope,
CancellationToken token
)
{
var db = scope.GetRequiredService<DatabaseContext>();
var poll = await db.Polls.FirstOrDefaultAsync(p => p.NoteId == job.NoteId, cancellationToken: token);
if (poll == null) return;
if (poll.ExpiresAt > DateTime.UtcNow + TimeSpan.FromMinutes(5)) return;
var note = await db.Notes.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == poll.NoteId, cancellationToken: token);
if (note == null) return;
//TODO: try to update poll before completing it
var notificationSvc = scope.GetRequiredService<NotificationService>();
await notificationSvc.GeneratePollEndedNotifications(note);
}
}
[ProtoContract]
[ProtoInclude(100, typeof(DriveFileDeleteJob))]
[ProtoInclude(101, typeof(PollExpiryJob))]
public class BackgroundTaskJob : Job;
[ProtoContract]
@ -136,4 +159,10 @@ public class DriveFileDeleteJob : BackgroundTaskJob
{
[ProtoMember(1)] public required string DriveFileId;
[ProtoMember(2)] public required bool Expire;
}
[ProtoContract]
public class PollExpiryJob : BackgroundTaskJob
{
[ProtoMember(1)] public required string NoteId;
}

View file

@ -9,6 +9,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;
@ -39,7 +40,8 @@ public class NoteService(
ActivityPub.ActivityRenderer activityRenderer,
EmojiService emojiSvc,
FollowupTaskService followupTaskSvc,
ActivityPub.ObjectResolver objectResolver
ActivityPub.ObjectResolver objectResolver,
QueueService queueSvc
)
{
private readonly List<string> _resolverHistory = [];
@ -61,6 +63,8 @@ public class NoteService(
throw GracefulException.BadRequest("Content warning cannot be longer than 100.000 characters");
if (renote?.IsPureRenote ?? false)
throw GracefulException.BadRequest("Cannot renote or quote a pure renote");
if (reply?.IsPureRenote ?? false)
throw GracefulException.BadRequest("Cannot reply to a pure renote");
var (mentionedUserIds, mentionedLocalUserIds, mentions, remoteMentions, splitDomainMapping) =
await ResolveNoteMentionsAsync(text);
@ -424,6 +428,36 @@ public class NoteService(
if (dbNote.Renote?.IsPureRenote ?? false)
throw GracefulException.UnprocessableEntity("Cannot renote or quote a pure renote");
if (dbNote.Reply?.IsPureRenote ?? false)
throw GracefulException.UnprocessableEntity("Cannot reply to a pure renote");
if (note is ASQuestion question)
{
if (question is { AnyOf: not null, OneOf: not null })
throw GracefulException.UnprocessableEntity("Polls cannot have both anyOf and oneOf set");
var choices = (question.AnyOf ?? question.OneOf)?.Where(p => p.Name != null).ToList() ??
throw GracefulException.UnprocessableEntity("Polls must have either anyOf or oneOf set");
if (choices.Count == 0)
throw GracefulException.UnprocessableEntity("Poll must have at least one option");
var poll = new Poll
{
Note = dbNote,
UserId = dbNote.User.Id,
UserHost = dbNote.UserHost,
ExpiresAt = question.EndTime ?? question.Closed,
Multiple = question.AnyOf != null,
Choices = choices.Select(p => p.Name).Cast<string>().ToList(),
NoteVisibility = dbNote.Visibility,
Votes = choices.Select(p => (int?)p.Replies?.TotalItems ?? 0).ToList()
};
await db.AddAsync(poll);
dbNote.HasPoll = true;
await EnqueuePollExpiryTask(poll);
}
if (dbNote.Reply != null)
{
@ -492,7 +526,10 @@ public class NoteService(
[SuppressMessage("ReSharper", "EntityFramework.NPlusOne.IncompleteDataQuery", Justification = "See above")]
public async Task<Note> ProcessNoteUpdateAsync(ASNote note, User actor)
{
var dbNote = await db.Notes.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Uri == note.Id);
var dbNote = await db.Notes.IncludeCommonProperties()
.Include(p => p.Poll)
.FirstOrDefaultAsync(p => p.Uri == note.Id);
if (dbNote == null) return await ProcessNoteAsync(note, actor);
if (dbNote.User != actor)
@ -549,6 +586,68 @@ public class NoteService(
dbNote.Tags = ResolveHashtags(dbNote.Text, note);
}
if (note is ASQuestion question)
{
if (question is { AnyOf: not null, OneOf: not null })
throw GracefulException.UnprocessableEntity("Polls cannot have both anyOf and oneOf set");
var choices = (question.AnyOf ?? question.OneOf)?.Where(p => p.Name != null).ToList() ??
throw GracefulException.UnprocessableEntity("Polls must have either anyOf or oneOf set");
if (choices.Count == 0)
throw GracefulException.UnprocessableEntity("Poll must have at least one option");
if (dbNote.Poll != null)
{
if (!dbNote.Poll.Choices.SequenceEqual(choices.Select(p => p.Name)))
{
await db.PollVotes.Where(p => p.Note == dbNote).ExecuteDeleteAsync();
dbNote.Poll.Choices = choices.Select(p => p.Name).Cast<string>().ToList();
dbNote.Poll.Votes = choices.Select(p => (int?)p.Replies?.TotalItems ?? 0).ToList();
dbNote.Poll.ExpiresAt = question.EndTime ?? question.Closed;
dbNote.Poll.Multiple = question.AnyOf != null;
db.Update(dbNote.Poll);
}
else
{
dbNote.Poll.Votes = choices.Select(p => (int?)p.Replies?.TotalItems ?? 0).ToList();
db.Update(dbNote.Poll);
}
}
else
{
var poll = new Poll
{
Note = dbNote,
UserId = dbNote.User.Id,
UserHost = dbNote.UserHost,
ExpiresAt = question.EndTime ?? question.Closed,
Multiple = question.AnyOf != null,
Choices = choices.Select(p => p.Name).Cast<string>().ToList(),
NoteVisibility = dbNote.Visibility,
Votes = choices.Select(p => (int?)p.Replies?.TotalItems ?? 0).ToList()
};
await db.AddAsync(poll);
await EnqueuePollExpiryTask(poll);
}
dbNote.HasPoll = true;
}
else
{
if (dbNote.HasPoll)
{
dbNote.HasPoll = false;
}
if (dbNote.Poll != null)
{
db.Remove(dbNote.Poll);
dbNote.Poll = null;
}
}
//TODO: handle updated alt text et al
var sensitive = (note.Sensitive ?? false) || dbNote.Cw != null;
var files = await ProcessAttachmentsAsync(note.Attachments, actor, sensitive);
@ -913,4 +1012,11 @@ public class NoteService(
await db.AddRangeAsync(pins);
await db.SaveChangesAsync();
}
private async Task EnqueuePollExpiryTask(Poll poll)
{
if (!poll.ExpiresAt.HasValue) return;
var job = new PollExpiryJob { NoteId = poll.NoteId };
await queueSvc.BackgroundTaskQueue.ScheduleAsync(job, poll.ExpiresAt.Value);
}
}

View file

@ -180,6 +180,43 @@ public class NotificationService(
eventSvc.RaiseNotification(this, notification);
}
public async Task GeneratePollEndedNotifications(Note note)
{
var notifications = await db.PollVotes
.Where(p => p.Note == note)
.Where(p => p.User.Host == null)
.Select(p => p.User)
.Distinct()
.Select(p => new Notification
{
Id = IdHelpers.GenerateSlowflakeId(DateTime.UtcNow),
CreatedAt = DateTime.UtcNow,
Notifiee = p,
Notifier = note.User,
Note = note,
Type = Notification.NotificationType.PollEnded
})
.ToListAsync();
if (note.UserHost == null && notifications.All(p => p.Notifiee != note.User))
{
notifications.Add(new Notification
{
Id = IdHelpers.GenerateSlowflakeId(DateTime.UtcNow),
CreatedAt = DateTime.UtcNow,
Notifiee = note.User,
Note = note,
Type = Notification.NotificationType.PollEnded
});
}
await db.AddRangeAsync(notifications);
await db.SaveChangesAsync();
foreach (var notification in notifications)
eventSvc.RaiseNotification(this, notification);
}
[SuppressMessage("ReSharper", "EntityFramework.UnsupportedServerSideFunctionCall", Justification = "Projectables")]
public async Task GenerateRenoteNotification(Note note)
{

View file

@ -0,0 +1,20 @@
using Iceshrimp.Backend.Core.Database.Tables;
namespace Iceshrimp.Backend.Core.Services;
public class PollService(
ActivityPub.ActivityRenderer activityRenderer,
ActivityPub.UserRenderer userRenderer,
ActivityPub.ActivityDeliverService deliverSvc
)
{
public async Task RegisterPollVote(PollVote pollVote, Poll poll, Note note)
{
if (poll.UserHost == null) return;
var vote = activityRenderer.RenderVote(pollVote, poll, note);
var actor = userRenderer.RenderLite(pollVote.User);
var activity = ActivityPub.ActivityRenderer.RenderCreate(vote, actor);
await deliverSvc.DeliverToAsync(activity, pollVote.User, note.User);
}
}

View file

@ -257,6 +257,15 @@ public class JobQueue<T>(
await _redisDb.ListRightPushAsync("queued", RedisValue.Unbox(RedisHelpers.Serialize(job)));
await _subscriber.PublishAsync(_queuedChannel, "");
}
public async Task ScheduleAsync(T job, DateTime triggerAt)
{
job.Status = Job.JobStatus.Delayed;
job.DelayedUntil = triggerAt;
var timestamp = (long)job.DelayedUntil.Value.Subtract(DateTime.UnixEpoch).TotalSeconds;
await _redisDb.SortedSetAddAsync("delayed", RedisValue.Unbox(RedisHelpers.Serialize(job)), timestamp);
await _subscriber.PublishAsync(_delayedChannel, "");
}
}
[ProtoContract]