From f33ded310751dd1b4bfd6fb792eec9adfde7019e Mon Sep 17 00:00:00 2001
From: nullobsi <me@nullob.si>
Date: Thu, 2 Dec 2021 18:14:44 -0800
Subject: [PATCH] feat: Undo Accept (#7980)

* allow breaking of follow

* send undo

* delete by using reject follow
---
 locales/ja-JP.yml                             |  1 +
 .../remote/activitypub/kernel/undo/accept.ts  | 27 ++++++
 .../remote/activitypub/kernel/undo/index.ts   |  4 +-
 .../api/endpoints/following/invalidate.ts     | 82 +++++++++++++++++++
 .../backend/src/services/following/delete.ts  |  7 ++
 packages/client/src/scripts/get-user-menu.ts  | 16 ++++
 6 files changed, 136 insertions(+), 1 deletion(-)
 create mode 100644 packages/backend/src/remote/activitypub/kernel/undo/accept.ts
 create mode 100644 packages/backend/src/server/api/endpoints/following/invalidate.ts

diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index d5fcd2d406..d5c009bbcf 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -792,6 +792,7 @@ pubSub: "Pub/Subのアカウント"
 lastCommunication: "直近の通信"
 resolved: "解決済み"
 unresolved: "未解決"
+breakFollow: "フォロワーを解除"
 itsOn: "オンになっています"
 itsOff: "オフになっています"
 emailRequiredForSignup: "アカウント登録にメールアドレスを必須にする"
diff --git a/packages/backend/src/remote/activitypub/kernel/undo/accept.ts b/packages/backend/src/remote/activitypub/kernel/undo/accept.ts
new file mode 100644
index 0000000000..5112d1d4ea
--- /dev/null
+++ b/packages/backend/src/remote/activitypub/kernel/undo/accept.ts
@@ -0,0 +1,27 @@
+import unfollow from '@/services/following/delete';
+import cancelRequest from '@/services/following/requests/cancel';
+import {IAccept} from '../../type';
+import { IRemoteUser } from '@/models/entities/user';
+import { Followings } from '@/models/index';
+import DbResolver from '../../db-resolver';
+
+export default async (actor: IRemoteUser, activity: IAccept): Promise<string> => {
+	const dbResolver = new DbResolver();
+
+	const follower = await dbResolver.getUserFromApId(activity.object);
+	if (follower == null) {
+		return `skip: follower not found`;
+	}
+
+	const following = await Followings.findOne({
+		followerId: follower.id,
+		followeeId: actor.id
+	});
+
+	if (following) {
+		await unfollow(follower, actor);
+		return `ok: unfollowed`;
+	}
+
+	return `skip: フォローされていない`;
+};
diff --git a/packages/backend/src/remote/activitypub/kernel/undo/index.ts b/packages/backend/src/remote/activitypub/kernel/undo/index.ts
index 14b1add152..8de78420e3 100644
--- a/packages/backend/src/remote/activitypub/kernel/undo/index.ts
+++ b/packages/backend/src/remote/activitypub/kernel/undo/index.ts
@@ -1,8 +1,9 @@
 import { IRemoteUser } from '@/models/entities/user';
-import { IUndo, isFollow, isBlock, isLike, isAnnounce, getApType } from '../../type';
+import {IUndo, isFollow, isBlock, isLike, isAnnounce, getApType, isAccept} from '../../type';
 import unfollow from './follow';
 import unblock from './block';
 import undoLike from './like';
+import undoAccept from './accept';
 import { undoAnnounce } from './announce';
 import Resolver from '../../resolver';
 import { apLogger } from '../../logger';
@@ -29,6 +30,7 @@ export default async (actor: IRemoteUser, activity: IUndo): Promise<string> => {
 	if (isBlock(object)) return await unblock(actor, object);
 	if (isLike(object)) return await undoLike(actor, object);
 	if (isAnnounce(object)) return await undoAnnounce(actor, object);
+	if (isAccept(object)) return await undoAccept(actor, object);
 
 	return `skip: unknown object type ${getApType(object)}`;
 };
diff --git a/packages/backend/src/server/api/endpoints/following/invalidate.ts b/packages/backend/src/server/api/endpoints/following/invalidate.ts
new file mode 100644
index 0000000000..c0e9df3652
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/following/invalidate.ts
@@ -0,0 +1,82 @@
+import $ from 'cafy';
+import { ID } from '@/misc/cafy-id';
+import * as ms from 'ms';
+import deleteFollowing from '@/services/following/delete';
+import define from '../../define';
+import { ApiError } from '../../error';
+import { getUser } from '../../common/getters';
+import { Followings, Users } from '@/models/index';
+
+export const meta = {
+	tags: ['following', 'users'],
+
+	limit: {
+		duration: ms('1hour'),
+		max: 100
+	},
+
+	requireCredential: true as const,
+
+	kind: 'write:following',
+
+	params: {
+		userId: {
+			validator: $.type(ID),
+		}
+	},
+
+	errors: {
+		noSuchUser: {
+			message: 'No such user.',
+			code: 'NO_SUCH_USER',
+			id: '5b12c78d-2b28-4dca-99d2-f56139b42ff8'
+		},
+
+		followerIsYourself: {
+			message: 'Follower is yourself.',
+			code: 'FOLLOWER_IS_YOURSELF',
+			id: '07dc03b9-03da-422d-885b-438313707662'
+		},
+
+		notFollowing: {
+			message: 'The other use is not following you.',
+			code: 'NOT_FOLLOWING',
+			id: '5dbf82f5-c92b-40b1-87d1-6c8c0741fd09'
+		},
+	},
+
+	res: {
+		type: 'object' as const,
+		optional: false as const, nullable: false as const,
+		ref: 'User'
+	}
+};
+
+export default define(meta, async (ps, user) => {
+	const followee = user;
+
+	// Check if the follower is yourself
+	if (user.id === ps.userId) {
+		throw new ApiError(meta.errors.followerIsYourself);
+	}
+
+	// Get follower
+	const follower = await getUser(ps.userId).catch(e => {
+		if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
+		throw e;
+	});
+
+	// Check not following
+	const exist = await Followings.findOne({
+		followerId: follower.id,
+		followeeId: followee.id
+	});
+
+	if (exist == null) {
+		throw new ApiError(meta.errors.notFollowing);
+	}
+
+	await deleteFollowing(follower, followee);
+
+	return await Users.pack(followee.id, user);
+});
diff --git a/packages/backend/src/services/following/delete.ts b/packages/backend/src/services/following/delete.ts
index 29e3372b6a..ea612147df 100644
--- a/packages/backend/src/services/following/delete.ts
+++ b/packages/backend/src/services/following/delete.ts
@@ -2,6 +2,7 @@ import { publishMainStream, publishUserEvent } from '@/services/stream';
 import { renderActivity } from '@/remote/activitypub/renderer/index';
 import renderFollow from '@/remote/activitypub/renderer/follow';
 import renderUndo from '@/remote/activitypub/renderer/undo';
+import renderReject from '@/remote/activitypub/renderer/reject';
 import { deliver } from '@/queue/index';
 import Logger from '../logger';
 import { registerOrFetchInstanceDoc } from '../register-or-fetch-instance-doc';
@@ -40,6 +41,12 @@ export default async function(follower: { id: User['id']; host: User['host']; ur
 		const content = renderActivity(renderUndo(renderFollow(follower, followee), follower));
 		deliver(follower, content, followee.inbox);
 	}
+
+	if (Users.isLocalUser(followee) && Users.isRemoteUser(follower)) {
+		// local user has null host
+		const content = renderActivity(renderReject(renderFollow(follower, followee), followee));
+		deliver(followee, content, follower.inbox);
+	}
 }
 
 export async function decrementFollowing(follower: { id: User['id']; host: User['host']; }, followee: { id: User['id']; host: User['host']; }) {
diff --git a/packages/client/src/scripts/get-user-menu.ts b/packages/client/src/scripts/get-user-menu.ts
index 0c04547101..ebe101bc0f 100644
--- a/packages/client/src/scripts/get-user-menu.ts
+++ b/packages/client/src/scripts/get-user-menu.ts
@@ -109,6 +109,14 @@ export function getUserMenu(user) {
 		return !confirm.canceled;
 	}
 
+	async function invalidateFollow() {
+		os.apiWithDialog('following/invalidate', {
+			userId: user.id
+		}).then(() => {
+			user.isFollowed = !user.isFollowed;
+		})
+	}
+
 	let menu = [{
 		icon: 'fas fa-at',
 		text: i18n.locale.copyUsername,
@@ -153,6 +161,14 @@ export function getUserMenu(user) {
 			action: toggleBlock
 		}]);
 
+		if (user.isFollowed) {
+			menu = menu.concat([{
+				icon: 'fas fa-unlink',
+				text: i18n.locale.breakFollow,
+				action: invalidateFollow
+			}]);
+		}
+
 		menu = menu.concat([null, {
 			icon: 'fas fa-exclamation-circle',
 			text: i18n.locale.reportAbuse,