diff --git a/CHANGELOG.md b/CHANGELOG.md index 79ae926c9c..3bd675deae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,9 +19,13 @@ - クライアント: 新しいライトテーマを追加 - API: ユーザーのリアクション一覧を取得する users/reactions を追加 - API: users/search および users/search-by-username-and-host を強化 +- ミュート及びブロックのインポートを行えるように +- クライアント: /share のクエリでリプライやファイル等の情報を渡せるように ### Bugfixes - クライアント: テーマの管理が行えない問題を修正 +- API: アプリケーション通知が取得できない問題を修正 +- ActivityPub: not reacted な Undo.Like がinboxに滞留するのを修正 ## 12.92.0 (2021/10/16) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3ec8baaacb..76267ab30e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -171,6 +171,9 @@ const users = userIds.length > 0 ? await Users.find({ SQLでは配列のインデックスは**1始まり**。 `[a, b, c]`の `a`にアクセスしたいなら`[0]`ではなく`[1]`と書く +### null IN +nullが含まれる可能性のあるカラムにINするときは、そのままだとおかしくなるのでORなどでnullのハンドリングをしよう。 + ### `undefined`にご用心 MongoDBの時とは違い、findOneでレコードを取得する時に対象レコードが存在しない場合 **`undefined`** が返ってくるので注意。 MongoDBは`null`で返してきてたので、その感覚で`if (x === null)`とか書くとバグる。代わりに`if (x == null)`と書いてください diff --git a/src/client/components/global/header.vue b/src/client/components/global/header.vue index a4466da498..2bf490c98a 100644 --- a/src/client/components/global/header.vue +++ b/src/client/components/global/header.vue @@ -203,6 +203,12 @@ export default defineComponent({ &.thin { --height: 50px; + + > .buttons { + > .button { + font-size: 0.9em; + } + } } &.slim { diff --git a/src/client/components/mfm.ts b/src/client/components/mfm.ts index 2bdd7d46ee..f3411cadc3 100644 --- a/src/client/components/mfm.ts +++ b/src/client/components/mfm.ts @@ -185,7 +185,7 @@ export default defineComponent({ } } if (style == null) { - return h('span', {}, ['[', token.props.name, ' ', ...genEl(token.children), ']']); + return h('span', {}, ['$[', token.props.name, ' ', ...genEl(token.children), ']']); } else { return h('span', { style: 'display: inline-block;' + style, diff --git a/src/client/pages/settings/import-export.vue b/src/client/pages/settings/import-export.vue index 2b49996dda..eeaa1f1602 100644 --- a/src/client/pages/settings/import-export.vue +++ b/src/client/pages/settings/import-export.vue @@ -16,11 +16,13 @@ - {{ $ts.export }} + {{ $ts.export }} + {{ $ts.import }} {{ $ts.export }} + {{ $ts.import }} @@ -58,11 +60,11 @@ export default defineComponent({ methods: { doExport(target) { os.api( - target == 'notes' ? 'i/export-notes' : - target == 'following' ? 'i/export-following' : - target == 'blocking' ? 'i/export-blocking' : - target == 'user-lists' ? 'i/export-user-lists' : - target == 'mute' ? 'i/export-mute' : + target === 'notes' ? 'i/export-notes' : + target === 'following' ? 'i/export-following' : + target === 'blocking' ? 'i/export-blocking' : + target === 'user-lists' ? 'i/export-user-lists' : + target === 'muting' ? 'i/export-mute' : null, {}) .then(() => { os.dialog({ @@ -81,8 +83,10 @@ export default defineComponent({ const file = await selectFile(e.currentTarget || e.target); os.api( - target == 'following' ? 'i/import-following' : - target == 'user-lists' ? 'i/import-user-lists' : + target === 'following' ? 'i/import-following' : + target === 'user-lists' ? 'i/import-user-lists' : + target === 'muting' ? 'i/import-muting' : + target === 'blocking' ? 'i/import-blocking' : null, { fileId: file.id }).then(() => { diff --git a/src/queue/index.ts b/src/queue/index.ts index 1e1d5da5a2..43c062bae7 100644 --- a/src/queue/index.ts +++ b/src/queue/index.ts @@ -163,6 +163,26 @@ export function createImportFollowingJob(user: ThinUser, fileId: DriveFile['id'] }); } +export function createImportMutingJob(user: ThinUser, fileId: DriveFile['id']) { + return dbQueue.add('importMuting', { + user: user, + fileId: fileId + }, { + removeOnComplete: true, + removeOnFail: true + }); +} + +export function createImportBlockingJob(user: ThinUser, fileId: DriveFile['id']) { + return dbQueue.add('importBlocking', { + user: user, + fileId: fileId + }, { + removeOnComplete: true, + removeOnFail: true + }); +} + export function createImportUserListsJob(user: ThinUser, fileId: DriveFile['id']) { return dbQueue.add('importUserLists', { user: user, diff --git a/src/queue/processors/db/import-blocking.ts b/src/queue/processors/db/import-blocking.ts new file mode 100644 index 0000000000..9951da669d --- /dev/null +++ b/src/queue/processors/db/import-blocking.ts @@ -0,0 +1,74 @@ +import * as Bull from 'bull'; + +import { queueLogger } from '../../logger'; +import { parseAcct } from '@/misc/acct'; +import { resolveUser } from '@/remote/resolve-user'; +import { downloadTextFile } from '@/misc/download-text-file'; +import { isSelfHost, toPuny } from '@/misc/convert-host'; +import { Users, DriveFiles, Blockings } from '@/models/index'; +import { DbUserImportJobData } from '@/queue/types'; +import block from '@/services/blocking/create'; + +const logger = queueLogger.createSubLogger('import-blocking'); + +export async function importBlocking(job: Bull.Job, done: any): Promise { + logger.info(`Importing blocking of ${job.data.user.id} ...`); + + const user = await Users.findOne(job.data.user.id); + if (user == null) { + done(); + return; + } + + const file = await DriveFiles.findOne({ + id: job.data.fileId + }); + if (file == null) { + done(); + return; + } + + const csv = await downloadTextFile(file.url); + + let linenum = 0; + + for (const line of csv.trim().split('\n')) { + linenum++; + + try { + const acct = line.split(',')[0].trim(); + const { username, host } = parseAcct(acct); + + let target = isSelfHost(host!) ? await Users.findOne({ + host: null, + usernameLower: username.toLowerCase() + }) : await Users.findOne({ + host: toPuny(host!), + usernameLower: username.toLowerCase() + }); + + if (host == null && target == null) continue; + + if (target == null) { + target = await resolveUser(username, host); + } + + if (target == null) { + throw `cannot resolve user: @${username}@${host}`; + } + + // skip myself + if (target.id === job.data.user.id) continue; + + logger.info(`Block[${linenum}] ${target.id} ...`); + + await block(user, target); + } catch (e) { + logger.warn(`Error in line:${linenum} ${e}`); + } + } + + logger.succ('Imported'); + done(); +} + diff --git a/src/queue/processors/db/import-muting.ts b/src/queue/processors/db/import-muting.ts new file mode 100644 index 0000000000..798f03a627 --- /dev/null +++ b/src/queue/processors/db/import-muting.ts @@ -0,0 +1,83 @@ +import * as Bull from 'bull'; + +import { queueLogger } from '../../logger'; +import { parseAcct } from '@/misc/acct'; +import { resolveUser } from '@/remote/resolve-user'; +import { downloadTextFile } from '@/misc/download-text-file'; +import { isSelfHost, toPuny } from '@/misc/convert-host'; +import { Users, DriveFiles, Mutings } from '@/models/index'; +import { DbUserImportJobData } from '@/queue/types'; +import { User } from '@/models/entities/user'; +import { genId } from '@/misc/gen-id'; + +const logger = queueLogger.createSubLogger('import-muting'); + +export async function importMuting(job: Bull.Job, done: any): Promise { + logger.info(`Importing muting of ${job.data.user.id} ...`); + + const user = await Users.findOne(job.data.user.id); + if (user == null) { + done(); + return; + } + + const file = await DriveFiles.findOne({ + id: job.data.fileId + }); + if (file == null) { + done(); + return; + } + + const csv = await downloadTextFile(file.url); + + let linenum = 0; + + for (const line of csv.trim().split('\n')) { + linenum++; + + try { + const acct = line.split(',')[0].trim(); + const { username, host } = parseAcct(acct); + + let target = isSelfHost(host!) ? await Users.findOne({ + host: null, + usernameLower: username.toLowerCase() + }) : await Users.findOne({ + host: toPuny(host!), + usernameLower: username.toLowerCase() + }); + + if (host == null && target == null) continue; + + if (target == null) { + target = await resolveUser(username, host); + } + + if (target == null) { + throw `cannot resolve user: @${username}@${host}`; + } + + // skip myself + if (target.id === job.data.user.id) continue; + + logger.info(`Mute[${linenum}] ${target.id} ...`); + + await mute(user, target); + } catch (e) { + logger.warn(`Error in line:${linenum} ${e}`); + } + } + + logger.succ('Imported'); + done(); +} + +async function mute(user: User, target: User) { + await Mutings.insert({ + id: genId(), + createdAt: new Date(), + muterId: user.id, + muteeId: target.id, + }); +} diff --git a/src/queue/processors/db/index.ts b/src/queue/processors/db/index.ts index b051a28e0b..97087642b7 100644 --- a/src/queue/processors/db/index.ts +++ b/src/queue/processors/db/index.ts @@ -9,6 +9,8 @@ import { exportUserLists } from './export-user-lists'; import { importFollowing } from './import-following'; import { importUserLists } from './import-user-lists'; import { deleteAccount } from './delete-account'; +import { importMuting } from './import-muting'; +import { importBlocking } from './import-blocking'; const jobs = { deleteDriveFiles, @@ -18,6 +20,8 @@ const jobs = { exportBlocking, exportUserLists, importFollowing, + importMuting, + importBlocking, importUserLists, deleteAccount, } as Record | Bull.ProcessPromiseFunction>; diff --git a/src/remote/activitypub/kernel/undo/like.ts b/src/remote/activitypub/kernel/undo/like.ts index 7f821cada0..107d3053e3 100644 --- a/src/remote/activitypub/kernel/undo/like.ts +++ b/src/remote/activitypub/kernel/undo/like.ts @@ -12,6 +12,10 @@ export default async (actor: IRemoteUser, activity: ILike) => { const note = await fetchNote(targetUri); if (!note) return `skip: target note not found ${targetUri}`; - await deleteReaction(actor, note); + await deleteReaction(actor, note).catch(e => { + if (e.id === '60527ec9-b4cb-4a88-a6bd-32d3ad26817d') return; + throw e; + }); + return `ok`; }; diff --git a/src/server/api/endpoints/i/import-blocking.ts b/src/server/api/endpoints/i/import-blocking.ts new file mode 100644 index 0000000000..d44d0b6077 --- /dev/null +++ b/src/server/api/endpoints/i/import-blocking.ts @@ -0,0 +1,60 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { createImportBlockingJob } from '@/queue/index'; +import * as ms from 'ms'; +import { ApiError } from '../../error'; +import { DriveFiles } from '@/models/index'; + +export const meta = { + secure: true, + requireCredential: true as const, + + limit: { + duration: ms('1hour'), + max: 1, + }, + + params: { + fileId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: 'ebb53e5f-6574-9c0c-0b92-7ca6def56d7e' + }, + + unexpectedFileType: { + message: 'We need csv file.', + code: 'UNEXPECTED_FILE_TYPE', + id: 'b6fab7d6-d945-d67c-dfdb-32da1cd12cfe' + }, + + tooBigFile: { + message: 'That file is too big.', + code: 'TOO_BIG_FILE', + id: 'b7fbf0b1-aeef-3b21-29ef-fadd4cb72ccf' + }, + + emptyFile: { + message: 'That file is empty.', + code: 'EMPTY_FILE', + id: '6f3a4dcc-f060-a707-4950-806fbdbe60d6' + }, + } +}; + +export default define(meta, async (ps, user) => { + const file = await DriveFiles.findOne(ps.fileId); + + if (file == null) throw new ApiError(meta.errors.noSuchFile); + //if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType); + if (file.size > 50000) throw new ApiError(meta.errors.tooBigFile); + if (file.size === 0) throw new ApiError(meta.errors.emptyFile); + + createImportBlockingJob(user, file.id); +}); diff --git a/src/server/api/endpoints/i/import-muting.ts b/src/server/api/endpoints/i/import-muting.ts new file mode 100644 index 0000000000..c17434c587 --- /dev/null +++ b/src/server/api/endpoints/i/import-muting.ts @@ -0,0 +1,60 @@ +import $ from 'cafy'; +import { ID } from '@/misc/cafy-id'; +import define from '../../define'; +import { createImportMutingJob } from '@/queue/index'; +import * as ms from 'ms'; +import { ApiError } from '../../error'; +import { DriveFiles } from '@/models/index'; + +export const meta = { + secure: true, + requireCredential: true as const, + + limit: { + duration: ms('1hour'), + max: 1, + }, + + params: { + fileId: { + validator: $.type(ID), + } + }, + + errors: { + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: 'e674141e-bd2a-ba85-e616-aefb187c9c2a' + }, + + unexpectedFileType: { + message: 'We need csv file.', + code: 'UNEXPECTED_FILE_TYPE', + id: '568c6e42-c86c-ba09-c004-517f83f9f1a8' + }, + + tooBigFile: { + message: 'That file is too big.', + code: 'TOO_BIG_FILE', + id: '9b4ada6d-d7f7-0472-0713-4f558bd1ec9c' + }, + + emptyFile: { + message: 'That file is empty.', + code: 'EMPTY_FILE', + id: 'd2f12af1-e7b4-feac-86a3-519548f2728e' + }, + } +}; + +export default define(meta, async (ps, user) => { + const file = await DriveFiles.findOne(ps.fileId); + + if (file == null) throw new ApiError(meta.errors.noSuchFile); + //if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType); + if (file.size > 50000) throw new ApiError(meta.errors.tooBigFile); + if (file.size === 0) throw new ApiError(meta.errors.emptyFile); + + createImportMutingJob(user, file.id); +}); diff --git a/src/server/api/endpoints/i/notifications.ts b/src/server/api/endpoints/i/notifications.ts index fcabbbc3dd..56668d03b7 100644 --- a/src/server/api/endpoints/i/notifications.ts +++ b/src/server/api/endpoints/i/notifications.ts @@ -6,6 +6,7 @@ import { makePaginationQuery } from '../../common/make-pagination-query'; import { Notifications, Followings, Mutings, Users } from '@/models/index'; import { notificationTypes } from '@/types'; import read from '@/services/note/read'; +import { Brackets } from 'typeorm'; export const meta = { tags: ['account', 'notifications'], @@ -94,10 +95,16 @@ export default define(meta, async (ps, user) => { .leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('renote.user', 'renoteUser'); - query.andWhere(`notification.notifierId NOT IN (${ mutingQuery.getQuery() })`); + query.andWhere(new Brackets(qb => { qb + .where(`notification.notifierId NOT IN (${ mutingQuery.getQuery() })`) + .orWhere('notification.notifierId IS NULL'); + })); query.setParameters(mutingQuery.getParameters()); - query.andWhere(`notification.notifierId NOT IN (${ suspendedQuery.getQuery() })`); + query.andWhere(new Brackets(qb => { qb + .where(`notification.notifierId NOT IN (${ suspendedQuery.getQuery() })`) + .orWhere('notification.notifierId IS NULL'); + })); if (ps.following) { query.andWhere(`((notification.notifierId IN (${ followingQuery.getQuery() })) OR (notification.notifierId = :meId))`, { meId: user.id }); diff --git a/src/server/api/endpoints/mute/create.ts b/src/server/api/endpoints/mute/create.ts index 5163ed63db..3fc64d3eba 100644 --- a/src/server/api/endpoints/mute/create.ts +++ b/src/server/api/endpoints/mute/create.ts @@ -67,7 +67,7 @@ export default define(meta, async (ps, user) => { } // Create mute - await Mutings.save({ + await Mutings.insert({ id: genId(), createdAt: new Date(), muterId: muter.id, diff --git a/src/server/api/endpoints/users/search-by-username-and-host.ts b/src/server/api/endpoints/users/search-by-username-and-host.ts index 3b8d024af5..1ec5e1a743 100644 --- a/src/server/api/endpoints/users/search-by-username-and-host.ts +++ b/src/server/api/endpoints/users/search-by-username-and-host.ts @@ -1,8 +1,9 @@ import $ from 'cafy'; import define from '../../define'; -import { Users } from '@/models/index'; +import { Followings, Users } from '@/models/index'; import { Brackets } from 'typeorm'; import { USER_ACTIVE_THRESHOLD } from '@/const'; +import { User } from '@/models/entities/user'; export const meta = { tags: ['users'], @@ -18,11 +19,6 @@ export const meta = { validator: $.optional.nullable.str, }, - offset: { - validator: $.optional.num.min(0), - default: 0, - }, - limit: { validator: $.optional.num.range(1, 100), default: 10, @@ -60,34 +56,59 @@ export default define(meta, async (ps, me) => { q.andWhere('user.updatedAt IS NOT NULL'); q.orderBy('user.updatedAt', 'DESC'); - const users = await q.take(ps.limit!).skip(ps.offset).getMany(); + const users = await q.take(ps.limit!).getMany(); return await Users.packMany(users, me, { detail: ps.detail }); } else if (ps.username) { - let users = await Users.createQueryBuilder('user') - .where('user.host IS NULL') - .andWhere('user.isSuspended = FALSE') - .andWhere('user.usernameLower LIKE :username', { username: ps.username.toLowerCase() + '%' }) - .andWhere(new Brackets(qb => { qb - .where('user.updatedAt IS NULL') - .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); - })) - .orderBy('user.updatedAt', 'DESC', 'NULLS LAST') - .take(ps.limit!) - .skip(ps.offset) - .getMany(); + let users: User[] = []; - if (users.length < ps.limit!) { - const otherUsers = await Users.createQueryBuilder('user') - .where('user.host IS NOT NULL') + if (me) { + const followingQuery = Followings.createQueryBuilder('following') + .select('following.followeeId') + .where('following.followerId = :followerId', { followerId: me.id }); + + const query = Users.createQueryBuilder('user') + .where(`user.id IN (${ followingQuery.getQuery() })`) + .andWhere(`user.id != :meId`, { meId: me.id }) .andWhere('user.isSuspended = FALSE') .andWhere('user.usernameLower LIKE :username', { username: ps.username.toLowerCase() + '%' }) + .andWhere(new Brackets(qb => { qb + .where('user.updatedAt IS NULL') + .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); + })); + + query.setParameters(followingQuery.getParameters()); + + users = await query + .orderBy('user.usernameLower', 'ASC') + .take(ps.limit!) + .getMany(); + + if (users.length < ps.limit!) { + const otherQuery = await Users.createQueryBuilder('user') + .where(`user.id NOT IN (${ followingQuery.getQuery() })`) + .andWhere(`user.id != :meId`, { meId: me.id }) + .andWhere('user.isSuspended = FALSE') + .andWhere('user.usernameLower LIKE :username', { username: ps.username.toLowerCase() + '%' }) + .andWhere('user.updatedAt IS NOT NULL'); + + otherQuery.setParameters(followingQuery.getParameters()); + + const otherUsers = await otherQuery + .orderBy('user.updatedAt', 'DESC') + .take(ps.limit! - users.length) + .getMany(); + + users = users.concat(otherUsers); + } + } else { + users = await Users.createQueryBuilder('user') + .where('user.isSuspended = FALSE') + .andWhere('user.usernameLower LIKE :username', { username: ps.username.toLowerCase() + '%' }) .andWhere('user.updatedAt IS NOT NULL') .orderBy('user.updatedAt', 'DESC') .take(ps.limit! - users.length) .getMany(); - - users = users.concat(otherUsers); } return await Users.packMany(users, me, { detail: ps.detail }); diff --git a/src/server/api/stream/channels/antenna.ts b/src/server/api/stream/channels/antenna.ts index 864e42dc66..3cbdfebb43 100644 --- a/src/server/api/stream/channels/antenna.ts +++ b/src/server/api/stream/channels/antenna.ts @@ -20,7 +20,7 @@ export default class extends Channel { } @autobind - private async onEvent(data: StreamMessages['antenna']['spec']) { + private async onEvent(data: StreamMessages['antenna']['payload']) { if (data.type === 'note') { const note = await Notes.pack(data.body.id, this.user, { detail: true }); @@ -32,6 +32,8 @@ export default class extends Channel { this.connection.cacheNote(note); this.send('note', note); + } else { + this.send(data.type, data.body); } } diff --git a/src/server/api/stream/channels/channel.ts b/src/server/api/stream/channels/channel.ts index c97a062c42..bf7942f522 100644 --- a/src/server/api/stream/channels/channel.ts +++ b/src/server/api/stream/channels/channel.ts @@ -53,7 +53,7 @@ export default class extends Channel { } @autobind - private onEvent(data: StreamMessages['channel']['spec']) { + private onEvent(data: StreamMessages['channel']['payload']) { if (data.type === 'typing') { const id = data.body; const begin = this.typers[id] == null; diff --git a/src/server/api/stream/channels/messaging.ts b/src/server/api/stream/channels/messaging.ts index a75181d6d7..c049e880b9 100644 --- a/src/server/api/stream/channels/messaging.ts +++ b/src/server/api/stream/channels/messaging.ts @@ -47,7 +47,7 @@ export default class extends Channel { } @autobind - private onEvent(data: StreamMessages['messaging']['spec'] | StreamMessages['groupMessaging']['spec']) { + private onEvent(data: StreamMessages['messaging']['payload'] | StreamMessages['groupMessaging']['payload']) { if (data.type === 'typing') { const id = data.body; const begin = this.typers[id] == null; diff --git a/src/server/api/stream/index.ts b/src/server/api/stream/index.ts index 93d56c0ac6..da4ea5ec99 100644 --- a/src/server/api/stream/index.ts +++ b/src/server/api/stream/index.ts @@ -63,7 +63,7 @@ export default class Connection { } @autobind - private onUserEvent(data: StreamMessages['user']['spec']) { // { type, body }と展開すると型も展開されてしまう + private onUserEvent(data: StreamMessages['user']['payload']) { // { type, body }と展開するとそれぞれ型が分離してしまう switch (data.type) { case 'follow': this.following.add(data.body.id); @@ -144,7 +144,7 @@ export default class Connection { } @autobind - private onBroadcastMessage(data: StreamMessages['broadcast']['spec']) { + private onBroadcastMessage(data: StreamMessages['broadcast']['payload']) { this.sendMessageToWs(data.type, data.body); } @@ -248,7 +248,7 @@ export default class Connection { } @autobind - private async onNoteStreamMessage(data: StreamMessages['note']['spec']) { + private async onNoteStreamMessage(data: StreamMessages['note']['payload']) { this.sendMessageToWs('noteUpdated', { id: data.body.id, type: data.type, diff --git a/src/server/api/stream/types.ts b/src/server/api/stream/types.ts index c58a627eb3..70eb5c5ce5 100644 --- a/src/server/api/stream/types.ts +++ b/src/server/api/stream/types.ts @@ -222,73 +222,73 @@ type EventUnionFromDictionary< export type StreamMessages = { internal: { name: 'internal'; - spec: EventUnionFromDictionary; + payload: EventUnionFromDictionary; }; broadcast: { name: 'broadcast'; - spec: EventUnionFromDictionary; + payload: EventUnionFromDictionary; }; user: { name: `user:${User['id']}`; - spec: EventUnionFromDictionary; + payload: EventUnionFromDictionary; }; main: { name: `mainStream:${User['id']}`; - spec: EventUnionFromDictionary; + payload: EventUnionFromDictionary; }; drive: { name: `driveStream:${User['id']}`; - spec: EventUnionFromDictionary; + payload: EventUnionFromDictionary; }; note: { name: `noteStream:${Note['id']}`; - spec: EventUnionFromDictionary; + payload: EventUnionFromDictionary; }; channel: { name: `channelStream:${Channel['id']}`; - spec: EventUnionFromDictionary; + payload: EventUnionFromDictionary; }; userList: { name: `userListStream:${UserList['id']}`; - spec: EventUnionFromDictionary; + payload: EventUnionFromDictionary; }; antenna: { name: `antennaStream:${Antenna['id']}`; - spec: EventUnionFromDictionary; + payload: EventUnionFromDictionary; }; messaging: { name: `messagingStream:${User['id']}-${User['id']}`; - spec: EventUnionFromDictionary; + payload: EventUnionFromDictionary; }; groupMessaging: { name: `messagingStream:${UserGroup['id']}`; - spec: EventUnionFromDictionary; + payload: EventUnionFromDictionary; }; messagingIndex: { name: `messagingIndexStream:${User['id']}`; - spec: EventUnionFromDictionary; + payload: EventUnionFromDictionary; }; reversi: { name: `reversiStream:${User['id']}`; - spec: EventUnionFromDictionary; + payload: EventUnionFromDictionary; }; reversiGame: { name: `reversiGameStream:${ReversiGame['id']}`; - spec: EventUnionFromDictionary; + payload: EventUnionFromDictionary; }; admin: { name: `adminStream:${User['id']}`; - spec: EventUnionFromDictionary; + payload: EventUnionFromDictionary; }; notes: { name: 'notesStream'; - spec: Packed<'Note'>; + payload: Packed<'Note'>; }; }; // API event definitions // ストリームごとのEmitterの辞書を用意 -type EventEmitterDictionary = { [x in keyof StreamMessages]: Emitter void }> }; +type EventEmitterDictionary = { [x in keyof StreamMessages]: Emitter void }> }; // 共用体型を交差型にする型 https://stackoverflow.com/questions/54938141/typescript-convert-union-to-intersection type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never; // Emitter辞書から共用体型を作り、UnionToIntersectionで交差型にする