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({