a fork of iceshrimp.net but a tweaked frontend to my personal liking. waow
fediverse social-media social iceshrimp fedi
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

[backend/masto-client] Note scheduling support

Kopper 0b001746 4e5b6655

+244 -13
+67 -1
Iceshrimp.Backend/Controllers/Mastodon/Renderers/NoteRenderer.cs
··· 39 39 KeywordMatches = ["RE: \ud83d\udd12"] // lock emoji 40 40 }; 41 41 42 - public async Task<StatusEntity> RenderAsync( 42 + public async Task<ScheduledStatusEntity> RenderScheduledAsync( 43 + Note note, User? user, NoteRendererDto? data = null, int recurse = 2 44 + ) 45 + { 46 + var attachments = data?.Attachments == null 47 + ? await GetAttachmentsAsync([note]) 48 + : [..data.Attachments.Where(p => note.FileIds.Contains(p.Id))]; 49 + 50 + var poll = note.HasPoll 51 + ? (data?.Polls ?? await GetPollsAsync([note], user)).FirstOrDefault(p => p.Id == note.Id) 52 + : null; 53 + 54 + var pollExpiresIn = (poll?.ExpiresAt != null && DateTime.TryParse(poll.ExpiresAt, out var time)) 55 + ? time 56 + : DateTime.MaxValue; 57 + 58 + var sensitive = note.Cw != null || attachments.Any(p => p.Sensitive); 59 + 60 + var visibility = flags.IsPleroma.Value && note.LocalOnly 61 + ? "local" 62 + : StatusEntity.EncodeVisibility(note.Visibility); 63 + 64 + return new ScheduledStatusEntity 65 + { 66 + Id = note.Id, 67 + ScheduledAt = note.ScheduledAt ?? DateTime.MinValue, 68 + Params = new ScheduledStatusEntity.Param 69 + { 70 + Text = note.Text, 71 + MediaIds = attachments.Select(a => a.Id).ToList(), 72 + Sensitive = sensitive, 73 + Cw = note.Cw, 74 + Visibility = visibility, 75 + ReplyId = note.ReplyId, 76 + LocalOnly = note.LocalOnly, 77 + QuoteId = note.IsQuote ? note.RenoteId : null, 78 + Poll = poll == null ? null : new ScheduledStatusEntity.Param.PollData 79 + { 80 + ExpiresIn = (long)(DateTime.UtcNow - pollExpiresIn).TotalSeconds, 81 + Multiple = poll.Multiple, 82 + Options = poll.Options.Select(p => p.Title).ToList(), 83 + }, 84 + }, 85 + Attachments = attachments 86 + }; 87 + } 88 + 89 + public async Task<StatusEntity> RenderAsync( 43 90 Note note, User? user, Filter.FilterContext? filterContext = null, NoteRendererDto? data = null, int recurse = 2 44 91 ) 45 92 { ··· 507 554 508 555 return await noteList.Select(p => RenderAsync(p, user, filterContext, data)).AwaitAllAsync(); 509 556 } 557 + 558 + public async Task<IEnumerable<ScheduledStatusEntity>> RenderManyScheduledAsync(IEnumerable<Note> notes, User? user) 559 + { 560 + var noteList = notes.ToList(); 561 + if (noteList.Count == 0) return []; 562 + 563 + var allNotes = noteList.SelectMany<Note, Note?>(p => [p, p.Renote, p.Renote?.Renote]) 564 + .OfType<Note>() 565 + .Distinct() 566 + .ToList(); 567 + 568 + var data = new NoteRendererDto 569 + { 570 + Attachments = await GetAttachmentsAsync(allNotes), 571 + Polls = await GetPollsAsync(allNotes, user), 572 + }; 573 + 574 + return await noteList.Select(p => RenderScheduledAsync(p, user, data)).AwaitAllAsync(); 575 + } 510 576 511 577 public class NoteRendererDto 512 578 {
+104
Iceshrimp.Backend/Controllers/Mastodon/ScheduledStatusController.cs
··· 1 + using System.Net; 2 + using System.Net.Mime; 3 + using Iceshrimp.Backend.Controllers.Mastodon.Attributes; 4 + using Iceshrimp.Backend.Controllers.Mastodon.Renderers; 5 + using Iceshrimp.Backend.Controllers.Mastodon.Schemas; 6 + using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities; 7 + using Iceshrimp.Backend.Controllers.Shared.Attributes; 8 + using Iceshrimp.Backend.Core.Database; 9 + using Iceshrimp.Backend.Core.Extensions; 10 + using Iceshrimp.Backend.Core.Middleware; 11 + using Iceshrimp.Backend.Core.Services; 12 + using Iceshrimp.Utils.DependencyInjection; 13 + using Microsoft.AspNetCore.Cors; 14 + using Microsoft.AspNetCore.Mvc; 15 + using Microsoft.AspNetCore.RateLimiting; 16 + using Microsoft.EntityFrameworkCore; 17 + 18 + namespace Iceshrimp.Backend.Controllers.Mastodon; 19 + 20 + [MastodonApiController] 21 + [Route("/api/v1/scheduled_statuses")] 22 + [Authenticate] 23 + [EnableCors("mastodon")] 24 + [EnableRateLimiting("sliding")] 25 + [Produces(MediaTypeNames.Application.Json)] 26 + public class ScheduledStatusController(DatabaseContext db, NoteRenderer noteRenderer, NoteService noteService) : ControllerBase, IScopedService 27 + { 28 + [Authenticate("read:statuses")] 29 + [LinkPagination(20, 40)] 30 + [ProducesResults(HttpStatusCode.OK)] 31 + [ProducesErrors(HttpStatusCode.Forbidden, HttpStatusCode.NotFound)] 32 + public async Task<List<ScheduledStatusEntity>> GetScheduledNotes(MastodonPaginationQuery query) 33 + { 34 + var user = HttpContext.GetUserOrFail(); 35 + 36 + return await db.Notes 37 + .IncludeUnpublished() 38 + .IncludeCommonProperties() 39 + .FilterByUser(user) 40 + .Where(p => p.ScheduledAt != null) 41 + .Paginate(query, ControllerContext) 42 + .RenderAllScheduledForMastodonAsync(noteRenderer, user); 43 + } 44 + 45 + [HttpGet("{id}")] 46 + [Authenticate("read:statuses")] 47 + [ProducesResults(HttpStatusCode.OK)] 48 + [ProducesErrors(HttpStatusCode.Forbidden, HttpStatusCode.NotFound)] 49 + public async Task<ScheduledStatusEntity> GetScheduledNote(string id) 50 + { 51 + var user = HttpContext.GetUserOrFail(); 52 + 53 + var note = await db.Notes 54 + .IncludeUnpublished() 55 + .Where(p => p.Id == id && p.User == user && p.ScheduledAt != null) 56 + .IncludeCommonProperties() 57 + .FirstOrDefaultAsync() ?? 58 + throw GracefulException.RecordNotFound(); 59 + 60 + return await noteRenderer.RenderScheduledAsync(note.EnforceRenoteReplyVisibility(), user); 61 + } 62 + 63 + [HttpPut("{id}")] 64 + [Authenticate("write:statuses")] 65 + [ProducesResults(HttpStatusCode.OK)] 66 + [ProducesErrors(HttpStatusCode.Forbidden, HttpStatusCode.NotFound)] 67 + public async Task<ScheduledStatusEntity> RescheduleScheduledNote(string id, [FromHybrid] StatusSchemas.RescheduleRequest request) 68 + { 69 + if (request.ScheduledAt.ToUniversalTime() < DateTime.UtcNow.AddMinutes(1)) 70 + throw GracefulException.UnprocessableEntity("Scheduled note can not be in the past"); 71 + 72 + var user = HttpContext.GetUserOrFail(); 73 + 74 + var note = await db.Notes 75 + .IncludeUnpublished() 76 + .Where(p => p.Id == id && p.User == user && p.ScheduledAt != null) 77 + .IncludeCommonProperties() 78 + .FirstOrDefaultAsync() ?? 79 + throw GracefulException.RecordNotFound(); 80 + 81 + await noteService.RescheduleNoteAsync(note, request.ScheduledAt); 82 + return await noteRenderer.RenderScheduledAsync(note.EnforceRenoteReplyVisibility(), user); 83 + } 84 + 85 + [HttpDelete("{id}")] 86 + [Authenticate("write:statuses")] 87 + [ProducesResults(HttpStatusCode.OK)] 88 + [ProducesErrors(HttpStatusCode.Forbidden, HttpStatusCode.NotFound)] 89 + public async Task<EmptyObject> DeleteScheduledNote(string id) 90 + { 91 + var user = HttpContext.GetUserOrFail(); 92 + 93 + var note = await db.Notes 94 + .IncludeUnpublished() 95 + .Where(p => p.Id == id && p.User == user && p.ScheduledAt != null) 96 + .IncludeCommonProperties() 97 + .FirstOrDefaultAsync(); 98 + 99 + if (note != null) await noteService.DeleteScheduledNoteAsync(note); 100 + return new EmptyObject(); 101 + } 102 + 103 + public class EmptyObject; 104 + }
+34 -1
Iceshrimp.Backend/Controllers/Mastodon/Schemas/Entities/StatusEntity.cs
··· 8 8 9 9 namespace Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities; 10 10 11 - public class StatusEntity : IIdentifiable, ICloneable 11 + public interface IPostNotePayload {} 12 + 13 + public class StatusEntity : IIdentifiable, ICloneable, IPostNotePayload 12 14 { 13 15 [JI] public string? MastoReplyUserId; 14 16 [J("text")] public required string? Text { get; set; } ··· 156 158 Manual, 157 159 Denied 158 160 } 161 + 162 + public class ScheduledStatusEntity : IIdentifiable, IPostNotePayload 163 + { 164 + [J("id")] public required string Id { get; set; } 165 + [J("scheduled_at")] public required DateTime ScheduledAt { get; set; } 166 + [J("params")] public required Param Params { get; set; } 167 + [J("media_attachments")] public required List<AttachmentEntity> Attachments { get; set; } 168 + 169 + public class Param 170 + { 171 + [J("text")] public string? Text { get; set; } 172 + [J("in_reply_to_id")] public string? ReplyId { get; set; } 173 + [J("sensitive")] public bool Sensitive { get; set; } = false; 174 + [J("spoiler_text")] public string? Cw { get; set; } 175 + [J("visibility")] public string Visibility { get; set; } = null!; 176 + [J("language")] public string? Language { get; set; } 177 + [J("scheduled_at")] public string? ScheduledAt { get; set; } 178 + [J("media_ids")] public List<string>? MediaIds { get; set; } 179 + [J("local_only")] public bool LocalOnly { get; set; } = false; 180 + [J("quote_id")] public string? QuoteId { get; set; } 181 + [J("poll")] public PollData? Poll { get; set; } 182 + 183 + public class PollData 184 + { 185 + [J("options")] public List<string> Options { get; set; } = null!; 186 + [J("expires_in")] public long ExpiresIn { get; set; } 187 + [J("multiple")] public bool Multiple { get; set; } = false; 188 + } 189 + } 190 + } 191 +
+9 -1
Iceshrimp.Backend/Controllers/Mastodon/Schemas/StatusSchemas.cs
··· 30 30 31 31 [B(Name = "scheduled_at")] 32 32 [J("scheduled_at")] 33 - public string? ScheduledAt { get; set; } 33 + public DateTime? ScheduledAt { get; set; } 34 34 35 35 [B(Name = "media_ids")] 36 36 [J("media_ids")] ··· 103 103 [J("visibility")] 104 104 public string? Visibility { get; set; } 105 105 } 106 + 107 + public class RescheduleRequest 108 + { 109 + [B(Name = "scheduled_at")] 110 + [JR] 111 + [J("scheduled_at")] 112 + public DateTime ScheduledAt { get; set; } 113 + } 106 114 }
+21 -9
Iceshrimp.Backend/Controllers/Mastodon/StatusController.cs
··· 37 37 CacheService cache, 38 38 IOptions<Config.InstanceSection> config, 39 39 IOptionsSnapshot<Config.SecuritySection> security, 40 - UserRenderer userRenderer 40 + UserRenderer userRenderer, 41 + ScheduledStatusController scheduledStatusController 41 42 ) : ControllerBase 42 43 { 43 44 private static readonly AsyncKeyedLocker<string> KeyedLocker = new(o => ··· 352 353 [Authorize("write:statuses")] 353 354 [ProducesResults(HttpStatusCode.OK)] 354 355 [ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.UnprocessableEntity)] 355 - public async Task<StatusEntity> PostNote([FromHybrid] StatusSchemas.PostStatusRequest request) 356 + public async Task<IPostNotePayload> PostNote([FromHybrid] StatusSchemas.PostStatusRequest request) 356 357 { 357 - //TODO: handle scheduled statuses 358 - if (request.ScheduledAt != null) 359 - throw GracefulException.UnprocessableEntity("Scheduled statuses are not supported yet"); 358 + var scheduled = request.ScheduledAt != null; 359 + if (scheduled && request.ScheduledAt < DateTime.UtcNow.AddMinutes(5)) 360 + throw GracefulException.UnprocessableEntity("Scheduled note must be at least 5 minutes in the future"); 360 361 361 362 var token = HttpContext.GetOauthToken() ?? throw new Exception("Token must not be null at this stage"); 362 363 var user = token.User; 363 - 364 + 364 365 Request.Headers.TryGetValue("Idempotency-Key", out var idempotencyKeyHeader); 365 366 var idempotencyKey = idempotencyKeyHeader.FirstOrDefault(); 366 367 if (idempotencyKey != null) ··· 384 385 throw GracefulException.RequestTimeout("Failed to resolve idempotency key note within 1000 ms"); 385 386 } 386 387 387 - return await GetNote(hit); 388 - } 388 + if (scheduled) 389 + return await scheduledStatusController.GetScheduledNote(hit); 390 + 391 + return await GetNote(hit); 392 + } 389 393 } 390 394 391 395 if (string.IsNullOrWhiteSpace(request.Text) && request.MediaIds is not { Count: > 0 } && request.Poll == null) ··· 502 506 Poll = poll, 503 507 LocalOnly = request.LocalOnly, 504 508 Preview = request.Preview, 509 + ScheduledAt = request.ScheduledAt, 505 510 }); 506 511 507 512 if (!request.Preview && idempotencyKey != null) 508 513 await cache.SetAsync($"idempotency:{user.Id}:{idempotencyKey}", note.Id, TimeSpan.FromHours(24)); 509 514 510 - return await noteRenderer.RenderAsync(note, user); 515 + if (scheduled) 516 + { 517 + return await noteRenderer.RenderScheduledAsync(note, user); 518 + } 519 + else 520 + { 521 + return await noteRenderer.RenderAsync(note, user); 522 + } 511 523 } 512 524 513 525 [HttpPut("{id}")]
+9 -1
Iceshrimp.Backend/Core/Extensions/QueryableExtensions.cs
··· 259 259 260 260 // TODO: name this filter when we update to EF 10 261 261 // https://learn.microsoft.com/en-us/ef/core/querying/filters?tabs=ef10#using-multiple-query-filters 262 - public IQueryable<Note> IncludeUnpublished(this IQueryable<Note> query) => query.IgnoreQueryFilters(); 262 + public IQueryable<Note> IncludeUnpublished() => query.IgnoreQueryFilters(); 263 263 264 264 public IQueryable<Note> FilterByUser(User user) 265 265 { ··· 529 529 .ToList(); 530 530 return (await renderer.RenderManyAsync(list, user, filterContext)).ToList(); 531 531 } 532 + 533 + public static async Task<List<ScheduledStatusEntity>> RenderAllScheduledForMastodonAsync( 534 + this IQueryable<Note> notes, NoteRenderer renderer, User? user 535 + ) 536 + { 537 + var list = await notes.ToListAsync(); 538 + return (await renderer.RenderManyScheduledAsync(list, user)).ToList(); 539 + } 532 540 533 541 public static async Task<List<AccountEntity>> RenderAllForMastodonAsync( 534 542 this IQueryable<User> users, UserRenderer renderer, User? localUser