diff --git a/PinkSea.Frontend/src/components/oekaki/PostViewOekakiImageContainer.vue b/PinkSea.Frontend/src/components/oekaki/PostViewOekakiImageContainer.vue
index c0ba2c0..bda9fb9 100644
--- a/PinkSea.Frontend/src/components/oekaki/PostViewOekakiImageContainer.vue
+++ b/PinkSea.Frontend/src/components/oekaki/PostViewOekakiImageContainer.vue
@@ -1,13 +1,13 @@
-
diff --git a/PinkSea.Lexicons/com/shinolabs/pinksea/preferences.json b/PinkSea.Lexicons/com/shinolabs/pinksea/preferences.json
new file mode 100644
index 0000000..4b13c08
--- /dev/null
+++ b/PinkSea.Lexicons/com/shinolabs/pinksea/preferences.json
@@ -0,0 +1,43 @@
+{
+ "$schema": "https://internect.info/lexicon-schema.json",
+ "lexicon": 1,
+ "id": "com.shinolabs.pinksea.preferences",
+ "defs": {
+ "main": {
+ "type": "record",
+ "description": "A list of user preferences.",
+ "record": {
+ "type": "object",
+ "required": [
+ "values"
+ ],
+ "properties": {
+ "values": {
+ "type": "array",
+ "description": "The preferences for this user",
+ "items": {
+ "type": "ref",
+ "ref": "#preference"
+ }
+ }
+ }
+ }
+ },
+ "preference": {
+ "type": "object",
+ "description": "A custom user preference.",
+ "properties": {
+ "key": {
+ "type": "string",
+ "description": "The key of the preference.",
+ "maxLength": 512
+ },
+ "value": {
+ "type": "string",
+ "description": "The value of the preference.",
+ "maxLength": 2048
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/PinkSea/Database/Models/UserModel.cs b/PinkSea/Database/Models/UserModel.cs
index fdf2149..257fcaa 100644
--- a/PinkSea/Database/Models/UserModel.cs
+++ b/PinkSea/Database/Models/UserModel.cs
@@ -60,6 +60,11 @@ public class UserModel
/// The links this user owns.
///
public virtual ICollection? Links { get; set; }
+
+ ///
+ /// The preferences this user has.
+ ///
+ public virtual ICollection? Preferences { get; set; }
///
/// The oekaki this user has made.
diff --git a/PinkSea/Database/Models/UserPreferenceModel.cs b/PinkSea/Database/Models/UserPreferenceModel.cs
new file mode 100644
index 0000000..92808ca
--- /dev/null
+++ b/PinkSea/Database/Models/UserPreferenceModel.cs
@@ -0,0 +1,32 @@
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace PinkSea.Database.Models;
+
+///
+/// A singular preference for a given user.
+///
+public class UserPreferenceModel
+{
+ ///
+ /// The key of the link.
+ ///
+ [Key]
+ [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
+ public int Id { get; set; }
+
+ ///
+ /// The DID of the user that owns this link.
+ ///
+ [ForeignKey(nameof(User))]
+ public required string UserDid { get; set; }
+
+ ///
+ /// The user that owns this link.
+ ///
+ public required UserModel User { get; set; }
+
+ public required string Key { get; set; }
+
+ public required string Value { get; set; }
+}
\ No newline at end of file
diff --git a/PinkSea/Database/PinkSeaDbContext.cs b/PinkSea/Database/PinkSeaDbContext.cs
index 75d7639..1ea565d 100644
--- a/PinkSea/Database/PinkSeaDbContext.cs
+++ b/PinkSea/Database/PinkSeaDbContext.cs
@@ -42,6 +42,11 @@ public class PinkSeaDbContext(
///
public DbSet Configuration { get; set; } = null!;
+ ///
+ /// The preferences table.
+ ///
+ public DbSet Preferences { get; set; } = null!;
+
///
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -54,7 +59,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
o.Author.AppViewBlocked ||
o.Author.RepoStatus != UserRepoStatus.Active));
});
-
+
if (Database.ProviderName != "Microsoft.EntityFrameworkCore.Sqlite")
return;
diff --git a/PinkSea/Lexicons/Objects/Preference.cs b/PinkSea/Lexicons/Objects/Preference.cs
new file mode 100644
index 0000000..47239d0
--- /dev/null
+++ b/PinkSea/Lexicons/Objects/Preference.cs
@@ -0,0 +1,12 @@
+using System.Text.Json.Serialization;
+
+namespace PinkSea.Lexicons.Objects;
+
+public class Preference
+{
+ [JsonPropertyName("key")]
+ public required string Key { get; set; }
+
+ [JsonPropertyName("value")]
+ public required string Value { get; set; }
+}
\ No newline at end of file
diff --git a/PinkSea/Lexicons/Procedures/PutPreferenceProcedureRequest.cs b/PinkSea/Lexicons/Procedures/PutPreferenceProcedureRequest.cs
new file mode 100644
index 0000000..ade6067
--- /dev/null
+++ b/PinkSea/Lexicons/Procedures/PutPreferenceProcedureRequest.cs
@@ -0,0 +1,21 @@
+using System.Text.Json.Serialization;
+
+namespace PinkSea.Lexicons.Procedures;
+
+///
+/// The request for the "com.shinolabs.pinksea.putPreference" xrpc call.
+///
+public class PutPreferenceProcedureRequest
+{
+ ///
+ /// The key of the preference.
+ ///
+ [JsonPropertyName("key")]
+ public required string Key { get; set; }
+
+ ///
+ /// The value of the preference.
+ ///
+ [JsonPropertyName("value")]
+ public required string Value { get; set; }
+}
\ No newline at end of file
diff --git a/PinkSea/Lexicons/Queries/GetPreferencesQueryRequest.cs b/PinkSea/Lexicons/Queries/GetPreferencesQueryRequest.cs
new file mode 100644
index 0000000..4fc1016
--- /dev/null
+++ b/PinkSea/Lexicons/Queries/GetPreferencesQueryRequest.cs
@@ -0,0 +1,6 @@
+namespace PinkSea.Lexicons.Queries;
+
+///
+/// The request for the "com.shinolabs.pinksea.getPreferences" xrpc query.
+///
+public class GetPreferencesQueryRequest();
\ No newline at end of file
diff --git a/PinkSea/Lexicons/Queries/GetPreferencesQueryResponse.cs b/PinkSea/Lexicons/Queries/GetPreferencesQueryResponse.cs
new file mode 100644
index 0000000..d01fe29
--- /dev/null
+++ b/PinkSea/Lexicons/Queries/GetPreferencesQueryResponse.cs
@@ -0,0 +1,16 @@
+using System.Text.Json.Serialization;
+using PinkSea.Lexicons.Objects;
+
+namespace PinkSea.Lexicons.Queries;
+
+///
+/// The response for the "com.shinolabs.pinksea.getPreferences" xrpc query.
+///
+public class GetPreferencesQueryResponse
+{
+ ///
+ /// The preferences.
+ ///
+ [JsonPropertyName("preferences")]
+ public required IReadOnlyList Preferences { get; set; }
+}
\ No newline at end of file
diff --git a/PinkSea/Lexicons/Records/Preferences.cs b/PinkSea/Lexicons/Records/Preferences.cs
new file mode 100644
index 0000000..ce068d3
--- /dev/null
+++ b/PinkSea/Lexicons/Records/Preferences.cs
@@ -0,0 +1,16 @@
+using System.Text.Json.Serialization;
+using PinkSea.Lexicons.Objects;
+
+namespace PinkSea.Lexicons.Records;
+
+///
+/// The "com.shinolabs.pinksea.preferences" lexicon.
+///
+public class Preferences
+{
+ ///
+ /// The preferences.
+ ///
+ [JsonPropertyName("values"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public required IReadOnlyList Values { get; set; }
+}
\ No newline at end of file
diff --git a/PinkSea/Migrations/20250827180633_Add user preferences..Designer.cs b/PinkSea/Migrations/20250827180633_Add user preferences..Designer.cs
new file mode 100644
index 0000000..08bb9a1
--- /dev/null
+++ b/PinkSea/Migrations/20250827180633_Add user preferences..Designer.cs
@@ -0,0 +1,328 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using PinkSea.Database;
+
+#nullable disable
+
+namespace PinkSea.Migrations
+{
+ [DbContext(typeof(PinkSeaDbContext))]
+ [Migration("20250827180633_Add user preferences.")]
+ partial class Adduserpreferences
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.10")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("PinkSea.Database.Models.ConfigurationModel", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ClientPrivateKey")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ClientPublicKey")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ImportedProfiles")
+ .HasColumnType("boolean");
+
+ b.Property("KeyId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("SynchronizedAccountStates")
+ .HasColumnType("boolean");
+
+ b.HasKey("Id");
+
+ b.ToTable("Configuration");
+ });
+
+ modelBuilder.Entity("PinkSea.Database.Models.OAuthStateModel", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("text");
+
+ b.Property("Json")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.ToTable("OAuthStates");
+ });
+
+ modelBuilder.Entity("PinkSea.Database.Models.OekakiModel", b =>
+ {
+ b.Property("Key")
+ .HasColumnType("text");
+
+ b.Property("AltText")
+ .HasColumnType("text");
+
+ b.Property("AuthorDid")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("BlobCid")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("BlueskyCrosspostRecordTid")
+ .HasColumnType("text");
+
+ b.Property("IndexedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("IsNsfw")
+ .HasColumnType("boolean");
+
+ b.Property("OekakiTid")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ParentId")
+ .HasColumnType("text");
+
+ b.Property("RecordCid")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Tombstone")
+ .HasColumnType("boolean");
+
+ b.HasKey("Key");
+
+ b.HasIndex("ParentId");
+
+ b.HasIndex("Tombstone");
+
+ b.HasIndex("AuthorDid", "OekakiTid");
+
+ b.ToTable("Oekaki");
+ });
+
+ modelBuilder.Entity("PinkSea.Database.Models.TagModel", b =>
+ {
+ b.Property("Name")
+ .HasColumnType("text");
+
+ b.HasKey("Name");
+
+ b.ToTable("Tags");
+ });
+
+ modelBuilder.Entity("PinkSea.Database.Models.TagOekakiRelationModel", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("OekakiId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("TagId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("OekakiId");
+
+ b.HasIndex("TagId");
+
+ b.ToTable("TagOekakiRelations");
+ });
+
+ modelBuilder.Entity("PinkSea.Database.Models.UserLinkModel", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Url")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("UserDid")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserDid");
+
+ b.ToTable("UserLinkModel");
+ });
+
+ modelBuilder.Entity("PinkSea.Database.Models.UserModel", b =>
+ {
+ b.Property("Did")
+ .HasColumnType("text");
+
+ b.Property("AppViewBlocked")
+ .HasColumnType("boolean");
+
+ b.Property("AvatarId")
+ .HasColumnType("text");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Description")
+ .HasColumnType("text");
+
+ b.Property("Handle")
+ .HasColumnType("text");
+
+ b.Property("Nickname")
+ .HasColumnType("text");
+
+ b.Property("RepoStatus")
+ .HasColumnType("integer");
+
+ b.HasKey("Did");
+
+ b.HasIndex("AvatarId");
+
+ b.ToTable("Users");
+ });
+
+ modelBuilder.Entity("PinkSea.Database.Models.UserPreferenceModel", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Key")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("UserDid")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Value")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserDid");
+
+ b.ToTable("Preferences");
+ });
+
+ modelBuilder.Entity("PinkSea.Database.Models.OekakiModel", b =>
+ {
+ b.HasOne("PinkSea.Database.Models.UserModel", "Author")
+ .WithMany("Oekaki")
+ .HasForeignKey("AuthorDid")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("PinkSea.Database.Models.OekakiModel", "Parent")
+ .WithMany()
+ .HasForeignKey("ParentId");
+
+ b.Navigation("Author");
+
+ b.Navigation("Parent");
+ });
+
+ modelBuilder.Entity("PinkSea.Database.Models.TagOekakiRelationModel", b =>
+ {
+ b.HasOne("PinkSea.Database.Models.OekakiModel", "Oekaki")
+ .WithMany("TagOekakiRelations")
+ .HasForeignKey("OekakiId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("PinkSea.Database.Models.TagModel", "Tag")
+ .WithMany()
+ .HasForeignKey("TagId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Oekaki");
+
+ b.Navigation("Tag");
+ });
+
+ modelBuilder.Entity("PinkSea.Database.Models.UserLinkModel", b =>
+ {
+ b.HasOne("PinkSea.Database.Models.UserModel", "User")
+ .WithMany("Links")
+ .HasForeignKey("UserDid")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("PinkSea.Database.Models.UserModel", b =>
+ {
+ b.HasOne("PinkSea.Database.Models.OekakiModel", "Avatar")
+ .WithMany()
+ .HasForeignKey("AvatarId");
+
+ b.Navigation("Avatar");
+ });
+
+ modelBuilder.Entity("PinkSea.Database.Models.UserPreferenceModel", b =>
+ {
+ b.HasOne("PinkSea.Database.Models.UserModel", "User")
+ .WithMany("Preferences")
+ .HasForeignKey("UserDid")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("PinkSea.Database.Models.OekakiModel", b =>
+ {
+ b.Navigation("TagOekakiRelations");
+ });
+
+ modelBuilder.Entity("PinkSea.Database.Models.UserModel", b =>
+ {
+ b.Navigation("Links");
+
+ b.Navigation("Oekaki");
+
+ b.Navigation("Preferences");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/PinkSea/Migrations/20250827180633_Add user preferences..cs b/PinkSea/Migrations/20250827180633_Add user preferences..cs
new file mode 100644
index 0000000..7a3aada
--- /dev/null
+++ b/PinkSea/Migrations/20250827180633_Add user preferences..cs
@@ -0,0 +1,48 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace PinkSea.Migrations
+{
+ ///
+ public partial class Adduserpreferences : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "Preferences",
+ columns: table => new
+ {
+ Id = table.Column(type: "integer", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ UserDid = table.Column(type: "text", nullable: false),
+ Key = table.Column(type: "text", nullable: false),
+ Value = table.Column(type: "text", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Preferences", x => x.Id);
+ table.ForeignKey(
+ name: "FK_Preferences_Users_UserDid",
+ column: x => x.UserDid,
+ principalTable: "Users",
+ principalColumn: "Did",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Preferences_UserDid",
+ table: "Preferences",
+ column: "UserDid");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "Preferences");
+ }
+ }
+}
diff --git a/PinkSea/Migrations/PinkSeaDbContextModelSnapshot.cs b/PinkSea/Migrations/PinkSeaDbContextModelSnapshot.cs
index 8bd09da..3e073a9 100644
--- a/PinkSea/Migrations/PinkSeaDbContextModelSnapshot.cs
+++ b/PinkSea/Migrations/PinkSeaDbContextModelSnapshot.cs
@@ -212,6 +212,33 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.ToTable("Users");
});
+ modelBuilder.Entity("PinkSea.Database.Models.UserPreferenceModel", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Key")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("UserDid")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Value")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserDid");
+
+ b.ToTable("Preferences");
+ });
+
modelBuilder.Entity("PinkSea.Database.Models.OekakiModel", b =>
{
b.HasOne("PinkSea.Database.Models.UserModel", "Author")
@@ -268,6 +295,17 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Navigation("Avatar");
});
+ modelBuilder.Entity("PinkSea.Database.Models.UserPreferenceModel", b =>
+ {
+ b.HasOne("PinkSea.Database.Models.UserModel", "User")
+ .WithMany("Preferences")
+ .HasForeignKey("UserDid")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
modelBuilder.Entity("PinkSea.Database.Models.OekakiModel", b =>
{
b.Navigation("TagOekakiRelations");
@@ -278,6 +316,8 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Navigation("Links");
b.Navigation("Oekaki");
+
+ b.Navigation("Preferences");
});
#pragma warning restore 612, 618
}
diff --git a/PinkSea/Program.cs b/PinkSea/Program.cs
index 814b8dc..b2113b8 100644
--- a/PinkSea/Program.cs
+++ b/PinkSea/Program.cs
@@ -36,6 +36,7 @@
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
+builder.Services.AddScoped();
builder.Services.AddTransient();
builder.Services.AddDbContext();
@@ -48,7 +49,7 @@
{
o.Endpoint = builder.Configuration["AppViewConfig:JetStreamEndpoint"];
o.CursorFilePath = builder.Configuration["AppViewConfig:CursorFilePath"];
- o.WantedCollections = ["com.shinolabs.pinksea.oekaki", "com.shinolabs.pinksea.profile"];
+ o.WantedCollections = ["com.shinolabs.pinksea.oekaki", "com.shinolabs.pinksea.profile", "com.shinolabs.pinksea.preferences"];
});
builder.Services.AddScoped();
diff --git a/PinkSea/Services/OekakiJetStreamEventHandler.cs b/PinkSea/Services/OekakiJetStreamEventHandler.cs
index 4ba9ba8..63cec27 100644
--- a/PinkSea/Services/OekakiJetStreamEventHandler.cs
+++ b/PinkSea/Services/OekakiJetStreamEventHandler.cs
@@ -1,6 +1,5 @@
using System.Text.Json;
using Microsoft.Extensions.Caching.Memory;
-using Org.BouncyCastle.Asn1.X509;
using PinkSea.AtProto.Resolvers.Did;
using PinkSea.AtProto.Streaming.JetStream;
using PinkSea.AtProto.Streaming.JetStream.Events;
@@ -18,6 +17,7 @@ namespace PinkSea.Services;
public class OekakiJetStreamEventHandler(
OekakiService oekakiService,
UserService userService,
+ PreferencesService preferencesService,
IDidResolver didResolver,
IHttpClientFactory httpClientFactory,
ILogger logger,
@@ -85,6 +85,9 @@ private Task HandleCommit(
"com.shinolabs.pinksea.profile" => ProcessCreatedProfile(
commit,
@event.Did),
+ "com.shinolabs.pinksea.preferences" => ProcessCreatedPreferences(
+ commit,
+ @event.Did),
_ => throw new ArgumentOutOfRangeException()
};
}
@@ -96,6 +99,9 @@ private Task HandleCommit(
"com.shinolabs.pinksea.profile" => ProcessCreatedProfile(
commit,
@event.Did),
+ "com.shinolabs.pinksea.preferences" => ProcessCreatedPreferences(
+ commit,
+ @event.Did),
_ => throw new ArgumentOutOfRangeException()
};
}
@@ -110,6 +116,9 @@ private Task HandleCommit(
"com.shinolabs.pinksea.profile" => ProcessDeletedProfile(
commit,
@event.Did),
+ "com.shinolabs.pinksea.preferences" => ProcessDeletedPreferences(
+ commit,
+ @event.Did),
_ => throw new ArgumentOutOfRangeException()
};
}
@@ -161,6 +170,23 @@ private async Task ProcessCreatedProfile(
await userService.UpdateProfile(authorDid, profileRecord);
}
+ ///
+ /// Processes a newly created or updated preferences.
+ ///
+ /// The commit that created the preferences.
+ /// The DID of the profile's author.
+ private async Task ProcessCreatedPreferences(
+ AtProtoCommit commit,
+ string authorDid)
+ {
+ var user = await userService.GetUserByDid(authorDid) ?? await userService.Create(authorDid);
+ var preferences = commit.Record!
+ .Value
+ .Deserialize()!;
+
+ await preferencesService.ImportRepoPreferences(user, preferences);
+ }
+
///
/// Processes a deleted profile.
///
@@ -197,6 +223,23 @@ await oekakiService.MarkOekakiAsDeleted(
authorDid,
commit.RecordKey);
}
+
+ ///
+ /// Processes deleted oekaki.
+ ///
+ /// The commit.
+ /// The author's DID.
+ private async Task ProcessDeletedPreferences(
+ AtProtoCommit commit,
+ string authorDid)
+ {
+ var user = await userService.GetUserByDid(authorDid);
+ if (user is null)
+ return;
+
+ // We just reset all the data with a blank profile.
+ await preferencesService.DeletePreferencesForUser(user);
+ }
///
/// Processes created oekaki.
diff --git a/PinkSea/Services/PreferencesService.cs b/PinkSea/Services/PreferencesService.cs
new file mode 100644
index 0000000..a8812e9
--- /dev/null
+++ b/PinkSea/Services/PreferencesService.cs
@@ -0,0 +1,127 @@
+using Microsoft.EntityFrameworkCore;
+using Org.BouncyCastle.Asn1.Cms;
+using PinkSea.AtProto.Models.OAuth;
+using PinkSea.AtProto.Shared.Lexicons.AtProto;
+using PinkSea.AtProto.Xrpc.Client;
+using PinkSea.Database;
+using PinkSea.Database.Models;
+using PinkSea.Lexicons.Objects;
+using PinkSea.Lexicons.Records;
+
+namespace PinkSea.Services;
+
+public class PreferencesService(
+ IXrpcClientFactory xrpcClientFactory,
+ ILogger logger,
+ PinkSeaDbContext dbContext)
+{
+ public async Task> GetAllPreferencesForUser(UserModel user)
+ {
+ var prefs = await dbContext.Preferences
+ .Where(u => u.UserDid == user.Did)
+ .ToListAsync();
+
+ return prefs;
+ }
+
+ public async Task SetPreferenceForUser(
+ UserModel user,
+ string key,
+ string value)
+ {
+ logger.LogInformation("Setting preference {Key} to {Value} for DID {Did}", key, value, user.Did);
+
+ var preference = await dbContext.Preferences
+ .Where(u => u.UserDid == user.Did && key == u.Key)
+ .FirstOrDefaultAsync();
+
+ if (preference == null)
+ {
+ preference = new UserPreferenceModel
+ {
+ UserDid = user.Did,
+ User = user,
+ Key = key,
+ Value = value
+ };
+
+ await dbContext.Preferences.AddAsync(preference);
+ }
+ else
+ {
+ preference.Value = value;
+ dbContext.Preferences.Update(preference);
+ }
+
+ await dbContext.SaveChangesAsync();
+ }
+
+ public async Task ImportRepoPreferences(
+ UserModel user,
+ Preferences record)
+ {
+ logger.LogInformation("Importing preferences for DID {Did}", user.Did);
+
+ await DeletePreferencesForUser(user);
+
+ foreach (var preference in record.Values)
+ {
+ var model = new UserPreferenceModel
+ {
+ UserDid = user.Did,
+ User = user,
+ Key = preference.Key,
+ Value = preference.Value
+ };
+
+ await dbContext.Preferences.AddAsync(model);
+ }
+
+ await dbContext.SaveChangesAsync();
+ }
+
+ public async Task DeletePreferencesForUser(UserModel user)
+ {
+ logger.LogInformation("Deleting preferences for DID {Did}", user.Did);
+
+ await dbContext.Preferences
+ .Where(p => p.UserDid == user.Did)
+ .ExecuteDeleteAsync();
+ }
+
+ public async Task PublishPreferencesUpdateToRepo(
+ OAuthState oauthState)
+ {
+ var preferences = await dbContext.Preferences
+ .Where(p => p.UserDid == oauthState.Did)
+ .Select(p => new Preference
+ {
+ Key = p.Key,
+ Value = p.Value
+ })
+ .ToListAsync();
+
+ var record = new Preferences
+ {
+ Values = preferences
+ };
+
+ using var xrpcClient = await xrpcClientFactory.GetForOAuthState(oauthState);
+ if (xrpcClient == null)
+ {
+ return false;
+ }
+
+ var result = await xrpcClient.Procedure(
+ "com.atproto.repo.putRecord",
+ new PutRecordRequest
+ {
+ Repo = oauthState.Did,
+ Collection = "com.shinolabs.pinksea.preferences",
+ RecordKey = "self",
+ Record = record
+ });
+
+ return result.IsSuccess;
+ }
+}
\ No newline at end of file
diff --git a/PinkSea/Xrpc/GetPreferencesQueryHandler.cs b/PinkSea/Xrpc/GetPreferencesQueryHandler.cs
new file mode 100644
index 0000000..b1f95c2
--- /dev/null
+++ b/PinkSea/Xrpc/GetPreferencesQueryHandler.cs
@@ -0,0 +1,54 @@
+using PinkSea.AtProto.Providers.Storage;
+using PinkSea.AtProto.Server.Xrpc;
+using PinkSea.AtProto.Shared.Xrpc;
+using PinkSea.Extensions;
+using PinkSea.Lexicons.Objects;
+using PinkSea.Lexicons.Queries;
+using PinkSea.Services;
+
+namespace PinkSea.Xrpc;
+
+///
+/// Handler for the "com.shinolabs.pinksea.getPreferences" xrpc call. Retrieves all the preferences for a given user.
+///
+[Xrpc("com.shinolabs.pinksea.getPreferences")]
+public class GetPreferencesQueryHandler(
+ UserService userService,
+ PreferencesService preferencesService,
+ IHttpContextAccessor contextAccessor,
+ IOAuthStateStorageProvider oauthStateStorageProvider)
+ : IXrpcQuery
+{
+ ///
+ public async Task> Handle(GetPreferencesQueryRequest request)
+ {
+ var state = contextAccessor.HttpContext?.GetStateToken();
+ if (state is null)
+ return XrpcErrorOr.Fail("NoAuthToken", "Missing authorization token.");
+
+ var oauthState = await oauthStateStorageProvider.GetForStateId(state);
+ if (oauthState is null)
+ return XrpcErrorOr.Fail("InvalidToken", "Invalid token.");
+
+ var user = await userService.GetUserByDid(oauthState.Did);
+ if (user is null)
+ {
+ // If the user is null, we should probably make one for them. We can also return a blank response, as we have no data.
+ await userService.Create(oauthState.Did);
+ return XrpcErrorOr.Ok(new GetPreferencesQueryResponse
+ {
+ Preferences = []
+ });
+ }
+
+ var preferences = await preferencesService.GetAllPreferencesForUser(user);
+ return XrpcErrorOr.Ok(new GetPreferencesQueryResponse
+ {
+ Preferences = preferences.Select(p => new Preference
+ {
+ Key = p.Key,
+ Value = p.Value
+ }).ToList()
+ });
+ }
+}
\ No newline at end of file
diff --git a/PinkSea/Xrpc/PutPreferenceProcedureHandler.cs b/PinkSea/Xrpc/PutPreferenceProcedureHandler.cs
new file mode 100644
index 0000000..0e13346
--- /dev/null
+++ b/PinkSea/Xrpc/PutPreferenceProcedureHandler.cs
@@ -0,0 +1,43 @@
+using PinkSea.AtProto.Providers.Storage;
+using PinkSea.AtProto.Server.Xrpc;
+using PinkSea.AtProto.Shared.Xrpc;
+using PinkSea.Extensions;
+using PinkSea.Lexicons;
+using PinkSea.Lexicons.Procedures;
+using PinkSea.Services;
+
+namespace PinkSea.Xrpc;
+
+///
+/// The handler for the "com.shinolabs.pinksea.putPreference" XRPC procedure. Sets a preference for a user.
+///
+[Xrpc("com.shinolabs.pinksea.putPreference")]
+public class PutPreferenceProcedureHandler(
+ UserService userService,
+ PreferencesService preferencesService,
+ IHttpContextAccessor contextAccessor,
+ IOAuthStateStorageProvider oauthStateStorageProvider)
+ : IXrpcProcedure
+{
+ ///
+ public async Task> Handle(PutPreferenceProcedureRequest request)
+ {
+ var state = contextAccessor.HttpContext?.GetStateToken();
+ if (state is null)
+ return XrpcErrorOr.Fail("NoAuthToken", "Missing authorization token.");
+
+ var oauthState = await oauthStateStorageProvider.GetForStateId(state);
+ if (oauthState is null)
+ return XrpcErrorOr.Fail("InvalidToken", "Invalid token.");
+
+ var user = await userService.GetUserByDid(oauthState.Did) ?? await userService.Create(oauthState.Did);
+
+ await preferencesService.SetPreferenceForUser(user, request.Key, request.Value);
+ if (await preferencesService.PublishPreferencesUpdateToRepo(oauthState))
+ {
+ return XrpcErrorOr.Ok(new Empty());
+ }
+
+ return XrpcErrorOr.Fail("FailedToSave", "Failed to save the preferences to your repository.");
+ }
+}
\ No newline at end of file