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.

Merge branch 'dev' of https://iceshrimp.dev/iceshrimp/iceshrimp.net into dev

Niko 8588a6fe 1c971dcc

+574 -60
+3 -2
Iceshrimp.Backend/Components/Queue/QueueNav.razor
··· 15 15 {"deliver", Icons.Truck}, 16 16 {"background-task", Icons.ListChecks}, 17 17 {"backfill", Icons.Archive}, 18 - {"backfill-user", Icons.FolderUser} 18 + {"backfill-user", Icons.FolderUser}, 19 + {"scheduled-post", Icons.Clock}, 19 20 }; 20 21 21 22 private List<NavBar.NavLink> _links = ··· 33 34 var links = Queue.QueueNames.Select(p => new NavBar.NavLink($"/queue/{p}", p, _icons.GetValueOrDefault(p))); 34 35 _links = _links.Concat(links).ToList(); 35 36 } 36 - } 37 + }
+2
Iceshrimp.Backend/Controllers/Federation/ActivityPubController.cs
··· 45 45 { 46 46 var actor = HttpContext.GetActor(); 47 47 var note = await db.Notes 48 + .IncludeUnpublished() // the intention is to reuse this mechanism for interaction controls as well, so let remote instances see unpublished notes 48 49 .IncludeCommonProperties() 49 50 .EnsureVisibleFor(actor) 50 51 .FirstOrDefaultAsync(p => p.Id == id); ··· 65 66 var actor = HttpContext.GetActor(); 66 67 67 68 var note = await db.Notes 69 + .IncludeUnpublished() 68 70 .IncludeCommonProperties() 69 71 .EnsureVisibleFor(actor) 70 72 .Where(p => p.Id == id && p.UserHost == null)
+75 -3
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 + var renoteUrl = note is { IsPureRenote: true, Renote: not null } 65 + ? note.Renote.Url ?? note.Renote.Uri ?? $"https://{config.Value.WebDomain}/notes/{note.RenoteId}" 66 + : null; 67 + 68 + return new ScheduledStatusEntity 69 + { 70 + Id = note.Id, 71 + ScheduledAt = note.ScheduledAt ?? DateTime.MinValue, 72 + Params = new ScheduledStatusEntity.Param 73 + { 74 + Text = renoteUrl != null ? $"🔁 {renoteUrl}" : note.Text, 75 + MediaIds = attachments.Select(a => a.Id).ToList(), 76 + Sensitive = sensitive, 77 + Cw = note.Cw, 78 + Visibility = visibility, 79 + ReplyId = note.ReplyId, 80 + LocalOnly = note.LocalOnly, 81 + QuoteId = note.IsQuote ? note.RenoteId : null, 82 + ReblogId = note.IsPureRenote ? note.RenoteId : null, 83 + Poll = poll == null ? null : new ScheduledStatusEntity.Param.ScheduledPollData 84 + { 85 + ExpiresIn = (long)(DateTime.UtcNow - pollExpiresIn).TotalSeconds, 86 + Multiple = poll.Multiple, 87 + Options = poll.Options.Select(p => p.Title).ToList(), 88 + }, 89 + }, 90 + Attachments = attachments 91 + }; 92 + } 93 + 94 + public async Task<StatusEntity> RenderAsync( 43 95 Note note, User? user, Filter.FilterContext? filterContext = null, NoteRendererDto? data = null, int recurse = 2 44 96 ) 45 97 { ··· 79 131 var pinned = data?.PinnedNotes?.Contains(note.Id) ?? 80 132 await db.UserNotePins.AnyAsync(p => p.Note == note && p.User == user); 81 133 var renoted = data?.Renotes?.Contains(note.Id) ?? 82 - await db.Notes.AnyAsync(p => p.Renote == note && p.User == user && p.IsPureRenote); 134 + await db.Notes.IncludeUnpublished().AnyAsync(p => p.Renote == note && p.User == user && p.IsPureRenote); 83 135 84 136 var noteEmoji = data?.Emoji?.Where(p => note.Emojis.Contains(p.Id)).ToList() ?? await GetEmojiAsync([note]); 85 137 ··· 433 485 { 434 486 if (user == null) return []; 435 487 if (notes.Count == 0) return []; 436 - return await db.Notes.Where(p => p.User == user && p.IsPureRenote && notes.Contains(p.Renote!)) 488 + return await db.Notes.IncludeUnpublished() 489 + .Where(p => p.User == user && p.IsPureRenote && notes.Contains(p.Renote!)) 437 490 .Select(p => p.RenoteId) 438 491 .Where(p => p != null) 439 492 .Distinct() ··· 507 560 508 561 return await noteList.Select(p => RenderAsync(p, user, filterContext, data)).AwaitAllAsync(); 509 562 } 563 + 564 + public async Task<IEnumerable<ScheduledStatusEntity>> RenderManyScheduledAsync(IEnumerable<Note> notes, User? user) 565 + { 566 + var noteList = notes.ToList(); 567 + if (noteList.Count == 0) return []; 568 + 569 + var allNotes = noteList.SelectMany<Note, Note?>(p => [p, p.Renote, p.Renote?.Renote]) 570 + .OfType<Note>() 571 + .Distinct() 572 + .ToList(); 573 + 574 + var data = new NoteRendererDto 575 + { 576 + Attachments = await GetAttachmentsAsync(allNotes), 577 + Polls = await GetPollsAsync(allNotes, user), 578 + }; 579 + 580 + return await noteList.Select(p => RenderScheduledAsync(p, user, data)).AwaitAllAsync(); 581 + } 510 582 511 583 public class NoteRendererDto 512 584 {
+2 -2
Iceshrimp.Backend/Controllers/Mastodon/Renderers/UserRenderer.cs
··· 194 194 .ToListAsync(); 195 195 } 196 196 197 - public async Task<AccountEntity> RenderAsync(User user, User? localUser, List<EmojiEntity>? emoji = null) 197 + public async Task<AccountEntity> RenderAsync(User user, User? localUser) 198 198 { 199 199 var data = new UserRendererDto 200 200 { 201 - Emoji = emoji ?? await GetEmojiAsync([user]), 201 + Emoji = await GetEmojiAsync([user]), 202 202 AvatarAlt = await GetAvatarAltAsync([user]), 203 203 BannerAlt = await GetBannerAltAsync([user]), 204 204 Instance = await GetInstanceAsync([user])
+105
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, IHttpContextAccessor httpContextAccessor) : ControllerBase, IScopedService 27 + { 28 + [HttpGet] 29 + [Authenticate("read:statuses")] 30 + [LinkPagination(20, 40)] 31 + [ProducesResults(HttpStatusCode.OK)] 32 + [ProducesErrors(HttpStatusCode.Forbidden, HttpStatusCode.NotFound)] 33 + public async Task<List<ScheduledStatusEntity>> GetScheduledNotes(MastodonPaginationQuery query) 34 + { 35 + var user = HttpContext.GetUserOrFail(); 36 + 37 + return await db.Notes 38 + .IncludeUnpublished() 39 + .IncludeCommonProperties() 40 + .FilterByUser(user) 41 + .Where(p => p.ScheduledAt != null) 42 + .Paginate(query, ControllerContext) 43 + .RenderAllScheduledForMastodonAsync(noteRenderer, user); 44 + } 45 + 46 + [HttpGet("{id}")] 47 + [Authenticate("read:statuses")] 48 + [ProducesResults(HttpStatusCode.OK)] 49 + [ProducesErrors(HttpStatusCode.Forbidden, HttpStatusCode.NotFound)] 50 + public async Task<ScheduledStatusEntity> GetScheduledNote(string id) 51 + { 52 + var user = httpContextAccessor.HttpContext!.GetUserOrFail(); // calling this function as a service breaks otherwise 53 + 54 + var note = await db.Notes 55 + .IncludeUnpublished() 56 + .Where(p => p.Id == id && p.User == user && p.ScheduledAt != null) 57 + .IncludeCommonProperties() 58 + .FirstOrDefaultAsync() ?? 59 + throw GracefulException.RecordNotFound(); 60 + 61 + return await noteRenderer.RenderScheduledAsync(note.EnforceRenoteReplyVisibility(), user); 62 + } 63 + 64 + [HttpPut("{id}")] 65 + [Authenticate("write:statuses")] 66 + [ProducesResults(HttpStatusCode.OK)] 67 + [ProducesErrors(HttpStatusCode.Forbidden, HttpStatusCode.NotFound)] 68 + public async Task<ScheduledStatusEntity> RescheduleScheduledNote(string id, [FromHybrid] StatusSchemas.RescheduleRequest request) 69 + { 70 + if (request.ScheduledAt.ToUniversalTime() < DateTime.UtcNow.AddMinutes(1)) 71 + throw GracefulException.UnprocessableEntity("Scheduled note can not be in the past"); 72 + 73 + var user = HttpContext.GetUserOrFail(); 74 + 75 + var note = await db.Notes 76 + .IncludeUnpublished() 77 + .Where(p => p.Id == id && p.User == user && p.ScheduledAt != null) 78 + .IncludeCommonProperties() 79 + .FirstOrDefaultAsync() ?? 80 + throw GracefulException.RecordNotFound(); 81 + 82 + await noteService.RescheduleNoteAsync(note, request.ScheduledAt); 83 + return await noteRenderer.RenderScheduledAsync(note.EnforceRenoteReplyVisibility(), user); 84 + } 85 + 86 + [HttpDelete("{id}")] 87 + [Authenticate("write:statuses")] 88 + [ProducesResults(HttpStatusCode.OK)] 89 + [ProducesErrors(HttpStatusCode.Forbidden, HttpStatusCode.NotFound)] 90 + public async Task<EmptyObject> DeleteScheduledNote(string id) 91 + { 92 + var user = HttpContext.GetUserOrFail(); 93 + 94 + var note = await db.Notes 95 + .IncludeUnpublished() 96 + .Where(p => p.Id == id && p.User == user && p.ScheduledAt != null) 97 + .IncludeCommonProperties() 98 + .FirstOrDefaultAsync(); 99 + 100 + if (note != null) await noteService.DeleteScheduledNoteAsync(note); 101 + return new EmptyObject(); 102 + } 103 + 104 + public class EmptyObject; 105 + }
+35 -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("reblog_id")] public string? ReblogId { get; set; } 182 + [J("poll")] public ScheduledPollData? Poll { get; set; } 183 + 184 + public class ScheduledPollData 185 + { 186 + [J("options")] public List<string> Options { get; set; } = null!; 187 + [J("expires_in")] public long ExpiresIn { get; set; } 188 + [J("multiple")] public bool Multiple { get; set; } = false; 189 + } 190 + } 191 + } 192 +
+9
Iceshrimp.Backend/Controllers/Mastodon/Schemas/InstanceInfoV2Response.cs
··· 24 24 [J("contact")] public InstanceContact Contact => new(adminContact); 25 25 [J("registrations")] public InstanceRegistrations Registrations => new(config.Security); 26 26 [J("configuration")] public InstanceConfigurationV2 Configuration => new(config.Instance); 27 + [J("api_versions")] public InstanceApiVersions ApiVersions => new(); 27 28 28 29 [J("usage")] public required InstanceUsage Usage { get; set; } 29 30 ··· 33 34 34 35 [J("thumbnail")] public required InstanceThumbnail Thumbnail { get; set; } 35 36 37 + 36 38 //TODO: add the rest 39 + } 40 + 41 + public class InstanceApiVersions 42 + { 43 + // this is modeled after https://codeberg.org/fediverse-pl/maep/pulls/2, however since the extensions aren't submitted 44 + // there (yet?) we'll use our own namespace for it 45 + [J("net.iceshrimp.scheduled_boosts")] public ushort ScheduledBoosts { get; set; } = 1; 37 46 } 38 47 39 48 public class InstanceConfigurationV2(Config.InstanceSection config)
+13 -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")] ··· 102 102 [B(Name = "visibility")] 103 103 [J("visibility")] 104 104 public string? Visibility { get; set; } 105 + 106 + [B(Name = "scheduled_at")] 107 + [J("scheduled_at")] 108 + public DateTime? ScheduledAt { get; set; } 105 109 } 110 + 111 + public class RescheduleRequest 112 + { 113 + [B(Name = "scheduled_at")] 114 + [JR] 115 + [J("scheduled_at")] 116 + public DateTime ScheduledAt { get; set; } 117 + } 106 118 }
+31 -12
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 => ··· 304 305 [Authorize("write:favourites")] 305 306 [ProducesResults(HttpStatusCode.OK)] 306 307 [ProducesErrors(HttpStatusCode.NotFound)] 307 - public async Task<StatusEntity> Renote(string id, [FromHybrid] StatusSchemas.ReblogRequest? request) 308 + public async Task<IPostNotePayload> Renote(string id, [FromHybrid] StatusSchemas.ReblogRequest? request) 308 309 { 310 + var scheduled = request?.ScheduledAt != null; 311 + if (scheduled && request?.ScheduledAt?.ToUniversalTime() < DateTime.UtcNow.AddMinutes(1)) 312 + throw GracefulException.UnprocessableEntity("Scheduled note can not be in the past"); 313 + 309 314 var user = HttpContext.GetUserOrFail(); 310 - var renote = await db.Notes.IncludeCommonProperties() 315 + var renote = await db.Notes.IncludeUnpublished() 316 + .IncludeCommonProperties() 311 317 .FirstOrDefaultAsync(p => p.RenoteId == id && p.User == user && p.IsPureRenote); 312 318 313 319 if (renote == null) ··· 322 328 ? StatusEntity.DecodeVisibility(request.Visibility) 323 329 : user.UserSettings?.DefaultRenoteVisibility ?? Note.NoteVisibility.Public; 324 330 325 - renote = await noteSvc.RenoteNoteAsync(note, user, renoteVisibility) ?? 331 + renote = await noteSvc.RenoteNoteAsync(note, user, renoteVisibility, request?.ScheduledAt) ?? 326 332 throw new Exception("Created renote was null"); 333 + if (renote.ScheduledAt != null) return await scheduledStatusController.GetScheduledNote(renote.Id); 334 + 327 335 note.RenoteCount++; // we do not want to call save changes after this point 328 336 } 329 337 ··· 352 360 [Authorize("write:statuses")] 353 361 [ProducesResults(HttpStatusCode.OK)] 354 362 [ProducesErrors(HttpStatusCode.BadRequest, HttpStatusCode.UnprocessableEntity)] 355 - public async Task<StatusEntity> PostNote([FromHybrid] StatusSchemas.PostStatusRequest request) 363 + public async Task<IPostNotePayload> PostNote([FromHybrid] StatusSchemas.PostStatusRequest request) 356 364 { 357 - //TODO: handle scheduled statuses 358 - if (request.ScheduledAt != null) 359 - throw GracefulException.UnprocessableEntity("Scheduled statuses are not supported yet"); 365 + var scheduled = request.ScheduledAt != null; 366 + if (scheduled && request.ScheduledAt?.ToUniversalTime() < DateTime.UtcNow.AddMinutes(1)) 367 + throw GracefulException.UnprocessableEntity("Scheduled note can not be in the past"); 360 368 361 369 var token = HttpContext.GetOauthToken() ?? throw new Exception("Token must not be null at this stage"); 362 370 var user = token.User; 363 - 371 + 364 372 Request.Headers.TryGetValue("Idempotency-Key", out var idempotencyKeyHeader); 365 373 var idempotencyKey = idempotencyKeyHeader.FirstOrDefault(); 366 374 if (idempotencyKey != null) ··· 384 392 throw GracefulException.RequestTimeout("Failed to resolve idempotency key note within 1000 ms"); 385 393 } 386 394 387 - return await GetNote(hit); 388 - } 395 + if (scheduled) 396 + return await scheduledStatusController.GetScheduledNote(hit); 397 + 398 + return await GetNote(hit); 399 + } 389 400 } 390 401 391 402 if (string.IsNullOrWhiteSpace(request.Text) && request.MediaIds is not { Count: > 0 } && request.Poll == null) ··· 502 513 Poll = poll, 503 514 LocalOnly = request.LocalOnly, 504 515 Preview = request.Preview, 516 + ScheduledAt = request.ScheduledAt, 505 517 }); 506 518 507 519 if (!request.Preview && idempotencyKey != null) 508 520 await cache.SetAsync($"idempotency:{user.Id}:{idempotencyKey}", note.Id, TimeSpan.FromHours(24)); 509 521 510 - return await noteRenderer.RenderAsync(note, user); 522 + if (scheduled) 523 + { 524 + return await noteRenderer.RenderScheduledAsync(note, user); 525 + } 526 + else 527 + { 528 + return await noteRenderer.RenderAsync(note, user); 529 + } 511 530 } 512 531 513 532 [HttpPut("{id}")]
+3 -2
Iceshrimp.Backend/Controllers/Web/Renderers/NoteRenderer.cs
··· 81 81 var liked = data?.LikedNotes?.Contains(note.Id) 82 82 ?? await db.NoteLikes.AnyAsync(p => p.Note == note && p.User == user); 83 83 var renoted = data?.Renotes?.Contains(note.Id) 84 - ?? await db.Notes.AnyAsync(p => p.Renote == note && p.User == user && p.IsPureRenote); 84 + ?? await db.Notes.IncludeUnpublished().AnyAsync(p => p.Renote == note && p.User == user && p.IsPureRenote); 85 85 var bookmarked = data?.BookmarkedNotes?.Contains(note.Id) 86 86 ?? await db.NoteBookmarks.AnyAsync(p => p.Note == note && p.User == user); 87 87 var pinned = data?.PinnedNotes?.Contains(note.Id) ··· 208 208 { 209 209 if (user == null) return []; 210 210 if (notes.Count == 0) return []; 211 - return await db.Notes.Where(p => p.User == user && p.IsPureRenote && notes.Contains(p.Renote!)) 211 + return await db.Notes.IncludeUnpublished() 212 + .Where(p => p.User == user && p.IsPureRenote && notes.Contains(p.Renote!)) 212 213 .Select(p => p.RenoteId) 213 214 .Where(p => p != null) 214 215 .Distinct()
+1
Iceshrimp.Backend/Core/Configuration/Config.cs
··· 357 357 [Range(1, int.MaxValue)] public int BackgroundTask { get; init; } = 4; 358 358 [Range(1, int.MaxValue)] public int Backfill { get; init; } = 10; 359 359 [Range(1, int.MaxValue)] public int BackfillUser { get; init; } = 10; 360 + [Range(1, int.MaxValue)] public int ScheduledPost { get; init; } = 20; 360 361 } 361 362 362 363 [ConfigurationSection("Queue")]
+10
Iceshrimp.Backend/Core/Database/Migrations/DatabaseContextModelSnapshot.cs
··· 2439 2439 .HasColumnType("character varying(256)") 2440 2440 .HasColumnName("name"); 2441 2441 2442 + b.Property<bool>("Published") 2443 + .ValueGeneratedOnAdd() 2444 + .HasColumnType("boolean") 2445 + .HasDefaultValue(true) 2446 + .HasColumnName("published"); 2447 + 2442 2448 b.Property<short>("QuotesCount") 2443 2449 .HasColumnType("smallint") 2444 2450 .HasColumnName("quotesCount"); ··· 2518 2524 .HasColumnType("character varying(32)") 2519 2525 .HasColumnName("replyUserId") 2520 2526 .HasComment("[Denormalized]"); 2527 + 2528 + b.Property<DateTime?>("ScheduledAt") 2529 + .HasColumnType("timestamp with time zone") 2530 + .HasColumnName("scheduledAt"); 2521 2531 2522 2532 b.Property<int>("Score") 2523 2533 .ValueGeneratedOnAdd()
+43
Iceshrimp.Backend/Core/Database/Migrations/v2025.1-beta6/20251019071520_AddScheduling.cs
··· 1 + using System; 2 + using Microsoft.EntityFrameworkCore.Infrastructure; 3 + using Microsoft.EntityFrameworkCore.Migrations; 4 + 5 + #nullable disable 6 + 7 + namespace Iceshrimp.Backend.Core.Database.Migrations.v2025._1beta6 8 + { 9 + /// <inheritdoc /> 10 + [DbContext(typeof(DatabaseContext))] 11 + [Migration("20251019071520_AddScheduling")] 12 + public partial class AddScheduling : Migration 13 + { 14 + /// <inheritdoc /> 15 + protected override void Up(MigrationBuilder migrationBuilder) 16 + { 17 + migrationBuilder.AddColumn<bool>( 18 + name: "published", 19 + table: "note", 20 + type: "boolean", 21 + nullable: false, 22 + defaultValue: true); 23 + 24 + migrationBuilder.AddColumn<DateTime>( 25 + name: "scheduledAt", 26 + table: "note", 27 + type: "timestamp with time zone", 28 + nullable: true); 29 + } 30 + 31 + /// <inheritdoc /> 32 + protected override void Down(MigrationBuilder migrationBuilder) 33 + { 34 + migrationBuilder.DropColumn( 35 + name: "published", 36 + table: "note"); 37 + 38 + migrationBuilder.DropColumn( 39 + name: "scheduledAt", 40 + table: "note"); 41 + } 42 + } 43 + }
+3
Iceshrimp.Backend/Core/Database/Tables/ChannelNotePin.cs
··· 48 48 entity.HasOne(d => d.Note) 49 49 .WithMany(p => p.ChannelNotePins) 50 50 .OnDelete(DeleteBehavior.Cascade); 51 + 52 + // Reverse of the filter from Note 53 + entity.HasQueryFilter("Unpublished", e => e.Note.Published); 51 54 } 52 55 } 53 56 }
+3
Iceshrimp.Backend/Core/Database/Tables/ClipNote.cs
··· 52 52 entity.HasOne(d => d.Note) 53 53 .WithMany(p => p.ClipNotes) 54 54 .OnDelete(DeleteBehavior.Cascade); 55 + 56 + // Reverse of the filter from Note 57 + entity.HasQueryFilter("Unpublished", e => e.Note.Published); 55 58 } 56 59 } 57 60 }
+3
Iceshrimp.Backend/Core/Database/Tables/InteractionStamp.cs
··· 50 50 { 51 51 entity.Property(e => e.TargetNoteId).HasComment("The note being interacted with"); 52 52 entity.Property(e => e.NoteId).HasComment("The note doing the interaction (quote, reply, whatever)"); 53 + 54 + // Reverse of the filter from Note 55 + entity.HasQueryFilter("Unpublished", e => e.Note.Published); 53 56 } 54 57 } 55 58 }
+8 -1
Iceshrimp.Backend/Core/Database/Tables/Note.cs
··· 224 224 [Column("combinedAltText")] 225 225 public string? CombinedAltText { get; set; } 226 226 227 + [Column("published")] public bool Published { get; set; } 228 + [Column("scheduledAt")] public DateTime? ScheduledAt { get; set; } 229 + 227 230 [ForeignKey(nameof(ChannelId))] 228 231 [InverseProperty(nameof(Tables.Channel.Notes))] 229 232 public virtual Channel? Channel { get; set; } ··· 322 325 [Projectable] 323 326 [SuppressMessage("ReSharper", "MergeIntoPattern", Justification = "Projectable chain must not contain patterns")] 324 327 public bool IsVisibleFor(User? user) => 328 + (ScheduledAt == null || User == user) && 325 329 (VisibilityIsPublicOrHome && (!LocalOnly || (user != null && user.IsLocalUser))) || 326 330 (user != null && CheckComplexVisibility(user)); 327 331 ··· 445 449 entity.HasOne(d => d.User) 446 450 .WithMany(p => p.Notes) 447 451 .OnDelete(DeleteBehavior.Cascade); 448 - } 452 + 453 + entity.HasQueryFilter("Unpublished", e => e.Published); 454 + entity.Property(e => e.Published).HasDefaultValue(true); 455 + } 449 456 } 450 457 }
+3
Iceshrimp.Backend/Core/Database/Tables/NoteBookmark.cs
··· 46 46 entity.HasOne(d => d.User) 47 47 .WithMany(p => p.NoteBookmarks) 48 48 .OnDelete(DeleteBehavior.Cascade); 49 + 50 + // Reverse of the filter from Note 51 + entity.HasQueryFilter("Unpublished", e => e.Note.Published); 49 52 } 50 53 } 51 54 }
+3
Iceshrimp.Backend/Core/Database/Tables/NoteEdit.cs
··· 49 49 entity.HasOne(d => d.Note) 50 50 .WithMany(p => p.NoteEdits) 51 51 .OnDelete(DeleteBehavior.Cascade); 52 + 53 + // Reverse of the filter from Note 54 + entity.HasQueryFilter("Unpublished", e => e.Note.Published); 52 55 } 53 56 } 54 57 }
+3
Iceshrimp.Backend/Core/Database/Tables/NoteLike.cs
··· 42 42 entity.HasOne(d => d.User) 43 43 .WithMany(p => p.NoteLikes) 44 44 .OnDelete(DeleteBehavior.Cascade); 45 + 46 + // Reverse of the filter from Note 47 + entity.HasQueryFilter("Unpublished", e => e.Note.Published); 45 48 } 46 49 } 47 50 }
+3
Iceshrimp.Backend/Core/Database/Tables/NoteReaction.cs
··· 52 52 entity.HasOne(d => d.User) 53 53 .WithMany(p => p.NoteReactions) 54 54 .OnDelete(DeleteBehavior.Cascade); 55 + 56 + // Reverse of the filter from Note 57 + entity.HasQueryFilter("Unpublished", e => e.Note.Published); 55 58 } 56 59 } 57 60 }
+3
Iceshrimp.Backend/Core/Database/Tables/NoteUnread.cs
··· 64 64 entity.HasOne(d => d.User) 65 65 .WithMany(p => p.NoteUnreads) 66 66 .OnDelete(DeleteBehavior.Cascade); 67 + 68 + // Reverse of the filter from Note 69 + entity.HasQueryFilter("Unpublished", e => e.Note.Published); 67 70 } 68 71 } 69 72 }
+3
Iceshrimp.Backend/Core/Database/Tables/NoteWatching.cs
··· 69 69 entity.HasOne(d => d.User) 70 70 .WithMany(p => p.NoteWatchings) 71 71 .OnDelete(DeleteBehavior.Cascade); 72 + 73 + // Reverse of the filter from Note 74 + entity.HasQueryFilter("Unpublished", e => e.Note.Published); 72 75 } 73 76 } 74 77 }
+3
Iceshrimp.Backend/Core/Database/Tables/Poll.cs
··· 62 62 entity.HasOne(d => d.Note) 63 63 .WithOne(p => p.Poll) 64 64 .OnDelete(DeleteBehavior.Cascade); 65 + 66 + // Reverse of the filter from Note 67 + entity.HasQueryFilter("Unpublished", e => e.Note.Published); 65 68 } 66 69 } 67 70 }
+3
Iceshrimp.Backend/Core/Database/Tables/PollVote.cs
··· 50 50 entity.HasOne(d => d.User) 51 51 .WithMany(p => p.PollVotes) 52 52 .OnDelete(DeleteBehavior.Cascade); 53 + 54 + // Reverse of the filter from Note 55 + entity.HasQueryFilter("Unpublished", e => e.Note.Published); 53 56 } 54 57 } 55 58 }
+3
Iceshrimp.Backend/Core/Database/Tables/PromoNote.cs
··· 36 36 entity.HasOne(d => d.Note) 37 37 .WithOne(p => p.PromoNote) 38 38 .OnDelete(DeleteBehavior.Cascade); 39 + 40 + // Reverse of the filter from Note 41 + entity.HasQueryFilter("Unpublished", e => e.Note.Published); 39 42 } 40 43 } 41 44 }
+3
Iceshrimp.Backend/Core/Database/Tables/PromoRead.cs
··· 46 46 entity.HasOne(d => d.User) 47 47 .WithMany(p => p.PromoReads) 48 48 .OnDelete(DeleteBehavior.Cascade); 49 + 50 + // Reverse of the filter from Note 51 + entity.HasQueryFilter("Unpublished", e => e.Note.Published); 49 52 } 50 53 } 51 54 }
+3
Iceshrimp.Backend/Core/Database/Tables/UserNotePin.cs
··· 46 46 entity.HasOne(d => d.User) 47 47 .WithMany(p => p.UserNotePins) 48 48 .OnDelete(DeleteBehavior.Cascade); 49 + 50 + // Reverse of the filter from Note 51 + entity.HasQueryFilter("Unpublished", e => e.Note.Published); 49 52 } 50 53 } 51 54 }
+12 -2
Iceshrimp.Backend/Core/Extensions/QueryableExtensions.cs
··· 257 257 return query.Where(note => note.Visibility == visibility); 258 258 } 259 259 260 + public IQueryable<Note> IncludeUnpublished() => query.IgnoreQueryFilters(["Unpublished"]); 261 + 260 262 public IQueryable<Note> FilterByUser(User user) 261 263 { 262 264 return query.Where(note => note.User == user); ··· 270 272 public IQueryable<Note> EnsureVisibleFor(User? user) 271 273 { 272 274 return user == null 273 - ? query.Where(note => note.VisibilityIsPublicOrHome && !note.LocalOnly) 275 + ? query.Where(note => note.VisibilityIsPublicOrHome && !note.LocalOnly && note.ScheduledAt == null) 274 276 : query.Where(note => note.IsVisibleFor(user)); 275 277 } 276 278 } ··· 525 527 .ToList(); 526 528 return (await renderer.RenderManyAsync(list, user, filterContext)).ToList(); 527 529 } 530 + 531 + public static async Task<List<ScheduledStatusEntity>> RenderAllScheduledForMastodonAsync( 532 + this IQueryable<Note> notes, NoteRenderer renderer, User? user 533 + ) 534 + { 535 + var list = await notes.ToListAsync(); 536 + return (await renderer.RenderManyScheduledAsync(list, user)).ToList(); 537 + } 528 538 529 539 public static async Task<List<AccountEntity>> RenderAllForMastodonAsync( 530 540 this IQueryable<User> users, UserRenderer renderer, User? localUser ··· 690 700 } 691 701 692 702 #pragma warning restore CS8602 // Dereference of a possibly null reference. 693 - } 703 + }
+1 -1
Iceshrimp.Backend/Core/Queues/BackgroundTaskQueue.cs
··· 387 387 public class ProfileFieldUpdateJobData : BackgroundTaskJobData 388 388 { 389 389 [JR] [J("userId")] public required string UserId { get; set; } 390 - } 390 + }
+40
Iceshrimp.Backend/Core/Queues/ScheduledPostQueue.cs
··· 1 + using Iceshrimp.Backend.Core.Database; 2 + using Iceshrimp.Backend.Core.Database.Tables; 3 + using Iceshrimp.Backend.Core.Extensions; 4 + using Iceshrimp.Backend.Core.Services; 5 + using Microsoft.EntityFrameworkCore; 6 + using J = System.Text.Json.Serialization.JsonPropertyNameAttribute; 7 + using JR = System.Text.Json.Serialization.JsonRequiredAttribute; 8 + 9 + namespace Iceshrimp.Backend.Core.Queues; 10 + 11 + public class ScheduledPostQueue(int parallelism) 12 + : PostgresJobQueue<ScheduledPostJobData>("scheduled-post", ScheduledPostQueueProcessorDelegateAsync, parallelism, TimeSpan.FromSeconds(60)) 13 + { 14 + private static async Task ScheduledPostQueueProcessorDelegateAsync( 15 + Job job, 16 + ScheduledPostJobData jobData, 17 + IServiceProvider scope, 18 + CancellationToken token 19 + ) 20 + { 21 + var db = scope.GetRequiredService<DatabaseContext>(); 22 + var noteSvc = scope.GetRequiredService<NoteService>(); 23 + var logger = scope.GetRequiredService<ILogger<BackgroundTaskQueue>>(); 24 + 25 + var note = await db.Notes.IncludeUnpublished().IncludeCommonProperties().Where(p => p.Id == jobData.NoteId).FirstOrDefaultAsync(token); 26 + if (note == null) 27 + { 28 + logger.LogDebug("Failed to post scheduled note {id}: note not found in database", jobData.NoteId); 29 + return; 30 + } 31 + 32 + note.ScheduledAt = null; 33 + await noteSvc.PublishNoteAsync(note); 34 + } 35 + } 36 + 37 + public class ScheduledPostJobData 38 + { 39 + [JR] [J("noteId")] public required string? NoteId { get; set; } 40 + }
+119 -32
Iceshrimp.Backend/Core/Services/NoteService.cs
··· 45 45 { 46 46 private const int DefaultRecursionLimit = 100; 47 47 48 + // https://misskey-hub.net/en/tools/aid-converter/ 49 + // this seems to be the timestamp for 'zzzzzzzzzzzzzzzz'. anything newer i would expect something to break 50 + private static readonly DateTime ScheduleIdTimeLimit = new DateTime(2089, 05, 24); 51 + 48 52 private static readonly AsyncKeyedLocker<string> KeyedLocker = new(o => 49 53 { 50 54 o.PoolSize = 100; ··· 77 81 public string? ReplyUri; 78 82 public string? RenoteUri; 79 83 public bool Preview = false; 80 - } 84 + public DateTime? ScheduledAt; 85 + } 81 86 82 87 public class NoteUpdateData 83 88 { ··· 275 280 throw GracefulException.UnprocessableEntity("Refusing to create a pure renote reply"); 276 281 } 277 282 278 - var noteId = data.Preview ? "preview" : IdHelpers.GenerateSnowflakeId(data.CreatedAt); 283 + // we need the note ids to sort correctly, but we also don't want to limit clients to a specific time 284 + // as some clients have "drafts" that are posts scheduled for absurd years. let's assume that if you're scheduling 285 + // something like that you don't care where it'll be sorted (not that we'll *actually* schedule it anyways, see ScheduleNoteAsync) 286 + var idTimestamp = data.ScheduledAt != null && data.ScheduledAt < ScheduleIdTimeLimit 287 + ? data.ScheduledAt 288 + : data.CreatedAt; 289 + 290 + var noteId = data.Preview ? "preview" : IdHelpers.GenerateSnowflakeId(idTimestamp); 279 291 var threadId = data.Reply?.ThreadId ?? noteId; 280 292 281 293 var context = data.ASNote?.Context; ··· 346 358 RenoteUserId = data.Renote?.UserId, 347 359 RenoteUserHost = data.Renote?.UserHost, 348 360 User = data.User, 349 - CreatedAt = data.CreatedAt ?? DateTime.UtcNow, 361 + CreatedAt = data.ScheduledAt ?? data.CreatedAt ?? DateTime.UtcNow, 350 362 UserHost = data.User.Host, 351 363 Visibility = data.Visibility, 352 364 FileIds = data.Attachments?.Select(p => p.Id).ToList() ?? [], ··· 390 402 391 403 await UpdateNoteCountersAsync(note, true); 392 404 await db.AddAsync(note); 393 - await db.SaveChangesAsync(); 405 + 406 + if (data.ScheduledAt != null) 407 + { 408 + await ScheduleNoteAsync(note, data.ScheduledAt.Value); 409 + } 410 + else 411 + { 412 + await PublishNoteAsync(note, mentionedLocalUserIds); 413 + } 414 + 415 + return note; 416 + } 417 + 418 + public async Task PublishNoteAsync(Note note, List<string>? mentionedLocalUserIds = null) 419 + { 420 + note.Published = true; 421 + 422 + mentionedLocalUserIds ??= await db.Users 423 + .Where(p => p.IsLocalUser && note.Mentions.Contains(p.Id)) 424 + .Select(p => p.Id) 425 + .ToListAsync(); 426 + 427 + await db.SaveChangesAsync(); 394 428 eventSvc.RaiseNotePublished(note); 395 429 await notificationSvc.GenerateMentionNotificationsAsync(note, mentionedLocalUserIds); 396 430 await notificationSvc.GenerateReplyNotificationsAsync(note, mentionedLocalUserIds); ··· 398 432 399 433 logger.LogDebug("Note {id} created successfully", note.Id); 400 434 401 - if (data.Uri != null || data.Url != null) 435 + if (note.Uri != null || note.Url != null) 402 436 { 403 437 _ = followupTaskSvc.ExecuteTaskAsync("ResolvePendingReplyRenoteTargets", async provider => 404 438 { 405 439 var bgDb = provider.GetRequiredService<DatabaseContext>(); 406 440 var count = 0; 407 441 408 - if (data.Uri != null) 442 + if (note.Uri != null) 409 443 { 410 444 count += 411 - await bgDb.Notes.Where(p => p.ReplyUri == data.Uri) 445 + await bgDb.Notes.Where(p => p.ReplyUri == note.Uri) 412 446 .ExecuteUpdateAsync(p => p.SetProperty(i => i.ReplyUri, _ => null) 413 447 .SetProperty(i => i.ReplyId, _ => note.Id) 414 448 .SetProperty(i => i.ReplyUserId, _ => note.UserId) 415 449 .SetProperty(i => i.ReplyUserHost, _ => note.UserHost) 416 450 .SetProperty(i => i.MastoReplyUserId, 417 - i => i.UserId != data.User.Id 451 + i => i.UserId != note.User.Id 418 452 ? i.UserId 419 - : mastoReplyUserId)); 453 + : note.MastoReplyUserId)); 420 454 421 455 count += 422 - await bgDb.Notes.Where(p => p.RenoteUri == data.Uri) 456 + await bgDb.Notes.Where(p => p.RenoteUri == note.Uri) 423 457 .ExecuteUpdateAsync(p => p.SetProperty(i => i.RenoteUri, _ => null) 424 458 .SetProperty(i => i.RenoteId, _ => note.Id) 425 459 .SetProperty(i => i.RenoteUserId, _ => note.UserId) 426 460 .SetProperty(i => i.RenoteUserHost, _ => note.UserHost)); 427 461 } 428 462 429 - if (data.Url != null) 463 + if (note.Url != null) 430 464 { 431 465 count += 432 - await bgDb.Notes.Where(p => p.ReplyUri == data.Url) 466 + await bgDb.Notes.Where(p => p.ReplyUri == note.Url) 433 467 .ExecuteUpdateAsync(p => p.SetProperty(i => i.ReplyUri, _ => null) 434 468 .SetProperty(i => i.ReplyId, _ => note.Id) 435 469 .SetProperty(i => i.ReplyUserId, _ => note.UserId) 436 470 .SetProperty(i => i.ReplyUserHost, _ => note.UserHost) 437 471 .SetProperty(i => i.MastoReplyUserId, 438 - i => i.UserId != data.User.Id 472 + i => i.UserId != note.User.Id 439 473 ? i.UserId 440 - : mastoReplyUserId)); 474 + : note.MastoReplyUserId)); 441 475 count += 442 - await bgDb.Notes.Where(p => p.RenoteUri == data.Url) 476 + await bgDb.Notes.Where(p => p.RenoteUri == note.Url) 443 477 .ExecuteUpdateAsync(p => p.SetProperty(i => i.RenoteUri, _ => null) 444 478 .SetProperty(i => i.RenoteId, _ => note.Id) 445 479 .SetProperty(i => i.RenoteUserId, _ => note.UserId) ··· 458 492 }); 459 493 } 460 494 461 - if (data.User.IsRemoteUser) 495 + if (note.User.IsRemoteUser) 462 496 { 463 497 _ = followupTaskSvc.ExecuteTaskAsync("UpdateInstanceNoteCounter", async provider => 464 498 { 465 499 var bgDb = provider.GetRequiredService<DatabaseContext>(); 466 500 var bgInstanceSvc = provider.GetRequiredService<InstanceService>(); 467 - var dbInstance = await bgInstanceSvc.GetUpdatedInstanceMetadataAsync(data.User); 501 + var dbInstance = await bgInstanceSvc.GetUpdatedInstanceMetadataAsync(note.User); 468 502 await bgDb.Instances.Where(p => p.Id == dbInstance.Id) 469 503 .ExecuteUpdateAsync(p => p.SetProperty(i => i.NotesCount, i => i.NotesCount + 1)); 470 504 }); 471 505 472 - return note; 506 + return; 473 507 } 474 508 475 - if (data.LocalOnly) return note; 509 + if (note.LocalOnly) return; 476 510 477 - var actor = userRenderer.RenderLite(data.User); 511 + var actor = userRenderer.RenderLite(note.User); 478 512 ASActivity activity = note is { IsPureRenote: true, Renote: not null } 479 513 ? ActivityPub.ActivityRenderer.RenderAnnounce(note.Renote.User == note.User 480 514 ? await noteRenderer.RenderAsync(note.Renote) 481 515 : noteRenderer.RenderLite(note.Renote), 482 516 note.GetPublicUri(config.Value), actor, 483 517 note.Visibility, 484 - data.User.GetPublicUri(config.Value) + "/followers", 518 + note.User.GetPublicUri(config.Value) + "/followers", 485 519 note.CreatedAt) 486 - : ActivityPub.ActivityRenderer.RenderCreate(await noteRenderer.RenderAsync(note, mentions), actor); 520 + : ActivityPub.ActivityRenderer.RenderCreate(await noteRenderer.RenderAsync(note), actor); 487 521 488 522 List<string> additionalUserIds = 489 523 note is { IsPureRenote: true, Renote: not null, Visibility: < Note.NoteVisibility.Followers } ··· 493 527 if (note.Reply?.ReplyUserId is { } replyUserId) 494 528 additionalUserIds.Add(replyUserId); 495 529 496 - var recipientIds = mentionedUserIds.Concat(additionalUserIds); 530 + var recipientIds = note.VisibleUserIds.Concat(additionalUserIds); 497 531 await deliverSvc.DeliverToConditionalAsync(activity, note.User, note, recipientIds); 532 + } 498 533 499 - return note; 500 - } 534 + private async Task ScheduleNoteAsync(Note note, DateTime scheduledAt) 535 + { 536 + scheduledAt = scheduledAt.ToUniversalTime(); 537 + 538 + note.CreatedAt = scheduledAt; 539 + note.ScheduledAt = scheduledAt; 540 + await db.SaveChangesAsync(); 541 + 542 + // some clients do "drafts" by scheduling notes into absurd years in the future, don't bother queueing them at all 543 + if (scheduledAt >= DateTime.Now.AddYears(10)) return; 544 + 545 + await queueSvc.ScheduledPostQueue.ScheduleAsync(new ScheduledPostJobData { NoteId = note.Id }, scheduledAt, $"schedule:{note.Id}"); 546 + } 547 + 548 + public async Task RescheduleNoteAsync(Note note, DateTime scheduledAt) 549 + { 550 + scheduledAt = scheduledAt.ToUniversalTime(); 551 + 552 + note.CreatedAt = scheduledAt; 553 + note.ScheduledAt = scheduledAt; 554 + await db.SaveChangesAsync(); 555 + 556 + // some clients do "drafts" by scheduling notes into absurd years in the future, don't bother queueing them at all 557 + if (scheduledAt >= DateTime.Now.AddYears(10)) return; 558 + 559 + await queueSvc.BackgroundTaskQueue.RescheduleAsync($"schedule:{note.Id}", scheduledAt); 560 + } 561 + 562 + public async Task DeleteScheduledNoteAsync(Note note) 563 + { 564 + await queueSvc.BackgroundTaskQueue.DequeueAsync($"schedule:{note.Id}"); 565 + await DeleteNoteAsync(note); 566 + } 501 567 502 568 /// <remarks> 503 569 /// This needs to be called before SaveChangesAsync on create, and afterwards on delete ··· 839 905 await db.SaveChangesAsync(); 840 906 eventSvc.RaiseNoteUpdated(note); 841 907 842 - if (!isEdit) return note; 908 + if (!note.Published || !isEdit) return note; 843 909 844 - await notificationSvc.GenerateMentionNotificationsAsync(note, mentionedLocalUserIds); 910 + await notificationSvc.GenerateMentionNotificationsAsync(note, mentionedLocalUserIds); 845 911 await notificationSvc.GenerateEditNotificationsAsync(note); 846 912 847 913 if (note.LocalOnly || note.User.IsRemoteUser) return note; ··· 872 938 eventSvc.RaiseNoteDeleted(note); 873 939 await db.SaveChangesAsync(); 874 940 await UpdateNoteCountersAsync(note, false); 941 + 942 + if (!note.Published) return; 875 943 876 944 if (note.User.IsRemoteUser) 877 945 { ··· 1524 1592 { 1525 1593 if (note.IsPureRenote) 1526 1594 throw GracefulException.BadRequest("Cannot like a pure renote"); 1595 + 1596 + if (!note.Published) 1597 + throw GracefulException.BadRequest("This note has not been published yet"); 1527 1598 1528 1599 if (!await db.NoteLikes.AnyAsync(p => p.Note == note && p.User == user)) 1529 1600 { ··· 1559 1630 1560 1631 public async Task<bool> UnlikeNoteAsync(Note note, User user) 1561 1632 { 1633 + if (!note.Published) 1634 + throw GracefulException.BadRequest("This note has not been published yet"); 1635 + 1562 1636 var like = await db.NoteLikes.Where(p => p.Note == note && p.User == user).FirstOrDefaultAsync(); 1563 1637 if (like == null) return false; 1564 1638 db.Remove(like); ··· 1584 1658 return true; 1585 1659 } 1586 1660 1587 - public async Task<Note?> RenoteNoteAsync(Note note, User user, Note.NoteVisibility? visibility = null) 1661 + public async Task<Note?> RenoteNoteAsync(Note note, User user, Note.NoteVisibility? visibility = null, DateTime? scheduledAt = null) 1588 1662 { 1663 + if (!note.Published) 1664 + throw GracefulException.BadRequest("This note has not been published yet"); 1665 + 1589 1666 visibility ??= user.UserSettings?.DefaultRenoteVisibility ?? Note.NoteVisibility.Public; 1590 1667 if (visibility == Note.NoteVisibility.Specified) 1591 1668 throw GracefulException.BadRequest("Renote visibility must be one of: public, unlisted, private"); 1592 1669 if (note.IsPureRenote) 1593 1670 throw GracefulException.BadRequest("Cannot renote a pure renote"); 1594 1671 1595 - if (!await db.Notes.AnyAsync(p => p.Renote == note && p.IsPureRenote && p.User == user)) 1672 + if (!await db.Notes.IncludeUnpublished().AnyAsync(p => p.Renote == note && p.IsPureRenote && p.User == user)) 1596 1673 return await CreateNoteAsync(new NoteCreationData 1597 1674 { 1598 - User = user, 1599 - Visibility = visibility.Value, 1600 - Renote = note 1675 + User = user, 1676 + Visibility = visibility.Value, 1677 + Renote = note, 1678 + ScheduledAt = scheduledAt 1601 1679 }); 1602 1680 1603 1681 return null; ··· 1663 1741 { 1664 1742 if (user.IsRemoteUser) throw new Exception("This method is only valid for local users"); 1665 1743 1744 + if (!note.Published) 1745 + throw GracefulException.BadRequest("This note has not been published yet"); 1666 1746 if (note.IsPureRenote) 1667 1747 throw GracefulException.BadRequest("Cannot pin a pure renote"); 1668 1748 if (note.User != user) ··· 1698 1778 public async Task UnpinNoteAsync(Note note, User user) 1699 1779 { 1700 1780 if (user.IsRemoteUser) throw new Exception("This method is only valid for local users"); 1781 + if (!note.Published) 1782 + throw GracefulException.BadRequest("This note has not been published yet"); 1701 1783 1702 1784 var count = await db.UserNotePins.Where(p => p.Note == note && p.User == user).ExecuteDeleteAsync(); 1703 1785 if (count == 0) return; ··· 1767 1849 1768 1850 public async Task<(string name, bool success)> ReactToNoteAsync(Note note, User user, string name) 1769 1851 { 1852 + if (!note.Published) 1853 + throw GracefulException.BadRequest("This note has not been published yet"); 1770 1854 if (note.IsPureRenote) 1771 1855 throw GracefulException.BadRequest("Cannot react to a pure renote"); 1772 1856 ··· 1816 1900 1817 1901 public async Task<(string name, bool success)> RemoveReactionFromNoteAsync(Note note, User user, string name) 1818 1902 { 1903 + if (!note.Published) 1904 + throw GracefulException.BadRequest("This note has not been published yet"); 1905 + 1819 1906 name = await emojiSvc.ResolveEmojiNameAsync(name, user.Host); 1820 1907 1821 1908 var reaction =
+20 -1
Iceshrimp.Backend/Core/Services/QueueService.cs
··· 32 32 public readonly PreDeliverQueue PreDeliverQueue = new(queueConcurrency.Value.PreDeliver); 33 33 public readonly BackfillQueue BackfillQueue = new(queueConcurrency.Value.Backfill); 34 34 public readonly BackfillUserQueue BackfillUserQueue = new(queueConcurrency.Value.BackfillUser); 35 + public readonly ScheduledPostQueue ScheduledPostQueue = new(queueConcurrency.Value.ScheduledPost); 35 36 36 37 public IEnumerable<string> QueueNames => _queues.Select(p => p.Name); 37 38 ··· 43 44 44 45 protected override async Task ExecuteAsync(CancellationToken token) 45 46 { 46 - _queues.AddRange([InboxQueue, PreDeliverQueue, DeliverQueue, BackgroundTaskQueue]); 47 + _queues.AddRange([InboxQueue, PreDeliverQueue, DeliverQueue, BackgroundTaskQueue, ScheduledPostQueue]); 47 48 48 49 if (backfill.Value.Replies.Enabled) 49 50 _queues.Add(BackfillQueue); ··· 626 627 await db.Jobs.Upsert(job).On(j => j.Mutex!).NoUpdate().RunAsync(); 627 628 RaiseJobDelayedEvent(); 628 629 } 630 + 631 + public async Task RescheduleAsync(string mutex, DateTime triggerAt) 632 + { 633 + await using var scope = GetScope(); 634 + await using var db = GetDbContext(scope); 635 + 636 + var affectedRows = await db.Jobs.Where(j => j.Mutex == mutex) 637 + .ExecuteUpdateAsync(q => q.SetProperty(j => j.DelayedUntil, triggerAt.ToUniversalTime())); 638 + if (affectedRows > 0) RaiseJobDelayedEvent(); 639 + } 640 + 641 + public async Task DequeueAsync(string mutex) 642 + { 643 + await using var scope = GetScope(); 644 + await using var db = GetDbContext(scope); 645 + 646 + await db.Jobs.Where(j => j.Mutex == mutex).ExecuteDeleteAsync(); 647 + } 629 648 }