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] Note scheduling

Kopper 4e5b6655 6f197c7b

+214 -31
+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)
+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 + }
+10 -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 + // TODO: name this filter when we update to EF 10 454 + // https://learn.microsoft.com/en-us/ef/core/querying/filters?tabs=ef10#using-multiple-query-filters 455 + entity.HasQueryFilter(e => e.Published); 456 + entity.Property(e => e.Published).HasDefaultValue(true); 457 + } 449 458 } 450 459 }
+6 -2
Iceshrimp.Backend/Core/Extensions/QueryableExtensions.cs
··· 257 257 return query.Where(note => note.Visibility == visibility); 258 258 } 259 259 260 + // TODO: name this filter when we update to EF 10 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(); 263 + 260 264 public IQueryable<Note> FilterByUser(User user) 261 265 { 262 266 return query.Where(note => note.User == user); ··· 270 274 public IQueryable<Note> EnsureVisibleFor(User? user) 271 275 { 272 276 return user == null 273 - ? query.Where(note => note.VisibilityIsPublicOrHome && !note.LocalOnly) 277 + ? query.Where(note => note.VisibilityIsPublicOrHome && !note.LocalOnly && note.ScheduledAt == null) 274 278 : query.Where(note => note.IsVisibleFor(user)); 275 279 } 276 280 } ··· 690 694 } 691 695 692 696 #pragma warning restore CS8602 // Dereference of a possibly null reference. 693 - } 697 + }
+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 + }
+78 -24
Iceshrimp.Backend/Core/Services/NoteService.cs
··· 77 77 public string? ReplyUri; 78 78 public string? RenoteUri; 79 79 public bool Preview = false; 80 - } 80 + public DateTime? ScheduledAt; 81 + } 81 82 82 83 public class NoteUpdateData 83 84 { ··· 346 347 RenoteUserId = data.Renote?.UserId, 347 348 RenoteUserHost = data.Renote?.UserHost, 348 349 User = data.User, 349 - CreatedAt = data.CreatedAt ?? DateTime.UtcNow, 350 + CreatedAt = data.ScheduledAt ?? data.CreatedAt ?? DateTime.UtcNow, 350 351 UserHost = data.User.Host, 351 352 Visibility = data.Visibility, 352 353 FileIds = data.Attachments?.Select(p => p.Id).ToList() ?? [], ··· 390 391 391 392 await UpdateNoteCountersAsync(note, true); 392 393 await db.AddAsync(note); 393 - await db.SaveChangesAsync(); 394 + 395 + if (data.ScheduledAt != null) 396 + { 397 + await ScheduleNoteAsync(note, data.ScheduledAt.Value); 398 + } 399 + else 400 + { 401 + await PublishNoteAsync(note, mentionedLocalUserIds); 402 + } 403 + 404 + return note; 405 + } 406 + 407 + public async Task PublishNoteAsync(Note note, List<string>? mentionedLocalUserIds = null) 408 + { 409 + note.Published = true; 410 + 411 + mentionedLocalUserIds ??= await db.Users 412 + .Where(p => p.IsLocalUser && note.Mentions.Contains(p.Id)) 413 + .Select(p => p.Id) 414 + .ToListAsync(); 415 + 416 + await db.SaveChangesAsync(); 394 417 eventSvc.RaiseNotePublished(note); 395 418 await notificationSvc.GenerateMentionNotificationsAsync(note, mentionedLocalUserIds); 396 419 await notificationSvc.GenerateReplyNotificationsAsync(note, mentionedLocalUserIds); ··· 398 421 399 422 logger.LogDebug("Note {id} created successfully", note.Id); 400 423 401 - if (data.Uri != null || data.Url != null) 424 + if (note.Uri != null || note.Url != null) 402 425 { 403 426 _ = followupTaskSvc.ExecuteTaskAsync("ResolvePendingReplyRenoteTargets", async provider => 404 427 { 405 428 var bgDb = provider.GetRequiredService<DatabaseContext>(); 406 429 var count = 0; 407 430 408 - if (data.Uri != null) 431 + if (note.Uri != null) 409 432 { 410 433 count += 411 - await bgDb.Notes.Where(p => p.ReplyUri == data.Uri) 434 + await bgDb.Notes.Where(p => p.ReplyUri == note.Uri) 412 435 .ExecuteUpdateAsync(p => p.SetProperty(i => i.ReplyUri, _ => null) 413 436 .SetProperty(i => i.ReplyId, _ => note.Id) 414 437 .SetProperty(i => i.ReplyUserId, _ => note.UserId) 415 438 .SetProperty(i => i.ReplyUserHost, _ => note.UserHost) 416 439 .SetProperty(i => i.MastoReplyUserId, 417 - i => i.UserId != data.User.Id 440 + i => i.UserId != note.User.Id 418 441 ? i.UserId 419 - : mastoReplyUserId)); 442 + : note.MastoReplyUserId)); 420 443 421 444 count += 422 - await bgDb.Notes.Where(p => p.RenoteUri == data.Uri) 445 + await bgDb.Notes.Where(p => p.RenoteUri == note.Uri) 423 446 .ExecuteUpdateAsync(p => p.SetProperty(i => i.RenoteUri, _ => null) 424 447 .SetProperty(i => i.RenoteId, _ => note.Id) 425 448 .SetProperty(i => i.RenoteUserId, _ => note.UserId) 426 449 .SetProperty(i => i.RenoteUserHost, _ => note.UserHost)); 427 450 } 428 451 429 - if (data.Url != null) 452 + if (note.Url != null) 430 453 { 431 454 count += 432 - await bgDb.Notes.Where(p => p.ReplyUri == data.Url) 455 + await bgDb.Notes.Where(p => p.ReplyUri == note.Url) 433 456 .ExecuteUpdateAsync(p => p.SetProperty(i => i.ReplyUri, _ => null) 434 457 .SetProperty(i => i.ReplyId, _ => note.Id) 435 458 .SetProperty(i => i.ReplyUserId, _ => note.UserId) 436 459 .SetProperty(i => i.ReplyUserHost, _ => note.UserHost) 437 460 .SetProperty(i => i.MastoReplyUserId, 438 - i => i.UserId != data.User.Id 461 + i => i.UserId != note.User.Id 439 462 ? i.UserId 440 - : mastoReplyUserId)); 463 + : note.MastoReplyUserId)); 441 464 count += 442 - await bgDb.Notes.Where(p => p.RenoteUri == data.Url) 465 + await bgDb.Notes.Where(p => p.RenoteUri == note.Url) 443 466 .ExecuteUpdateAsync(p => p.SetProperty(i => i.RenoteUri, _ => null) 444 467 .SetProperty(i => i.RenoteId, _ => note.Id) 445 468 .SetProperty(i => i.RenoteUserId, _ => note.UserId) ··· 458 481 }); 459 482 } 460 483 461 - if (data.User.IsRemoteUser) 484 + if (note.User.IsRemoteUser) 462 485 { 463 486 _ = followupTaskSvc.ExecuteTaskAsync("UpdateInstanceNoteCounter", async provider => 464 487 { 465 488 var bgDb = provider.GetRequiredService<DatabaseContext>(); 466 489 var bgInstanceSvc = provider.GetRequiredService<InstanceService>(); 467 - var dbInstance = await bgInstanceSvc.GetUpdatedInstanceMetadataAsync(data.User); 490 + var dbInstance = await bgInstanceSvc.GetUpdatedInstanceMetadataAsync(note.User); 468 491 await bgDb.Instances.Where(p => p.Id == dbInstance.Id) 469 492 .ExecuteUpdateAsync(p => p.SetProperty(i => i.NotesCount, i => i.NotesCount + 1)); 470 493 }); 471 494 472 - return note; 495 + return; 473 496 } 474 497 475 - if (data.LocalOnly) return note; 498 + if (note.LocalOnly) return; 476 499 477 - var actor = userRenderer.RenderLite(data.User); 500 + var actor = userRenderer.RenderLite(note.User); 478 501 ASActivity activity = note is { IsPureRenote: true, Renote: not null } 479 502 ? ActivityPub.ActivityRenderer.RenderAnnounce(note.Renote.User == note.User 480 503 ? await noteRenderer.RenderAsync(note.Renote) 481 504 : noteRenderer.RenderLite(note.Renote), 482 505 note.GetPublicUri(config.Value), actor, 483 506 note.Visibility, 484 - data.User.GetPublicUri(config.Value) + "/followers", 507 + note.User.GetPublicUri(config.Value) + "/followers", 485 508 note.CreatedAt) 486 - : ActivityPub.ActivityRenderer.RenderCreate(await noteRenderer.RenderAsync(note, mentions), actor); 509 + : ActivityPub.ActivityRenderer.RenderCreate(await noteRenderer.RenderAsync(note), actor); 487 510 488 511 List<string> additionalUserIds = 489 512 note is { IsPureRenote: true, Renote: not null, Visibility: < Note.NoteVisibility.Followers } ··· 493 516 if (note.Reply?.ReplyUserId is { } replyUserId) 494 517 additionalUserIds.Add(replyUserId); 495 518 496 - var recipientIds = mentionedUserIds.Concat(additionalUserIds); 519 + var recipientIds = note.VisibleUserIds.Concat(additionalUserIds); 497 520 await deliverSvc.DeliverToConditionalAsync(activity, note.User, note, recipientIds); 521 + } 522 + 523 + private async Task ScheduleNoteAsync(Note note, DateTime scheduledAt) 524 + { 525 + scheduledAt = scheduledAt.ToUniversalTime(); 526 + 527 + note.ScheduledAt = scheduledAt; 528 + await db.SaveChangesAsync(); 529 + 530 + // some clients do "drafts" by scheduling notes into absurd years in the future, don't bother queueing them at all 531 + if (scheduledAt >= DateTime.Now.AddYears(10)) return; 498 532 499 - return note; 500 - } 533 + await queueSvc.ScheduledPostQueue.ScheduleAsync(new ScheduledPostJobData { NoteId = note.Id }, scheduledAt, $"schedule:{note.Id}"); 534 + } 535 + 536 + public async Task RescheduleNoteAsync(Note note, DateTime scheduledAt) 537 + { 538 + scheduledAt = scheduledAt.ToUniversalTime(); 539 + 540 + note.CreatedAt = scheduledAt; 541 + note.ScheduledAt = scheduledAt; 542 + await db.SaveChangesAsync(); 543 + 544 + // some clients do "drafts" by scheduling notes into absurd years in the future, don't bother queueing them at all 545 + if (scheduledAt >= DateTime.Now.AddYears(10)) return; 546 + 547 + await queueSvc.BackgroundTaskQueue.RescheduleAsync($"schedule:{note.Id}", scheduledAt); 548 + } 549 + 550 + public async Task DeleteScheduledNoteAsync(Note note) 551 + { 552 + await queueSvc.BackgroundTaskQueue.DequeueAsync($"schedule:{note.Id}"); 553 + await DeleteNoteAsync(note); 554 + } 501 555 502 556 /// <remarks> 503 557 /// This needs to be called before SaveChangesAsync on create, and afterwards on delete
+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 }