activitypub: process Move activities and movedTo field on actors #309

Manually merged
Johann150 merged 10 commits from account-moving into main 2023-03-23 20:48:19 +00:00
17 changed files with 225 additions and 21 deletions

View file

@ -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"

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View 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"`);
}
}

View file

@ -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
*/

View file

@ -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;

View file

@ -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,

View file

@ -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,

View file

@ -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}`);
}

View 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
Review

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 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.
Review

This would stop a -> b, a -> c which also comes from previous discussion in the Misskey issue. If a really wanted to do that, they would have to make sure that the instance updates their data in between. For example by sending an Update activity where the object is the actor themself.

This would stop `a -> b, a -> c` which also comes from previous discussion in the Misskey issue. If `a` really wanted to do that, they would have to make sure that the instance updates their data in between. For example by sending an `Update` activity where the object is the actor themself.
Review

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.

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.
// no move target
if (activity.target == null) return;
Review

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.
Review

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.

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
Review

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.
Review

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,
});
}),
]);
}

View file

@ -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
Review

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.
Review

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.

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.
Review

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

View file

@ -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;

View file

@ -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
Review

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.

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.
Review

Good idea in general, but this has nothing to do with Move activities so it should be done outside of this PR.

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>

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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,

View file

@ -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':