handle Update
activities for Notes
#395
17 changed files with 695 additions and 419 deletions
|
@ -1304,6 +1304,7 @@ _notification:
|
||||||
reaction: "Reactions"
|
reaction: "Reactions"
|
||||||
pollVote: "Votes on polls"
|
pollVote: "Votes on polls"
|
||||||
pollEnded: "Polls ending"
|
pollEnded: "Polls ending"
|
||||||
|
updated: "Watched Note was updated"
|
||||||
receiveFollowRequest: "Received follow requests"
|
receiveFollowRequest: "Received follow requests"
|
||||||
followRequestAccepted: "Accepted follow requests"
|
followRequestAccepted: "Accepted follow requests"
|
||||||
groupInvited: "Group invitations"
|
groupInvited: "Group invitations"
|
||||||
|
|
|
@ -0,0 +1,34 @@
|
||||||
|
export class noteEditing1685997617959 {
|
||||||
|
name = 'noteEditing1685997617959';
|
||||||
|
|
||||||
|
async up(queryRunner) {
|
||||||
|
await queryRunner.query(`ALTER TABLE "note" ADD "updatedAt" TIMESTAMP WITH TIME ZONE`);
|
||||||
|
await queryRunner.query(`COMMENT ON COLUMN "note"."updatedAt" IS 'The updated date of the Note.'`);
|
||||||
|
|
||||||
|
await queryRunner.query(`ALTER TYPE "public"."notification_type_enum" RENAME TO "notification_type_enum_old"`);
|
||||||
|
await queryRunner.query(`CREATE TYPE "public"."notification_type_enum" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'move', 'app', 'updated')`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "notification" ALTER COLUMN "type" TYPE "public"."notification_type_enum" USING "type"::"text"::"public"."notification_type_enum"`);
|
||||||
|
await queryRunner.query(`DROP TYPE "public"."notification_type_enum_old"`);
|
||||||
|
await queryRunner.query(`ALTER TYPE "public"."user_profile_mutingnotificationtypes_enum" RENAME TO "user_profile_mutingnotificationtypes_enum_old"`);
|
||||||
|
await queryRunner.query(`CREATE TYPE "public"."user_profile_mutingnotificationtypes_enum" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'move', 'app', 'updated')`);
|
||||||
|
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"[] USING "mutingNotificationTypes"::"text"::"public"."user_profile_mutingnotificationtypes_enum"[]`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" SET DEFAULT '{}'`);
|
||||||
|
await queryRunner.query(`DROP TYPE "public"."user_profile_mutingnotificationtypes_enum_old"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner) {
|
||||||
|
await queryRunner.query(`CREATE TYPE "public"."user_profile_mutingnotificationtypes_enum_old" AS ENUM('move', '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"`);
|
||||||
|
await queryRunner.query(`CREATE TYPE "public"."notification_type_enum_old" AS ENUM('move', '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 "note" DROP COLUMN "updatedAt"`);
|
||||||
|
}
|
||||||
|
}
|
|
@ -19,6 +19,12 @@ export class Note {
|
||||||
})
|
})
|
||||||
public createdAt: Date;
|
public createdAt: Date;
|
||||||
|
|
||||||
|
@Column('timestamp with time zone', {
|
||||||
|
nullable: true,
|
||||||
|
comment: 'The updated date of the Note.',
|
||||||
|
})
|
||||||
|
public updatedAt: Date | null;
|
||||||
|
|
||||||
@Index()
|
@Index()
|
||||||
@Column({
|
@Column({
|
||||||
...id(),
|
...id(),
|
||||||
|
|
|
@ -169,6 +169,7 @@ export const NoteRepository = db.getRepository(Note).extend({
|
||||||
const packed: Packed<'Note'> = await awaitAll({
|
const packed: Packed<'Note'> = await awaitAll({
|
||||||
id: note.id,
|
id: note.id,
|
||||||
createdAt: note.createdAt.toISOString(),
|
createdAt: note.createdAt.toISOString(),
|
||||||
|
updatedAt: note.updatedAt?.toISOString() ?? null,
|
||||||
Johann150 marked this conversation as resolved
Outdated
|
|||||||
userId: note.userId,
|
userId: note.userId,
|
||||||
user: Users.pack(note.user ?? note.userId, me, {
|
user: Users.pack(note.user ?? note.userId, me, {
|
||||||
detail: false,
|
detail: false,
|
||||||
|
|
|
@ -12,6 +12,11 @@ export const packedNoteSchema = {
|
||||||
optional: false, nullable: false,
|
optional: false, nullable: false,
|
||||||
format: 'date-time',
|
format: 'date-time',
|
||||||
},
|
},
|
||||||
|
updatedAt: {
|
||||||
|
type: 'string',
|
||||||
|
optional: false, nullable: true,
|
||||||
|
format: 'date-time',
|
||||||
|
},
|
||||||
text: {
|
text: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
optional: false, nullable: true,
|
optional: false, nullable: true,
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
import { IRemoteUser } from '@/models/entities/user.js';
|
import { IRemoteUser } from '@/models/entities/user.js';
|
||||||
import { getApId, getApType, IUpdate, isActor } from '@/remote/activitypub/type.js';
|
import { getApId, getOneApId, getApType, IUpdate, isActor, isPost } from '@/remote/activitypub/type.js';
|
||||||
import { apLogger } from '@/remote/activitypub/logger.js';
|
import { apLogger } from '@/remote/activitypub/logger.js';
|
||||||
import { updateQuestion } from '@/remote/activitypub/models/question.js';
|
import { updateQuestion } from '@/remote/activitypub/models/question.js';
|
||||||
import { Resolver } from '@/remote/activitypub/resolver.js';
|
import { Resolver } from '@/remote/activitypub/resolver.js';
|
||||||
import { updatePerson } from '@/remote/activitypub/models/person.js';
|
import { updatePerson } from '@/remote/activitypub/models/person.js';
|
||||||
|
import { update as updateNote } from '@/remote/activitypub/kernel/update/note.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updateアクティビティを捌きます
|
* Updateアクティビティを捌きます
|
||||||
|
@ -30,6 +31,8 @@ export default async (actor: IRemoteUser, activity: IUpdate, resolver: Resolver)
|
||||||
} else if (getApType(object) === 'Question') {
|
} else if (getApType(object) === 'Question') {
|
||||||
await updateQuestion(object, resolver).catch(e => console.log(e));
|
await updateQuestion(object, resolver).catch(e => console.log(e));
|
||||||
return 'ok: Question updated';
|
return 'ok: Question updated';
|
||||||
|
} else if (isPost(object)) {
|
||||||
|
return await updateNote(actor, object, resolver);
|
||||||
} else {
|
} else {
|
||||||
return `skip: Unknown type: ${getApType(object)}`;
|
return `skip: Unknown type: ${getApType(object)}`;
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { IRemoteUser } from '@/models/entities/user.js';
|
||||||
|
import { getApId } from '@/remote/activitypub/type.js';
|
||||||
|
import { Resolver } from '@/remote/activitypub/resolver.js';
|
||||||
|
import { Notes } from '@/models/index.js';
|
||||||
|
import createNote from '@/remote/activitypub/kernel/create/note.js';
|
||||||
|
import { getApLock } from '@/misc/app-lock.js';
|
||||||
|
import { updateNote } from '@/remote/activitypub/models/note.js';
|
||||||
|
|
||||||
|
export async function update(actor: IRemoteUser, note: IObject, resolver: Resolver): Promise<string> {
|
||||||
|
// check whether note exists
|
||||||
|
const uri = getApId(note);
|
||||||
|
const exists = await Notes.findOneBy({ uri });
|
||||||
|
|
||||||
|
if (exists == null) {
|
||||||
|
// does not yet exist, handle as if this was a create activity
|
||||||
|
// and since this is not a direct creation, handle it silently
|
||||||
|
createNote(resolver, actor, note, true);
|
||||||
|
|
||||||
|
const unlock = await getApLock(uri);
|
||||||
Johann150 marked this conversation as resolved
Outdated
helene
commented
I assume this is used to get exclusive ownership of an AP object? I assume this is used to get exclusive ownership of an AP object?
Johann150
commented
Yes this is also used in the respective AP kernel function for Yes this is also used in the respective AP kernel function for `Create/Note`.
helene
commented
I'd assume it's also used for I'd assume it's also used for `Delete`s; it does not seem to be used in the alternative path for `updateNote` though, which could be a problem (I assume that is why there was a `unlock()` there but no matching definition)
|
|||||||
|
try {
|
||||||
|
// if creating was successful...
|
||||||
|
const existsNow = await Notes.findOneByOrFail({ uri });
|
||||||
|
// set the updatedAt timestamp since the note was changed
|
||||||
|
await Notes.update(existsNow.id, { updatedAt: new Date() });
|
||||||
|
return 'ok: unknown note created and marked as updated';
|
||||||
Johann150 marked this conversation as resolved
Outdated
helene
commented
`s/unkown/unknown`
|
|||||||
|
} catch (e) {
|
||||||
|
return `skip: updated note unknown and creating rejected: ${e.message}`;
|
||||||
|
} finally {
|
||||||
|
unlock();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// check that actor is authorized to update this note
|
||||||
|
if (actor.id !== exists.userId) {
|
||||||
|
return 'skip: actor not authorized to update Note';
|
||||||
|
}
|
||||||
|
// this does not redo the checks from the Create Note kernel
|
||||||
|
// since if the note made it into the database, we assume
|
||||||
|
// those checks must have been passed before.
|
||||||
|
|
||||||
|
const unlock = await getApLock(uri);
|
||||||
|
try {
|
||||||
|
await updateNote(note, actor, resolver);
|
||||||
|
return 'ok: note updated';
|
||||||
|
} catch (e) {
|
||||||
|
return `skip: update note rejected: ${e.message}`;
|
||||||
|
} finally {
|
||||||
Johann150 marked this conversation as resolved
Outdated
helene
commented
`unlock` is undefined here, I suggest removing the `finally` block
|
|||||||
|
unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,14 +1,14 @@
|
||||||
import promiseLimit from 'promise-limit';
|
import promiseLimit from 'promise-limit';
|
||||||
|
import * as foundkey from 'foundkey-js';
|
||||||
import config from '@/config/index.js';
|
import config from '@/config/index.js';
|
||||||
import post from '@/services/note/create.js';
|
import post from '@/services/note/create.js';
|
||||||
import { IRemoteUser } from '@/models/entities/user.js';
|
import { User, IRemoteUser } from '@/models/entities/user.js';
|
||||||
import { unique, toArray, toSingle } from '@/prelude/array.js';
|
import { unique, toArray, toSingle } from '@/prelude/array.js';
|
||||||
import { vote } from '@/services/note/polls/vote.js';
|
import { vote } from '@/services/note/polls/vote.js';
|
||||||
import { DriveFile } from '@/models/entities/drive-file.js';
|
import { DriveFile } from '@/models/entities/drive-file.js';
|
||||||
import { deliverQuestionUpdate } from '@/services/note/polls/update.js';
|
import { deliverQuestionUpdate } from '@/services/note/polls/update.js';
|
||||||
import { extractPunyHost } from '@/misc/convert-host.js';
|
import { extractPunyHost } from '@/misc/convert-host.js';
|
||||||
import { Polls, MessagingMessages } from '@/models/index.js';
|
import { Polls, MessagingMessages, Notes } from '@/models/index.js';
|
||||||
import { Note } from '@/models/entities/note.js';
|
import { Note } from '@/models/entities/note.js';
|
||||||
import { Emoji } from '@/models/entities/emoji.js';
|
import { Emoji } from '@/models/entities/emoji.js';
|
||||||
import { genId } from '@/misc/gen-id.js';
|
import { genId } from '@/misc/gen-id.js';
|
||||||
|
@ -27,6 +27,8 @@ import { resolveImage } from './image.js';
|
||||||
import { extractApHashtags, extractQuoteUrl, extractEmojis } from './tag.js';
|
import { extractApHashtags, extractQuoteUrl, extractEmojis } from './tag.js';
|
||||||
import { extractPollFromQuestion } from './question.js';
|
import { extractPollFromQuestion } from './question.js';
|
||||||
import { extractApMentions } from './mention.js';
|
import { extractApMentions } from './mention.js';
|
||||||
|
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
|
||||||
|
import { sideEffects } from '@/services/note/side-effects.js';
|
||||||
|
|
||||||
export function validateNote(object: IObject): Error | null {
|
export function validateNote(object: IObject): Error | null {
|
||||||
if (object == null) {
|
if (object == null) {
|
||||||
|
@ -324,3 +326,45 @@ export async function resolveNote(value: string | IObject, resolver: Resolver):
|
||||||
unlock();
|
unlock();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a note.
|
||||||
|
*
|
||||||
|
* If the target Note is not registered, it will be ignored.
|
||||||
|
*/
|
||||||
|
export async function updateNote(value: IPost, actor: User, resolver: Resolver): Promise<Note | null> {
|
||||||
|
const err = validateNote(value);
|
||||||
|
if (err) {
|
||||||
|
apLogger.error(`${err.message}`);
|
||||||
|
throw new Error('invalid updated note');
|
||||||
|
}
|
||||||
|
|
||||||
|
const uri = getApId(value);
|
||||||
|
const exists = await Notes.findOneBy({ uri });
|
||||||
|
if (exists == null) return null;
|
||||||
|
|
||||||
|
let quoteUri = null;
|
||||||
|
if (exists.renoteId && !foundkey.entities.isPureRenote(exists)) {
|
||||||
|
const quote = await Notes.findOneBy({ id: exists.renoteId });
|
||||||
|
quoteUri = quote.uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
// process content and update attached files (e.g. also image descriptions)
|
||||||
|
const processedContent = await processContent(actor, value, quoteUri, resolver);
|
||||||
|
|
||||||
|
// update note content itself
|
||||||
|
await Notes.update(exists.id, {
|
||||||
|
updatedAt: new Date(),
|
||||||
|
|
||||||
|
cw: processedContent.cw,
|
||||||
|
fileIds: processedContent.files.map(file => file.id),
|
||||||
|
attachedFileTypes: processedContent.files.map(file => file.type),
|
||||||
|
text: processedContent.text,
|
||||||
|
emojis: processedContent.apEmoji,
|
||||||
|
tags: processedContent.apHashtags.map(tag => normalizeForSearch(tag)),
|
||||||
|
url: processedContent.url,
|
||||||
|
name: processedContent.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
await sideEffects(actor, await Notes.findOneByOrFail({ id: exists.id }), false, false);
|
||||||
|
}
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { WebSocket } from 'ws';
|
||||||
import { readNote } from '@/services/note/read.js';
|
import { readNote } from '@/services/note/read.js';
|
||||||
import { User } from '@/models/entities/user.js';
|
import { User } from '@/models/entities/user.js';
|
||||||
import { Channel as ChannelModel } from '@/models/entities/channel.js';
|
import { Channel as ChannelModel } from '@/models/entities/channel.js';
|
||||||
import { Followings, Mutings, RenoteMutings, UserProfiles, ChannelFollowings, Blockings } from '@/models/index.js';
|
import { Followings, Mutings, Notes, RenoteMutings, UserProfiles, ChannelFollowings, Blockings } from '@/models/index.js';
|
||||||
import { AccessToken } from '@/models/entities/access-token.js';
|
import { AccessToken } from '@/models/entities/access-token.js';
|
||||||
import { UserProfile } from '@/models/entities/user-profile.js';
|
import { UserProfile } from '@/models/entities/user-profile.js';
|
||||||
import { publishChannelStream, publishGroupMessagingStream, publishMessagingStream } from '@/services/stream.js';
|
import { publishChannelStream, publishGroupMessagingStream, publishMessagingStream } from '@/services/stream.js';
|
||||||
|
@ -14,6 +14,7 @@ import { channels } from './channels/index.js';
|
||||||
import Channel from './channel.js';
|
import Channel from './channel.js';
|
||||||
import { StreamEventEmitter, StreamMessages } from './types.js';
|
import { StreamEventEmitter, StreamMessages } from './types.js';
|
||||||
import Logger from '@/services/logger.js';
|
import Logger from '@/services/logger.js';
|
||||||
|
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||||
|
|
||||||
const logger = new Logger('streaming');
|
const logger = new Logger('streaming');
|
||||||
|
|
||||||
|
@ -254,12 +255,44 @@ export class Connection {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async onNoteStreamMessage(data: StreamMessages['note']['payload']) {
|
private async onNoteStreamMessage(data: StreamMessages['note']['payload']) {
|
||||||
|
if (data.type === 'updated') {
|
||||||
|
const note = data.body.body.note;
|
||||||
|
// FIXME analogous to Channel.withPackedNote, but for some reason, the note
|
||||||
|
// stream is not handled as a channel but instead handled at the top level
|
||||||
|
// so this code is duplicated here I guess.
|
||||||
|
try {
|
||||||
|
// because `note` was previously JSON.stringify'ed, the fields that
|
||||||
|
// were objects before are now strings and have to be restored or
|
||||||
|
// removed from the object
|
||||||
|
note.createdAt = new Date(note.createdAt);
|
||||||
|
note.reply = null;
|
||||||
|
note.renote = null;
|
||||||
|
note.user = null;
|
||||||
|
note.channel = null;
|
||||||
|
|
||||||
|
const packed = await Notes.pack(note, this.user, { detail: true });
|
||||||
|
|
||||||
|
this.sendMessageToWs('noteUpdated', {
|
||||||
|
id: data.body.id,
|
||||||
|
type: 'updated',
|
||||||
|
body: { note: packed },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof IdentifiableError && err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') {
|
||||||
|
// skip: note not visible to user
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
logger.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
this.sendMessageToWs('noteUpdated', {
|
this.sendMessageToWs('noteUpdated', {
|
||||||
id: data.body.id,
|
id: data.body.id,
|
||||||
type: data.type,
|
type: data.type,
|
||||||
body: data.body.body,
|
body: data.body.body,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* チャンネル接続要求時
|
* チャンネル接続要求時
|
||||||
|
|
|
@ -128,6 +128,9 @@ export interface NoteStreamTypes {
|
||||||
reaction: string;
|
reaction: string;
|
||||||
userId: User['id'];
|
userId: User['id'];
|
||||||
};
|
};
|
||||||
|
updated: {
|
||||||
|
note: Note;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
type NoteStreamEventTypes = {
|
type NoteStreamEventTypes = {
|
||||||
[key in keyof NoteStreamTypes]: {
|
[key in keyof NoteStreamTypes]: {
|
||||||
|
|
|
@ -1,109 +1,21 @@
|
||||||
import { ArrayOverlap, Not, In } from 'typeorm';
|
|
||||||
import * as mfm from 'mfm-js';
|
import * as mfm from 'mfm-js';
|
||||||
import { db } from '@/db/postgre.js';
|
import { db } from '@/db/postgre.js';
|
||||||
import { publishMainStream, publishNotesStream } from '@/services/stream.js';
|
|
||||||
import { DeliverManager } from '@/remote/activitypub/deliver-manager.js';
|
|
||||||
import renderNote from '@/remote/activitypub/renderer/note.js';
|
|
||||||
import renderCreate from '@/remote/activitypub/renderer/create.js';
|
|
||||||
import renderAnnounce from '@/remote/activitypub/renderer/announce.js';
|
|
||||||
import { renderActivity } from '@/remote/activitypub/renderer/index.js';
|
|
||||||
import { resolveUser } from '@/remote/resolve-user.js';
|
import { resolveUser } from '@/remote/resolve-user.js';
|
||||||
import { concat } from '@/prelude/array.js';
|
import { concat } from '@/prelude/array.js';
|
||||||
import { insertNoteUnread } from '@/services/note/unread.js';
|
|
||||||
import { extractMentions } from '@/misc/extract-mentions.js';
|
import { extractMentions } from '@/misc/extract-mentions.js';
|
||||||
import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js';
|
import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js';
|
||||||
import { extractHashtags } from '@/misc/extract-hashtags.js';
|
import { extractHashtags } from '@/misc/extract-hashtags.js';
|
||||||
import { Note } from '@/models/entities/note.js';
|
import { Note } from '@/models/entities/note.js';
|
||||||
import { Mutings, Users, NoteWatchings, Notes, Instances, MutedNotes, Channels, ChannelFollowings, NoteThreadMutings } from '@/models/index.js';
|
import { Users, Channels } from '@/models/index.js';
|
||||||
import { DriveFile } from '@/models/entities/drive-file.js';
|
import { DriveFile } from '@/models/entities/drive-file.js';
|
||||||
import { App } from '@/models/entities/app.js';
|
import { App } from '@/models/entities/app.js';
|
||||||
import { User, ILocalUser, IRemoteUser } from '@/models/entities/user.js';
|
import { User } from '@/models/entities/user.js';
|
||||||
import { genId } from '@/misc/gen-id.js';
|
import { genId } from '@/misc/gen-id.js';
|
||||||
import { notesChart, perUserNotesChart, activeUsersChart, instanceChart } from '@/services/chart/index.js';
|
|
||||||
import { Poll, IPoll } from '@/models/entities/poll.js';
|
import { Poll, IPoll } from '@/models/entities/poll.js';
|
||||||
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
|
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
|
||||||
import { checkHitAntenna } from '@/misc/check-hit-antenna.js';
|
|
||||||
import { checkWordMute } from '@/misc/check-word-mute.js';
|
|
||||||
import { countSameRenotes } from '@/misc/count-same-renotes.js';
|
|
||||||
import { Channel } from '@/models/entities/channel.js';
|
import { Channel } from '@/models/entities/channel.js';
|
||||||
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
|
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
|
||||||
import { getAntennas } from '@/misc/antenna-cache.js';
|
import { sideEffects } from './side-effects.js';
|
||||||
import { endedPollNotificationQueue } from '@/queue/queues.js';
|
|
||||||
import { webhookDeliver } from '@/queue/index.js';
|
|
||||||
import { UserProfile } from '@/models/entities/user-profile.js';
|
|
||||||
import { getActiveWebhooks } from '@/misc/webhook-cache.js';
|
|
||||||
import { IActivity } from '@/remote/activitypub/type.js';
|
|
||||||
import { renderNoteOrRenoteActivity } from '@/remote/activitypub/renderer/note-or-renote.js';
|
|
||||||
import { updateHashtags } from '../update-hashtag.js';
|
|
||||||
import { registerOrFetchInstanceDoc } from '../register-or-fetch-instance-doc.js';
|
|
||||||
import { createNotification } from '../create-notification.js';
|
|
||||||
import { addNoteToAntenna } from '../add-note-to-antenna.js';
|
|
||||||
import { deliverToRelays } from '../relay.js';
|
|
||||||
import { mutedWordsCache, index } from './index.js';
|
|
||||||
|
|
||||||
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
|
|
||||||
|
|
||||||
class NotificationManager {
|
|
||||||
private notifier: { id: User['id']; };
|
|
||||||
private note: Note;
|
|
||||||
private queue: {
|
|
||||||
target: ILocalUser['id'];
|
|
||||||
reason: NotificationType;
|
|
||||||
}[];
|
|
||||||
|
|
||||||
constructor(notifier: { id: User['id']; }, note: Note) {
|
|
||||||
this.notifier = notifier;
|
|
||||||
this.note = note;
|
|
||||||
this.queue = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
public push(notifiee: ILocalUser['id'], reason: NotificationType): void {
|
|
||||||
// No notification to yourself.
|
|
||||||
if (this.notifier.id === notifiee) return;
|
|
||||||
|
|
||||||
const exist = this.queue.find(x => x.target === notifiee);
|
|
||||||
|
|
||||||
if (exist) {
|
|
||||||
// If you have been "mentioned and replied to," make the notification as a reply, not as a mention.
|
|
||||||
if (reason !== 'mention') {
|
|
||||||
exist.reason = reason;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.queue.push({
|
|
||||||
reason,
|
|
||||||
target: notifiee,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async deliver(): Promise<void> {
|
|
||||||
for (const x of this.queue) {
|
|
||||||
// check if the sender or thread are muted
|
|
||||||
const userMuted = await Mutings.countBy({
|
|
||||||
muterId: x.target,
|
|
||||||
muteeId: this.notifier.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const threadMuted = await NoteThreadMutings.countBy({
|
|
||||||
userId: x.target,
|
|
||||||
threadId: In([
|
|
||||||
// replies
|
|
||||||
this.note.threadId ?? this.note.id,
|
|
||||||
// renotes
|
|
||||||
this.note.renoteId ?? undefined,
|
|
||||||
]),
|
|
||||||
mutingNotificationTypes: ArrayOverlap([x.reason]),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!userMuted && !threadMuted) {
|
|
||||||
createNotification(x.target, x.reason, {
|
|
||||||
notifierId: this.notifier.id,
|
|
||||||
noteId: this.note.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type MinimumUser = {
|
type MinimumUser = {
|
||||||
id: User['id'];
|
id: User['id'];
|
||||||
|
@ -238,252 +150,8 @@ export default async (user: { id: User['id']; username: User['username']; host:
|
||||||
|
|
||||||
res(note);
|
res(note);
|
||||||
|
|
||||||
// Update Statistics
|
sideEffects(user, note, silent, true);
|
||||||
notesChart.update(note, true);
|
|
||||||
perUserNotesChart.update(user, note, true);
|
|
||||||
|
|
||||||
// Register host
|
|
||||||
if (Users.isRemoteUser(user)) {
|
|
||||||
registerOrFetchInstanceDoc(user.host).then(i => {
|
|
||||||
Instances.increment({ id: i.id }, 'notesCount', 1);
|
|
||||||
instanceChart.updateNote(i.host, note, true);
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// Hashtag Update
|
|
||||||
if (data.visibility === 'public' || data.visibility === 'home') {
|
|
||||||
updateHashtags(user, tags);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Increment notes count (user)
|
|
||||||
incNotesCountOfUser(user);
|
|
||||||
|
|
||||||
// Word mute
|
|
||||||
mutedWordsCache.fetch('').then(us => {
|
|
||||||
for (const u of us) {
|
|
||||||
checkWordMute(note, { id: u.userId }, u.mutedWords).then(shouldMute => {
|
|
||||||
if (shouldMute) {
|
|
||||||
MutedNotes.insert({
|
|
||||||
id: genId(),
|
|
||||||
userId: u.userId,
|
|
||||||
noteId: note.id,
|
|
||||||
reason: 'word',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Antenna
|
|
||||||
for (const antenna of (await getAntennas())) {
|
|
||||||
checkHitAntenna(antenna, note, user).then(hit => {
|
|
||||||
if (hit) {
|
|
||||||
addNoteToAntenna(antenna, note, user);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Channel
|
|
||||||
if (note.channelId) {
|
|
||||||
ChannelFollowings.findBy({ followeeId: note.channelId }).then(followings => {
|
|
||||||
for (const following of followings) {
|
|
||||||
insertNoteUnread(following.followerId, note, {
|
|
||||||
isSpecified: false,
|
|
||||||
isMentioned: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.reply) {
|
|
||||||
saveReply(data.reply, note);
|
|
||||||
}
|
|
||||||
|
|
||||||
// When there is no re-note of the specified note by the specified user except for this post
|
|
||||||
if (data.renote && (await countSameRenotes(user.id, data.renote.id, note.id) === 0)) {
|
|
||||||
incRenoteCount(data.renote);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.poll && data.poll.expiresAt) {
|
|
||||||
const delay = data.poll.expiresAt.getTime() - Date.now();
|
|
||||||
endedPollNotificationQueue.add({
|
|
||||||
noteId: note.id,
|
|
||||||
}, {
|
|
||||||
delay,
|
|
||||||
removeOnComplete: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!silent) {
|
|
||||||
if (Users.isLocalUser(user)) activeUsersChart.write(user);
|
|
||||||
|
|
||||||
// Create unread notifications
|
|
||||||
if (data.visibility === 'specified') {
|
|
||||||
if (data.visibleUsers == null) throw new Error('invalid param');
|
|
||||||
|
|
||||||
for (const u of data.visibleUsers) {
|
|
||||||
// Local users only
|
|
||||||
if (!Users.isLocalUser(u)) continue;
|
|
||||||
|
|
||||||
insertNoteUnread(u.id, note, {
|
|
||||||
isSpecified: true,
|
|
||||||
isMentioned: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for (const u of mentionedUsers) {
|
|
||||||
// Local users only
|
|
||||||
if (!Users.isLocalUser(u)) continue;
|
|
||||||
|
|
||||||
insertNoteUnread(u.id, note, {
|
|
||||||
isSpecified: false,
|
|
||||||
isMentioned: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
publishNotesStream(note);
|
|
||||||
|
|
||||||
const webhooks = await getActiveWebhooks().then(webhooks => webhooks.filter(x => x.userId === user.id && x.on.includes('note')));
|
|
||||||
|
|
||||||
for (const webhook of webhooks) {
|
|
||||||
webhookDeliver(webhook, 'note', {
|
|
||||||
note: await Notes.pack(note, user),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const nm = new NotificationManager(user, note);
|
|
||||||
const nmRelatedPromises = [];
|
|
||||||
|
|
||||||
await createMentionedEvents(mentionedUsers, note, nm);
|
|
||||||
|
|
||||||
// If has in reply to note
|
|
||||||
if (data.reply) {
|
|
||||||
// Fetch watchers
|
|
||||||
nmRelatedPromises.push(notifyToWatchersOfReplyee(data.reply, user, nm));
|
|
||||||
|
|
||||||
// 通知
|
|
||||||
if (data.reply.userHost === null) {
|
|
||||||
const threadMuted = await NoteThreadMutings.countBy({
|
|
||||||
userId: data.reply.userId,
|
|
||||||
threadId: data.reply.threadId || data.reply.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!threadMuted) {
|
|
||||||
nm.push(data.reply.userId, 'reply');
|
|
||||||
|
|
||||||
const packedReply = await Notes.pack(note, { id: data.reply.userId });
|
|
||||||
publishMainStream(data.reply.userId, 'reply', packedReply);
|
|
||||||
|
|
||||||
const webhooks = (await getActiveWebhooks()).filter(x => x.userId === data.reply!.userId && x.on.includes('reply'));
|
|
||||||
for (const webhook of webhooks) {
|
|
||||||
webhookDeliver(webhook, 'reply', {
|
|
||||||
note: packedReply,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If it is renote
|
|
||||||
if (data.renote) {
|
|
||||||
const type = data.text ? 'quote' : 'renote';
|
|
||||||
|
|
||||||
// Notify
|
|
||||||
if (data.renote.userHost === null) {
|
|
||||||
nm.push(data.renote.userId, type);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch watchers
|
|
||||||
nmRelatedPromises.push(notifyToWatchersOfRenotee(data.renote, user, nm, type));
|
|
||||||
|
|
||||||
// Publish event
|
|
||||||
if ((user.id !== data.renote.userId) && data.renote.userHost === null) {
|
|
||||||
const packedRenote = await Notes.pack(note, { id: data.renote.userId });
|
|
||||||
publishMainStream(data.renote.userId, 'renote', packedRenote);
|
|
||||||
|
|
||||||
const webhooks = (await getActiveWebhooks()).filter(x => x.userId === data.renote!.userId && x.on.includes('renote'));
|
|
||||||
for (const webhook of webhooks) {
|
|
||||||
webhookDeliver(webhook, 'renote', {
|
|
||||||
note: packedRenote,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Promise.all(nmRelatedPromises).then(() => {
|
|
||||||
nm.deliver();
|
|
||||||
});
|
|
||||||
|
|
||||||
//#region AP deliver
|
|
||||||
if (Users.isLocalUser(user) && !data.localOnly) {
|
|
||||||
(async () => {
|
|
||||||
const noteActivity = renderActivity(await renderNoteOrRenoteActivity(note));
|
|
||||||
const dm = new DeliverManager(user, noteActivity);
|
|
||||||
|
|
||||||
// Delivered to remote users who have been mentioned
|
|
||||||
for (const u of mentionedUsers.filter(u => Users.isRemoteUser(u))) {
|
|
||||||
dm.addDirectRecipe(u as IRemoteUser);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the post is a reply and the poster is a local user and the poster of the post to which you are replying is a remote user, deliver
|
|
||||||
if (data.reply && data.reply.userHost !== null) {
|
|
||||||
const u = await Users.findOneBy({ id: data.reply.userId });
|
|
||||||
if (u && Users.isRemoteUser(u)) dm.addDirectRecipe(u);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the post is a Renote and the poster is a local user and the poster of the original Renote post is a remote user, deliver
|
|
||||||
if (data.renote && data.renote.userHost !== null) {
|
|
||||||
const u = await Users.findOneBy({ id: data.renote.userId });
|
|
||||||
if (u && Users.isRemoteUser(u)) dm.addDirectRecipe(u);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deliver to followers
|
|
||||||
if (['public', 'home', 'followers'].includes(note.visibility)) {
|
|
||||||
dm.addFollowersRecipe();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (['public'].includes(note.visibility)) {
|
|
||||||
deliverToRelays(user, noteActivity);
|
|
||||||
}
|
|
||||||
|
|
||||||
dm.execute();
|
|
||||||
})();
|
|
||||||
}
|
|
||||||
//#endregion
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.channel) {
|
|
||||||
Channels.increment({ id: data.channel.id }, 'notesCount', 1);
|
|
||||||
Channels.update(data.channel.id, {
|
|
||||||
lastNotedAt: new Date(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const count = await Notes.countBy({
|
|
||||||
userId: user.id,
|
|
||||||
channelId: data.channel.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
// This process takes place after the note is created, so if there is only one note, you can determine that it is the first submission.
|
|
||||||
// TODO: but there's also the messiness of deleting a note and posting it multiple times, which is incremented by the number of times it's posted, so I'd like to do something about that.
|
|
||||||
if (count === 1) {
|
|
||||||
Channels.increment({ id: data.channel.id }, 'usersCount', 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register to search database
|
|
||||||
index(note);
|
|
||||||
});
|
|
||||||
|
|
||||||
function incRenoteCount(renote: Note): void {
|
|
||||||
Notes.createQueryBuilder().update()
|
|
||||||
.set({
|
|
||||||
renoteCount: () => '"renoteCount" + 1',
|
|
||||||
score: () => '"score" + 1',
|
|
||||||
})
|
|
||||||
.where('id = :id', { id: renote.id })
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function insertNote(user: { id: User['id']; host: User['host']; }, data: Option, tags: string[], emojis: string[], mentionedUsers: MinimumUser[]): Promise<Note> {
|
async function insertNote(user: { id: User['id']; host: User['host']; }, data: Option, tags: string[], emojis: string[], mentionedUsers: MinimumUser[]): Promise<Note> {
|
||||||
const createdAt = data.createdAt ?? new Date();
|
const createdAt = data.createdAt ?? new Date();
|
||||||
|
@ -563,77 +231,6 @@ async function insertNote(user: { id: User['id']; host: User['host']; }, data: O
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function notifyToWatchersOfRenotee(renote: Note, user: { id: User['id']; }, nm: NotificationManager, type: NotificationType): Promise<void> {
|
|
||||||
const watchers = await NoteWatchings.findBy({
|
|
||||||
noteId: renote.id,
|
|
||||||
userId: Not(user.id),
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const watcher of watchers) {
|
|
||||||
nm.push(watcher.userId, type);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function notifyToWatchersOfReplyee(reply: Note, user: { id: User['id']; }, nm: NotificationManager): Promise<void> {
|
|
||||||
const watchers = await NoteWatchings.findBy({
|
|
||||||
noteId: reply.id,
|
|
||||||
userId: Not(user.id),
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const watcher of watchers) {
|
|
||||||
nm.push(watcher.userId, 'reply');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createMentionedEvents(mentionedUsers: MinimumUser[], note: Note, nm: NotificationManager): Promise<void> {
|
|
||||||
for (const u of mentionedUsers.filter(u => Users.isLocalUser(u))) {
|
|
||||||
const threadMuted = await NoteThreadMutings.countBy({
|
|
||||||
userId: u.id,
|
|
||||||
threadId: note.threadId || note.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (threadMuted) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// note with "specified" visibility might not be visible to mentioned users
|
|
||||||
try {
|
|
||||||
const detailPackedNote = await Notes.pack(note, u, {
|
|
||||||
detail: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
publishMainStream(u.id, 'mention', detailPackedNote);
|
|
||||||
|
|
||||||
const webhooks = (await getActiveWebhooks()).filter(x => x.userId === u.id && x.on.includes('mention'));
|
|
||||||
for (const webhook of webhooks) {
|
|
||||||
webhookDeliver(webhook, 'mention', {
|
|
||||||
note: detailPackedNote,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') continue;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create notification
|
|
||||||
nm.push(u.id, 'mention');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveReply(reply: Note): void {
|
|
||||||
Notes.increment({ id: reply.id }, 'repliesCount', 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function incNotesCountOfUser(user: { id: User['id']; }): void {
|
|
||||||
Users.createQueryBuilder().update()
|
|
||||||
.set({
|
|
||||||
updatedAt: new Date(),
|
|
||||||
notesCount: () => '"notesCount" + 1',
|
|
||||||
})
|
|
||||||
.where('id = :id', { id: user.id })
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function extractMentionedUsers(user: { host: User['host']; }, tokens: mfm.MfmNode[]): Promise<User[]> {
|
async function extractMentionedUsers(user: { host: User['host']; }, tokens: mfm.MfmNode[]): Promise<User[]> {
|
||||||
if (tokens.length === 0) return [];
|
if (tokens.length === 0) return [];
|
||||||
|
|
||||||
|
|
474
packages/backend/src/services/note/side-effects.ts
Normal file
474
packages/backend/src/services/note/side-effects.ts
Normal file
|
@ -0,0 +1,474 @@
|
||||||
|
import { ArrayOverlap, Not, In } from 'typeorm';
|
||||||
|
import * as mfm from 'mfm-js';
|
||||||
|
import { publishMainStream, publishNoteStream, publishNotesStream } from '@/services/stream.js';
|
||||||
|
import { DeliverManager } from '@/remote/activitypub/deliver-manager.js';
|
||||||
|
import { renderActivity } from '@/remote/activitypub/renderer/index.js';
|
||||||
|
import { resolveUser } from '@/remote/resolve-user.js';
|
||||||
|
import { insertNoteUnread } from '@/services/note/unread.js';
|
||||||
|
import { extractMentions } from '@/misc/extract-mentions.js';
|
||||||
|
import { extractHashtags } from '@/misc/extract-hashtags.js';
|
||||||
|
import { Note } from '@/models/entities/note.js';
|
||||||
|
import { AntennaNotes, Mutings, Users, NoteWatchings, Notes, Instances, MutedNotes, Channels, ChannelFollowings, NoteThreadMutings } from '@/models/index.js';
|
||||||
|
import { User, ILocalUser, IRemoteUser } from '@/models/entities/user.js';
|
||||||
|
import { genId } from '@/misc/gen-id.js';
|
||||||
|
import { notesChart, perUserNotesChart, activeUsersChart, instanceChart } from '@/services/chart/index.js';
|
||||||
|
import { checkHitAntenna } from '@/misc/check-hit-antenna.js';
|
||||||
|
import { checkWordMute } from '@/misc/check-word-mute.js';
|
||||||
|
import { countSameRenotes } from '@/misc/count-same-renotes.js';
|
||||||
|
import { getAntennas } from '@/misc/antenna-cache.js';
|
||||||
|
import { endedPollNotificationQueue } from '@/queue/queues.js';
|
||||||
|
import { webhookDeliver } from '@/queue/index.js';
|
||||||
|
import { getActiveWebhooks } from '@/misc/webhook-cache.js';
|
||||||
|
import { renderNoteOrRenoteActivity } from '@/remote/activitypub/renderer/note-or-renote.js';
|
||||||
|
import { updateHashtags } from '../update-hashtag.js';
|
||||||
|
import { registerOrFetchInstanceDoc } from '../register-or-fetch-instance-doc.js';
|
||||||
|
import { createNotification } from '../create-notification.js';
|
||||||
|
import { addNoteToAntenna } from '../add-note-to-antenna.js';
|
||||||
|
import { deliverToRelays } from '../relay.js';
|
||||||
|
import { mutedWordsCache, index } from './index.js';
|
||||||
|
import { Polls } from '@/models/index.js';
|
||||||
|
import { Poll } from '@/models/entities/poll.js';
|
||||||
|
|
||||||
|
|
||||||
|
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention' | 'update';
|
||||||
|
|
||||||
|
class NotificationManager {
|
||||||
|
private notifier: { id: User['id']; };
|
||||||
|
private note: Note;
|
||||||
|
private queue: {
|
||||||
|
target: ILocalUser['id'];
|
||||||
|
reason: NotificationType;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
constructor(notifier: { id: User['id']; }, note: Note) {
|
||||||
|
this.notifier = notifier;
|
||||||
|
this.note = note;
|
||||||
|
this.queue = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public push(notifiee: ILocalUser['id'], reason: NotificationType): void {
|
||||||
|
// No notification to yourself.
|
||||||
|
if (this.notifier.id === notifiee) return;
|
||||||
|
|
||||||
|
const exist = this.queue.find(x => x.target === notifiee);
|
||||||
|
|
||||||
|
if (exist) {
|
||||||
|
// If you have been "mentioned and replied to," make the notification as a reply, not as a mention.
|
||||||
|
if (reason !== 'mention') {
|
||||||
|
exist.reason = reason;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.queue.push({
|
||||||
|
reason,
|
||||||
|
target: notifiee,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deliver(): Promise<void> {
|
||||||
|
for (const x of this.queue) {
|
||||||
|
// check if the sender or thread are muted
|
||||||
|
const userMuted = await Mutings.countBy({
|
||||||
|
muterId: x.target,
|
||||||
|
muteeId: this.notifier.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const threadMuted = await NoteThreadMutings.countBy({
|
||||||
|
userId: x.target,
|
||||||
|
threadId: In([
|
||||||
|
// replies
|
||||||
|
this.note.threadId ?? this.note.id,
|
||||||
|
// renotes
|
||||||
|
this.note.renoteId ?? undefined,
|
||||||
|
]),
|
||||||
|
mutingNotificationTypes: ArrayOverlap([x.reason]),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!userMuted && !threadMuted) {
|
||||||
|
createNotification(x.target, x.reason, {
|
||||||
|
notifierId: this.notifier.id,
|
||||||
|
noteId: this.note.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform side effects for a Note such as incrementing statistics, updating hashtag usage etc.
|
||||||
|
*
|
||||||
|
* @param user The author of the note.
|
||||||
|
* @param note The note for which the side effects should be performed.
|
||||||
|
* @param silent Whether notifications and similar side effects should be suppressed.
|
||||||
|
* @param created Whether statistics should be incremented (i.e. the note was inserted and not updated in the database)
|
||||||
|
*/
|
||||||
|
export async function sideEffects(user: User, note: Note, silent = false, created = true): Promise<void> {
|
||||||
|
if (created) {
|
||||||
|
// Update Statistics
|
||||||
|
notesChart.update(note, true);
|
||||||
|
perUserNotesChart.update(user, note, true);
|
||||||
|
Users.createQueryBuilder().update()
|
||||||
|
.set({
|
||||||
|
updatedAt: new Date(),
|
||||||
|
notesCount: () => '"notesCount" + 1',
|
||||||
|
})
|
||||||
|
.where('id = :id', { id: user.id })
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
if (Users.isLocalUser(user)) {
|
||||||
|
activeUsersChart.write(user);
|
||||||
|
} else {
|
||||||
|
// Remote user, register host
|
||||||
|
registerOrFetchInstanceDoc(user.host).then(i => {
|
||||||
|
Instances.increment({ id: i.id }, 'notesCount', 1);
|
||||||
|
instanceChart.updateNote(i.host, note, true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channel
|
||||||
|
if (note.channelId) {
|
||||||
|
ChannelFollowings.findBy({ followeeId: note.channelId }).then(followings => {
|
||||||
|
for (const following of followings) {
|
||||||
|
insertNoteUnread(following.followerId, note, {
|
||||||
|
isSpecified: false,
|
||||||
|
isMentioned: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Channels.increment({ id: note.channelId }, 'notesCount', 1);
|
||||||
|
Channels.update(note.channelId, {
|
||||||
|
lastNotedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const count = await Notes.countBy({
|
||||||
|
userId: user.id,
|
||||||
|
channelId: note.channelId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// This process takes place after the note is created, so if there is only one note, you can determine that it is the first submission.
|
||||||
|
// TODO: but there's also the messiness of deleting a note and posting it multiple times, which is incremented by the number of times it's posted, so I'd like to do something about that.
|
||||||
|
if (count === 1) {
|
||||||
|
Channels.increment({ id: note.channelId }, 'usersCount', 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (note.replyId) {
|
||||||
|
Notes.increment({ id: note.replyId }, 'repliesCount', 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// When there is no re-note of the specified note by the specified user except for this post
|
||||||
|
if (note.renoteId && (await countSameRenotes(user.id, note.renoteId, note.id) === 0)) {
|
||||||
|
Notes.createQueryBuilder().update()
|
||||||
|
.set({
|
||||||
|
renoteCount: () => '"renoteCount" + 1',
|
||||||
|
score: () => '"score" + 1',
|
||||||
|
})
|
||||||
|
.where('id = :id', { id: note.renoteId })
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
// create job for ended poll notifications
|
||||||
|
if (note.hasPoll) {
|
||||||
|
Polls.findOneByOrFail({ noteId: note.id })
|
||||||
|
.then((poll: Poll) => {
|
||||||
|
if (poll.expiresAt) {
|
||||||
|
const delay = poll.expiresAt.getTime() - Date.now();
|
||||||
|
endedPollNotificationQueue.add({
|
||||||
|
noteId: note.id,
|
||||||
|
}, {
|
||||||
|
delay,
|
||||||
|
removeOnComplete: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { mentionedUsers, tags } = await extractFromMfm(user, note);
|
||||||
|
|
||||||
|
// Hashtag Update
|
||||||
|
if (note.visibility === 'public' || note.visibility === 'home') {
|
||||||
|
updateHashtags(user, tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Word mute
|
||||||
|
mutedWordsCache.fetch('').then(us => {
|
||||||
|
for (const u of us) {
|
||||||
|
checkWordMute(note, { id: u.userId }, u.mutedWords).then(shouldMute => {
|
||||||
|
if (shouldMute) {
|
||||||
|
MutedNotes.insert({
|
||||||
|
id: genId(),
|
||||||
|
userId: u.userId,
|
||||||
|
noteId: note.id,
|
||||||
|
reason: 'word',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Antenna
|
||||||
|
if (!created) {
|
||||||
|
// Provisionally remove from antenna, it may be added again in the next step.
|
||||||
|
// But if it is not removed, it can cause duplicate key errors when trying to
|
||||||
|
// add it to the same antenna again.
|
||||||
|
await AntennaNotes.delete({
|
||||||
|
noteId: note.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
getAntennas()
|
||||||
|
.then(async antennas => {
|
||||||
|
await Promise.all(antennas.map(antenna => {
|
||||||
|
return checkHitAntenna(antenna, note, user)
|
||||||
|
.then(hit => { if (hit) return addNoteToAntenna(antenna, note, user); });
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
if (!silent && created) {
|
||||||
|
// Create unread notifications
|
||||||
|
if (note.visibility === 'specified') {
|
||||||
|
if (note.visibleUserIds == null) {
|
||||||
|
throw new Error('specified note but does not have any visible user ids');
|
||||||
|
}
|
||||||
|
|
||||||
|
Users.findBy({
|
||||||
|
id: In(note.visibleUserIds),
|
||||||
|
}).then((visibleUsers: User[]) => {
|
||||||
|
visibleUsers
|
||||||
|
.filter(u => Users.isLocalUser(u))
|
||||||
|
.forEach(u => {
|
||||||
|
insertNoteUnread(u.id, note, {
|
||||||
|
isSpecified: true,
|
||||||
|
isMentioned: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
mentionedUsers
|
||||||
|
.filter(u => Users.isLocalUser(u))
|
||||||
|
.forEach(u => {
|
||||||
|
insertNoteUnread(u.id, note, {
|
||||||
|
isSpecified: false,
|
||||||
|
isMentioned: true,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
publishNotesStream(note);
|
||||||
|
|
||||||
|
const webhooks = await getActiveWebhooks().then(webhooks => webhooks.filter(x => x.userId === user.id && x.on.includes('note')));
|
||||||
|
for (const webhook of webhooks) {
|
||||||
|
webhookDeliver(webhook, 'note', {
|
||||||
|
note: await Notes.pack(note, user),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const nm = new NotificationManager(user, note);
|
||||||
|
const nmRelatedPromises = [];
|
||||||
|
|
||||||
|
await createMentionedEvents(mentionedUsers, note, nm);
|
||||||
|
|
||||||
|
// If it is in reply to another note
|
||||||
|
if (note.replyId) {
|
||||||
|
const reply = await Notes.findOneByOrFail({ id: note.replyId });
|
||||||
|
|
||||||
|
// Fetch watchers
|
||||||
|
nmRelatedPromises.push(notifyWatchers(note.replyId, user, nm, 'reply'));
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
if (reply.userHost === null) {
|
||||||
|
const threadMuted = await NoteThreadMutings.countBy({
|
||||||
|
userId: reply.userId,
|
||||||
|
threadId: reply.threadId ?? reply.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!threadMuted) {
|
||||||
|
nm.push(reply.userId, 'reply');
|
||||||
|
|
||||||
|
const packedReply = await Notes.pack(note, { id: reply.userId });
|
||||||
|
publishMainStream(reply.userId, 'reply', packedReply);
|
||||||
|
|
||||||
|
const webhooks = (await getActiveWebhooks()).filter(x => x.userId === reply.userId && x.on.includes('reply'));
|
||||||
|
for (const webhook of webhooks) {
|
||||||
|
webhookDeliver(webhook, 'reply', {
|
||||||
|
note: packedReply,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it is a renote
|
||||||
|
if (note.renoteId) {
|
||||||
|
const type = note.text ? 'quote' : 'renote';
|
||||||
|
const renote = await Notes.findOneByOrFail({ id : note.renoteId });
|
||||||
|
|
||||||
|
// Notify
|
||||||
|
if (renote.userHost === null) {
|
||||||
|
nm.push(renote.userId, type);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch watchers
|
||||||
|
nmRelatedPromises.push(notifyWatchers(note.renoteId, user, nm, type));
|
||||||
|
|
||||||
|
// Publish event
|
||||||
|
if ((user.id !== renote.userId) && renote.userHost === null) {
|
||||||
|
const packedRenote = await Notes.pack(note, { id: renote.userId });
|
||||||
|
publishMainStream(renote.userId, 'renote', packedRenote);
|
||||||
|
|
||||||
|
const webhooks = (await getActiveWebhooks()).filter(x => x.userId === renote.userId && x.on.includes('renote'));
|
||||||
|
for (const webhook of webhooks) {
|
||||||
|
webhookDeliver(webhook, 'renote', {
|
||||||
|
note: packedRenote,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Promise.all(nmRelatedPromises).then(() => {
|
||||||
|
nm.deliver();
|
||||||
|
});
|
||||||
|
|
||||||
|
//#region AP deliver
|
||||||
|
if (Users.isLocalUser(user) && !note.localOnly && created) {
|
||||||
|
(async () => {
|
||||||
|
const noteActivity = renderActivity(await renderNoteOrRenoteActivity(note));
|
||||||
|
const dm = new DeliverManager(user, noteActivity);
|
||||||
|
|
||||||
|
// Delivered to remote users who have been mentioned
|
||||||
|
for (const u of mentionedUsers.filter(u => Users.isRemoteUser(u))) {
|
||||||
|
dm.addDirectRecipe(u as IRemoteUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the post is a reply and the poster is a local user and the poster of the post to which you are replying is a remote user, deliver
|
||||||
|
if (note.replyId) {
|
||||||
|
const subquery = Notes.createaQueryBuilder()
|
||||||
|
.select('userId')
|
||||||
|
.where('"id" = :replyId', { replyId: note.replyId });
|
||||||
|
const u = await Users.createQueryBuilder()
|
||||||
|
.where('"id" IN (' + subquery.getQuery() + ')')
|
||||||
|
.andWhere('"userHost" IS NOT NULL')
|
||||||
|
.getOne();
|
||||||
|
if (u != null) dm.addDirectRecipe(u);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the post is a Renote and the poster is a local user and the poster of the original Renote post is a remote user, deliver
|
||||||
|
if (note.renoteId) {
|
||||||
|
const subquery = Notes.createaQueryBuilder()
|
||||||
|
.select('userId')
|
||||||
|
.where('"id" = :renoteId', { renoteId: note.renoteId });
|
||||||
|
const u = await Users.createQueryBuilder()
|
||||||
|
.where('"id" IN (' + subquery.getQuery() + ')')
|
||||||
|
.andWhere('"userHost" IS NOT NULL')
|
||||||
|
.getOne();
|
||||||
|
if (u != null) dm.addDirectRecipe(u);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deliver to followers
|
||||||
|
if (['public', 'home', 'followers'].includes(note.visibility)) {
|
||||||
|
dm.addFollowersRecipe();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['public'].includes(note.visibility)) {
|
||||||
|
deliverToRelays(user, noteActivity);
|
||||||
|
}
|
||||||
|
|
||||||
|
dm.execute();
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
//#endregion
|
||||||
|
} else if (!created) {
|
||||||
|
// updating a note does not change its un-/read status
|
||||||
|
// updating does not trigger notifications for replied to or renoted notes
|
||||||
|
// updating does not trigger notifications for mentioned users (since mentions cannot be changed)
|
||||||
|
|
||||||
|
// TODO publish to streaming API
|
||||||
|
publishNoteStream(note.id, 'updated', { note });
|
||||||
|
|
||||||
|
const nm = new NotificationManager(user, note);
|
||||||
|
notifyWatchers(note.id, user, nm, 'update');
|
||||||
|
await nm.deliver();
|
||||||
|
|
||||||
|
// TODO AP deliver
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register to search database
|
||||||
|
index(note);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function notifyWatchers(noteId: Note['id'], user: { id: User['id']; }, nm: NotificationManager, type: NotificationType): Promise<void> {
|
||||||
|
const watchers = await NoteWatchings.findBy({
|
||||||
|
noteId,
|
||||||
|
userId: Not(user.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const watcher of watchers) {
|
||||||
|
nm.push(watcher.userId, type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createMentionedEvents(mentionedUsers: User[], note: Note, nm: NotificationManager): Promise<void> {
|
||||||
|
for (const u of mentionedUsers.filter(u => Users.isLocalUser(u))) {
|
||||||
|
const threadMuted = await NoteThreadMutings.countBy({
|
||||||
|
userId: u.id,
|
||||||
|
threadId: note.threadId || note.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (threadMuted) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// note with "specified" visibility might not be visible to mentioned users
|
||||||
|
try {
|
||||||
|
const detailPackedNote = await Notes.pack(note, u, {
|
||||||
|
detail: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
publishMainStream(u.id, 'mention', detailPackedNote);
|
||||||
|
|
||||||
|
const webhooks = (await getActiveWebhooks()).filter(x => x.userId === u.id && x.on.includes('mention'));
|
||||||
|
for (const webhook of webhooks) {
|
||||||
|
webhookDeliver(webhook, 'mention', {
|
||||||
|
note: detailPackedNote,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') continue;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create notification
|
||||||
|
nm.push(u.id, 'mention');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractFromMfm(user: { host: User['host']; }, note: Note): Promise<{ mentionedUsers: User[], tags: string[] }> {
|
||||||
|
const tokens = mfm.parse(note.text ?? '').concat(mfm.parse(note.cw ?? ''));
|
||||||
|
|
||||||
|
const tags = extractHashtags(tokens);
|
||||||
|
|
||||||
|
if (tokens.length === 0) {
|
||||||
|
return {
|
||||||
|
mentionedUsers: [],
|
||||||
|
tags,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const mentions = extractMentions(tokens);
|
||||||
|
|
||||||
|
let mentionedUsers = (await Promise.all(mentions.map(m =>
|
||||||
|
resolveUser(m.username, m.host || user.host).catch(() => null),
|
||||||
|
))).filter(x => x != null) as User[];
|
||||||
|
|
||||||
|
// Drop duplicate users
|
||||||
|
mentionedUsers = mentionedUsers.filter((u, i, self) =>
|
||||||
|
i === self.findIndex(u2 => u.id === u2.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
mentionedUsers,
|
||||||
|
tags,
|
||||||
|
};
|
||||||
|
}
|
|
@ -79,6 +79,12 @@
|
||||||
<MkA class="created-at" :to="notePage(appearNote)">
|
<MkA class="created-at" :to="notePage(appearNote)">
|
||||||
<MkTime :time="appearNote.createdAt" mode="detail"/>
|
<MkTime :time="appearNote.createdAt" mode="detail"/>
|
||||||
</MkA>
|
</MkA>
|
||||||
|
<!-- TODO link to edit history? -->
|
||||||
|
<div v-if="appearNote.updatedAt != null">
|
||||||
|
<i class="fas fa-pencil"></i>
|
||||||
|
{{ i18n.ts.updatedAt }}
|
||||||
|
<MkTime :time="appearNote.updatedAt" mode="detail"/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<XReactionsViewer ref="reactionsViewer" :note="appearNote"/>
|
<XReactionsViewer ref="reactionsViewer" :note="appearNote"/>
|
||||||
<button class="button _button" @click="reply()">
|
<button class="button _button" @click="reply()">
|
||||||
|
|
|
@ -9,6 +9,9 @@
|
||||||
<MkA class="created-at" :to="notePage(note)">
|
<MkA class="created-at" :to="notePage(note)">
|
||||||
<MkTime :time="note.createdAt"/>
|
<MkTime :time="note.createdAt"/>
|
||||||
</MkA>
|
</MkA>
|
||||||
|
<span class="updated-at" v-if="note.updatedAt != null" ref="updatedEl">
|
||||||
|
<i class="fas fa-pencil"></i>
|
||||||
|
</span>
|
||||||
<MkVisibility :note="note"/>
|
<MkVisibility :note="note"/>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
@ -69,6 +72,10 @@ defineProps<{
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
|
|
||||||
|
> .updated-at {
|
||||||
|
margin-left: var(--marginHalf);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
export const notificationTypes = ['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'move', 'app'] as const;
|
export const notificationTypes = ['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'update', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'move', 'app'] as const;
|
||||||
|
|
||||||
export const noteNotificationTypes = ['mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded'] as const;
|
export const noteNotificationTypes = ['mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'update'] as const;
|
||||||
|
|
||||||
export const mutedNoteReasons = ['word', 'manual', 'spam', 'other'] as const;
|
export const mutedNoteReasons = ['word', 'manual', 'spam', 'other'] as const;
|
||||||
|
|
||||||
|
|
|
@ -129,6 +129,7 @@ export type DriveFolder = TODO;
|
||||||
export type Note = {
|
export type Note = {
|
||||||
id: ID;
|
id: ID;
|
||||||
createdAt: DateString;
|
createdAt: DateString;
|
||||||
|
updatedAt: DateString | null;
|
||||||
text: string | null;
|
text: string | null;
|
||||||
cw: string | null;
|
cw: string | null;
|
||||||
user: User;
|
user: User;
|
||||||
|
@ -206,6 +207,11 @@ export type Notification = {
|
||||||
user: User;
|
user: User;
|
||||||
userId: User['id'];
|
userId: User['id'];
|
||||||
note: Note;
|
note: Note;
|
||||||
|
} | {
|
||||||
|
type: 'update';
|
||||||
|
user: User;
|
||||||
|
userId: User['id'];
|
||||||
|
note: Note;
|
||||||
} | {
|
} | {
|
||||||
type: 'follow';
|
type: 'follow';
|
||||||
user: User;
|
user: User;
|
||||||
|
|
|
@ -142,6 +142,12 @@ export type NoteUpdatedEvent = {
|
||||||
choice: number;
|
choice: number;
|
||||||
userId: User['id'];
|
userId: User['id'];
|
||||||
};
|
};
|
||||||
|
} | {
|
||||||
|
id: Note['id'];
|
||||||
|
type: 'updated';
|
||||||
|
body: {
|
||||||
|
note: Note;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BroadcastEvents = {
|
export type BroadcastEvents = {
|
||||||
|
|
Loading…
Reference in a new issue
s/udpatedAt/updatedAt