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,
|
||||||
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);
|
||||||
|
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';
|
||||||
|
} 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 {
|
||||||
|
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,11 +255,43 @@ export class Connection {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async onNoteStreamMessage(data: StreamMessages['note']['payload']) {
|
private async onNoteStreamMessage(data: StreamMessages['note']['payload']) {
|
||||||
this.sendMessageToWs('noteUpdated', {
|
if (data.type === 'updated') {
|
||||||
id: data.body.id,
|
const note = data.body.body.note;
|
||||||
type: data.type,
|
// FIXME analogous to Channel.withPackedNote, but for some reason, the note
|
||||||
body: data.body.body,
|
// 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', {
|
||||||
|
id: data.body.id,
|
||||||
|
type: data.type,
|
||||||
|
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,253 +150,9 @@ 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