diff --git a/PrivaPub.Tests/Federation/NoteParserTests.cs b/PrivaPub.Tests/Federation/NoteParserTests.cs
index cb626f6..b777186 100644
--- a/PrivaPub.Tests/Federation/NoteParserTests.cs
+++ b/PrivaPub.Tests/Federation/NoteParserTests.cs
@@ -54,6 +54,7 @@ namespace PrivaPub.Tests.Federation
Assert.Equal("a cat", media.Description);
Assert.Equal(new[] { 0.5f, -1f }, media.Focus);
Assert.Equal(new DateTime(2026, 9, 30, 10, 0, 0, DateTimeKind.Utc), note.Published);
+ Assert.Null(note.Updated);
}
[Fact]
diff --git a/PrivaPub.Tests/Federation/RendererTests.cs b/PrivaPub.Tests/Federation/RendererTests.cs
index 15fe57d..f80e50c 100644
--- a/PrivaPub.Tests/Federation/RendererTests.cs
+++ b/PrivaPub.Tests/Federation/RendererTests.cs
@@ -57,7 +57,7 @@ namespace PrivaPub.Tests.Federation
Language = "en",
Mentions = new() { new PostMention { ActorURI = "https://m.example/users/bob", Handle = "@bob@m.example" } },
Tags = new() { "cats" },
- CreationDate = new DateTime(2026, 9, 30, 17, 45, 12, DateTimeKind.Utc)
+ CreationDate = new DateTime(2026, 9, 30, 17, 45, 12, 345, DateTimeKind.Utc)
};
var note = ActivityPubRenderer.Note(post, Alice, default, "https://m.example/users/bob/statuses/1");
@@ -67,7 +67,7 @@ namespace PrivaPub.Tests.Federation
Assert.Equal("
On <cats>
body
", note["content"]!.GetValue());
Assert.Equal("On ", note["summary"]!.GetValue());
Assert.True(note["sensitive"]!.GetValue());
- Assert.Equal("2026-09-30T17:45:12Z", note["published"]!.GetValue());
+ Assert.Equal("2026-09-30T17:45:12.345Z", note["published"]!.GetValue());
Assert.Equal(new[] { ActivityPubRenderer.Public }, note["to"]!.AsArray().Select(n => n!.GetValue()));
Assert.Equal(new[] { Alice.Followers, "https://m.example/users/bob" }, note["cc"]!.AsArray().Select(n => n!.GetValue()));
var tags = note["tag"]!.AsArray();
diff --git a/PrivaPub.Tests/Infrastructure/PrivacyIdsTests.cs b/PrivaPub.Tests/Infrastructure/PrivacyIdsTests.cs
index 70df2fe..07ab7d2 100644
--- a/PrivaPub.Tests/Infrastructure/PrivacyIdsTests.cs
+++ b/PrivaPub.Tests/Infrastructure/PrivacyIdsTests.cs
@@ -33,6 +33,22 @@ namespace PrivaPub.Tests.Infrastructure
Assert.Equal(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), ObjectId.Parse(older).CreationTime);
}
+ [Fact]
+ public void A_post_arriving_live_sorts_after_everything_already_stored()
+ {
+ var local = ObjectId.GenerateNewId().ToString();
+ var reply = PrivacyIds.Arrived(DateTime.UtcNow.AddSeconds(-30));
+ var skewed = PrivacyIds.Arrived(DateTime.UtcNow.AddYears(50));
+
+ Assert.True(string.CompareOrdinal(local, reply) < 0);
+ Assert.True(string.CompareOrdinal(reply, skewed) < 0);
+ Assert.True(ObjectId.Parse(skewed).CreationTime < DateTime.UtcNow.AddMinutes(1));
+ }
+
+ [Fact]
+ public void A_backfilled_post_keeps_its_published_time() =>
+ Assert.Equal(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), ObjectId.Parse(PrivacyIds.Arrived(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc))).CreationTime);
+
[Fact]
public void A_published_time_in_the_future_is_clamped() =>
Assert.True(ObjectId.Parse(PrivacyIds.At(DateTime.UtcNow.AddYears(50))).CreationTime < DateTime.UtcNow.AddDays(2));
diff --git a/PrivaPub/Federation/Inbox/Handlers/AnnounceHandler.cs b/PrivaPub/Federation/Inbox/Handlers/AnnounceHandler.cs
index e230b6e..a420110 100644
--- a/PrivaPub/Federation/Inbox/Handlers/AnnounceHandler.cs
+++ b/PrivaPub/Federation/Inbox/Handlers/AnnounceHandler.cs
@@ -71,7 +71,7 @@ namespace PrivaPub.Federation.Inbox.Handlers
: DateTime.UtcNow;
var reblog = new PostEntity
{
- ID = PrivacyIds.At(published),
+ ID = PrivacyIds.Arrived(published),
ObjectURI = announceId,
ActivityURI = announceId,
ActorURI = actor.ActorURI,
diff --git a/PrivaPub/Federation/Inbox/RemotePosts.cs b/PrivaPub/Federation/Inbox/RemotePosts.cs
index accdb12..7daf9db 100644
--- a/PrivaPub/Federation/Inbox/RemotePosts.cs
+++ b/PrivaPub/Federation/Inbox/RemotePosts.cs
@@ -60,7 +60,7 @@ namespace PrivaPub.Federation.Inbox
return new PostEntity
{
- ID = PrivacyIds.At(note.Published),
+ ID = PrivacyIds.Arrived(note.Published),
ObjectURI = note.Id,
ActorURI = author.ActorURI,
AuthorAccountId = author.ID,
diff --git a/PrivaPub/Federation/Objects/NoteParser.cs b/PrivaPub/Federation/Objects/NoteParser.cs
index 9d76089..5d13cfd 100644
--- a/PrivaPub/Federation/Objects/NoteParser.cs
+++ b/PrivaPub/Federation/Objects/NoteParser.cs
@@ -163,7 +163,7 @@ namespace PrivaPub.Federation.Objects
static DateTime? Time(string value) =>
DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed)
? parsed.UtcDateTime
- : default;
+ : (DateTime?)null;
static int? Int(JsonNode node) =>
node is JsonValue value && value.TryGetValue(out var number) && number is > 0 and < 100_000 ? number : (int?)null;
diff --git a/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs b/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs
index 6c68444..3bf3985 100644
--- a/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs
+++ b/PrivaPub/Federation/Rendering/ActivityPubRenderer.cs
@@ -48,7 +48,7 @@ namespace PrivaPub.Federation.Rendering
string.IsNullOrEmpty(markdown) ? string.Empty : Markdown.ToHtml(markdown, Pipeline).Trim();
public static string Timestamp(DateTime value) =>
- DateTime.SpecifyKind(value, DateTimeKind.Utc).ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture);
+ DateTime.SpecifyKind(value, DateTimeKind.Utc).ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture);
public static string Day(DateTime value) =>
DateTime.SpecifyKind(value, DateTimeKind.Utc).Date.ToString("yyyy-MM-ddT00:00:00Z", CultureInfo.InvariantCulture);
diff --git a/PrivaPub/Infrastructure/Data/Migrations/_006_unedited_remote_posts_lose_year_one.cs b/PrivaPub/Infrastructure/Data/Migrations/_006_unedited_remote_posts_lose_year_one.cs
new file mode 100644
index 0000000..96ace52
--- /dev/null
+++ b/PrivaPub/Infrastructure/Data/Migrations/_006_unedited_remote_posts_lose_year_one.cs
@@ -0,0 +1,26 @@
+using MongoDB.Entities;
+
+using PrivaPub.Models.Post;
+
+namespace PrivaPub.Infrastructure.Data.Migrations
+{
+ public class _006_unedited_remote_posts_lose_year_one : IMigration
+ {
+ public async Task UpgradeAsync()
+ {
+ var posts = await DB.Default.Find()
+ .Match(p => p.EditedAt == DateTime.MinValue || p.UpdateDate == DateTime.MinValue || p.Revisions.Any(r => r.EditedAt == DateTime.MinValue))
+ .ExecuteAsync();
+ foreach (var post in posts)
+ {
+ foreach (var revision in post.Revisions.Where(r => r.EditedAt == DateTime.MinValue))
+ revision.EditedAt = post.CreationDate;
+ await DB.Default.Update().MatchID(post.ID)
+ .Modify(p => p.EditedAt, post.EditedAt == DateTime.MinValue ? null : post.EditedAt)
+ .Modify(p => p.UpdateDate, post.UpdateDate == DateTime.MinValue ? post.CreationDate : post.UpdateDate)
+ .Modify(p => p.Revisions, post.Revisions)
+ .ExecuteAsync();
+ }
+ }
+ }
+}
diff --git a/PrivaPub/Infrastructure/Ids/PrivacyIds.cs b/PrivaPub/Infrastructure/Ids/PrivacyIds.cs
index 73c635f..5aac6c1 100644
--- a/PrivaPub/Infrastructure/Ids/PrivacyIds.cs
+++ b/PrivaPub/Infrastructure/Ids/PrivacyIds.cs
@@ -1,12 +1,19 @@
using System.Buffers.Binary;
using System.Security.Cryptography;
+using MongoDB.Bson;
+
namespace PrivaPub.Infrastructure.Ids
{
public static class PrivacyIds
{
public static string ForDay(DateTime when) => At(DateTime.SpecifyKind(when, DateTimeKind.Utc).Date);
+ public static string Arrived(DateTime published) =>
+ DateTime.SpecifyKind(published, DateTimeKind.Utc) > DateTime.UtcNow - LiveWindow ? ObjectId.GenerateNewId().ToString() : At(published);
+
+ static readonly TimeSpan LiveWindow = TimeSpan.FromHours(1);
+
public static string At(DateTime when)
{
var seconds = Math.Clamp(new DateTimeOffset(DateTime.SpecifyKind(when, DateTimeKind.Utc)).ToUnixTimeSeconds(), 0,