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> Shown(CancellationToken token); Task FindShown(string id, CancellationToken token); Task Dismiss(string id, string avatarId, CancellationToken token); /// 200 when it is done, 404 for an announcement not shown, 422 (with why) for a name that isn't one emoji. Task<(int Status, string Error)> React(string id, string avatarId, string name, bool add, CancellationToken token); Task> All(CancellationToken token); Task Insert(AnnouncementDraft draft, CancellationToken token); Task Update(string id, AnnouncementDraft draft, CancellationToken token); Task 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> 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> Shown(CancellationToken token) => (await DB.Default.Find().Match(IsShown()).ExecuteAsync(token)) .OrderBy(a => a.StartsAt ?? a.PublishedAt ?? a.CreatedAt).ThenBy(a => a.ID).ToList(); public Task FindShown(string id, CancellationToken token) => string.IsNullOrEmpty(id) ? Task.FromResult(default) : DB.Default.Find().Match(a => a.ID == id).Match(IsShown()).ExecuteFirstAsync(token); public async Task Dismiss(string id, string avatarId, CancellationToken token) { if (await FindShown(id, token) == default) return false; try { await DB.Default.Update() .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(), 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(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().Match(r => r.AnnouncementId == id && r.AvatarId == avatarId && r.Name == name).ExecuteAnyAsync(token)) return (StatusCodes.Status200OK, default); var names = await DB.Default.Fluent().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> All(CancellationToken token) => await DB.Default.Find().Sort(a => a.ID, Order.Descending).Limit(200).ExecuteAsync(token); public async Task 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 Update(string id, AnnouncementDraft draft, CancellationToken token) { var announcement = string.IsNullOrEmpty(id) ? default : await DB.Default.Find().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 Delete(string id, CancellationToken token) { var announcement = string.IsNullOrEmpty(id) ? default : await DB.Default.Find().Match(a => a.ID == id).ExecuteFirstAsync(token); if (announcement == default) return default; await DB.Default.DeleteAsync(a => a.ID == id); await DB.Default.DeleteAsync(r => r.AnnouncementId == id); await DB.Default.DeleteAsync(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); } }