From 44fb31ab13a92fbfc18c1c1cc5a0f712ce54e95b Mon Sep 17 00:00:00 2001
From: Laura Hausmann <laura@hausmann.dev>
Date: Wed, 18 Oct 2023 00:48:34 +0200
Subject: [PATCH] [mastodon-client] Use new backend service for user (profile)
 updates

This fixes profile updates not immediately federating when edited through the Mastodon client API.
---
 .../src/server/api/endpoints/i/update.ts      | 64 +----------------
 .../src/server/api/mastodon/helpers/user.ts   | 17 +++--
 packages/backend/src/services/i/update.ts     | 70 ++++++++++++++++++-
 3 files changed, 81 insertions(+), 70 deletions(-)

diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts
index a2a912698..3af61f6f1 100644
--- a/packages/backend/src/server/api/endpoints/i/update.ts
+++ b/packages/backend/src/server/api/endpoints/i/update.ts
@@ -2,7 +2,7 @@ import RE2 from "re2";
 import * as mfm from "mfm-js";
 import { publishMainStream, publishUserEvent } from "@/services/stream.js";
 import acceptAllFollowRequests from "@/services/following/requests/accept-all.js";
-import { publishToFollowers } from "@/services/i/update.js";
+import { publishToFollowers, updateUserProfileData } from "@/services/i/update.js";
 import { extractCustomEmojisFromMfm } from "@/misc/extract-custom-emojis-from-mfm.js";
 import { extractHashtags } from "@/misc/extract-hashtags.js";
 import { updateUsertags } from "@/services/update-hashtag.js";
@@ -274,65 +274,5 @@ export default define(meta, paramDef, async (ps, _user, token) => {
 			});
 	}
 
-	//#region emojis/tags
-
-	let emojis = [] as string[];
-	let tags = [] as string[];
-
-	const newName = updates.name === undefined ? user.name : updates.name;
-	const newDescription =
-		profileUpdates.description === undefined
-			? profile.description
-			: profileUpdates.description;
-
-	if (newName != null) {
-		const tokens = mfm.parseSimple(newName);
-		emojis = emojis.concat(extractCustomEmojisFromMfm(tokens!));
-	}
-
-	if (newDescription != null) {
-		const tokens = mfm.parse(newDescription);
-		emojis = emojis.concat(extractCustomEmojisFromMfm(tokens!));
-		tags = extractHashtags(tokens!)
-			.map((tag) => normalizeForSearch(tag))
-			.splice(0, 32);
-	}
-
-	updates.emojis = emojis;
-	updates.tags = tags;
-
-	// ハッシュタグ更新
-	updateUsertags(user, tags);
-	//#endregion
-
-	if (Object.keys(updates).length > 0) await Users.update(user.id, updates);
-	if (Object.keys(profileUpdates).length > 0) {
-		await UserProfiles.update(user.id, profileUpdates);
-		await UserProfiles.updateMentions(user.id);
-	}
-
-	const iObj = await Users.pack<true, true>(user.id, user, {
-		detail: true,
-		includeSecrets: isSecure,
-	});
-
-	// Publish meUpdated event
-	publishMainStream(user.id, "meUpdated", iObj);
-	publishUserEvent(
-		user.id,
-		"updateUserProfile",
-		await UserProfiles.findOneBy({ userId: user.id }),
-	);
-
-	// 鍵垢を解除したとき、溜まっていたフォローリクエストがあるならすべて承認
-	if (user.isLocked && ps.isLocked === false) {
-		acceptAllFollowRequests(user);
-	}
-
-	// フォロワーにUpdateを配信
-	UserProfiles.updateMentions(user.id).finally(() => {
-		publishToFollowers(user.id);
-	});
-
-	return iObj;
+	return updateUserProfileData(user, profile, updates, profileUpdates, isSecure);
 });
diff --git a/packages/backend/src/server/api/mastodon/helpers/user.ts b/packages/backend/src/server/api/mastodon/helpers/user.ts
index 0a679c3e4..5c46442e8 100644
--- a/packages/backend/src/server/api/mastodon/helpers/user.ts
+++ b/packages/backend/src/server/api/mastodon/helpers/user.ts
@@ -44,6 +44,7 @@ import { MastoContext } from "@/server/api/mastodon/index.js";
 import { resolveUser } from "@/remote/resolve-user.js";
 import { updatePerson } from "@/remote/activitypub/models/person.js";
 import { promiseEarlyReturn } from "@/prelude/promise.js";
