From 805bd9534f73ab2caccb9e3c29610354505b4770 Mon Sep 17 00:00:00 2001
From: Gabriel Kaszewski
Date: Fri, 29 May 2026 13:54:25 +0200
Subject: [PATCH] feat: add profile fields for local users
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
DB→domain→API→AP→frontend end-to-end. Fields stored as
JSONB, exposed via PATCH /users/me, serialized as AP
PropertyValue attachment. Editor in federation settings,
display on profile card.
---
Cargo.lock | 1 +
.../adapters/postgres-federation/Cargo.toml | 1 +
.../adapters/postgres-federation/src/lib.rs | 25 ++++-
crates/adapters/postgres-search/src/lib.rs | 1 +
.../migrations/020_user_profile_fields.sql | 1 +
crates/adapters/postgres/src/feed/mod.rs | 1 +
crates/adapters/postgres/src/follow/mod.rs | 6 +-
.../adapters/postgres/src/top_friend/mod.rs | 2 +-
crates/adapters/postgres/src/user/mod.rs | 50 ++++++++--
crates/api-types/src/requests.rs | 1 +
crates/api-types/src/responses.rs | 5 +-
crates/application/src/testing.rs | 1 +
crates/domain/src/models/user.rs | 3 +
crates/presentation/src/handlers/auth.rs | 10 +-
crates/presentation/src/handlers/users/mod.rs | 4 +
.../app/settings/federation/page.tsx | 5 +
.../app/users/[username]/page.tsx | 21 ++++
.../components/profile-fields-editor.tsx | 99 +++++++++++++++++++
thoughts-frontend/lib/api.ts | 14 +--
19 files changed, 224 insertions(+), 27 deletions(-)
create mode 100644 crates/adapters/postgres/migrations/020_user_profile_fields.sql
create mode 100644 thoughts-frontend/components/profile-fields-editor.tsx
diff --git a/Cargo.lock b/Cargo.lock
index 2d58288..b363034 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2554,6 +2554,7 @@ dependencies = [
"async-trait",
"chrono",
"k-ap",
+ "serde_json",
"sqlx",
"tokio",
"tracing",
diff --git a/crates/adapters/postgres-federation/Cargo.toml b/crates/adapters/postgres-federation/Cargo.toml
index 7a260eb..cf91c97 100644
--- a/crates/adapters/postgres-federation/Cargo.toml
+++ b/crates/adapters/postgres-federation/Cargo.toml
@@ -12,6 +12,7 @@ tracing = { workspace = true }
async-trait = { workspace = true }
anyhow = { workspace = true }
url = { workspace = true }
+serde_json = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["full"] }
diff --git a/crates/adapters/postgres-federation/src/lib.rs b/crates/adapters/postgres-federation/src/lib.rs
index 77d3a46..5143bef 100644
--- a/crates/adapters/postgres-federation/src/lib.rs
+++ b/crates/adapters/postgres-federation/src/lib.rs
@@ -13,8 +13,9 @@ impl IntoAnyhow for std::result::Result {
}
use k_ap::{
- ActivityRepository, ActorRepository, ApActorType, ApUser, ApUserRepository, BlockedDomain,
- BlocklistRepository, FollowRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
+ ActivityRepository, ActorRepository, ApActorType, ApProfileField, ApUser, ApUserRepository,
+ BlockedDomain, BlocklistRepository, FollowRepository, Follower, FollowerStatus,
+ FollowingStatus, RemoteActor,
};
// ── PostgresFederationRepository ─────────────────────────────────────────────
@@ -751,6 +752,7 @@ struct UserRow {
avatar_url: Option,
header_url: Option,
also_known_as: Option,
+ profile_fields: Option,
}
pub struct PgApUserRepository {
@@ -767,6 +769,19 @@ impl PgApUserRepository {
let profile_url = url::Url::parse(&format!("{}/users/{}", self.base_url, r.id)).ok();
let avatar_url = r.avatar_url.and_then(|u| url::Url::parse(&u).ok());
let banner_url = r.header_url.and_then(|u| url::Url::parse(&u).ok());
+ let attachment = r
+ .profile_fields
+ .and_then(|v| v.as_array().cloned())
+ .map(|arr| {
+ arr.into_iter()
+ .filter_map(|item| {
+ let name = item.get("name")?.as_str()?.to_string();
+ let value = item.get("value")?.as_str()?.to_string();
+ Some(ApProfileField { name, value })
+ })
+ .collect()
+ })
+ .unwrap_or_default();
ApUser {
id: r.id,
username: r.username,
@@ -776,7 +791,7 @@ impl PgApUserRepository {
banner_url,
also_known_as: r.also_known_as.into_iter().collect(),
profile_url,
- attachment: vec![],
+ attachment,
manually_approves_followers: true,
discoverable: true,
actor_type: ApActorType::default(),
@@ -789,7 +804,7 @@ impl PgApUserRepository {
impl ApUserRepository for PgApUserRepository {
async fn find_by_id(&self, id: uuid::Uuid) -> Result
+ {user.profileFields.length > 0 && (
+
+ {user.profileFields.map((field) => (
+
+
+ {field.name}
+
+
+ {field.value}
+
+
+ ))}
+
+ )}
+
{isOwnProfile && (
(initial);
+ const [saving, setSaving] = useState(false);
+
+ const update = (i: number, key: "name" | "value", val: string) => {
+ setFields((prev) => prev.map((f, j) => (j === i ? { ...f, [key]: val } : f)));
+ };
+
+ const add = () => {
+ if (fields.length >= MAX_FIELDS) return;
+ setFields((prev) => [...prev, { name: "", value: "" }]);
+ };
+
+ const remove = (i: number) => {
+ setFields((prev) => prev.filter((_, j) => j !== i));
+ };
+
+ const save = async () => {
+ if (!token) return;
+ const clean = fields.filter((f) => f.name.trim() || f.value.trim());
+ setSaving(true);
+ try {
+ await updateProfile({ profileFields: clean }, token);
+ setFields(clean);
+ toast.success("Profile fields saved.");
+ } catch {
+ toast.error("Failed to save profile fields.");
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+
+
Profile fields
+
+ Add up to {MAX_FIELDS} custom fields visible on your profile and
+ across the fediverse (e.g. Website, Pronouns).
+
+
+
+
+
+
+ {fields.length < MAX_FIELDS && (
+
+ )}
+
+
+
+ );
+}
diff --git a/thoughts-frontend/lib/api.ts b/thoughts-frontend/lib/api.ts
index ad0612e..e61e9bd 100644
--- a/thoughts-frontend/lib/api.ts
+++ b/thoughts-frontend/lib/api.ts
@@ -1,6 +1,12 @@
import { cache } from "react";
import { z } from "zod";
+export const ProfileFieldSchema = z.object({
+ name: z.string(),
+ value: z.string(),
+});
+export type ProfileField = z.infer
;
+
export const UserSchema = z.object({
id: z.string().uuid(),
username: z.string(),
@@ -9,6 +15,7 @@ export const UserSchema = z.object({
avatarUrl: z.string().nullable(),
headerUrl: z.string().nullable(),
customCss: z.string().nullable(),
+ profileFields: z.array(ProfileFieldSchema).default([]),
local: z.boolean(),
isFollowedByViewer: z.boolean(),
joinedAt: z.coerce.date().nullable(),
@@ -16,12 +23,6 @@ export const UserSchema = z.object({
export const MeSchema = UserSchema;
-export const ProfileFieldSchema = z.object({
- name: z.string(),
- value: z.string(),
-});
-export type ProfileField = z.infer;
-
export const RemoteActorSchema = z.object({
handle: z.string(),
displayName: z.string().nullable(),
@@ -77,6 +78,7 @@ export const UpdateProfileSchema = z.object({
displayName: z.string().max(50).optional(),
bio: z.string().max(4000).optional(),
customCss: z.string().optional(),
+ profileFields: z.array(ProfileFieldSchema).max(4).optional(),
});
export const SearchResultsSchema = z.object({