Files
SocialPub/PrivaPub/Domain/Social/Announcements.cs
T

162 lines
7.6 KiB
C#
Raw Normal View History

using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Domain.Content;
using PrivaPub.Domain.Timelines;
using PrivaPub.Models.Social;
using System.Linq.Expressions;
namespace PrivaPub.Domain.Social
{
public sealed record AnnouncementDraft(string Text, DateTime? StartsAt, DateTime? EndsAt, bool AllDay, bool IsPublished);
public interface IAnnouncements
{
Task<List<Announcement>> Shown(CancellationToken token);
Task<Announcement> FindShown(string id, CancellationToken token);
Task<bool> Dismiss(string id, string avatarId, CancellationToken token);
/// <summary>200 when it is done, 404 for an announcement not shown, 422 (with why) for a name that isn't one emoji.</summary>
Task<(int Status, string Error)> React(string id, string avatarId, string name, bool add, CancellationToken token);
Task<List<Announcement>> All(CancellationToken token);
Task<Announcement> Insert(AnnouncementDraft draft, CancellationToken token);
Task<Announcement> Update(string id, AnnouncementDraft draft, CancellationToken token);
Task<Announcement> Delete(string id, CancellationToken token);
}
// The administrators' announcements (completion program R5): written on the administrator's page, shown to every persona
// while published and not ended, each persona's dismissal and reactions kept apart and never federated. Every change a
// client shows is told to every persona's home stream, as Mastodon tells it: `announcement` (made, edited or published),
// `announcement.reaction` and `announcement.delete` (deleted or no longer shown).
public class Announcements : IAnnouncements
{
public const int MaxReactionNames = 8;//as Mastodon: eight different emoji on one announcement
const int MaxNameLength = 32;
public static Expression<Func<Announcement, bool>> IsShown()
{
var now = DateTime.UtcNow;
return a => a.IsPublished && (a.EndsAt == null || a.EndsAt > now);
}
static bool Showing(Announcement a) => a != default && a.IsPublished && (a.EndsAt == null || a.EndsAt > DateTime.UtcNow);
// as Mastodon orders them: by when they are about, else when they were published
public async Task<List<Announcement>> Shown(CancellationToken token) =>
(await DB.Default.Find<Announcement>().Match(IsShown()).ExecuteAsync(token))
.OrderBy(a => a.StartsAt ?? a.PublishedAt ?? a.CreatedAt).ThenBy(a => a.ID).ToList();
public Task<Announcement> FindShown(string id, CancellationToken token) =>
string.IsNullOrEmpty(id) ? Task.FromResult<Announcement>(default)
: DB.Default.Find<Announcement>().Match(a => a.ID == id).Match(IsShown()).ExecuteFirstAsync(token);
public async Task<bool> Dismiss(string id, string avatarId, CancellationToken token)
{
if (await FindShown(id, token) == default)
return false;
try
{
await DB.Default.Update<AnnouncementRead>()
.Match(r => r.AnnouncementId == id && r.AvatarId == avatarId)
.Modify(b => b.SetOnInsert(r => r.ReadAt, DateTime.UtcNow))
.Option(o => o.IsUpsert = true)
.ExecuteAsync(token);
}
catch (MongoException ex) when (ex is MongoCommandException { Code: 11000 } or MongoWriteException { WriteError.Category: ServerErrorCategory.DuplicateKey })
{
//dismissed at the same moment by another request
}
return true;
}
public async Task<(int Status, string Error)> React(string id, string avatarId, string name, bool add, CancellationToken token)
{
if (await FindShown(id, token) == default)
return (StatusCodes.Status404NotFound, "Record not found");
// one unicode emoji (custom emoji come with R7): the same normalisation as a post's reactions
var picked = Reactions.Normalise(name, new List<Models.Post.CustomEmoji>(), requireKnownCustom: true);
if (picked == default || picked.Url != default || picked.Name.Length > MaxNameLength)
return (StatusCodes.Status422UnprocessableEntity, "Validation failed: Name is not a recognized emoji");
name = picked.Name;
if (!add)
{
var removed = await DB.Default.DeleteAsync<AnnouncementReaction>(r => r.AnnouncementId == id && r.AvatarId == avatarId && r.Name == name);
if (removed.DeletedCount > 0)
Streams.ToUsers(new Streams.Event("announcement.reaction", id, name));
return (StatusCodes.Status200OK, default);
}
if (await DB.Default.Find<AnnouncementReaction>().Match(r => r.AnnouncementId == id && r.AvatarId == avatarId && r.Name == name).ExecuteAnyAsync(token))
return (StatusCodes.Status200OK, default);
var names = await DB.Default.Fluent<AnnouncementReaction>().Match(r => r.AnnouncementId == id).Group(r => r.Name, g => g.Key).ToListAsync(token);
if (!names.Contains(name) && names.Count >= MaxReactionNames)
return (StatusCodes.Status422UnprocessableEntity, "Validation failed: Maximum number of reactions reached");
try
{
await DB.Default.SaveAsync(new AnnouncementReaction { AnnouncementId = id, AvatarId = avatarId, Name = name }, token);
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
return (StatusCodes.Status200OK, default);//the same reaction at the same moment
}
Streams.ToUsers(new Streams.Event("announcement.reaction", id, name));
return (StatusCodes.Status200OK, default);
}
public async Task<List<Announcement>> All(CancellationToken token) =>
await DB.Default.Find<Announcement>().Sort(a => a.ID, Order.Descending).Limit(200).ExecuteAsync(token);
public async Task<Announcement> Insert(AnnouncementDraft draft, CancellationToken token)
{
var announcement = new Announcement();
Apply(announcement, draft);
await DB.Default.SaveAsync(announcement, token);
if (Showing(announcement))
Streams.ToUsers(new Streams.Event("announcement", announcement.ID));
return announcement;
}
public async Task<Announcement> Update(string id, AnnouncementDraft draft, CancellationToken token)
{
var announcement = string.IsNullOrEmpty(id) ? default : await DB.Default.Find<Announcement>().Match(a => a.ID == id).ExecuteFirstAsync(token);
if (announcement == default)
return default;
var wasShown = Showing(announcement);
Apply(announcement, draft);
announcement.UpdatedAt = DateTime.UtcNow;
await DB.Default.SaveAsync(announcement, token);
if (Showing(announcement))
Streams.ToUsers(new Streams.Event("announcement", announcement.ID));
else if (wasShown)
Streams.ToUsers(new Streams.Event("announcement.delete", announcement.ID));
return announcement;
}
public async Task<Announcement> Delete(string id, CancellationToken token)
{
var announcement = string.IsNullOrEmpty(id) ? default : await DB.Default.Find<Announcement>().Match(a => a.ID == id).ExecuteFirstAsync(token);
if (announcement == default)
return default;
await DB.Default.DeleteAsync<Announcement>(a => a.ID == id);
await DB.Default.DeleteAsync<AnnouncementRead>(r => r.AnnouncementId == id);
await DB.Default.DeleteAsync<AnnouncementReaction>(r => r.AnnouncementId == id);
if (Showing(announcement))
Streams.ToUsers(new Streams.Event("announcement.delete", id));
return announcement;
}
static void Apply(Announcement announcement, AnnouncementDraft draft)
{
announcement.Text = draft.Text?.Trim();
announcement.Html = ContentRenderer.Document(announcement.Text);
announcement.StartsAt = Utc(draft.StartsAt);
announcement.EndsAt = Utc(draft.EndsAt);
announcement.AllDay = draft.AllDay;
if (draft.IsPublished && !announcement.IsPublished)
announcement.PublishedAt = DateTime.UtcNow;
announcement.IsPublished = draft.IsPublished;
}
static DateTime? Utc(DateTime? value) => value == default ? (DateTime?)null : value.Value.Kind == DateTimeKind.Local ? value.Value.ToUniversalTime()
: DateTime.SpecifyKind(value.Value, DateTimeKind.Utc);
}
}