162 lines
7.6 KiB
C#
162 lines
7.6 KiB
C#
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);
|
||
|
|
}
|
||
|
|
}
|