+import { updateUserProfileData } from "@/services/i/update.js";
 
 export type AccountCache = {
     locks: AsyncLock;
@@ -181,12 +182,18 @@ export class UserHelpers {
 
         if (formData.fields_attributes) {
             profileUpdates.fields = await Promise.all(formData.fields_attributes.map(async field => {
-                const verified = field.value.startsWith("http") ? await verifyLink(field.value, user.username) : undefined;
+                if (!(field.name.trim() === "" && field.value.trim() === "")) {
+                    if (field.name.trim() === "") throw new MastoApiError(400, "Field name can not be empty");
+                    if (field.value.trim() === "") throw new MastoApiError(400, "Field value can not be empty");
+                }
+                const verified = field.value.startsWith("http")
+                    ? (await promiseEarlyReturn(verifyLink(field.value, user.username), 1500)) ?? false
+                    : undefined;
                 return {
                     ...field,
                     verified
                 };
-            }));
+            })).then(p => p.filter(field => field.name.trim().length > 0 && field.value.length > 0));
         }
 
         if (formData.display_name) updates.name = formData.display_name;
@@ -195,11 +202,7 @@ export class UserHelpers {
         if (formData.bot) updates.isBot = formData.bot;
         if (formData.discoverable) updates.isExplorable = formData.discoverable;
 
-        if (Object.keys(updates).length > 0) await Users.update(user.id, updates);
-        if (Object.keys(profileUpdates).length > 0) {
-            await UserProfiles.update({ userId: user.id }, profileUpdates);
-            await promiseEarlyReturn(UserProfiles.updateMentions(user.id), 1500);
-        }
+        await updateUserProfileData(user, null, updates, profileUpdates, false);
 
         return this.verifyCredentials(ctx);
     }
diff --git a/packages/backend/src/services/i/update.ts b/packages/backend/src/services/i/update.ts
index cc950ac85..2f8260da6 100644
--- a/packages/backend/src/services/i/update.ts
+++ b/packages/backend/src/services/i/update.ts
@@ -1,10 +1,78 @@
 import renderUpdate from "@/remote/activitypub/renderer/update.js";
 import { renderActivity } from "@/remote/activitypub/renderer/index.js";
-import { Users } from "@/models/index.js";
+import { UserProfiles, Users } from "@/models/index.js";
 import type { User } from "@/models/entities/user.js";
 import { renderPerson } from "@/remote/activitypub/renderer/person.js";
 import { deliverToFollowers } from "@/remote/activitypub/deliver-manager.js";
 import { deliverToRelays } from "../relay.js";
+import { extractCustomEmojisFromMfm } from "@/misc/extract-custom-emojis-from-mfm.js";
+import { extractHashtags } from "@/misc/extract-hashtags.js";
+import { normalizeForSearch } from "@/misc/normalize-for-search.js";
+import { updateUsertags } from "@/services/update-hashtag.js";
+import { publishMainStream, publishUserEvent } from "@/services/stream.js";
+import acceptAllFollowRequests from "@/services/following/requests/accept-all.js";
+import { UserProfile } from "@/models/entities/user-profile.js";
+import mfm from "mfm-js";
+import { promiseEarlyReturn } from "@/prelude/promise.js";
+
+export async function updateUserProfileData(user: User, profile: UserProfile | null, updates: Partial<User>, profileUpdates: Partial<UserProfile>, isSecure: boolean) {
+	if (!profile) profile = await UserProfiles.findOneByOrFail({ userId: user.id });
+
+	let emojis = [] as string[];
+	let tags = [] as string[];
+
+	const newName = updates.name === undefined ? user.name : updates.name;
+	const newDescription =
+		profileUpdates.description === undefined
+			? profile.description
+			: profileUpdates.description;
+
+	if (newName != null) {
+		const tokens = mfm.parseSimple(newName);
+		emojis = emojis.concat(extractCustomEmojisFromMfm(tokens!));
+	}
+
+	if (newDescription != null) {
+		const tokens = mfm.parse(newDescription);
+		emojis = emojis.concat(extractCustomEmojisFromMfm(tokens!));
+		tags = extractHashtags(tokens!)
+			.map((tag) => normalizeForSearch(tag))
+			.splice(0, 32);
+	}
+
+	updates.emojis = emojis;
+	updates.tags = tags;
+
+	updateUsertags(user, tags);
+
+	if (Object.keys(updates).length > 0) await Users.update(user.id, updates);
+	if (Object.keys(profileUpdates).length > 0) {
+		await UserProfiles.update(user.id, profileUpdates);
+		await promiseEarlyReturn(UserProfiles.updateMentions(user.id), 1500);
+	}
+
+	const iObj = await Users.pack<true, true>(user.id, user, {
+		detail: true,
+		includeSecrets: isSecure,
+	});
+
+	publishMainStream(user.id, "meUpdated", iObj);
+	publishUserEvent(
+		user.id,
+		"updateUserProfile",
+		await UserProfiles.findOneByOrFail({ userId: user.id }),
+	);
+
+	if (user.isLocked && updates.isLocked === false) {
+		acceptAllFollowRequests(user);
+	}
+
+	UserProfiles.updateMentions(user.id).finally(() => {
+		publishToFollowers(user.id);
+	});
+
+	return iObj;
+}
 
 export async function publishToFollowers(userId: User["id"]) {
 	const user = await Users.findOneBy({ id: userId });