activitypub: process Move
activities and movedTo
field on actors #309
|
@ -826,6 +826,7 @@ oauthErrorGoBack: "An error happened while trying to authenticate a 3rd party ap
|
|||
\ Please go back and try again."
|
||||
appAuthorization: "App authorization"
|
||||
noPermissionsRequested: "(No permissions requested.)"
|
||||
movedTo: "This user has moved to {handle}."
|
||||
_emailUnavailable:
|
||||
used: "This email address is already being used"
|
||||
format: "The format of this email address is invalid"
|
||||
|
@ -1303,6 +1304,7 @@ _notification:
|
|||
receiveFollowRequest: "Received follow requests"
|
||||
followRequestAccepted: "Accepted follow requests"
|
||||
groupInvited: "Group invitations"
|
||||
move: "Others moving accounts"
|
||||
app: "Notifications from linked apps"
|
||||
_actions:
|
||||
followBack: "followed you back"
|
||||
|
|
BIN
packages/backend/assets/notification-badges/suitcase-solid.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
35
packages/backend/migration/1668977715500-movedTo.js
Normal file
|
@ -0,0 +1,35 @@
|
|||
export class movedTo1668977715500 {
|
||||
name = 'movedTo1668977715500';
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "user" ADD "movedToId" character varying(32)`);
|
||||
await queryRunner.query(`ALTER TABLE "notification" ADD "moveTargetId" character varying(32)`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "notification"."moveTargetId" IS 'The ID of the moved to account.'`);
|
||||
await queryRunner.query(`ALTER TABLE "user" ADD CONSTRAINT "FK_16fef167e4253ccdc8971b01f6e" FOREIGN KEY ("movedToId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "notification" ADD CONSTRAINT "FK_078db271ad52ccc345b7b2b026a" FOREIGN KEY ("moveTargetId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TYPE "notification_type_enum" ADD VALUE 'move'`);
|
||||
await queryRunner.query(`ALTER TYPE "user_profile_mutingnotificationtypes_enum" ADD VALUE 'move'`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
// remove 'move' from user muting notifications type enum
|
||||
await queryRunner.query(`CREATE TYPE "public"."user_profile_mutingnotificationtypes_enum_old" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app', 'pollEnded')`);
|
||||
await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" DROP DEFAULT`);
|
||||
await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" TYPE "public"."user_profile_mutingnotificationtypes_enum_old"[] USING "mutingNotificationTypes"::"text"::"public"."user_profile_mutingnotificationtypes_enum_old"[]`);
|
||||
await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" SET DEFAULT '{}'`);
|
||||
await queryRunner.query(`DROP TYPE "public"."user_profile_mutingnotificationtypes_enum"`);
|
||||
await queryRunner.query(`ALTER TYPE "public"."user_profile_mutingnotificationtypes_enum_old" RENAME TO "user_profile_mutingnotificationtypes_enum"`);
|
||||
|
||||
// remove 'move' from notification type enum
|
||||
await queryRunner.query(`DELETE FROM "notification" WHERE "type" = 'move'`);
|
||||
await queryRunner.query(`CREATE TYPE "public"."notification_type_enum_old" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app')`);
|
||||
await queryRunner.query(`ALTER TABLE "notification" ALTER COLUMN "type" TYPE "public"."notification_type_enum_old" USING "type"::"text"::"public"."notification_type_enum_old"`);
|
||||
await queryRunner.query(`DROP TYPE "public"."notification_type_enum"`);
|
||||
await queryRunner.query(`ALTER TYPE "public"."notification_type_enum_old" RENAME TO "notification_type_enum"`);
|
||||
|
||||
await queryRunner.query(`ALTER TABLE "notification" DROP CONSTRAINT "FK_078db271ad52ccc345b7b2b026a"`);
|
||||
await queryRunner.query(`ALTER TABLE "user" DROP CONSTRAINT "FK_16fef167e4253ccdc8971b01f6e"`);
|
||||
await queryRunner.query(`ALTER TABLE "notification" DROP COLUMN "moveTargetId"`);
|
||||
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "movedToId"`);
|
||||
}
|
||||
}
|
|
@ -52,19 +52,20 @@ export class Notification {
|
|||
public notifier: User | null;
|
||||
|
||||
/**
|
||||
* 通知の種類。
|
||||
* follow - フォローされた
|
||||
* mention - 投稿で自分が言及された
|
||||
* reply - (自分または自分がWatchしている)投稿が返信された
|
||||
* renote - (自分または自分がWatchしている)投稿がRenoteされた
|
||||
* quote - (自分または自分がWatchしている)投稿が引用Renoteされた
|
||||
* reaction - (自分または自分がWatchしている)投稿にリアクションされた
|
||||
* pollVote - (自分または自分がWatchしている)投稿のアンケートに投票された
|
||||
* pollEnded - 自分のアンケートもしくは自分が投票したアンケートが終了した
|
||||
* receiveFollowRequest - フォローリクエストされた
|
||||
* followRequestAccepted - 自分の送ったフォローリクエストが承認された
|
||||
* groupInvited - グループに招待された
|
||||
* app - アプリ通知
|
||||
* Type of notification.
|
||||
* follow - notifier followed notifiee
|
||||
* mention - notifiee was mentioned
|
||||
* reply - notifiee (author or watching) was replied to
|
||||
* renote - notifiee (author or watching) was renoted
|
||||
* quote - notifiee (author or watching) was quoted
|
||||
* reaction - notifiee (author or watching) had a reaction added to the note
|
||||
* pollVote - new vote in a poll notifiee authored or watched
|
||||
* pollEnded - notifiee's poll ended
|
||||
* receiveFollowRequest - notifiee received a new follow request
|
||||
* followRequestAccepted - notifier accepted notifees follow request
|
||||
* groupInvited - notifiee was invited into a group
|
||||
* move - notifier moved
|
||||
* app - custom application notification
|
||||
*/
|
||||
@Index()
|
||||
@Column('enum', {
|
||||
|
@ -129,6 +130,19 @@ export class Notification {
|
|||
})
|
||||
public choice: number | null;
|
||||
|
||||
@Column({
|
||||
...id(),
|
||||
nullable: true,
|
||||
comment: 'The ID of the moved to account.',
|
||||
})
|
||||
public moveTargetId: User['id'] | null;
|
||||
|
||||
@ManyToOne(() => User, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn()
|
||||
public moveTarget: User | null;
|
||||
|
||||
/**
|
||||
* アプリ通知のbody
|
||||
*/
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { Entity, Column, Index, OneToOne, JoinColumn, PrimaryColumn } from 'typeorm';
|
||||
import { Entity, Column, Index, OneToOne, ManyToOne, JoinColumn, PrimaryColumn } from 'typeorm';
|
||||
import { id } from '../id.js';
|
||||
import { DriveFile } from './drive-file.js';
|
||||
|
||||
|
@ -230,6 +230,18 @@ export class User {
|
|||
})
|
||||
public federateBlocks: boolean;
|
||||
|
||||
@Column({
|
||||
...id(),
|
||||
nullable: true,
|
||||
})
|
||||
public movedToId: User['id'] | null;
|
||||
|
||||
@ManyToOne(() => User, {
|
||||
onDelete: 'SET NULL'
|
||||
})
|
||||
@JoinColumn()
|
||||
public movedTo: User | null;
|
||||
|
||||
constructor(data: Partial<User>) {
|
||||
if (data == null) return;
|
||||
|
||||
|
|
|
@ -44,6 +44,9 @@ export const NotificationRepository = db.getRepository(Notification).extend({
|
|||
...(notification.type === 'groupInvited' ? {
|
||||
invitation: UserGroupInvitations.pack(notification.userGroupInvitationId!),
|
||||
} : {}),
|
||||
...(notification.type === 'move' ? {
|
||||
moveTarget: Users.pack(notification.moveTarget ?? notification.moveTargetId),
|
||||
} : {}),
|
||||
...(notification.type === 'app' ? {
|
||||
body: notification.customBody,
|
||||
header: notification.customHeader || token?.name,
|
||||
|
|
|
@ -300,6 +300,10 @@ export const UserRepository = db.getRepository(User).extend({
|
|||
}),
|
||||
emojis: populateEmojis(user.emojis, user.host),
|
||||
onlineStatus: this.getOnlineStatus(user),
|
||||
movedTo: !user.movedToId ? undefined : this.pack(user.movedTo ?? user.movedToId, me, {
|
||||
...opts,
|
||||
detail: false,
|
||||
}),
|
||||
|
||||
...(opts.detail ? {
|
||||
url: profile!.url,
|
||||
|
|
|
@ -4,7 +4,7 @@ import { Resolver } from '@/remote/activitypub/resolver.js';
|
|||
import { extractDbHost } from '@/misc/convert-host.js';
|
||||
import { shouldBlockInstance } from '@/misc/should-block-instance.js';
|
||||
import { apLogger } from '../logger.js';
|
||||
import { IObject, isCreate, isDelete, isUpdate, isRead, isFollow, isAccept, isReject, isAdd, isRemove, isAnnounce, isLike, isUndo, isBlock, isCollectionOrOrderedCollection, isCollection, isFlag, getApId } from '../type.js';
|
||||
import { IObject, isCreate, isDelete, isUpdate, isRead, isFollow, isAccept, isReject, isAdd, isRemove, isAnnounce, isLike, isUndo, isBlock, isCollectionOrOrderedCollection, isCollection, isFlag, isMove, getApId } from '../type.js';
|
||||
import create from './create/index.js';
|
||||
import performDeleteActivity from './delete/index.js';
|
||||
import performUpdateActivity from './update/index.js';
|
||||
|
@ -19,6 +19,7 @@ import add from './add/index.js';
|
|||
import remove from './remove/index.js';
|
||||
import block from './block/index.js';
|
||||
import flag from './flag/index.js';
|
||||
import { move } from './move/index.js';
|
||||
|
||||
export async function performActivity(actor: CacheableRemoteUser, activity: IObject, resolver: Resolver): Promise<void> {
|
||||
if (isCollectionOrOrderedCollection(activity)) {
|
||||
|
@ -73,6 +74,8 @@ async function performOneActivity(actor: CacheableRemoteUser, activity: IObject,
|
|||
await block(actor, activity);
|
||||
} else if (isFlag(activity)) {
|
||||
await flag(actor, activity);
|
||||
} else if (isMove(activity)) {
|
||||
await move(actor, activity, resolver);
|
||||
} else {
|
||||
apLogger.warn(`unrecognized activity type: ${(activity as any).type}`);
|
||||
}
|
||||
|
|
62
packages/backend/src/remote/activitypub/kernel/move/index.ts
Normal file
|
@ -0,0 +1,62 @@
|
|||
import { IsNull } from 'typeorm';
|
||||
import { CacheableRemoteUser } from '@/models/entities/user.js';
|
||||
import { resolvePerson } from '@/remote/activitypub/models/person.js';
|
||||
import { Followings, Users } from '@/models/index.js';
|
||||
import { createNotification } from '@/services/create-notification.js';
|
||||
import Resolver from '../../resolver.js';
|
||||
import { IMove, isActor, getApId } from '../../type.js';
|
||||
|
||||
export async function move(actor: CacheableRemoteUser, activity: IMove, resolver: Resolver): Promise<void> {
|
||||
// actor is not move origin
|
||||
if (activity.object == null || getApId(activity.object) !== actor.uri) return;
|
||||
|
||||
// actor already moved
|
||||
if (actor.movedTo != null) return;
|
||||
Johann150 marked this conversation as resolved
|
||||
|
||||
// no move target
|
||||
if (activity.target == null) return;
|
||||
amybones
commented
Do we expect this to be a common case? If no, it might be worth adding a warning log so that some data about how often this happens can be collected. The log can always be removed if it appears to be frequent. I bring this up since in my deployment of foundkey I've added some warning logs to a place to try and help me track down (which I'm not done doing so no PRs here) what I think are some bugs. My expectation in general, and it's tooootally fine if this isn't how foundkey approaches these things, is to add warnings or errors for conditions that are foreseeable but it's unclear if they happen in practice. That way there's an ability to collect some data to inform what to do with the code in the future. Do we expect this to be a common case? If no, it might be worth adding a warning log so that some data about how often this happens can be collected. The log can always be removed if it appears to be frequent. I bring this up since in my deployment of foundkey I've added some warning logs to a place to try and help me track down (which I'm not done doing so no PRs here) what I think are some bugs.
My expectation in general, and it's tooootally fine if this isn't how foundkey approaches these things, is to add warnings or errors for conditions that are foreseeable but it's unclear if they happen in practice. That way there's an ability to collect some data to inform what to do with the code in the future.
Johann150
commented
I don't expect this to be a common case, it's just a sanity check. If the I don't expect this to be a common case, it's just a sanity check. If the `target` attribute of a `Move` activity that is intended to be used for account moves is not included, the activity does not make sense. Or maybe this is a `Move` activity that has different semantics in which case it does also not make sense to process it in this handler. I also don't think it would be necessary to log a malformed Activity.
|
||||
|
||||
/* the database resolver can not be used here, because:
|
||||
* 1. It must be ensured that the latest data is used.
|
||||
* 2. The AP representation is needed, because `alsoKnownAs`
|
||||
* is not stored in the database.
|
||||
* This also checks whether the move target is blocked
|
||||
*/
|
||||
const movedToAp = await resolver.resolve(getApId(activity.target));
|
||||
|
||||
// move target is not an actor
|
||||
if (!isActor(movedToAp)) return;
|
||||
|
||||
// move destination has not accepted
|
||||
if (!Array.isArray(movedToAp.alsoKnownAs) || !movedToAp.alsoKnownAs.includes(actor.id)) return;
|
||||
|
||||
// ensure the user exists
|
||||
const movedTo = await resolvePerson(getApId(activity.target), resolver, movedToAp);
|
||||
// move target is already suspended
|
||||
if (movedTo.isSuspended) return;
|
||||
|
||||
// process move for local followers
|
||||
const followings = Followings.find({
|
||||
select: {
|
||||
followerId: true,
|
||||
},
|
||||
where: {
|
||||
followeeId: actor.id,
|
||||
followerHost: IsNull(),
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
Users.update(actor.id, {
|
||||
movedToId: movedTo.id,
|
||||
}),
|
||||
...followings.map(async (following) => {
|
||||
// TODO: autoAcceptMove?
|
||||
Johann150 marked this conversation as resolved
amybones
commented
The only reason I can think of to not auto accept is if the local user has follow requests turned on, right? But even then, if they already accepted the original request, then it feels silly to make them accept again. If it's technically difficult to do here for some reason, in which case it seems fine to leave this as a todo. The only reason I can think of to not auto accept is if the local user has follow requests turned on, right? But even then, if they already accepted the original request, then it feels silly to make them accept again. If it's technically difficult to do here for some reason, in which case it seems fine to leave this as a todo.
Johann150
commented
What you are talking about is the new/moved actor following someone. But this comment is about the other direction. There is already a separate issue for this, see #311. Due to concerns raised in the issue when this was discussed for Misskey, we decided that there should be a separate option for this. What you are talking about is the new/moved actor following someone. But this comment is about the other direction.
There is already a separate issue for this, see #311. Due to concerns raised in the issue when this was discussed for Misskey, we decided that there should be a separate option for this.
|
||||
|
||||
await createNotification(following.followerId, 'move', {
|
||||
notifierId: actor.id,
|
||||
moveTargetId: movedTo.id,
|
||||
});
|
||||
}),
|
||||
]);
|
||||
}
|
|
@ -39,7 +39,7 @@ const summaryLength = 2048;
|
|||
* @param x Fetched object
|
||||
* @param uri Fetch target URI
|
||||
*/
|
||||
function validateActor(x: IObject): IActor {
|
||||
async function validateActor(x: IObject, resolver: Resolver): Promise<IActor> {
|
||||
if (x == null) {
|
||||
throw new Error('invalid Actor: object is null');
|
||||
}
|
||||
|
@ -61,6 +61,22 @@ function validateActor(x: IObject): IActor {
|
|||
throw new StatusError('cannot resolve local user', 400, 'cannot resolve local user');
|
||||
}
|
||||
|
||||
if (x.movedTo !== undefined) {
|
||||
if (!(typeof x.movedTo === 'string' && x.movedTo.length > 0)) {
|
||||
throw new Error('invalid Actor: wrong movedTo');
|
||||
}
|
||||
if (x.movedTo === uri) {
|
||||
throw new Error('invalid Actor: moved to self');
|
||||
}
|
||||
// This may throw an exception if we cannot resolve the move target.
|
||||
// If we are processing an incoming activity, this is desired behaviour
|
||||
// because that will cause the activity to be retried.
|
||||
await resolvePerson(x.movedTo, resolver)
|
||||
.then(moveTarget => {
|
||||
x.movedTo = moveTarget.id
|
||||
});
|
||||
}
|
||||
|
||||
Johann150 marked this conversation as resolved
amybones
commented
I understand having a warning in the log, but I'm not totally convinced that deleting the movedTo information makes sense to me? Things that I think of: It could be that the destination isn't resolvable because it's a fedi server that possibly somehow conforms to the account moving spec, but not the lookup part, which doesn't make a ton of sense to me. Or, the destination is temporarily unreachable at the time an attempt to resolve the new account is made. For the first case, it's unlikely that the new account is going to properly broadcast updates over activitypub, so sure. But for the second case, this means that the foundkey database is going to be inconsistent. The user is going to lose that follow silently, as well as have no indication that there's something amiss, since if they go look at the profile page for the old follow wondering where they went, it'll just show up normally, and they won't have a chance to go find the person or manually re-follow. I understand having a warning in the log, but I'm not totally convinced that deleting the movedTo information makes sense to me?
Things that I think of: It could be that the destination isn't resolvable because it's a fedi server that possibly somehow conforms to the account moving spec, but not the lookup part, which doesn't make a ton of sense to me. Or, the destination is temporarily unreachable at the time an attempt to resolve the new account is made.
For the first case, it's unlikely that the new account is going to properly broadcast updates over activitypub, so sure. But for the second case, this means that the foundkey database is going to be inconsistent. The user is going to lose that follow silently, as well as have no indication that there's something amiss, since if they go look at the profile page for the old follow wondering where they went, it'll just show up normally, and they won't have a chance to go find the person or manually re-follow.
Johann150
commented
I agree with you for the 2nd case, it should be an atomic operation. The correct behaviour is to throw an error there. Since there is no try/catch statement around I agree with you for the 2nd case, it should be an atomic operation.
The correct behaviour is to throw an error there. Since there is no try/catch statement around `resolvePerson` in the move handler this would cause the queue handler to retry this incoming activity at a later time.
amybones
commented
Aha. Okay, I missed that bubbling up to a retry, thanks! Aha. Okay, I missed that bubbling up to a retry, thanks!
|
||||
if (!(typeof x.inbox === 'string' && x.inbox.length > 0)) {
|
||||
throw new Error('invalid Actor: wrong inbox');
|
||||
}
|
||||
|
@ -137,7 +153,7 @@ export async function fetchPerson(uri: string): Promise<CacheableUser | null> {
|
|||
export async function createPerson(value: string | IObject, resolver: Resolver): Promise<User> {
|
||||
const object = await resolver.resolve(value) as any;
|
||||
|
||||
const person = validateActor(object);
|
||||
const person = await validateActor(object, resolver);
|
||||
|
||||
apLogger.info(`Creating the Person: ${person.id}`);
|
||||
|
||||
|
@ -177,6 +193,7 @@ export async function createPerson(value: string | IObject, resolver: Resolver):
|
|||
isBot,
|
||||
isCat: (person as any).isCat === true,
|
||||
showTimelineReplies: false,
|
||||
movedToId: person.movedTo,
|
||||
})) as IRemoteUser;
|
||||
|
||||
await transactionalEntityManager.save(new UserProfile({
|
||||
|
@ -287,7 +304,7 @@ export async function updatePerson(value: IObject | string, resolver: Resolver):
|
|||
|
||||
const object = await resolver.resolve(value);
|
||||
|
||||
const person = validateActor(object);
|
||||
const person = await validateActor(object, resolver);
|
||||
|
||||
apLogger.info(`Updating the Person: ${person.id}`);
|
||||
|
||||
|
@ -328,6 +345,7 @@ export async function updatePerson(value: IObject | string, resolver: Resolver):
|
|||
isCat: (person as any).isCat === true,
|
||||
isLocked: !!person.manuallyApprovesFollowers,
|
||||
isExplorable: !!person.discoverable,
|
||||
movedToId: person.movedTo,
|
||||
} as Partial<User>;
|
||||
|
||||
if (avatar) {
|
||||
|
@ -376,7 +394,7 @@ export async function updatePerson(value: IObject | string, resolver: Resolver):
|
|||
* If the target Person is registered in FoundKey, return it; otherwise, fetch it from a remote server and return it.
|
||||
* Fetch the person from the remote server, register it in FoundKey, and return it.
|
||||
*/
|
||||
export async function resolvePerson(uri: string, resolver: Resolver): Promise<CacheableUser> {
|
||||
export async function resolvePerson(uri: string, resolver: Resolver, hint?: IObject): Promise<CacheableUser> {
|
||||
if (typeof uri !== 'string') throw new Error('uri is not string');
|
||||
|
||||
//#region このサーバーに既に登録されていたらそれを返す
|
||||
|
@ -388,7 +406,7 @@ export async function resolvePerson(uri: string, resolver: Resolver): Promise<Ca
|
|||
//#endregion
|
||||
|
||||
// リモートサーバーからフェッチしてきて登録
|
||||
return await createPerson(uri, resolver);
|
||||
return await createPerson(hint ?? uri, resolver);
|
||||
}
|
||||
|
||||
export function analyzeAttachments(attachments: IObject | IObject[] | undefined) {
|
||||
|
|
|
@ -296,6 +296,10 @@ export interface IFlag extends IActivity {
|
|||
type: 'Flag';
|
||||
}
|
||||
|
||||
export interface IMove extends IActivity {
|
||||
type: 'Move';
|
||||
}
|
||||
|
||||
export const isCreate = (object: IObject): object is ICreate => getApType(object) === 'Create';
|
||||
export const isDelete = (object: IObject): object is IDelete => getApType(object) === 'Delete';
|
||||
export const isUpdate = (object: IObject): object is IUpdate => getApType(object) === 'Update';
|
||||
|
@ -310,6 +314,7 @@ export const isLike = (object: IObject): object is ILike => getApType(object) ==
|
|||
export const isAnnounce = (object: IObject): object is IAnnounce => getApType(object) === 'Announce';
|
||||
export const isBlock = (object: IObject): object is IBlock => getApType(object) === 'Block';
|
||||
export const isFlag = (object: IObject): object is IFlag => getApType(object) === 'Flag';
|
||||
export const isMove = (object: IObject): object is IMove => getApType(object) === 'Move';
|
||||
|
||||
export interface ILink {
|
||||
href: string;
|
||||
|
|
|
@ -15,6 +15,7 @@
|
|||
<i v-else-if="notification.type === 'quote'" class="fas fa-quote-left"></i>
|
||||
<i v-else-if="notification.type === 'pollVote'" class="fas fa-poll-h"></i>
|
||||
<i v-else-if="notification.type === 'pollEnded'" class="fas fa-poll-h"></i>
|
||||
<i v-else-if="notification.type === 'move'" class="fas fa-suitcase"></i>
|
||||
<!-- notification.reaction が null になることはまずないが、ここでoptional chaining使うと一部ブラウザで刺さるので念の為 -->
|
||||
<MkEmoji
|
||||
v-else-if="notification.type === 'reaction'"
|
||||
|
@ -86,6 +87,14 @@
|
|||
<button class="_textButton" @click="rejectGroupInvitation()">{{ i18n.ts.reject }}</button>
|
||||
</div>
|
||||
</span>
|
||||
<span v-if="notification.type === 'move'" class="text" style="opacity: 0.6;">
|
||||
Johann150 marked this conversation as resolved
amybones
commented
Something that might be worth refactoring in this PR but is otherwise fine: The CSS class Something that might be worth refactoring in this PR but is otherwise fine: The CSS class `text` is used several times with a local override of `opacity: 0.6` it might be worth factoring out a dedicated CSS class for this like `dim-notification-text` or even just a class that only applies the opacity. IIRC this will help a bit with rendering performance.
Johann150
commented
Good idea in general, but this has nothing to do with Good idea in general, but this has nothing to do with `Move` activities so it should be done outside of this PR.
|
||||
<I18n :src="i18n.ts.movedTo">
|
||||
<template #handle>
|
||||
<MkAcct :user="notification.moveTarget"/>
|
||||
</template>
|
||||
</I18n>
|
||||
<div v-if="full"><MkFollowButton :user="notification.moveTarget" :full="true"/></div>
|
||||
</span>
|
||||
<span v-if="notification.type === 'app'" class="text">
|
||||
<Mfm :text="notification.body" :nowrap="!full"/>
|
||||
</span>
|
||||
|
|
|
@ -9,6 +9,16 @@
|
|||
<div class="profile">
|
||||
<MkRemoteCaution v-if="user.host != null" :href="user.url" class="warn"/>
|
||||
|
||||
<MkInfo v-if="user.movedTo" class="moved">
|
||||
<I18n :src="i18n.ts.movedTo" tag="span">
|
||||
<template #handle>
|
||||
<MkA :to="userPage(user.movedTo)" style="color: var(--accent);">
|
||||
<MkAcct :user="user.movedTo"/>
|
||||
</MkA>
|
||||
</template>
|
||||
</I18n>
|
||||
</MkInfo>
|
||||
|
||||
<div :key="user.id" class="_block main">
|
||||
<div class="banner-container" :style="style">
|
||||
<div ref="bannerEl" class="banner" :style="style"></div>
|
||||
|
@ -193,6 +203,10 @@ onUnmounted(() => {
|
|||
|
||||
> .profile {
|
||||
|
||||
> .moved {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
> .main {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
export const notificationTypes = ['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app'] as const;
|
||||
export const notificationTypes = ['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'move', 'app'] as const;
|
||||
|
||||
export const noteNotificationTypes = ['mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded'] as const;
|
||||
|
||||
|
|
|
@ -28,6 +28,7 @@ export type UserLite = {
|
|||
faviconUrl: Instance['faviconUrl'];
|
||||
themeColor: Instance['themeColor'];
|
||||
};
|
||||
movedTo?: UserLite;
|
||||
};
|
||||
|
||||
export type UserDetailed = UserLite & {
|
||||
|
@ -223,6 +224,11 @@ export type Notification = {
|
|||
invitation: UserGroup;
|
||||
user: User;
|
||||
userId: User['id'];
|
||||
} | {
|
||||
type: 'move',
|
||||
user: User;
|
||||
userId: User['id'];
|
||||
moveTarget: User;
|
||||
} | {
|
||||
type: 'app';
|
||||
header?: string | null;
|
||||
|
|
|
@ -217,6 +217,20 @@ async function composeNotification<K extends keyof pushNotificationDataMap>(data
|
|||
],
|
||||
}];
|
||||
|
||||
case 'move':
|
||||
return [t('_notification.moved', { name: getUserName(data.body.user) }), {
|
||||
body: getUserName(data.body.moveTarget),
|
||||
icon: data.body.moveTarget.avatarUrl,
|
||||
badge: iconUrl('suitcase'),
|
||||
data,
|
||||
action: [
|
||||
{
|
||||
action: 'accept',
|
||||
title: t('follow'),
|
||||
},
|
||||
],
|
||||
}];
|
||||
|
||||
case 'app':
|
||||
return [data.body.header || data.body.body, {
|
||||
body: data.body.header && data.body.body,
|
||||
|
|
|
@ -96,6 +96,9 @@ self.addEventListener('notificationclick', <K extends keyof pushNotificationData
|
|||
case 'groupInvited':
|
||||
await swos.api('users/groups/invitations/accept', id, { invitationId: data.body.invitation.id });
|
||||
break;
|
||||
case 'move':
|
||||
await swos.api('following/create', id, { userId: data.body.moveTarget.id });
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case 'reject':
|
||||
|
|
This would prevent cycles of
a -> b -> a
, right? It's uncommon but I've seen people do cycles like this in the past when, e.g. they move and then the instance they move to winds up blocking an instance they have a lot of connections with, or in the case of mastodon.lol their new instance shuts down.This would stop
a -> b, a -> c
which also comes from previous discussion in the Misskey issue. Ifa
really wanted to do that, they would have to make sure that the instance updates their data in between. For example by sending anUpdate
activity where the object is the actor themself.Got it. Thanks for clarifying the data model, I see now what's happening there :) As I said, I'm still getting familiar with the code base.