Merge remote-tracking branch 'misskey-dev/develop' into io
This commit is contained in:
@@ -65,11 +65,11 @@
|
||||
"utf-8-validate": "6.0.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.515.0",
|
||||
"@aws-sdk/lib-storage": "3.515.0",
|
||||
"@bull-board/api": "5.14.1",
|
||||
"@bull-board/fastify": "5.14.1",
|
||||
"@bull-board/ui": "5.14.1",
|
||||
"@aws-sdk/client-s3": "3.525.0",
|
||||
"@aws-sdk/lib-storage": "3.525.0",
|
||||
"@bull-board/api": "5.14.2",
|
||||
"@bull-board/fastify": "5.14.2",
|
||||
"@bull-board/ui": "5.14.2",
|
||||
"@discordapp/twemoji": "15.0.2",
|
||||
"@fastify/accepts": "4.3.0",
|
||||
"@fastify/cookie": "9.3.1",
|
||||
@@ -79,15 +79,15 @@
|
||||
"@fastify/multipart": "8.1.0",
|
||||
"@fastify/static": "7.0.1",
|
||||
"@fastify/view": "9.0.0",
|
||||
"@misskey-dev/sharp-read-bmp": "^1.2.0",
|
||||
"@misskey-dev/summaly": "^5.0.3",
|
||||
"@misskey-dev/sharp-read-bmp": "1.2.0",
|
||||
"@misskey-dev/summaly": "5.0.3",
|
||||
"@nestjs/common": "10.3.3",
|
||||
"@nestjs/core": "10.3.3",
|
||||
"@nestjs/testing": "10.3.3",
|
||||
"@peertube/http-signature": "1.7.0",
|
||||
"@simplewebauthn/server": "9.0.3",
|
||||
"@sinonjs/fake-timers": "11.2.2",
|
||||
"@smithy/node-http-handler": "2.3.1",
|
||||
"@smithy/node-http-handler": "2.4.1",
|
||||
"@swc/cli": "0.1.65",
|
||||
"@swc/core": "1.3.107",
|
||||
"@twemoji/parser": "15.0.0",
|
||||
@@ -98,7 +98,7 @@
|
||||
"bcryptjs": "2.4.3",
|
||||
"blurhash": "2.0.5",
|
||||
"body-parser": "1.20.2",
|
||||
"bullmq": "5.3.0",
|
||||
"bullmq": "5.4.1",
|
||||
"cacheable-lookup": "7.0.0",
|
||||
"cbor": "9.0.2",
|
||||
"chalk": "5.3.0",
|
||||
@@ -118,8 +118,8 @@
|
||||
"got": "14.2.0",
|
||||
"happy-dom": "10.0.3",
|
||||
"hpagent": "1.2.0",
|
||||
"htmlescape": "^1.1.1",
|
||||
"http-link-header": "1.1.1",
|
||||
"htmlescape": "1.1.1",
|
||||
"http-link-header": "1.1.2",
|
||||
"ioredis": "5.3.2",
|
||||
"ip-cidr": "3.1.0",
|
||||
"ipaddr.js": "2.1.0",
|
||||
@@ -139,7 +139,7 @@
|
||||
"nanoid": "5.0.6",
|
||||
"nested-property": "4.0.0",
|
||||
"node-fetch": "3.3.2",
|
||||
"nodemailer": "6.9.9",
|
||||
"nodemailer": "6.9.11",
|
||||
"nsfwjs": "3.0.0",
|
||||
"oauth": "0.10.0",
|
||||
"oauth2orize": "1.12.0",
|
||||
@@ -165,7 +165,7 @@
|
||||
"rename": "1.0.4",
|
||||
"rss-parser": "3.13.0",
|
||||
"rxjs": "7.8.1",
|
||||
"sanitize-html": "2.12.0",
|
||||
"sanitize-html": "2.12.1",
|
||||
"secure-json-parse": "2.7.0",
|
||||
"sharp": "0.33.2",
|
||||
"slacc": "0.0.10",
|
||||
@@ -173,7 +173,7 @@
|
||||
"stringz": "2.1.0",
|
||||
"systeminformation": "5.22.0",
|
||||
"tinycolor2": "1.6.0",
|
||||
"tmp": "0.2.1",
|
||||
"tmp": "0.2.3",
|
||||
"tsc-alias": "1.8.8",
|
||||
"tsconfig-paths": "4.2.0",
|
||||
"typeorm": "0.3.20",
|
||||
@@ -206,12 +206,12 @@
|
||||
"@types/jsrsasign": "10.5.12",
|
||||
"@types/mime-types": "2.1.4",
|
||||
"@types/ms": "0.7.34",
|
||||
"@types/node": "20.11.19",
|
||||
"@types/node": "20.11.24",
|
||||
"@types/nodemailer": "6.4.14",
|
||||
"@types/oauth": "0.9.4",
|
||||
"@types/oauth2orize": "1.11.3",
|
||||
"@types/oauth2orize-pkce": "0.1.2",
|
||||
"@types/pg": "8.11.0",
|
||||
"@types/pg": "8.11.2",
|
||||
"@types/pug": "2.0.10",
|
||||
"@types/punycode": "2.1.4",
|
||||
"@types/qrcode": "1.5.5",
|
||||
@@ -219,7 +219,7 @@
|
||||
"@types/ratelimiter": "3.4.6",
|
||||
"@types/rename": "1.0.7",
|
||||
"@types/sanitize-html": "2.11.0",
|
||||
"@types/semver": "7.5.7",
|
||||
"@types/semver": "7.5.8",
|
||||
"@types/simple-oauth2": "5.0.7",
|
||||
"@types/sinonjs__fake-timers": "8.1.5",
|
||||
"@types/tinycolor2": "1.4.6",
|
||||
@@ -227,17 +227,17 @@
|
||||
"@types/vary": "1.1.3",
|
||||
"@types/web-push": "3.6.3",
|
||||
"@types/ws": "8.5.10",
|
||||
"@typescript-eslint/eslint-plugin": "7.0.2",
|
||||
"@typescript-eslint/parser": "7.0.2",
|
||||
"@typescript-eslint/eslint-plugin": "7.1.0",
|
||||
"@typescript-eslint/parser": "7.1.0",
|
||||
"aws-sdk-client-mock": "3.0.1",
|
||||
"cross-env": "7.0.3",
|
||||
"eslint": "8.56.0",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-plugin-import": "2.29.1",
|
||||
"execa": "8.0.1",
|
||||
"fkill": "^9.0.0",
|
||||
"jest": "29.7.0",
|
||||
"jest-mock": "29.7.0",
|
||||
"nodemon": "3.0.3",
|
||||
"nodemon": "3.1.0",
|
||||
"pid-port": "1.0.0",
|
||||
"simple-oauth2": "5.0.0"
|
||||
}
|
||||
|
@@ -69,6 +69,7 @@ export interface MainEventTypes {
|
||||
file: Packed<'DriveFile'>;
|
||||
};
|
||||
readAllNotifications: undefined;
|
||||
notificationFlushed: undefined;
|
||||
unreadNotification: Packed<'Notification'>;
|
||||
unreadMention: MiNote['id'];
|
||||
readAllUnreadMentions: undefined;
|
||||
|
@@ -271,9 +271,18 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||
}
|
||||
}
|
||||
|
||||
if (this.utilityService.isKeyWordIncluded(data.cw ?? data.text ?? '', meta.prohibitedWords)) {
|
||||
const hasProhibitedWords = await this.checkProhibitedWordsContain(
|
||||
meta.prohibitedWords,
|
||||
{
|
||||
cw: data.cw,
|
||||
text: data.text,
|
||||
pollChoices: data.poll?.choices,
|
||||
},
|
||||
);
|
||||
|
||||
if (hasProhibitedWords) {
|
||||
this.logger.error('Request rejected because prohibited words are included', { user: user.id, note: data });
|
||||
throw new IdentifiableError('057d8d3e-b7ca-4f8b-b38c-dcdcbf34dc30', 'Notes including prohibited words are not allowed.');
|
||||
throw new IdentifiableError('689ee33f-f97c-479a-ac49-1b9f8140af99', 'Notes including prohibited words are not allowed.');
|
||||
}
|
||||
|
||||
const inSilencedInstance = this.utilityService.isSilencedHost(meta.silencedHosts, user.host);
|
||||
@@ -412,6 +421,10 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||
}
|
||||
}
|
||||
|
||||
if (mentionedUsers.length > 0 && mentionedUsers.length > policies.mentionLimit) {
|
||||
throw new IdentifiableError('9f466dab-c856-48cd-9e65-ff90ff750580', `Notes including mentions are limited to ${policies.mentionLimit} users.`);
|
||||
}
|
||||
|
||||
const note = await this.insertNote(user, data, tags, emojis, mentionedUsers);
|
||||
|
||||
setImmediate('post created', { signal: this.#shutdownController.signal }).then(
|
||||
@@ -1061,6 +1074,13 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||
}
|
||||
}
|
||||
|
||||
public async checkProhibitedWordsContain(prohibitedWords: string[], content: Parameters<UtilityService['concatNoteContentsForKeyWordCheck']>[0]) {
|
||||
return this.utilityService.isKeyWordIncluded(
|
||||
this.utilityService.concatNoteContentsForKeyWordCheck(content),
|
||||
prohibitedWords,
|
||||
);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public dispose(): void {
|
||||
this.#shutdownController.abort();
|
||||
|
@@ -122,6 +122,14 @@ export class NotificationService implements OnApplicationShutdown {
|
||||
return null;
|
||||
}
|
||||
} else if (recieveConfig?.type === 'mutualFollow') {
|
||||
const [isFollowing, isFollower] = await Promise.all([
|
||||
this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => Object.hasOwn(followings, notifierId)),
|
||||
this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => Object.hasOwn(followings, notifieeId)),
|
||||
]);
|
||||
if (!(isFollowing && isFollower)) {
|
||||
return null;
|
||||
}
|
||||
} else if (recieveConfig?.type === 'followingOrFollower') {
|
||||
const [isFollowing, isFollower] = await Promise.all([
|
||||
this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => Object.hasOwn(followings, notifierId)),
|
||||
this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => Object.hasOwn(followings, notifieeId)),
|
||||
@@ -155,6 +163,8 @@ export class NotificationService implements OnApplicationShutdown {
|
||||
|
||||
const packed = await this.notificationEntityService.pack(notification, notifieeId, {});
|
||||
|
||||
if (packed == null) return null;
|
||||
|
||||
// Publish notification event
|
||||
this.globalEventService.publishMainStream(notifieeId, 'notification', packed);
|
||||
|
||||
@@ -204,6 +214,15 @@ export class NotificationService implements OnApplicationShutdown {
|
||||
*/
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async flushAllNotifications(userId: MiUser['id']) {
|
||||
await Promise.all([
|
||||
this.redisClient.del(`notificationTimeline:${userId}`),
|
||||
this.redisClient.del(`latestReadNotification:${userId}`),
|
||||
]);
|
||||
this.globalEventService.publishMainStream(userId, 'notificationFlushed');
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public dispose(): void {
|
||||
this.#shutdownController.abort();
|
||||
|
@@ -42,6 +42,7 @@ export type RolePolicies = {
|
||||
canDeleteContent: boolean;
|
||||
canUpdateAvatar: boolean;
|
||||
canUpdateBanner: boolean;
|
||||
mentionLimit: number;
|
||||
canInvite: boolean;
|
||||
inviteLimit: number;
|
||||
inviteLimitCycle: number;
|
||||
@@ -76,6 +77,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
|
||||
canDeleteContent: true,
|
||||
canUpdateAvatar: true,
|
||||
canUpdateBanner: true,
|
||||
mentionLimit: 20,
|
||||
canInvite: false,
|
||||
inviteLimit: 0,
|
||||
inviteLimitCycle: 60 * 24 * 7,
|
||||
@@ -215,17 +217,20 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private evalCond(user: MiUser, value: RoleCondFormulaValue): boolean {
|
||||
private evalCond(user: MiUser, roles: MiRole[], value: RoleCondFormulaValue): boolean {
|
||||
try {
|
||||
switch (value.type) {
|
||||
case 'and': {
|
||||
return value.values.every(v => this.evalCond(user, v));
|
||||
return value.values.every(v => this.evalCond(user, roles, v));
|
||||
}
|
||||
case 'or': {
|
||||
return value.values.some(v => this.evalCond(user, v));
|
||||
return value.values.some(v => this.evalCond(user, roles, v));
|
||||
}
|
||||
case 'not': {
|
||||
return !this.evalCond(user, value.value);
|
||||
return !this.evalCond(user, roles, value.value);
|
||||
}
|
||||
case 'roleAssignedTo': {
|
||||
return roles.some(r => r.id === value.roleId);
|
||||
}
|
||||
case 'isLocal': {
|
||||
return this.userEntityService.isLocalUser(user);
|
||||
@@ -287,7 +292,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
||||
const assigns = await this.getUserAssigns(userId);
|
||||
const assignedRoles = roles.filter(r => assigns.map(x => x.roleId).includes(r.id));
|
||||
const user = roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userId) : null;
|
||||
const matchedCondRoles = roles.filter(r => r.target === 'conditional' && this.evalCond(user!, r.condFormula));
|
||||
const matchedCondRoles = roles.filter(r => r.target === 'conditional' && this.evalCond(user!, assignedRoles, r.condFormula));
|
||||
return [...assignedRoles, ...matchedCondRoles];
|
||||
}
|
||||
|
||||
@@ -300,13 +305,13 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
||||
let assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId }));
|
||||
// 期限切れのロールを除外
|
||||
assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now));
|
||||
const assignedRoleIds = assigns.map(x => x.roleId);
|
||||
const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({}));
|
||||
const assignedBadgeRoles = roles.filter(r => r.asBadge && assignedRoleIds.includes(r.id));
|
||||
const assignedRoles = roles.filter(r => assigns.map(x => x.roleId).includes(r.id));
|
||||
const assignedBadgeRoles = assignedRoles.filter(r => r.asBadge);
|
||||
const badgeCondRoles = roles.filter(r => r.asBadge && (r.target === 'conditional'));
|
||||
if (badgeCondRoles.length > 0) {
|
||||
const user = roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userId) : null;
|
||||
const matchedBadgeCondRoles = badgeCondRoles.filter(r => this.evalCond(user!, r.condFormula));
|
||||
const matchedBadgeCondRoles = badgeCondRoles.filter(r => this.evalCond(user!, assignedRoles, r.condFormula));
|
||||
return [...assignedBadgeRoles, ...matchedBadgeCondRoles];
|
||||
} else {
|
||||
return assignedBadgeRoles;
|
||||
@@ -346,6 +351,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
||||
canDeleteContent: calc('canDeleteContent', vs => vs.some(v => v === true)),
|
||||
canUpdateAvatar: calc('canUpdateAvatar', vs => vs.some(v => v === true)),
|
||||
canUpdateBanner: calc('canUpdateBanner', vs => vs.some(v => v === true)),
|
||||
mentionLimit: calc('mentionLimit', vs => Math.max(...vs)),
|
||||
canInvite: calc('canInvite', vs => vs.some(v => v === true)),
|
||||
inviteLimit: calc('inviteLimit', vs => Math.max(...vs)),
|
||||
inviteLimitCycle: calc('inviteLimitCycle', vs => Math.max(...vs)),
|
||||
|
@@ -48,6 +48,20 @@ export class UtilityService {
|
||||
return sensitiveMediaHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public concatNoteContentsForKeyWordCheck(content: {
|
||||
cw?: string | null;
|
||||
text?: string | null;
|
||||
pollChoices?: string[] | null;
|
||||
others?: string[] | null;
|
||||
}): string {
|
||||
/**
|
||||
* ノートの内容を結合してキーワードチェック用の文字列を生成する
|
||||
* cwとtextは内容が繋がっているかもしれないので間に何も入れずにチェックする
|
||||
*/
|
||||
return `${content.cw ?? ''}${content.text ?? ''}\n${(content.pollChoices ?? []).join('\n')}\n${(content.others ?? []).join('\n')}`;
|
||||
}
|
||||
|
||||
private static readonly isFilterRegExpPattern = /^\/(.+)\/(.*)$/;
|
||||
|
||||
@bindThis
|
||||
|
@@ -24,6 +24,8 @@ import { StatusError } from '@/misc/status-error.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { checkHttps } from '@/misc/check-https.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { isNotNull } from '@/misc/is-not-null.js';
|
||||
import { getOneApId, getApId, getOneApHrefNullable, validPost, isEmoji, getApType } from '../type.js';
|
||||
import { ApLoggerService } from '../ApLoggerService.js';
|
||||
import { ApMfmService } from '../ApMfmService.js';
|
||||
@@ -37,7 +39,6 @@ import { ApQuestionService } from './ApQuestionService.js';
|
||||
import { ApImageService } from './ApImageService.js';
|
||||
import type { Resolver } from '../ApResolverService.js';
|
||||
import type { IObject, IPost } from '../type.js';
|
||||
import { isNotNull } from '@/misc/is-not-null.js';
|
||||
|
||||
@Injectable()
|
||||
export class ApNoteService {
|
||||
@@ -121,6 +122,8 @@ export class ApNoteService {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
if (resolver == null) resolver = this.apResolverService.createResolver();
|
||||
|
||||
const meta = await this.metaService.fetch();
|
||||
|
||||
const object = await resolver.resolve(value);
|
||||
|
||||
const entryUri = getApId(value);
|
||||
@@ -156,11 +159,47 @@ export class ApNoteService {
|
||||
throw new Error('invalid note.attributedTo: ' + note.attributedTo);
|
||||
}
|
||||
|
||||
const actor = await this.apPersonService.resolvePerson(getOneApId(note.attributedTo), resolver) as MiRemoteUser;
|
||||
const uri = getOneApId(note.attributedTo);
|
||||
|
||||
// 投稿者が凍結されていたらスキップ
|
||||
// ローカルで投稿者を検索し、もし凍結されていたらスキップ
|
||||
const cachedActor = await this.apPersonService.fetchPerson(uri) as MiRemoteUser;
|
||||
if (cachedActor && cachedActor.isSuspended) {
|
||||
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', `User ${cachedActor.id} has been suspended.`);
|
||||
}
|
||||
|
||||
const apMentions = await this.apMentionService.extractApMentions(note.tag, resolver);
|
||||
const apHashtags = extractApHashtags(note.tag);
|
||||
|
||||
const cw = note.summary === '' ? null : note.summary;
|
||||
|
||||
// テキストのパース
|
||||
let text: string | null = null;
|
||||
if (note.source?.mediaType === 'text/x.misskeymarkdown' && typeof note.source.content === 'string') {
|
||||
text = note.source.content;
|
||||
} else if (typeof note._misskey_content !== 'undefined') {
|
||||
text = note._misskey_content;
|
||||
} else if (typeof note.content === 'string') {
|
||||
text = this.apMfmService.htmlToMfm(note.content, note.tag);
|
||||
}
|
||||
|
||||
const poll = await this.apQuestionService.extractPollFromQuestion(note, resolver).catch(() => undefined);
|
||||
|
||||
//#region Contents Check
|
||||
// 添付ファイルとユーザーをこのサーバーで登録する前に内容をチェックする
|
||||
/**
|
||||
* 禁止ワードチェック
|
||||
*/
|
||||
const hasProhibitedWords = await this.noteCreateService.checkProhibitedWordsContain(meta.prohibitedWords, { cw, text, pollChoices: poll?.choices });
|
||||
if (hasProhibitedWords) {
|
||||
throw new IdentifiableError('689ee33f-f97c-479a-ac49-1b9f8140af99', 'Notes including prohibited words are not allowed.');
|
||||
}
|
||||
//#endregion
|
||||
|
||||
const actor = cachedActor ?? await this.apPersonService.resolvePerson(uri, resolver) as MiRemoteUser;
|
||||
|
||||
// 解決した投稿者が凍結されていたらスキップ
|
||||
if (actor.isSuspended) {
|
||||
throw new Error('actor has been suspended');
|
||||
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', `User ${actor.id} has been suspended.`);
|
||||
}
|
||||
|
||||
const noteAudience = await this.apAudienceService.parseAudience(actor, note.to, note.cc, resolver);
|
||||
@@ -175,10 +214,6 @@ export class ApNoteService {
|
||||
}
|
||||
}
|
||||
|
||||
const apMentions = await this.apMentionService.extractApMentions(note.tag, resolver);
|
||||
const apHashtags = extractApHashtags(note.tag);
|
||||
|
||||
const meta = await this.metaService.fetch();
|
||||
const isSensitiveMediaHost = this.utilityService.isSensitiveMediaHost(meta.sensitiveMediaHosts, this.utilityService.extractDbHost(note.id ?? entryUri));
|
||||
|
||||
// 添付ファイル
|
||||
@@ -240,18 +275,6 @@ export class ApNoteService {
|
||||
}
|
||||
}
|
||||
|
||||
const cw = note.summary === '' ? null : note.summary;
|
||||
|
||||
// テキストのパース
|
||||
let text: string | null = null;
|
||||
if (note.source?.mediaType === 'text/x.misskeymarkdown' && typeof note.source.content === 'string') {
|
||||
text = note.source.content;
|
||||
} else if (typeof note._misskey_content !== 'undefined') {
|
||||
text = note._misskey_content;
|
||||
} else if (typeof note.content === 'string') {
|
||||
text = this.apMfmService.htmlToMfm(note.content, note.tag);
|
||||
}
|
||||
|
||||
// vote
|
||||
if (reply && reply.hasPoll) {
|
||||
const poll = await this.pollsRepository.findOneByOrFail({ noteId: reply.id });
|
||||
@@ -281,8 +304,6 @@ export class ApNoteService {
|
||||
|
||||
const apEmojis = emojis.map(emoji => emoji.name);
|
||||
|
||||
const poll = await this.apQuestionService.extractPollFromQuestion(note, resolver).catch(() => undefined);
|
||||
|
||||
try {
|
||||
return await this.noteCreateService.create(actor, {
|
||||
createdAt: note.published ? new Date(note.published) : null,
|
||||
|
@@ -14,14 +14,14 @@ import type { MiNote } from '@/models/Note.js';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { isNotNull } from '@/misc/is-not-null.js';
|
||||
import { FilterUnionByProperty, notificationTypes } from '@/types.js';
|
||||
import { FilterUnionByProperty, groupedNotificationTypes } from '@/types.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { RoleEntityService } from './RoleEntityService.js';
|
||||
import type { OnModuleInit } from '@nestjs/common';
|
||||
import type { UserEntityService } from './UserEntityService.js';
|
||||
import type { NoteEntityService } from './NoteEntityService.js';
|
||||
|
||||
const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollEnded'] as (typeof notificationTypes[number])[]);
|
||||
const NOTE_REQUIRED_GROUPED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'renote:grouped', 'quote', 'reaction', 'reaction:grouped', 'pollEnded']);
|
||||
const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'renote:grouped', 'quote', 'reaction', 'reaction:grouped', 'pollEnded'] as (typeof groupedNotificationTypes[number])[]);
|
||||
|
||||
@Injectable()
|
||||
export class NotificationEntityService implements OnModuleInit {
|
||||
@@ -41,6 +41,8 @@ export class NotificationEntityService implements OnModuleInit {
|
||||
@Inject(DI.followRequestsRepository)
|
||||
private followRequestsRepository: FollowRequestsRepository,
|
||||
|
||||
private cacheService: CacheService,
|
||||
|
||||
//private userEntityService: UserEntityService,
|
||||
//private noteEntityService: NoteEntityService,
|
||||
) {
|
||||
@@ -52,129 +54,48 @@ export class NotificationEntityService implements OnModuleInit {
|
||||
this.roleEntityService = this.moduleRef.get('RoleEntityService');
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async pack(
|
||||
src: MiNotification,
|
||||
/**
|
||||
* 通知をパックする共通処理
|
||||
*/
|
||||
async #packInternal <T extends MiNotification | MiGroupedNotification> (
|
||||
src: T,
|
||||
meId: MiUser['id'],
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
options: {
|
||||
|
||||
checkValidNotifier?: boolean;
|
||||
},
|
||||
hint?: {
|
||||
packedNotes: Map<MiNote['id'], Packed<'Note'>>;
|
||||
packedUsers: Map<MiUser['id'], Packed<'UserLite'>>;
|
||||
},
|
||||
): Promise<Packed<'Notification'>> {
|
||||
): Promise<Packed<'Notification'> | null> {
|
||||
const notification = src;
|
||||
const noteIfNeed = NOTE_REQUIRED_NOTIFICATION_TYPES.has(notification.type) && 'noteId' in notification ? (
|
||||
|
||||
if (options.checkValidNotifier !== false && !(await this.#isValidNotifier(notification, meId))) return null;
|
||||
|
||||
const needsNote = NOTE_REQUIRED_NOTIFICATION_TYPES.has(notification.type) && 'noteId' in notification;
|
||||
const noteIfNeed = needsNote ? (
|
||||
hint?.packedNotes != null
|
||||
? hint.packedNotes.get(notification.noteId)
|
||||
: this.noteEntityService.pack(notification.noteId, { id: meId }, {
|
||||
detail: true,
|
||||
})
|
||||
) : undefined;
|
||||
const userIfNeed = 'notifierId' in notification ? (
|
||||
hint?.packedUsers != null
|
||||
? hint.packedUsers.get(notification.notifierId)
|
||||
: this.userEntityService.pack(notification.notifierId, { id: meId })
|
||||
) : undefined;
|
||||
const role = notification.type === 'roleAssigned' ? await this.roleEntityService.pack(notification.roleId, { id: meId }) : undefined;
|
||||
|
||||
return await awaitAll({
|
||||
id: notification.id,
|
||||
createdAt: new Date(notification.createdAt).toISOString(),
|
||||
type: notification.type,
|
||||
userId: 'notifierId' in notification ? notification.notifierId : undefined,
|
||||
...(userIfNeed != null ? { user: userIfNeed } : {}),
|
||||
...(noteIfNeed != null ? { note: noteIfNeed } : {}),
|
||||
...(notification.type === 'reaction' ? {
|
||||
reaction: notification.reaction,
|
||||
} : {}),
|
||||
...(notification.type === 'roleAssigned' ? {
|
||||
role: role,
|
||||
} : {}),
|
||||
...(notification.type === 'achievementEarned' ? {
|
||||
achievement: notification.achievement,
|
||||
} : {}),
|
||||
...(notification.type === 'app' ? {
|
||||
body: notification.customBody,
|
||||
header: notification.customHeader,
|
||||
icon: notification.customIcon,
|
||||
} : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async packMany(
|
||||
notifications: MiNotification[],
|
||||
meId: MiUser['id'],
|
||||
): Promise<Packed<'Notification'>[]> {
|
||||
if (notifications.length === 0) return [];
|
||||
|
||||
let validNotifications = notifications;
|
||||
|
||||
const noteIds = validNotifications.map(x => 'noteId' in x ? x.noteId : null).filter(isNotNull);
|
||||
const notes = noteIds.length > 0 ? await this.notesRepository.find({
|
||||
where: { id: In(noteIds) },
|
||||
relations: ['user', 'reply', 'reply.user', 'renote', 'renote.user'],
|
||||
}) : [];
|
||||
const packedNotesArray = await this.noteEntityService.packMany(notes, { id: meId }, {
|
||||
detail: true,
|
||||
});
|
||||
const packedNotes = new Map(packedNotesArray.map(p => [p.id, p]));
|
||||
|
||||
validNotifications = validNotifications.filter(x => !('noteId' in x) || packedNotes.has(x.noteId));
|
||||
|
||||
const userIds = validNotifications.map(x => 'notifierId' in x ? x.notifierId : null).filter(isNotNull);
|
||||
const users = userIds.length > 0 ? await this.usersRepository.find({
|
||||
where: { id: In(userIds) },
|
||||
}) : [];
|
||||
const packedUsersArray = await this.userEntityService.packMany(users, { id: meId });
|
||||
const packedUsers = new Map(packedUsersArray.map(p => [p.id, p]));
|
||||
|
||||
// 既に解決されたフォローリクエストの通知を除外
|
||||
const followRequestNotifications = validNotifications.filter((x): x is FilterUnionByProperty<MiGroupedNotification, 'type', 'receiveFollowRequest'> => x.type === 'receiveFollowRequest');
|
||||
if (followRequestNotifications.length > 0) {
|
||||
const reqs = await this.followRequestsRepository.find({
|
||||
where: { followerId: In(followRequestNotifications.map(x => x.notifierId)) },
|
||||
});
|
||||
validNotifications = validNotifications.filter(x => (x.type !== 'receiveFollowRequest') || reqs.some(r => r.followerId === x.notifierId));
|
||||
}
|
||||
|
||||
return (await Promise.allSettled(validNotifications.map(x => this.pack(x, meId, {}, { packedNotes, packedUsers }))))
|
||||
.filter(result => result.status === 'fulfilled')
|
||||
.map(result => (result as PromiseFulfilledResult<Packed<'Notification'>>).value);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async packGrouped(
|
||||
src: MiGroupedNotification,
|
||||
meId: MiUser['id'],
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
options: {
|
||||
|
||||
},
|
||||
hint?: {
|
||||
packedNotes: Map<MiNote['id'], Packed<'Note'>>;
|
||||
packedUsers: Map<MiUser['id'], Packed<'UserLite'>>;
|
||||
},
|
||||
): Promise<Packed<'Notification'>> {
|
||||
const notification = src;
|
||||
const noteIfNeed = NOTE_REQUIRED_GROUPED_NOTIFICATION_TYPES.has(notification.type) && 'noteId' in notification ? (
|
||||
hint?.packedNotes != null
|
||||
? hint.packedNotes.get(notification.noteId)
|
||||
: this.noteEntityService.pack(notification.noteId, { id: meId }, {
|
||||
detail: true,
|
||||
})
|
||||
) : undefined;
|
||||
const userIfNeed = 'notifierId' in notification ? (
|
||||
// if the note has been deleted, don't show this notification
|
||||
if (needsNote && !noteIfNeed) return null;
|
||||
|
||||
const needsUser = 'notifierId' in notification;
|
||||
const userIfNeed = needsUser ? (
|
||||
hint?.packedUsers != null
|
||||
? hint.packedUsers.get(notification.notifierId)
|
||||
: this.userEntityService.pack(notification.notifierId, { id: meId })
|
||||
) : undefined;
|
||||
// if the user has been deleted, don't show this notification
|
||||
if (needsUser && !userIfNeed) return null;
|
||||
|
||||
// #region Grouped notifications
|
||||
if (notification.type === 'reaction:grouped') {
|
||||
const reactions = await Promise.allSettled(notification.reactions.map(async reaction => {
|
||||
const reactions = (await Promise.allSettled(notification.reactions.map(async reaction => {
|
||||
const user = hint?.packedUsers != null
|
||||
? hint.packedUsers.get(reaction.userId)!
|
||||
: await this.userEntityService.pack(reaction.userId, { id: meId });
|
||||
@@ -182,35 +103,53 @@ export class NotificationEntityService implements OnModuleInit {
|
||||
user,
|
||||
reaction: reaction.reaction,
|
||||
};
|
||||
}));
|
||||
})))
|
||||
.filter(result => result.status === 'fulfilled' && isNotNull(result.value.user))
|
||||
.map(result => (result as PromiseFulfilledResult<{ user: Packed<'User'>; reaction: string; }>).value);
|
||||
// if all users have been deleted, don't show this notification
|
||||
if (reactions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await awaitAll({
|
||||
id: notification.id,
|
||||
createdAt: new Date(notification.createdAt).toISOString(),
|
||||
type: notification.type,
|
||||
note: noteIfNeed,
|
||||
reactions: reactions.filter(result => result.status === 'fulfilled')
|
||||
.map(result => (result as PromiseFulfilledResult<{ user: Packed<'User'>; reaction: string; }>).value),
|
||||
reactions,
|
||||
});
|
||||
} else if (notification.type === 'renote:grouped') {
|
||||
const users = await Promise.allSettled(notification.userIds.map(userId => {
|
||||
const users = (await Promise.allSettled(notification.userIds.map(userId => {
|
||||
const packedUser = hint?.packedUsers != null ? hint.packedUsers.get(userId) : null;
|
||||
if (packedUser) {
|
||||
return packedUser;
|
||||
}
|
||||
|
||||
return this.userEntityService.pack(userId, { id: meId });
|
||||
}));
|
||||
})))
|
||||
.filter(result => result.status === 'fulfilled' && isNotNull(result.value))
|
||||
.map(result => (result as PromiseFulfilledResult<Packed<'User'>>).value);
|
||||
// if all users have been deleted, don't show this notification
|
||||
if (users.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await awaitAll({
|
||||
id: notification.id,
|
||||
createdAt: new Date(notification.createdAt).toISOString(),
|
||||
type: notification.type,
|
||||
note: noteIfNeed,
|
||||
users: users.filter(result => result.status === 'fulfilled')
|
||||
.map(result => (result as PromiseFulfilledResult<Packed<'User'>>).value),
|
||||
users,
|
||||
});
|
||||
}
|
||||
// #endregion
|
||||
|
||||
const role = notification.type === 'roleAssigned' ? await this.roleEntityService.pack(notification.roleId, { id: meId }) : undefined;
|
||||
const needsRole = notification.type === 'roleAssigned';
|
||||
const role = needsRole ? await this.roleEntityService.pack(notification.roleId, { id: meId }) : undefined;
|
||||
// if the role has been deleted, don't show this notification
|
||||
if (needsRole && !role) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await awaitAll({
|
||||
id: notification.id,
|
||||
@@ -236,15 +175,16 @@ export class NotificationEntityService implements OnModuleInit {
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async packGroupedMany(
|
||||
notifications: MiGroupedNotification[],
|
||||
async #packManyInternal <T extends MiNotification | MiGroupedNotification> (
|
||||
notifications: T[],
|
||||
meId: MiUser['id'],
|
||||
) {
|
||||
): Promise<T[]> {
|
||||
if (notifications.length === 0) return [];
|
||||
|
||||
let validNotifications = notifications;
|
||||
|
||||
validNotifications = await this.#filterValidNotifier(validNotifications, meId);
|
||||
|
||||
const noteIds = validNotifications.map(x => 'noteId' in x ? x.noteId : null).filter(isNotNull);
|
||||
const notes = noteIds.length > 0 ? await this.notesRepository.find({
|
||||
where: { id: In(noteIds) },
|
||||
@@ -270,7 +210,7 @@ export class NotificationEntityService implements OnModuleInit {
|
||||
const packedUsers = new Map(packedUsersArray.map(p => [p.id, p]));
|
||||
|
||||
// 既に解決されたフォローリクエストの通知を除外
|
||||
const followRequestNotifications = validNotifications.filter((x): x is FilterUnionByProperty<MiGroupedNotification, 'type', 'receiveFollowRequest'> => x.type === 'receiveFollowRequest');
|
||||
const followRequestNotifications = validNotifications.filter((x): x is FilterUnionByProperty<T, 'type', 'receiveFollowRequest'> => x.type === 'receiveFollowRequest');
|
||||
if (followRequestNotifications.length > 0) {
|
||||
const reqs = await this.followRequestsRepository.find({
|
||||
where: { followerId: In(followRequestNotifications.map(x => x.notifierId)) },
|
||||
@@ -278,11 +218,107 @@ export class NotificationEntityService implements OnModuleInit {
|
||||
validNotifications = validNotifications.filter(x => (x.type !== 'receiveFollowRequest') || reqs.some(r => r.followerId === x.notifierId));
|
||||
}
|
||||
|
||||
return (await Promise.allSettled(validNotifications.map(x => this.packGrouped(x, meId, {}, {
|
||||
packedNotes,
|
||||
packedUsers,
|
||||
}))))
|
||||
.filter(result => result.status === 'fulfilled')
|
||||
.map(result => (result as PromiseFulfilledResult<Packed<'Notification'>>).value);
|
||||
const packPromises = validNotifications.map(x => {
|
||||
return this.pack(
|
||||
x,
|
||||
meId,
|
||||
{ checkValidNotifier: false },
|
||||
{ packedNotes, packedUsers },
|
||||
);
|
||||
});
|
||||
|
||||
return (await Promise.allSettled(packPromises))
|
||||
.filter(result => result.status === 'fulfilled' && isNotNull(result.value))
|
||||
.map(result => (result as PromiseFulfilledResult<T>).value);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async pack(
|
||||
src: MiNotification | MiGroupedNotification,
|
||||
meId: MiUser['id'],
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
options: {
|
||||
checkValidNotifier?: boolean;
|
||||
},
|
||||
hint?: {
|
||||
packedNotes: Map<MiNote['id'], Packed<'Note'>>;
|
||||
packedUsers: Map<MiUser['id'], Packed<'UserLite'>>;
|
||||
},
|
||||
): Promise<Packed<'Notification'> | null> {
|
||||
return await this.#packInternal(src, meId, options, hint);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async packMany(
|
||||
notifications: MiNotification[],
|
||||
meId: MiUser['id'],
|
||||
): Promise<MiNotification[]> {
|
||||
return await this.#packManyInternal(notifications, meId);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async packGroupedMany(
|
||||
notifications: MiGroupedNotification[],
|
||||
meId: MiUser['id'],
|
||||
): Promise<MiGroupedNotification[]> {
|
||||
return await this.#packManyInternal(notifications, meId);
|
||||
}
|
||||
|
||||
/**
|
||||
* notifierが存在するか、ミュートされていないか、サスペンドされていないかを確認するvalidator
|
||||
*/
|
||||
#validateNotifier <T extends MiNotification | MiGroupedNotification> (
|
||||
notification: T,
|
||||
userIdsWhoMeMuting: Set<MiUser['id']>,
|
||||
userMutedInstances: Set<string>,
|
||||
notifiers: MiUser[],
|
||||
): boolean {
|
||||
if (!('notifierId' in notification)) return true;
|
||||
if (userIdsWhoMeMuting.has(notification.notifierId)) return false;
|
||||
|
||||
const notifier = notifiers.find(x => x.id === notification.notifierId) ?? null;
|
||||
|
||||
if (notifier == null) return false;
|
||||
if (notifier.isSuspended) return false;
|
||||
if (notifier.host && userMutedInstances.has(notifier.host)) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* notifierが存在するか、ミュートされていないか、サスペンドされていないかを実際に確認する
|
||||
*/
|
||||
async #isValidNotifier(
|
||||
notification: MiNotification | MiGroupedNotification,
|
||||
meId: MiUser['id'],
|
||||
): Promise<boolean> {
|
||||
return (await this.#filterValidNotifier([notification], meId)).length === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* notifierが存在するか、ミュートされていないか、サスペンドされていないかを実際に複数確認する
|
||||
*/
|
||||
async #filterValidNotifier <T extends MiNotification | MiGroupedNotification> (
|
||||
notifications: T[],
|
||||
meId: MiUser['id'],
|
||||
): Promise<T[]> {
|
||||
const [
|
||||
userIdsWhoMeMuting,
|
||||
userMutedInstances,
|
||||
] = (await Promise.allSettled([
|
||||
this.cacheService.userMutingsCache.fetch(meId),
|
||||
this.cacheService.userProfileCache.fetch(meId).then(p => new Set(p.mutedInstances)),
|
||||
])).map(result => result.status === 'fulfilled' ? result.value : new Set<string>());
|
||||
|
||||
const notifierIds = notifications.map(notification => 'notifierId' in notification ? notification.notifierId : null).filter(isNotNull);
|
||||
const notifiers = notifierIds.length > 0 ? await this.usersRepository.find({
|
||||
where: { id: In(notifierIds) },
|
||||
}) : [];
|
||||
|
||||
return ((await Promise.allSettled(notifications.map(async (notification) => {
|
||||
const isValid = this.#validateNotifier(notification, userIdsWhoMeMuting, userMutedInstances, notifiers);
|
||||
return isValid ? notification : null;
|
||||
}))).filter(result => result.status === 'fulfilled' && isNotNull(result.value))
|
||||
.map(result => (result as PromiseFulfilledResult<T>).value));
|
||||
}
|
||||
}
|
||||
|
@@ -317,7 +317,7 @@ export class UserEntityService implements OnModuleInit {
|
||||
const meId = me ? me.id : null;
|
||||
const isMe = meId === user.id;
|
||||
const iAmModerator = me ? await this.roleService.isModerator(me as MiUser) : false;
|
||||
if (user.isSuspended && !iAmModerator) throw new IdentifiableError('8ca4f428-b32e-4f83-ac43-406ed7cd0452', 'This user is suspended.');
|
||||
if (user.isSuspended && !iAmModerator) throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', `User ${user.id} has been suspended.`);
|
||||
|
||||
const relation = meId && !isMe && isDetailed ? await this.getRelation(meId, user.id) : null;
|
||||
const pins = isDetailed ? await this.userNotePiningsRepository.createQueryBuilder('pin')
|
||||
|
36
packages/backend/src/misc/FileWriterStream.ts
Normal file
36
packages/backend/src/misc/FileWriterStream.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs/promises';
|
||||
import type { PathLike } from 'node:fs';
|
||||
|
||||
/**
|
||||
* `fs.createWriteStream()`相当のことを行う`WritableStream` (Web標準)
|
||||
*/
|
||||
export class FileWriterStream extends WritableStream<Uint8Array> {
|
||||
constructor(path: PathLike) {
|
||||
let file: fs.FileHandle | null = null;
|
||||
|
||||
super({
|
||||
start: async () => {
|
||||
file = await fs.open(path, 'a');
|
||||
},
|
||||
write: async (chunk, controller) => {
|
||||
if (file === null) {
|
||||
controller.error();
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
await file.write(chunk);
|
||||
},
|
||||
close: async () => {
|
||||
await file?.close();
|
||||
},
|
||||
abort: async () => {
|
||||
await file?.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
35
packages/backend/src/misc/JsonArrayStream.ts
Normal file
35
packages/backend/src/misc/JsonArrayStream.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { TransformStream } from 'node:stream/web';
|
||||
|
||||
/**
|
||||
* ストリームに流れてきた各データについて`JSON.stringify()`した上で、それらを一つの配列にまとめる
|
||||
*/
|
||||
export class JsonArrayStream extends TransformStream<unknown, string> {
|
||||
constructor() {
|
||||
/** 最初の要素かどうかを変数に記録 */
|
||||
let isFirst = true;
|
||||
|
||||
super({
|
||||
start(controller) {
|
||||
controller.enqueue('[');
|
||||
},
|
||||
flush(controller) {
|
||||
controller.enqueue(']');
|
||||
},
|
||||
transform(chunk, controller) {
|
||||
if (isFirst) {
|
||||
isFirst = false;
|
||||
} else {
|
||||
// 妥当なJSON配列にするためには最初以外の要素の前に`,`を挿入しなければならない
|
||||
controller.enqueue(',\n');
|
||||
}
|
||||
|
||||
controller.enqueue(JSON.stringify(chunk));
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
@@ -46,6 +46,7 @@ import {
|
||||
packedRoleCondFormulaLogicsSchema,
|
||||
packedRoleCondFormulaValueNot,
|
||||
packedRoleCondFormulaValueIsLocalOrRemoteSchema,
|
||||
packedRoleCondFormulaValueAssignedRoleSchema,
|
||||
packedRoleCondFormulaValueCreatedSchema,
|
||||
packedRoleCondFormulaFollowersOrFollowingOrNotesSchema,
|
||||
packedRoleCondFormulaValueSchema,
|
||||
@@ -104,6 +105,7 @@ export const refs = {
|
||||
RoleCondFormulaLogics: packedRoleCondFormulaLogicsSchema,
|
||||
RoleCondFormulaValueNot: packedRoleCondFormulaValueNot,
|
||||
RoleCondFormulaValueIsLocalOrRemote: packedRoleCondFormulaValueIsLocalOrRemoteSchema,
|
||||
RoleCondFormulaValueAssignedRole: packedRoleCondFormulaValueAssignedRoleSchema,
|
||||
RoleCondFormulaValueCreated: packedRoleCondFormulaValueCreatedSchema,
|
||||
RoleCondFormulaFollowersOrFollowingOrNotes: packedRoleCondFormulaFollowersOrFollowingOrNotesSchema,
|
||||
RoleCondFormulaValue: packedRoleCondFormulaValueSchema,
|
||||
|
@@ -29,6 +29,11 @@ type CondFormulaValueIsRemote = {
|
||||
type: 'isRemote';
|
||||
};
|
||||
|
||||
type CondFormulaValueRoleAssignedTo = {
|
||||
type: 'roleAssignedTo';
|
||||
roleId: string;
|
||||
};
|
||||
|
||||
type CondFormulaValueCreatedLessThan = {
|
||||
type: 'createdLessThan';
|
||||
sec: number;
|
||||
@@ -75,6 +80,7 @@ export type RoleCondFormulaValue = { id: string } & (
|
||||
CondFormulaValueNot |
|
||||
CondFormulaValueIsLocal |
|
||||
CondFormulaValueIsRemote |
|
||||
CondFormulaValueRoleAssignedTo |
|
||||
CondFormulaValueCreatedLessThan |
|
||||
CondFormulaValueCreatedMoreThan |
|
||||
CondFormulaValueFollowersLessThanOrEq |
|
||||
|
@@ -244,6 +244,8 @@ export class MiUserProfile {
|
||||
type: 'follower';
|
||||
} | {
|
||||
type: 'mutualFollow';
|
||||
} | {
|
||||
type: 'followingOrFollower';
|
||||
} | {
|
||||
type: 'list';
|
||||
userListId: MiUserList['id'];
|
||||
|
@@ -57,6 +57,26 @@ export const packedRoleCondFormulaValueIsLocalOrRemoteSchema = {
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const packedRoleCondFormulaValueAssignedRoleSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string', optional: false,
|
||||
},
|
||||
type: {
|
||||
type: 'string',
|
||||
nullable: false, optional: false,
|
||||
enum: ['roleAssignedTo'],
|
||||
},
|
||||
roleId: {
|
||||
type: 'string',
|
||||
nullable: false, optional: false,
|
||||
format: 'id',
|
||||
example: 'xxxxxxxxxx',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const packedRoleCondFormulaValueCreatedSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -115,6 +135,9 @@ export const packedRoleCondFormulaValueSchema = {
|
||||
{
|
||||
ref: 'RoleCondFormulaValueIsLocalOrRemote',
|
||||
},
|
||||
{
|
||||
ref: 'RoleCondFormulaValueAssignedRole',
|
||||
},
|
||||
{
|
||||
ref: 'RoleCondFormulaValueCreated',
|
||||
},
|
||||
@@ -164,6 +187,10 @@ export const packedRolePoliciesSchema = {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
mentionLimit: {
|
||||
type: 'integer',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
canInvite: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
|
@@ -13,7 +13,7 @@ export const notificationRecieveConfig = {
|
||||
type: {
|
||||
type: 'string',
|
||||
nullable: false,
|
||||
enum: ['all', 'following', 'follower', 'mutualFollow', 'never'],
|
||||
enum: ['all', 'following', 'follower', 'mutualFollow', 'followingOrFollower', 'never'],
|
||||
},
|
||||
},
|
||||
required: ['type'],
|
||||
|
@@ -3,7 +3,7 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import { ReadableStream, TextEncoderStream } from 'node:stream/web';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { MoreThan } from 'typeorm';
|
||||
import { format as dateFormat } from 'date-fns';
|
||||
@@ -12,16 +12,89 @@ import type { NotesRepository, PollsRepository, UsersRepository } from '@/models
|
||||
import type Logger from '@/logger.js';
|
||||
import { DriveService } from '@/core/DriveService.js';
|
||||
import { createTemp } from '@/misc/create-temp.js';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
import type { MiPoll } from '@/models/Poll.js';
|
||||
import type { MiNote } from '@/models/Note.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
|
||||
import { Packed } from '@/misc/json-schema.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { JsonArrayStream } from '@/misc/JsonArrayStream.js';
|
||||
import { FileWriterStream } from '@/misc/FileWriterStream.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import type * as Bull from 'bullmq';
|
||||
import type { DbJobDataWithUser } from '../types.js';
|
||||
|
||||
class NoteStream extends ReadableStream<Record<string, unknown>> {
|
||||
constructor(
|
||||
job: Bull.Job,
|
||||
notesRepository: NotesRepository,
|
||||
pollsRepository: PollsRepository,
|
||||
driveFileEntityService: DriveFileEntityService,
|
||||
idService: IdService,
|
||||
user: MiUser,
|
||||
) {
|
||||
let exportedNotesCount = 0;
|
||||
let cursor: MiNote['id'] | null = null;
|
||||
|
||||
const serialize = (
|
||||
note: MiNote,
|
||||
poll: MiPoll | null,
|
||||
files: Packed<'DriveFile'>[],
|
||||
): Record<string, unknown> => {
|
||||
return {
|
||||
id: note.id,
|
||||
text: note.text,
|
||||
createdAt: idService.parse(note.id).date.toISOString(),
|
||||
fileIds: note.fileIds,
|
||||
files: files,
|
||||
replyId: note.replyId,
|
||||
renoteId: note.renoteId,
|
||||
poll: poll,
|
||||
cw: note.cw,
|
||||
visibility: note.visibility,
|
||||
visibleUserIds: note.visibleUserIds,
|
||||
localOnly: note.localOnly,
|
||||
reactionAcceptance: note.reactionAcceptance,
|
||||
};
|
||||
};
|
||||
|
||||
super({
|
||||
async pull(controller): Promise<void> {
|
||||
const notes = await notesRepository.find({
|
||||
where: {
|
||||
userId: user.id,
|
||||
...(cursor !== null ? { id: MoreThan(cursor) } : {}),
|
||||
},
|
||||
take: 100, // 100件ずつ取得
|
||||
order: { id: 1 },
|
||||
});
|
||||
|
||||
if (notes.length === 0) {
|
||||
job.updateProgress(100);
|
||||
controller.close();
|
||||
}
|
||||
|
||||
cursor = notes.at(-1)?.id ?? null;
|
||||
|
||||
for (const note of notes) {
|
||||
const poll = note.hasPoll
|
||||
? await pollsRepository.findOneByOrFail({ noteId: note.id }) // N+1
|
||||
: null;
|
||||
const files = await driveFileEntityService.packManyByIds(note.fileIds, user); // N+1
|
||||
const content = serialize(note, poll, files);
|
||||
|
||||
controller.enqueue(content);
|
||||
exportedNotesCount++;
|
||||
}
|
||||
|
||||
const total = await notesRepository.countBy({ userId: user.id });
|
||||
job.updateProgress(exportedNotesCount / total);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ExportNotesProcessorService {
|
||||
private logger: Logger;
|
||||
@@ -59,67 +132,19 @@ export class ExportNotesProcessorService {
|
||||
this.logger.info(`Temp file is ${path}`);
|
||||
|
||||
try {
|
||||
const stream = fs.createWriteStream(path, { flags: 'a' });
|
||||
// メモリが足りなくならないようにストリームで処理する
|
||||
await new NoteStream(
|
||||
job,
|
||||
this.notesRepository,
|
||||
this.pollsRepository,
|
||||
this.driveFileEntityService,
|
||||
this.idService,
|
||||
user,
|
||||
)
|
||||
.pipeThrough(new JsonArrayStream())
|
||||
.pipeThrough(new TextEncoderStream())
|
||||
.pipeTo(new FileWriterStream(path));
|
||||
|
||||
const write = (text: string): Promise<void> => {
|
||||
return new Promise<void>((res, rej) => {
|
||||
stream.write(text, err => {
|
||||
if (err) {
|
||||
this.logger.error(err);
|
||||
rej(err);
|
||||
} else {
|
||||
res();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
await write('[');
|
||||
|
||||
let exportedNotesCount = 0;
|
||||
let cursor: MiNote['id'] | null = null;
|
||||
|
||||
while (true) {
|
||||
const notes = await this.notesRepository.find({
|
||||
where: {
|
||||
userId: user.id,
|
||||
...(cursor ? { id: MoreThan(cursor) } : {}),
|
||||
},
|
||||
take: 100,
|
||||
order: {
|
||||
id: 1,
|
||||
},
|
||||
}) as MiNote[];
|
||||
|
||||
if (notes.length === 0) {
|
||||
job.updateProgress(100);
|
||||
break;
|
||||
}
|
||||
|
||||
cursor = notes[notes.length - 1].id;
|
||||
|
||||
for (const note of notes) {
|
||||
let poll: MiPoll | undefined;
|
||||
if (note.hasPoll) {
|
||||
poll = await this.pollsRepository.findOneByOrFail({ noteId: note.id });
|
||||
}
|
||||
const files = await this.driveFileEntityService.packManyByIds(note.fileIds, user);
|
||||
const content = JSON.stringify(this.serialize(note, poll, files));
|
||||
const isFirst = exportedNotesCount === 0;
|
||||
await write(isFirst ? content : ',\n' + content);
|
||||
exportedNotesCount++;
|
||||
}
|
||||
|
||||
const total = await this.notesRepository.countBy({
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
job.updateProgress(exportedNotesCount / total);
|
||||
}
|
||||
|
||||
await write(']');
|
||||
|
||||
stream.end();
|
||||
this.logger.succ(`Exported to: ${path}`);
|
||||
|
||||
const fileName = 'notes-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.json';
|
||||
@@ -130,22 +155,4 @@ export class ExportNotesProcessorService {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
private serialize(note: MiNote, poll: MiPoll | null = null, files: Packed<'DriveFile'>[]): Record<string, unknown> {
|
||||
return {
|
||||
id: note.id,
|
||||
text: note.text,
|
||||
createdAt: this.idService.parse(note.id).date.toISOString(),
|
||||
fileIds: note.fileIds,
|
||||
files: files,
|
||||
replyId: note.replyId,
|
||||
renoteId: note.renoteId,
|
||||
poll: poll,
|
||||
cw: note.cw,
|
||||
visibility: note.visibility,
|
||||
visibleUserIds: note.visibleUserIds,
|
||||
localOnly: note.localOnly,
|
||||
reactionAcceptance: note.reactionAcceptance,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@@ -185,8 +185,12 @@ export class InboxProcessorService {
|
||||
await this.apInboxService.performActivity(authUser.user, activity, job.data.user?.id);
|
||||
} catch (e) {
|
||||
if (e instanceof IdentifiableError) {
|
||||
if (e.id === 'e11b3a16-f543-4885-8eb1-66cad131dbfd') return 'blocked mentions from unfamiliar user';
|
||||
if (e.id === '057d8d3e-b7ca-4f8b-b38c-dcdcbf34dc30') return 'blocked notes with prohibited words';
|
||||
if ([
|
||||
'e11b3a16-f543-4885-8eb1-66cad131dbfd',
|
||||
'689ee33f-f97c-479a-ac49-1b9f8140af99',
|
||||
'9f466dab-c856-48cd-9e65-ff90ff750580',
|
||||
'85ab9bd7-3a41-4530-959d-f07073900109',
|
||||
].includes(e.id)) return e.message;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
@@ -302,6 +302,7 @@ import * as ep___notes_translate from './endpoints/notes/translate.js';
|
||||
import * as ep___notes_unrenote from './endpoints/notes/unrenote.js';
|
||||
import * as ep___notes_userListTimeline from './endpoints/notes/user-list-timeline.js';
|
||||
import * as ep___notifications_create from './endpoints/notifications/create.js';
|
||||
import * as ep___notifications_flush from './endpoints/notifications/flush.js';
|
||||
import * as ep___notifications_markAllAsRead from './endpoints/notifications/mark-all-as-read.js';
|
||||
import * as ep___notifications_testNotification from './endpoints/notifications/test-notification.js';
|
||||
import * as ep___pagePush from './endpoints/page-push.js';
|
||||
@@ -683,6 +684,7 @@ const $notes_translate: Provider = { provide: 'ep:notes/translate', useClass: ep
|
||||
const $notes_unrenote: Provider = { provide: 'ep:notes/unrenote', useClass: ep___notes_unrenote.default };
|
||||
const $notes_userListTimeline: Provider = { provide: 'ep:notes/user-list-timeline', useClass: ep___notes_userListTimeline.default };
|
||||
const $notifications_create: Provider = { provide: 'ep:notifications/create', useClass: ep___notifications_create.default };
|
||||
const $notifications_flush: Provider = { provide: 'ep:notifications/flush', useClass: ep___notifications_flush.default };
|
||||
const $notifications_markAllAsRead: Provider = { provide: 'ep:notifications/mark-all-as-read', useClass: ep___notifications_markAllAsRead.default };
|
||||
const $notifications_testNotification: Provider = { provide: 'ep:notifications/test-notification', useClass: ep___notifications_testNotification.default };
|
||||
const $pagePush: Provider = { provide: 'ep:page-push', useClass: ep___pagePush.default };
|
||||
@@ -1068,6 +1070,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
|
||||
$notes_unrenote,
|
||||
$notes_userListTimeline,
|
||||
$notifications_create,
|
||||
$notifications_flush,
|
||||
$notifications_markAllAsRead,
|
||||
$notifications_testNotification,
|
||||
$pagePush,
|
||||
@@ -1447,7 +1450,9 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
|
||||
$notes_unrenote,
|
||||
$notes_userListTimeline,
|
||||
$notifications_create,
|
||||
$notifications_flush,
|
||||
$notifications_markAllAsRead,
|
||||
$notifications_testNotification,
|
||||
$pagePush,
|
||||
$pages_create,
|
||||
$pages_delete,
|
||||
|
@@ -302,6 +302,7 @@ import * as ep___notes_translate from './endpoints/notes/translate.js';
|
||||
import * as ep___notes_unrenote from './endpoints/notes/unrenote.js';
|
||||
import * as ep___notes_userListTimeline from './endpoints/notes/user-list-timeline.js';
|
||||
import * as ep___notifications_create from './endpoints/notifications/create.js';
|
||||
import * as ep___notifications_flush from './endpoints/notifications/flush.js';
|
||||
import * as ep___notifications_markAllAsRead from './endpoints/notifications/mark-all-as-read.js';
|
||||
import * as ep___notifications_testNotification from './endpoints/notifications/test-notification.js';
|
||||
import * as ep___pagePush from './endpoints/page-push.js';
|
||||
@@ -681,6 +682,7 @@ const eps = [
|
||||
['notes/unrenote', ep___notes_unrenote],
|
||||
['notes/user-list-timeline', ep___notes_userListTimeline],
|
||||
['notifications/create', ep___notifications_create],
|
||||
['notifications/flush', ep___notifications_flush],
|
||||
['notifications/mark-all-as-read', ep___notifications_markAllAsRead],
|
||||
['notifications/test-notification', ep___notifications_testNotification],
|
||||
['page-push', ep___pagePush],
|
||||
|
@@ -3,11 +3,11 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Brackets, In } from 'typeorm';
|
||||
import { In } from 'typeorm';
|
||||
import * as Redis from 'ioredis';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { NotesRepository } from '@/models/_.js';
|
||||
import { obsoleteNotificationTypes, notificationTypes, FilterUnionByProperty } from '@/types.js';
|
||||
import { obsoleteNotificationTypes, groupedNotificationTypes, FilterUnionByProperty } from '@/types.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { NoteReadService } from '@/core/NoteReadService.js';
|
||||
import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js';
|
||||
@@ -48,10 +48,10 @@ export const paramDef = {
|
||||
markAsRead: { type: 'boolean', default: true },
|
||||
// 後方互換のため、廃止された通知タイプも受け付ける
|
||||
includeTypes: { type: 'array', items: {
|
||||
type: 'string', enum: [...notificationTypes, ...obsoleteNotificationTypes],
|
||||
type: 'string', enum: [...groupedNotificationTypes, ...obsoleteNotificationTypes],
|
||||
} },
|
||||
excludeTypes: { type: 'array', items: {
|
||||
type: 'string', enum: [...notificationTypes, ...obsoleteNotificationTypes],
|
||||
type: 'string', enum: [...groupedNotificationTypes, ...obsoleteNotificationTypes],
|
||||
} },
|
||||
},
|
||||
required: [],
|
||||
@@ -79,12 +79,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
return [];
|
||||
}
|
||||
// excludeTypes に全指定されている場合はクエリしない
|
||||
if (notificationTypes.every(type => ps.excludeTypes?.includes(type))) {
|
||||
if (groupedNotificationTypes.every(type => ps.excludeTypes?.includes(type))) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const includeTypes = ps.includeTypes && ps.includeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][];
|
||||
const excludeTypes = ps.excludeTypes && ps.excludeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][];
|
||||
const includeTypes = ps.includeTypes && ps.includeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof groupedNotificationTypes[number][];
|
||||
const excludeTypes = ps.excludeTypes && ps.excludeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof groupedNotificationTypes[number][];
|
||||
|
||||
const limit = (ps.limit + EXTRA_LIMIT) + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1
|
||||
const notificationsRes = await this.redisClient.xrevrange(
|
||||
@@ -162,7 +162,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
}
|
||||
|
||||
groupedNotifications = groupedNotifications.slice(0, ps.limit);
|
||||
|
||||
const noteIds = groupedNotifications
|
||||
.filter((notification): notification is FilterUnionByProperty<MiNotification, 'type', 'mention' | 'reply' | 'quote'> => ['mention', 'reply', 'quote'].includes(notification.type))
|
||||
.map(notification => notification.noteId!);
|
||||
|
@@ -3,7 +3,7 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Brackets, In } from 'typeorm';
|
||||
import { In } from 'typeorm';
|
||||
import * as Redis from 'ioredis';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { NotesRepository } from '@/models/_.js';
|
||||
|
@@ -94,6 +94,12 @@ export const meta = {
|
||||
id: '3ac74a84-8fd5-4bb0-870f-01804f82ce15',
|
||||
},
|
||||
|
||||
cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility: {
|
||||
message: 'You cannot reply to a specified visibility note with extended visibility.',
|
||||
code: 'CANNOT_REPLY_TO_SPECIFIED_VISIBILITY_NOTE_WITH_EXTENDED_VISIBILITY',
|
||||
id: 'ed940410-535c-4d5e-bfa3-af798671e93c',
|
||||
},
|
||||
|
||||
cannotCreateAlreadyExpiredPoll: {
|
||||
message: 'Poll is already expired.',
|
||||
code: 'CANNOT_CREATE_ALREADY_EXPIRED_POLL',
|
||||
@@ -129,6 +135,12 @@ export const meta = {
|
||||
code: 'CONTAINS_PROHIBITED_WORDS',
|
||||
id: 'aa6e01d3-a85c-669d-758a-76aab43af334',
|
||||
},
|
||||
|
||||
containsTooManyMentions: {
|
||||
message: 'Cannot post because it exceeds the allowed number of mentions.',
|
||||
code: 'CONTAINS_TOO_MANY_MENTIONS',
|
||||
id: '4de0363a-3046-481b-9b0f-feff3e211025',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -361,6 +373,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
} else if (!await this.noteEntityService.isVisibleForMe(reply, me.id)) {
|
||||
logger.error('Cannot reply to an invisible Note.', { replyId: ps.replyId });
|
||||
throw new ApiError(meta.errors.cannotReplyToInvisibleNote);
|
||||
} else if (reply.visibility === 'specified' && ps.visibility !== 'specified') {
|
||||
throw new ApiError(meta.errors.cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility);
|
||||
}
|
||||
|
||||
// Check blocking
|
||||
@@ -437,7 +451,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
logger.error('Failed to create a note.', { error: err });
|
||||
|
||||
if (err instanceof IdentifiableError) {
|
||||
if (err.id === '057d8d3e-b7ca-4f8b-b38c-dcdcbf34dc30') throw new ApiError(meta.errors.containsProhibitedWords);
|
||||
if (err.id === '689ee33f-f97c-479a-ac49-1b9f8140af99') throw new ApiError(meta.errors.containsProhibitedWords);
|
||||
if (err.id === '9f466dab-c856-48cd-9e65-ff90ff750580') throw new ApiError(meta.errors.containsTooManyMentions);
|
||||
}
|
||||
|
||||
throw err;
|
||||
|
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { NotificationService } from '@/core/NotificationService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['notifications', 'account'],
|
||||
|
||||
requireCredential: true,
|
||||
|
||||
kind: 'write:notifications',
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: [],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
private notificationService: NotificationService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
this.notificationService.flushAllNotifications(me.id);
|
||||
});
|
||||
}
|
||||
}
|
@@ -76,7 +76,15 @@ class HomeTimelineChannel extends Channel {
|
||||
}
|
||||
}
|
||||
|
||||
if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return;
|
||||
// 純粋なリノート(引用リノートでないリノート)の場合
|
||||
if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && note.poll == null) {
|
||||
if (!this.withRenotes) return;
|
||||
if (note.renote.reply) {
|
||||
const reply = note.renote.reply;
|
||||
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く
|
||||
if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId)) return;
|
||||
}
|
||||
}
|
||||
|
||||
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
|
||||
if (isUserRelated(note, this.userIdsWhoMeMuting)) return;
|
||||
|
@@ -579,7 +579,7 @@ export class ClientServerService {
|
||||
...await this.generateCommonPugData(meta),
|
||||
});
|
||||
} catch (err) {
|
||||
if ((err as IdentifiableError).id === '8ca4f428-b32e-4f83-ac43-406ed7cd0452') {
|
||||
if ((err as IdentifiableError).id === '85ab9bd7-3a41-4530-959d-f07073900109') {
|
||||
return await renderBase(reply);
|
||||
}
|
||||
throw err;
|
||||
@@ -625,7 +625,7 @@ export class ClientServerService {
|
||||
...await this.generateCommonPugData(meta),
|
||||
});
|
||||
} catch (err) {
|
||||
if ((err as IdentifiableError).id === '8ca4f428-b32e-4f83-ac43-406ed7cd0452') {
|
||||
if ((err as IdentifiableError).id === '85ab9bd7-3a41-4530-959d-f07073900109') {
|
||||
return await renderBase(reply);
|
||||
}
|
||||
throw err;
|
||||
@@ -658,7 +658,7 @@ export class ClientServerService {
|
||||
...await this.generateCommonPugData(meta),
|
||||
});
|
||||
} catch (err) {
|
||||
if ((err as IdentifiableError).id === '8ca4f428-b32e-4f83-ac43-406ed7cd0452') {
|
||||
if ((err as IdentifiableError).id === '85ab9bd7-3a41-4530-959d-f07073900109') {
|
||||
return await renderBase(reply);
|
||||
}
|
||||
throw err;
|
||||
@@ -691,7 +691,7 @@ export class ClientServerService {
|
||||
...await this.generateCommonPugData(meta),
|
||||
});
|
||||
} catch (err) {
|
||||
if ((err as IdentifiableError).id === '8ca4f428-b32e-4f83-ac43-406ed7cd0452') {
|
||||
if ((err as IdentifiableError).id === '85ab9bd7-3a41-4530-959d-f07073900109') {
|
||||
return await renderBase(reply);
|
||||
}
|
||||
throw err;
|
||||
@@ -722,7 +722,7 @@ export class ClientServerService {
|
||||
...await this.generateCommonPugData(meta),
|
||||
});
|
||||
} catch (err) {
|
||||
if ((err as IdentifiableError).id === '8ca4f428-b32e-4f83-ac43-406ed7cd0452') {
|
||||
if ((err as IdentifiableError).id === '85ab9bd7-3a41-4530-959d-f07073900109') {
|
||||
return await renderBase(reply);
|
||||
}
|
||||
throw err;
|
||||
|
@@ -33,7 +33,15 @@ export const notificationTypes = [
|
||||
'roleAssigned',
|
||||
'achievementEarned',
|
||||
'app',
|
||||
'test'] as const;
|
||||
'test',
|
||||
] as const;
|
||||
|
||||
export const groupedNotificationTypes = [
|
||||
...notificationTypes,
|
||||
'reaction:grouped',
|
||||
'renote:grouped',
|
||||
] as const;
|
||||
|
||||
export const obsoleteNotificationTypes = ['pollVote', 'groupInvited'] as const;
|
||||
|
||||
export const noteVisibilities = ['public', 'home', 'followers', 'specified'] as const;
|
||||
|
@@ -117,5 +117,185 @@ describe('Mute', () => {
|
||||
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true);
|
||||
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false);
|
||||
});
|
||||
|
||||
test('通知にミュートしているユーザーからのリプライが含まれない', async () => {
|
||||
const aliceNote = await post(alice, { text: 'hi' });
|
||||
await post(bob, { text: '@alice hi', replyId: aliceNote.id });
|
||||
await post(carol, { text: '@alice hi', replyId: aliceNote.id });
|
||||
|
||||
const res = await api('/i/notifications', {}, alice);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(Array.isArray(res.body), true);
|
||||
|
||||
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true);
|
||||
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false);
|
||||
});
|
||||
|
||||
test('通知にミュートしているユーザーからのリプライが含まれない', async () => {
|
||||
await post(alice, { text: 'hi' });
|
||||
await post(bob, { text: '@alice hi' });
|
||||
await post(carol, { text: '@alice hi' });
|
||||
|
||||
const res = await api('/i/notifications', {}, alice);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(Array.isArray(res.body), true);
|
||||
|
||||
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true);
|
||||
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false);
|
||||
});
|
||||
|
||||
test('通知にミュートしているユーザーからの引用リノートが含まれない', async () => {
|
||||
const aliceNote = await post(alice, { text: 'hi' });
|
||||
await post(bob, { text: 'hi', renoteId: aliceNote.id });
|
||||
await post(carol, { text: 'hi', renoteId: aliceNote.id });
|
||||
|
||||
const res = await api('/i/notifications', {}, alice);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(Array.isArray(res.body), true);
|
||||
|
||||
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true);
|
||||
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false);
|
||||
});
|
||||
|
||||
test('通知にミュートしているユーザーからのリノートが含まれない', async () => {
|
||||
const aliceNote = await post(alice, { text: 'hi' });
|
||||
await post(bob, { renoteId: aliceNote.id });
|
||||
await post(carol, { renoteId: aliceNote.id });
|
||||
|
||||
const res = await api('/i/notifications', {}, alice);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(Array.isArray(res.body), true);
|
||||
|
||||
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true);
|
||||
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false);
|
||||
});
|
||||
|
||||
test('通知にミュートしているユーザーからのフォロー通知が含まれない', async () => {
|
||||
await api('/i/follow', { userId: alice.id }, bob);
|
||||
await api('/i/follow', { userId: alice.id }, carol);
|
||||
|
||||
const res = await api('/i/notifications', {}, alice);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(Array.isArray(res.body), true);
|
||||
|
||||
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true);
|
||||
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false);
|
||||
});
|
||||
|
||||
test('通知にミュートしているユーザーからのフォローリクエストが含まれない', async () => {
|
||||
await api('/i/update/', { isLocked: true }, alice);
|
||||
await api('/following/create', { userId: alice.id }, bob);
|
||||
await api('/following/create', { userId: alice.id }, carol);
|
||||
|
||||
const res = await api('/i/notifications', {}, alice);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(Array.isArray(res.body), true);
|
||||
|
||||
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true);
|
||||
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Notification (Grouped)', () => {
|
||||
test('通知にミュートしているユーザーの通知が含まれない(リアクション)', async () => {
|
||||
const aliceNote = await post(alice, { text: 'hi' });
|
||||
await react(bob, aliceNote, 'like');
|
||||
await react(carol, aliceNote, 'like');
|
||||
|
||||
const res = await api('/i/notifications-grouped', {}, alice);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(Array.isArray(res.body), true);
|
||||
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true);
|
||||
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false);
|
||||
});
|
||||
test('通知にミュートしているユーザーからのリプライが含まれない', async () => {
|
||||
const aliceNote = await post(alice, { text: 'hi' });
|
||||
await post(bob, { text: '@alice hi', replyId: aliceNote.id });
|
||||
await post(carol, { text: '@alice hi', replyId: aliceNote.id });
|
||||
|
||||
const res = await api('/i/notifications-grouped', {}, alice);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(Array.isArray(res.body), true);
|
||||
|
||||
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true);
|
||||
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false);
|
||||
});
|
||||
|
||||
test('通知にミュートしているユーザーからのリプライが含まれない', async () => {
|
||||
await post(alice, { text: 'hi' });
|
||||
await post(bob, { text: '@alice hi' });
|
||||
await post(carol, { text: '@alice hi' });
|
||||
|
||||
const res = await api('/i/notifications-grouped', {}, alice);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(Array.isArray(res.body), true);
|
||||
|
||||
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true);
|
||||
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false);
|
||||
});
|
||||
|
||||
test('通知にミュートしているユーザーからの引用リノートが含まれない', async () => {
|
||||
const aliceNote = await post(alice, { text: 'hi' });
|
||||
await post(bob, { text: 'hi', renoteId: aliceNote.id });
|
||||
await post(carol, { text: 'hi', renoteId: aliceNote.id });
|
||||
|
||||
const res = await api('/i/notifications-grouped', {}, alice);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(Array.isArray(res.body), true);
|
||||
|
||||
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true);
|
||||
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false);
|
||||
});
|
||||
|
||||
test('通知にミュートしているユーザーからのリノートが含まれない', async () => {
|
||||
const aliceNote = await post(alice, { text: 'hi' });
|
||||
await post(bob, { renoteId: aliceNote.id });
|
||||
await post(carol, { renoteId: aliceNote.id });
|
||||
|
||||
const res = await api('/i/notifications-grouped', {}, alice);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(Array.isArray(res.body), true);
|
||||
|
||||
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true);
|
||||
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false);
|
||||
});
|
||||
|
||||
test('通知にミュートしているユーザーからのフォロー通知が含まれない', async () => {
|
||||
await api('/i/follow', { userId: alice.id }, bob);
|
||||
await api('/i/follow', { userId: alice.id }, carol);
|
||||
|
||||
const res = await api('/i/notifications-grouped', {}, alice);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(Array.isArray(res.body), true);
|
||||
|
||||
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true);
|
||||
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false);
|
||||
});
|
||||
|
||||
test('通知にミュートしているユーザーからのフォローリクエストが含まれない', async () => {
|
||||
await api('/i/update/', { isLocked: true }, alice);
|
||||
await api('/following/create', { userId: alice.id }, bob);
|
||||
await api('/following/create', { userId: alice.id }, carol);
|
||||
|
||||
const res = await api('/i/notifications-grouped', {}, alice);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(Array.isArray(res.body), true);
|
||||
|
||||
assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true);
|
||||
assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -178,6 +178,87 @@ describe('Note', () => {
|
||||
assert.strictEqual(deleteRes.status, 204);
|
||||
});
|
||||
|
||||
test('visibility: followersなノートに対してフォロワーはリプライできる', async () => {
|
||||
await api('/following/create', {
|
||||
userId: alice.id,
|
||||
}, bob);
|
||||
|
||||
const aliceNote = await api('/notes/create', {
|
||||
text: 'direct note to bob',
|
||||
visibility: 'followers',
|
||||
}, alice);
|
||||
|
||||
assert.strictEqual(aliceNote.status, 200);
|
||||
|
||||
const replyId = aliceNote.body.createdNote.id;
|
||||
const bobReply = await api('/notes/create', {
|
||||
text: 'reply to alice note',
|
||||
replyId,
|
||||
}, bob);
|
||||
|
||||
assert.strictEqual(bobReply.status, 200);
|
||||
assert.strictEqual(bobReply.body.createdNote.replyId, replyId);
|
||||
|
||||
await api('/following/delete', {
|
||||
userId: alice.id,
|
||||
}, bob);
|
||||
});
|
||||
|
||||
test('visibility: followersなノートに対してフォロワーでないユーザーがリプライしようとすると怒られる', async () => {
|
||||
const aliceNote = await api('/notes/create', {
|
||||
text: 'direct note to bob',
|
||||
visibility: 'followers',
|
||||
}, alice);
|
||||
|
||||
assert.strictEqual(aliceNote.status, 200);
|
||||
|
||||
const bobReply = await api('/notes/create', {
|
||||
text: 'reply to alice note',
|
||||
replyId: aliceNote.body.createdNote.id,
|
||||
}, bob);
|
||||
|
||||
assert.strictEqual(bobReply.status, 400);
|
||||
assert.strictEqual(bobReply.body.error.code, 'CANNOT_REPLY_TO_AN_INVISIBLE_NOTE');
|
||||
});
|
||||
|
||||
test('visibility: specifiedなノートに対してvisibility: specifiedで返信できる', async () => {
|
||||
const aliceNote = await api('/notes/create', {
|
||||
text: 'direct note to bob',
|
||||
visibility: 'specified',
|
||||
visibleUserIds: [bob.id],
|
||||
}, alice);
|
||||
|
||||
assert.strictEqual(aliceNote.status, 200);
|
||||
|
||||
const bobReply = await api('/notes/create', {
|
||||
text: 'reply to alice note',
|
||||
replyId: aliceNote.body.createdNote.id,
|
||||
visibility: 'specified',
|
||||
visibleUserIds: [alice.id],
|
||||
}, bob);
|
||||
|
||||
assert.strictEqual(bobReply.status, 200);
|
||||
});
|
||||
|
||||
test('visibility: specifiedなノートに対してvisibility: follwersで返信しようとすると怒られる', async () => {
|
||||
const aliceNote = await api('/notes/create', {
|
||||
text: 'direct note to bob',
|
||||
visibility: 'specified',
|
||||
visibleUserIds: [bob.id],
|
||||
}, alice);
|
||||
|
||||
assert.strictEqual(aliceNote.status, 200);
|
||||
|
||||
const bobReply = await api('/notes/create', {
|
||||
text: 'reply to alice note with visibility: followers',
|
||||
replyId: aliceNote.body.createdNote.id,
|
||||
visibility: 'followers',
|
||||
}, bob);
|
||||
|
||||
assert.strictEqual(bobReply.status, 400);
|
||||
assert.strictEqual(bobReply.body.error.code, 'CANNOT_REPLY_TO_SPECIFIED_VISIBILITY_NOTE_WITH_EXTENDED_VISIBILITY');
|
||||
});
|
||||
|
||||
test('文字数ぎりぎりで怒られない', async () => {
|
||||
const post = {
|
||||
text: '!'.repeat(MAX_NOTE_TEXT_LENGTH), // 3000文字
|
||||
@@ -683,6 +764,171 @@ describe('Note', () => {
|
||||
assert.strictEqual(note3.status, 400);
|
||||
assert.strictEqual(note3.body.error.code, 'CONTAINS_PROHIBITED_WORDS');
|
||||
});
|
||||
|
||||
test('メンションの数が上限を超えるとエラーになる', async () => {
|
||||
const res = await api('admin/roles/create', {
|
||||
name: 'test',
|
||||
description: '',
|
||||
color: null,
|
||||
iconUrl: null,
|
||||
displayOrder: 0,
|
||||
target: 'manual',
|
||||
condFormula: {},
|
||||
isAdministrator: false,
|
||||
isModerator: false,
|
||||
isPublic: false,
|
||||
isExplorable: false,
|
||||
asBadge: false,
|
||||
canEditMembersByModerator: false,
|
||||
policies: {
|
||||
mentionLimit: {
|
||||
useDefault: false,
|
||||
priority: 1,
|
||||
value: 0,
|
||||
},
|
||||
},
|
||||
}, alice);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
|
||||
await new Promise(x => setTimeout(x, 2));
|
||||
|
||||
const assign = await api('admin/roles/assign', {
|
||||
userId: alice.id,
|
||||
roleId: res.body.id,
|
||||
}, alice);
|
||||
|
||||
assert.strictEqual(assign.status, 204);
|
||||
|
||||
await new Promise(x => setTimeout(x, 2));
|
||||
|
||||
const note = await api('/notes/create', {
|
||||
text: '@bob potentially annoying text',
|
||||
}, alice);
|
||||
|
||||
assert.strictEqual(note.status, 400);
|
||||
assert.strictEqual(note.body.error.code, 'CONTAINS_TOO_MANY_MENTIONS');
|
||||
|
||||
await api('admin/roles/unassign', {
|
||||
userId: alice.id,
|
||||
roleId: res.body.id,
|
||||
});
|
||||
|
||||
await api('admin/roles/delete', {
|
||||
roleId: res.body.id,
|
||||
}, alice);
|
||||
});
|
||||
|
||||
test('ダイレクト投稿もエラーになる', async () => {
|
||||
const res = await api('admin/roles/create', {
|
||||
name: 'test',
|
||||
description: '',
|
||||
color: null,
|
||||
iconUrl: null,
|
||||
displayOrder: 0,
|
||||
target: 'manual',
|
||||
condFormula: {},
|
||||
isAdministrator: false,
|
||||
isModerator: false,
|
||||
isPublic: false,
|
||||
isExplorable: false,
|
||||
asBadge: false,
|
||||
canEditMembersByModerator: false,
|
||||
policies: {
|
||||
mentionLimit: {
|
||||
useDefault: false,
|
||||
priority: 1,
|
||||
value: 0,
|
||||
},
|
||||
},
|
||||
}, alice);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
|
||||
await new Promise(x => setTimeout(x, 2));
|
||||
|
||||
const assign = await api('admin/roles/assign', {
|
||||
userId: alice.id,
|
||||
roleId: res.body.id,
|
||||
}, alice);
|
||||
|
||||
assert.strictEqual(assign.status, 204);
|
||||
|
||||
await new Promise(x => setTimeout(x, 2));
|
||||
|
||||
const note = await api('/notes/create', {
|
||||
text: 'potentially annoying text',
|
||||
visibility: 'specified',
|
||||
visibleUserIds: [ bob.id ],
|
||||
}, alice);
|
||||
|
||||
assert.strictEqual(note.status, 400);
|
||||
assert.strictEqual(note.body.error.code, 'CONTAINS_TOO_MANY_MENTIONS');
|
||||
|
||||
await api('admin/roles/unassign', {
|
||||
userId: alice.id,
|
||||
roleId: res.body.id,
|
||||
});
|
||||
|
||||
await api('admin/roles/delete', {
|
||||
roleId: res.body.id,
|
||||
}, alice);
|
||||
});
|
||||
|
||||
test('ダイレクトの宛先とメンションが同じ場合は重複してカウントしない', async () => {
|
||||
const res = await api('admin/roles/create', {
|
||||
name: 'test',
|
||||
description: '',
|
||||
color: null,
|
||||
iconUrl: null,
|
||||
displayOrder: 0,
|
||||
target: 'manual',
|
||||
condFormula: {},
|
||||
isAdministrator: false,
|
||||
isModerator: false,
|
||||
isPublic: false,
|
||||
isExplorable: false,
|
||||
asBadge: false,
|
||||
canEditMembersByModerator: false,
|
||||
policies: {
|
||||
mentionLimit: {
|
||||
useDefault: false,
|
||||
priority: 1,
|
||||
value: 1,
|
||||
},
|
||||
},
|
||||
}, alice);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
|
||||
await new Promise(x => setTimeout(x, 2));
|
||||
|
||||
const assign = await api('admin/roles/assign', {
|
||||
userId: alice.id,
|
||||
roleId: res.body.id,
|
||||
}, alice);
|
||||
|
||||
assert.strictEqual(assign.status, 204);
|
||||
|
||||
await new Promise(x => setTimeout(x, 2));
|
||||
|
||||
const note = await api('/notes/create', {
|
||||
text: '@bob potentially annoying text',
|
||||
visibility: 'specified',
|
||||
visibleUserIds: [ bob.id ],
|
||||
}, alice);
|
||||
|
||||
assert.strictEqual(note.status, 200);
|
||||
|
||||
await api('admin/roles/unassign', {
|
||||
userId: alice.id,
|
||||
roleId: res.body.id,
|
||||
});
|
||||
|
||||
await api('admin/roles/delete', {
|
||||
roleId: res.body.id,
|
||||
}, alice);
|
||||
});
|
||||
});
|
||||
|
||||
describe('notes/delete', () => {
|
||||
|
@@ -40,9 +40,9 @@ describe('Streaming', () => {
|
||||
let chinatsu: misskey.entities.SignupResponse;
|
||||
let takumi: misskey.entities.SignupResponse;
|
||||
|
||||
let kyokoNote: any;
|
||||
let kanakoNote: any;
|
||||
let takumiNote: any;
|
||||
let kyokoNote: misskey.entities.Note;
|
||||
let kanakoNote: misskey.entities.Note;
|
||||
let takumiNote: misskey.entities.Note;
|
||||
let list: any;
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -70,6 +70,9 @@ describe('Streaming', () => {
|
||||
// Follow: ayano => akari
|
||||
await follow(ayano, akari);
|
||||
|
||||
// Follow: kyoko => chitose
|
||||
await api('following/create', { userId: chitose.id }, kyoko);
|
||||
|
||||
// Mute: chitose => kanako
|
||||
await api('mute/create', { userId: kanako.id }, chitose);
|
||||
|
||||
@@ -172,7 +175,28 @@ describe('Streaming', () => {
|
||||
*/
|
||||
|
||||
test('フォローしているユーザーのフォローしていないユーザーの visibility: followers な投稿への返信が流れない', async () => {
|
||||
// TODO
|
||||
const chitoseNote = await post(chitose, { text: 'followers-only post', visibility: 'followers' });
|
||||
|
||||
const fired = await waitFire(
|
||||
ayano, 'homeTimeline', // ayano:home
|
||||
() => api('notes/create', { text: 'reply to chitose\'s followers-only post', replyId: chitoseNote.id }, kyoko), // kyoko's reply to chitose's followers-only post
|
||||
msg => msg.type === 'note' && msg.body.userId === kyoko.id, // wait kyoko
|
||||
);
|
||||
|
||||
assert.strictEqual(fired, false);
|
||||
});
|
||||
|
||||
test('フォローしているユーザーのフォローしていないユーザーの visibility: followers な投稿への返信のリノートが流れない', async () => {
|
||||
const chitoseNote = await post(chitose, { text: 'followers-only post', visibility: 'followers' });
|
||||
const kyokoReply = await post(kyoko, { text: 'reply to followers-only post', replyId: chitoseNote.id });
|
||||
|
||||
const fired = await waitFire(
|
||||
ayano, 'homeTimeline', // ayano:home
|
||||
() => api('notes/create', { renoteId: kyokoReply.id }, kyoko), // kyoko's renote of kyoko's reply to chitose's followers-only post
|
||||
msg => msg.type === 'note' && msg.body.userId === kyoko.id, // wait kyoko
|
||||
);
|
||||
|
||||
assert.strictEqual(fired, false);
|
||||
});
|
||||
|
||||
test('フォローしていないユーザーの投稿は流れない', async () => {
|
||||
@@ -204,6 +228,79 @@ describe('Streaming', () => {
|
||||
|
||||
assert.strictEqual(fired, false);
|
||||
});
|
||||
|
||||
/**
|
||||
* TODO: 落ちる
|
||||
* @see https://github.com/misskey-dev/misskey/issues/13474
|
||||
test('visibility: specified なノートで visibleUserIds に自分が含まれているときそのノートへのリプライが流れてくる', async () => {
|
||||
const chitoseToKyokoAndAyano = await post(chitose, { text: 'direct note from chitose to kyoko and ayano', visibility: 'specified', visibleUserIds: [kyoko.id, ayano.id] });
|
||||
|
||||
const fired = await waitFire(
|
||||
ayano, 'homeTimeline', // ayano:home
|
||||
() => api('notes/create', { text: 'direct reply from kyoko to chitose and ayano', replyId: chitoseToKyokoAndAyano.id, visibility: 'specified', visibleUserIds: [chitose.id, ayano.id] }, kyoko),
|
||||
msg => msg.type === 'note' && msg.body.userId === kyoko.id,
|
||||
);
|
||||
|
||||
assert.strictEqual(fired, true);
|
||||
});
|
||||
*/
|
||||
|
||||
test('visibility: specified な投稿に対するリプライで visibleUserIds が拡張されたとき、その拡張されたユーザーの HTL にはそのリプライが流れない', async () => {
|
||||
const chitoseToKyoko = await post(chitose, { text: 'direct note from chitose to kyoko', visibility: 'specified', visibleUserIds: [kyoko.id] });
|
||||
|
||||
const fired = await waitFire(
|
||||
ayano, 'homeTimeline', // ayano:home
|
||||
() => api('notes/create', { text: 'direct reply from kyoko to chitose and ayano', replyId: chitoseToKyoko.id, visibility: 'specified', visibleUserIds: [chitose.id, ayano.id] }, kyoko),
|
||||
msg => msg.type === 'note' && msg.body.userId === kyoko.id,
|
||||
);
|
||||
|
||||
assert.strictEqual(fired, false);
|
||||
});
|
||||
|
||||
test('visibility: specified な投稿に対するリプライで visibleUserIds が収縮されたとき、その収縮されたユーザーの HTL にはそのリプライが流れない', async () => {
|
||||
const chitoseToKyokoAndAyano = await post(chitose, { text: 'direct note from chitose to kyoko and ayano', visibility: 'specified', visibleUserIds: [kyoko.id, ayano.id] });
|
||||
|
||||
const fired = await waitFire(
|
||||
ayano, 'homeTimeline', // ayano:home
|
||||
() => api('notes/create', { text: 'direct reply from kyoko to chitose', replyId: chitoseToKyokoAndAyano.id, visibility: 'specified', visibleUserIds: [chitose.id] }, kyoko),
|
||||
msg => msg.type === 'note' && msg.body.userId === kyoko.id,
|
||||
);
|
||||
|
||||
assert.strictEqual(fired, false);
|
||||
});
|
||||
|
||||
test('withRenotes: false のときリノートが流れない', async () => {
|
||||
const fired = await waitFire(
|
||||
ayano, 'homeTimeline', // ayano:home
|
||||
() => api('notes/create', { renoteId: kyokoNote.id }, kyoko), // kyoko renote
|
||||
msg => msg.type === 'note' && msg.body.userId === kyoko.id, // wait kyoko
|
||||
{ withRenotes: false },
|
||||
);
|
||||
|
||||
assert.strictEqual(fired, false);
|
||||
});
|
||||
|
||||
test('withRenotes: false のとき引用リノートが流れる', async () => {
|
||||
const fired = await waitFire(
|
||||
ayano, 'homeTimeline', // ayano:home
|
||||
() => api('notes/create', { text: 'quote', renoteId: kyokoNote.id }, kyoko), // kyoko quote
|
||||
msg => msg.type === 'note' && msg.body.userId === kyoko.id, // wait kyoko
|
||||
{ withRenotes: false },
|
||||
);
|
||||
|
||||
assert.strictEqual(fired, true);
|
||||
});
|
||||
|
||||
test('withRenotes: false のとき投票のみのリノートが流れる', async () => {
|
||||
const fired = await waitFire(
|
||||
ayano, 'homeTimeline', // ayano:home
|
||||
() => api('notes/create', { poll: { choices: ['kinoko', 'takenoko'] }, renoteId: kyokoNote.id }, kyoko), // kyoko renote with poll
|
||||
msg => msg.type === 'note' && msg.body.userId === kyoko.id, // wait kyoko
|
||||
{ withRenotes: false },
|
||||
);
|
||||
|
||||
assert.strictEqual(fired, true);
|
||||
});
|
||||
}); // Home
|
||||
|
||||
describe('Local Timeline', () => {
|
||||
|
@@ -251,6 +251,34 @@ describe('RoleService', () => {
|
||||
expect(user2Policies.canManageCustomEmojis).toBe(true);
|
||||
});
|
||||
|
||||
test('コンディショナルロール: マニュアルロールにアサイン済み', async () => {
|
||||
const [user1, user2, role1] = await Promise.all([
|
||||
createUser(),
|
||||
createUser(),
|
||||
createRole({
|
||||
name: 'manual role',
|
||||
}),
|
||||
]);
|
||||
const role2 = await createRole({
|
||||
name: 'conditional role',
|
||||
target: 'conditional',
|
||||
condFormula: {
|
||||
// idはバックエンドのロジックに必要ない?
|
||||
id: 'bdc612bd-9d54-4675-ae83-0499c82ea670',
|
||||
type: 'roleAssignedTo',
|
||||
roleId: role1.id,
|
||||
},
|
||||
});
|
||||
await roleService.assign(user2.id, role1.id);
|
||||
|
||||
const [u1role, u2role] = await Promise.all([
|
||||
roleService.getUserRoles(user1.id),
|
||||
roleService.getUserRoles(user2.id),
|
||||
]);
|
||||
expect(u1role.some(r => r.id === role2.id)).toBe(false);
|
||||
expect(u2role.some(r => r.id === role2.id)).toBe(true);
|
||||
});
|
||||
|
||||
test('expired role', async () => {
|
||||
const user = await createUser();
|
||||
const role = await createRole({
|
||||
|
@@ -356,7 +356,7 @@ export const uploadUrl = async (user: UserToken, url: string): Promise<Packed<'D
|
||||
return catcher;
|
||||
};
|
||||
|
||||
export function connectStream(user: UserToken, channel: string, listener: (message: Record<string, any>) => any, params?: any): Promise<WebSocket> {
|
||||
export function connectStream<C extends keyof misskey.Channels>(user: UserToken, channel: C, listener: (message: Record<string, any>) => any, params?: misskey.Channels[C]['params']): Promise<WebSocket> {
|
||||
return new Promise((res, rej) => {
|
||||
const url = new URL(`ws://127.0.0.1:${port}/streaming`);
|
||||
const options: ClientOptions = {};
|
||||
@@ -391,7 +391,7 @@ export function connectStream(user: UserToken, channel: string, listener: (messa
|
||||
});
|
||||
}
|
||||
|
||||
export const waitFire = async (user: UserToken, channel: string, trgr: () => any, cond: (msg: Record<string, any>) => boolean, params?: any) => {
|
||||
export const waitFire = async <C extends keyof misskey.Channels>(user: UserToken, channel: C, trgr: () => any, cond: (msg: Record<string, any>) => boolean, params?: misskey.Channels[C]['params']) => {
|
||||
return new Promise<boolean>(async (res, rej) => {
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
|
||||
@@ -436,7 +436,7 @@ export const waitFire = async (user: UserToken, channel: string, trgr: () => any
|
||||
*/
|
||||
export function makeStreamCatcher<T>(
|
||||
user: UserToken,
|
||||
channel: string,
|
||||
channel: keyof misskey.Channels,
|
||||
cond: (message: Record<string, any>) => boolean,
|
||||
extractor: (message: Record<string, any>) => T,
|
||||
timeout = 60 * 1000): Promise<T> {
|
||||
|
@@ -35,12 +35,12 @@
|
||||
"broadcast-channel": "7.0.0",
|
||||
"buraha": "0.0.1",
|
||||
"canvas-confetti": "1.9.2",
|
||||
"chart.js": "4.4.1",
|
||||
"chart.js": "4.4.2",
|
||||
"chartjs-adapter-date-fns": "3.0.0",
|
||||
"chartjs-chart-matrix": "2.0.1",
|
||||
"chartjs-plugin-gradient": "0.6.1",
|
||||
"chartjs-plugin-zoom": "2.0.1",
|
||||
"chromatic": "10.9.6",
|
||||
"chromatic": "11.0.0",
|
||||
"compare-versions": "6.1.0",
|
||||
"cropperjs": "2.0.0-beta.4",
|
||||
"date-fns": "3.3.1",
|
||||
@@ -59,12 +59,12 @@
|
||||
"photoswipe": "5.4.3",
|
||||
"punycode": "2.3.1",
|
||||
"rollup": "4.12.0",
|
||||
"sanitize-html": "2.12.0",
|
||||
"sanitize-html": "2.12.1",
|
||||
"sass": "1.71.1",
|
||||
"shiki": "1.1.6",
|
||||
"shiki": "1.1.7",
|
||||
"strict-event-emitter-types": "2.0.0",
|
||||
"textarea-caret": "3.1.0",
|
||||
"three": "0.161.0",
|
||||
"three": "0.162.0",
|
||||
"throttle-debounce": "5.0.0",
|
||||
"tinycolor2": "1.6.0",
|
||||
"tsc-alias": "1.8.8",
|
||||
@@ -79,58 +79,58 @@
|
||||
"devDependencies": {
|
||||
"@misskey-dev/eslint-plugin": "1.0.0",
|
||||
"@misskey-dev/summaly": "5.0.3",
|
||||
"@storybook/addon-actions": "8.0.0-beta.2",
|
||||
"@storybook/addon-essentials": "8.0.0-beta.2",
|
||||
"@storybook/addon-interactions": "8.0.0-beta.2",
|
||||
"@storybook/addon-links": "8.0.0-beta.2",
|
||||
"@storybook/addon-mdx-gfm": "8.0.0-beta.2",
|
||||
"@storybook/addon-storysource": "8.0.0-beta.2",
|
||||
"@storybook/blocks": "8.0.0-beta.2",
|
||||
"@storybook/components": "8.0.0-beta.2",
|
||||
"@storybook/core-events": "8.0.0-beta.2",
|
||||
"@storybook/manager-api": "8.0.0-beta.2",
|
||||
"@storybook/preview-api": "8.0.0-beta.2",
|
||||
"@storybook/react": "8.0.0-beta.2",
|
||||
"@storybook/react-vite": "8.0.0-beta.2",
|
||||
"@storybook/test": "8.0.0-beta.2",
|
||||
"@storybook/theming": "8.0.0-beta.2",
|
||||
"@storybook/types": "8.0.0-beta.2",
|
||||
"@storybook/vue3": "8.0.0-beta.2",
|
||||
"@storybook/vue3-vite": "8.0.0-beta.2",
|
||||
"@storybook/addon-actions": "8.0.0-beta.6",
|
||||
"@storybook/addon-essentials": "8.0.0-beta.6",
|
||||
"@storybook/addon-interactions": "8.0.0-beta.6",
|
||||
"@storybook/addon-links": "8.0.0-beta.6",
|
||||
"@storybook/addon-mdx-gfm": "8.0.0-beta.6",
|
||||
"@storybook/addon-storysource": "8.0.0-beta.6",
|
||||
"@storybook/blocks": "8.0.0-beta.6",
|
||||
"@storybook/components": "8.0.0-beta.6",
|
||||
"@storybook/core-events": "8.0.0-beta.6",
|
||||
"@storybook/manager-api": "8.0.0-beta.6",
|
||||
"@storybook/preview-api": "8.0.0-beta.6",
|
||||
"@storybook/react": "8.0.0-beta.6",
|
||||
"@storybook/react-vite": "8.0.0-beta.6",
|
||||
"@storybook/test": "8.0.0-beta.6",
|
||||
"@storybook/theming": "8.0.0-beta.6",
|
||||
"@storybook/types": "8.0.0-beta.6",
|
||||
"@storybook/vue3": "8.0.0-beta.6",
|
||||
"@storybook/vue3-vite": "8.0.0-beta.6",
|
||||
"@testing-library/vue": "8.0.2",
|
||||
"@types/escape-regexp": "0.0.3",
|
||||
"@types/estree": "1.0.5",
|
||||
"@types/matter-js": "0.19.6",
|
||||
"@types/micromatch": "4.0.6",
|
||||
"@types/node": "20.11.19",
|
||||
"@types/node": "20.11.24",
|
||||
"@types/punycode": "2.1.4",
|
||||
"@types/sanitize-html": "2.11.0",
|
||||
"@types/throttle-debounce": "5.0.2",
|
||||
"@types/tinycolor2": "1.4.6",
|
||||
"@types/uuid": "9.0.8",
|
||||
"@types/ws": "8.5.10",
|
||||
"@typescript-eslint/eslint-plugin": "7.0.2",
|
||||
"@typescript-eslint/parser": "7.0.2",
|
||||
"@typescript-eslint/eslint-plugin": "7.1.0",
|
||||
"@typescript-eslint/parser": "7.1.0",
|
||||
"@vitest/coverage-v8": "0.34.6",
|
||||
"@vue/runtime-core": "3.4.15",
|
||||
"acorn": "8.11.3",
|
||||
"cross-env": "7.0.3",
|
||||
"cypress": "13.6.5",
|
||||
"eslint": "8.56.0",
|
||||
"cypress": "13.6.6",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-plugin-import": "2.29.1",
|
||||
"eslint-plugin-vue": "9.21.1",
|
||||
"eslint-plugin-vue": "9.22.0",
|
||||
"fast-glob": "3.3.2",
|
||||
"happy-dom": "10.0.3",
|
||||
"happy-dom": "13.6.2",
|
||||
"intersection-observer": "0.12.2",
|
||||
"micromatch": "4.0.5",
|
||||
"msw": "2.2.1",
|
||||
"msw": "2.2.2",
|
||||
"msw-storybook-addon": "2.0.0-beta.1",
|
||||
"nodemon": "3.0.3",
|
||||
"nodemon": "3.1.0",
|
||||
"prettier": "3.2.5",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"start-server-and-test": "2.0.3",
|
||||
"storybook": "8.0.0-beta.2",
|
||||
"storybook": "8.0.0-beta.6",
|
||||
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
|
||||
"vite-plugin-turbosnap": "1.0.3",
|
||||
"vitest": "0.34.6",
|
||||
|
@@ -290,7 +290,7 @@ export async function openAccountMenu(opts: {
|
||||
text: i18n.ts.profile,
|
||||
to: `/@${ $i.username }`,
|
||||
avatar: $i,
|
||||
}, { type: 'divider' }, ...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises, {
|
||||
}, { type: 'divider' as const }, ...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises, {
|
||||
type: 'parent' as const,
|
||||
icon: 'ti ti-plus',
|
||||
text: i18n.ts.addAccount,
|
||||
|
@@ -47,11 +47,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<template v-if="select.items">
|
||||
<option v-for="item in select.items" :value="item.value">{{ item.text }}</option>
|
||||
</template>
|
||||
<template v-else>
|
||||
<optgroup v-for="groupedItem in select.groupedItems" :label="groupedItem.label">
|
||||
<option v-for="item in groupedItem.items" :value="item.value">{{ item.text }}</option>
|
||||
</optgroup>
|
||||
</template>
|
||||
</MkSelect>
|
||||
<div v-if="(showOkButton || showCancelButton) && !actions" :class="$style.buttons">
|
||||
<MkButton v-if="showOkButton" data-cy-modal-dialog-ok inline primary rounded :autofocus="!input && !select" :disabled="okButtonDisabledReason" @click="ok">{{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }}</MkButton>
|
||||
@@ -74,7 +69,7 @@ import MkTextarea from '@/components/MkTextarea.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
type Input = {
|
||||
type: 'text' | 'number' | 'password' | 'email' | 'url' | 'date' | 'time' | 'search' | 'datetime-local' | 'textarea';
|
||||
type?: 'text' | 'number' | 'password' | 'email' | 'url' | 'date' | 'time' | 'search' | 'datetime-local' | 'textarea';
|
||||
placeholder?: string | null;
|
||||
autocomplete?: string;
|
||||
default: string | number | null;
|
||||
@@ -84,23 +79,18 @@ type Input = {
|
||||
|
||||
type Select = {
|
||||
items: {
|
||||
value: string;
|
||||
value: any;
|
||||
text: string;
|
||||
}[];
|
||||
groupedItems: {
|
||||
label: string;
|
||||
items: {
|
||||
value: string;
|
||||
text: string;
|
||||
}[];
|
||||
}[];
|
||||
default: string | null;
|
||||
};
|
||||
|
||||
type Result = string | number | true | null;
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
type?: 'success' | 'error' | 'warning' | 'info' | 'question' | 'waiting';
|
||||
title: string;
|
||||
text?: string;
|
||||
title?: string | null;
|
||||
text?: string | null;
|
||||
input?: Input;
|
||||
select?: Select;
|
||||
icon?: string;
|
||||
@@ -117,13 +107,21 @@ const props = withDefaults(defineProps<{
|
||||
cancelText?: string;
|
||||
}>(), {
|
||||
type: 'info',
|
||||
title: undefined,
|
||||
text: undefined,
|
||||
input: undefined,
|
||||
select: undefined,
|
||||
icon: undefined,
|
||||
actions: undefined,
|
||||
showOkButton: true,
|
||||
showCancelButton: false,
|
||||
cancelableByBgClick: true,
|
||||
okText: undefined,
|
||||
cancelText: undefined,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'done', v: { canceled: boolean; result: any }): void;
|
||||
(ev: 'done', v: { canceled: true } | { canceled: false, result: Result }): void;
|
||||
(ev: 'closed'): void;
|
||||
}>();
|
||||
|
||||
@@ -149,8 +147,11 @@ const okButtonDisabledReason = computed<null | 'charactersExceeded' | 'character
|
||||
return null;
|
||||
});
|
||||
|
||||
function done(canceled: boolean, result?) {
|
||||
emit('done', { canceled, result });
|
||||
// overload function を使いたいので lint エラーを無視する
|
||||
function done(canceled: true): void;
|
||||
function done(canceled: false, result: Result): void; // eslint-disable-line no-redeclare
|
||||
function done(canceled: boolean, result?: Result): void { // eslint-disable-line no-redeclare
|
||||
emit('done', { canceled, result } as { canceled: true } | { canceled: false, result: Result });
|
||||
modal.value?.close();
|
||||
}
|
||||
|
||||
|
@@ -39,13 +39,13 @@ withDefaults(defineProps<{
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'done', r?: Misskey.entities.DriveFile[]): void;
|
||||
(ev: 'done', r?: Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]): void;
|
||||
(ev: 'closed'): void;
|
||||
}>();
|
||||
|
||||
const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
|
||||
|
||||
const selected = ref<Misskey.entities.DriveFile[]>([]);
|
||||
const selected = ref<Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]>([]);
|
||||
|
||||
function ok() {
|
||||
emit('done', selected.value);
|
||||
@@ -57,7 +57,7 @@ function cancel() {
|
||||
dialog.value?.close();
|
||||
}
|
||||
|
||||
function onChangeSelection(files: Misskey.entities.DriveFile[]) {
|
||||
selected.value = files;
|
||||
function onChangeSelection(v: Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]) {
|
||||
selected.value = v;
|
||||
}
|
||||
</script>
|
||||
|
@@ -56,7 +56,7 @@ const props = withDefaults(defineProps<{
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'done', v: any): void;
|
||||
(ev: 'done', v: string): void;
|
||||
(ev: 'close'): void;
|
||||
(ev: 'closed'): void;
|
||||
}>();
|
||||
@@ -64,7 +64,7 @@ const emit = defineEmits<{
|
||||
const modal = shallowRef<InstanceType<typeof MkModal>>();
|
||||
const picker = shallowRef<InstanceType<typeof MkEmojiPicker>>();
|
||||
|
||||
function chosen(emoji: any) {
|
||||
function chosen(emoji: string) {
|
||||
emit('done', emoji);
|
||||
if (props.choseAndClose) {
|
||||
modal.value?.close();
|
||||
|
@@ -21,37 +21,37 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
<MkSpacer :marginMin="20" :marginMax="32">
|
||||
<div v-if="Object.keys(form).filter(item => !form[item].hidden).length > 0" class="_gaps_m">
|
||||
<template v-for="item in Object.keys(form).filter(item => !form[item].hidden)">
|
||||
<MkInput v-if="form[item].type === 'number'" v-model="values[item]" type="number" :step="form[item].step || 1">
|
||||
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template>
|
||||
<template v-if="form[item].description" #caption>{{ form[item].description }}</template>
|
||||
<template v-for="(v, k) in Object.fromEntries(Object.entries(form).filter(([_, v]) => !('hidden' in v) || 'hidden' in v && !v.hidden))">
|
||||
<MkInput v-if="v.type === 'number'" v-model="values[k]" type="number" :step="v.step || 1">
|
||||
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
|
||||
<template v-if="v.description" #caption>{{ v.description }}</template>
|
||||
</MkInput>
|
||||
<MkInput v-else-if="form[item].type === 'string' && !form[item].multiline" v-model="values[item]" type="text" :mfmAutocomplete="form[item].treatAsMfm">
|
||||
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template>
|
||||
<template v-if="form[item].description" #caption>{{ form[item].description }}</template>
|
||||
<MkInput v-else-if="v.type === 'string' && !v.multiline" v-model="values[k]" type="text" :mfmAutocomplete="v.treatAsMfm">
|
||||
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
|
||||
<template v-if="v.description" #caption>{{ v.description }}</template>
|
||||
</MkInput>
|
||||
<MkTextarea v-else-if="form[item].type === 'string' && form[item].multiline" v-model="values[item]" :mfmAutocomplete="form[item].treatAsMfm" :mfmPreview="form[item].treatAsMfm">
|
||||
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template>
|
||||
<template v-if="form[item].description" #caption>{{ form[item].description }}</template>
|
||||
<MkTextarea v-else-if="v.type === 'string' && v.multiline" v-model="values[k]" :mfmAutocomplete="v.treatAsMfm" :mfmPreview="v.treatAsMfm">
|
||||
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
|
||||
<template v-if="v.description" #caption>{{ v.description }}</template>
|
||||
</MkTextarea>
|
||||
<MkSwitch v-else-if="form[item].type === 'boolean'" v-model="values[item]">
|
||||
<span v-text="form[item].label || item"></span>
|
||||
<template v-if="form[item].description" #caption>{{ form[item].description }}</template>
|
||||
<MkSwitch v-else-if="v.type === 'boolean'" v-model="values[k]">
|
||||
<span v-text="v.label || k"></span>
|
||||
<template v-if="v.description" #caption>{{ v.description }}</template>
|
||||
</MkSwitch>
|
||||
<MkSelect v-else-if="form[item].type === 'enum'" v-model="values[item]">
|
||||
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template>
|
||||
<option v-for="option in form[item].enum" :key="option.value" :value="option.value">{{ option.label }}</option>
|
||||
<MkSelect v-else-if="v.type === 'enum'" v-model="values[k]">
|
||||
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
|
||||
<option v-for="option in v.enum" :key="option.value" :value="option.value">{{ option.label }}</option>
|
||||
</MkSelect>
|
||||
<MkRadios v-else-if="form[item].type === 'radio'" v-model="values[item]">
|
||||
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template>
|
||||
<option v-for="option in form[item].options" :key="option.value" :value="option.value">{{ option.label }}</option>
|
||||
<MkRadios v-else-if="v.type === 'radio'" v-model="values[k]">
|
||||
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
|
||||
<option v-for="option in v.options" :key="option.value" :value="option.value">{{ option.label }}</option>
|
||||
</MkRadios>
|
||||
<MkRange v-else-if="form[item].type === 'range'" v-model="values[item]" :min="form[item].min" :max="form[item].max" :step="form[item].step" :textConverter="form[item].textConverter">
|
||||
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template>
|
||||
<template v-if="form[item].description" #caption>{{ form[item].description }}</template>
|
||||
<MkRange v-else-if="v.type === 'range'" v-model="values[k]" :min="v.min" :max="v.max" :step="v.step" :textConverter="v.textConverter">
|
||||
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
|
||||
<template v-if="v.description" #caption>{{ v.description }}</template>
|
||||
</MkRange>
|
||||
<MkButton v-else-if="form[item].type === 'button'" @click="form[item].action($event, values)">
|
||||
<span v-text="form[item].content || item"></span>
|
||||
<MkButton v-else-if="v.type === 'button'" @click="v.action($event, values)">
|
||||
<span v-text="v.content || k"></span>
|
||||
</MkButton>
|
||||
</template>
|
||||
</div>
|
||||
@@ -72,19 +72,21 @@ import MkSelect from './MkSelect.vue';
|
||||
import MkRange from './MkRange.vue';
|
||||
import MkButton from './MkButton.vue';
|
||||
import MkRadios from './MkRadios.vue';
|
||||
import type { Form } from '@/scripts/form.js';
|
||||
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { infoImageUrl } from '@/instance.js';
|
||||
|
||||
const props = defineProps<{
|
||||
title: string;
|
||||
form: any;
|
||||
form: Form;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'done', v: {
|
||||
canceled?: boolean;
|
||||
result?: any;
|
||||
canceled: true;
|
||||
} | {
|
||||
result: Record<string, any>;
|
||||
}): void;
|
||||
(ev: 'closed'): void;
|
||||
}>();
|
||||
|
@@ -35,6 +35,7 @@ import { notificationTypes } from '@/const.js';
|
||||
import { infoImageUrl } from '@/instance.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
|
||||
const props = defineProps<{
|
||||
excludeTypes?: typeof notificationTypes[number][];
|
||||
@@ -75,15 +76,19 @@ function reload() {
|
||||
});
|
||||
}
|
||||
|
||||
let connection;
|
||||
let connection: Misskey.ChannelConnection<Misskey.Channels['main']>;
|
||||
|
||||
onMounted(() => {
|
||||
connection = useStream().useChannel('main');
|
||||
connection.on('notification', onNotification);
|
||||
connection.on('notificationFlushed', reload);
|
||||
});
|
||||
|
||||
onActivated(() => {
|
||||
pagingComponent.value?.reload();
|
||||
connection = useStream().useChannel('main');
|
||||
connection.on('notification', onNotification);
|
||||
connection.on('notificationFlushed', reload);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
|
@@ -173,7 +173,7 @@ const emit = defineEmits<{
|
||||
const textareaEl = shallowRef<HTMLTextAreaElement | null>(null);
|
||||
const cwInputEl = shallowRef<HTMLInputElement | null>(null);
|
||||
const hashtagsInputEl = shallowRef<HTMLInputElement | null>(null);
|
||||
const visibilityButton = shallowRef<HTMLElement | null>(null);
|
||||
const visibilityButton = shallowRef<HTMLElement>();
|
||||
|
||||
const posting = ref(false);
|
||||
const posted = ref(false);
|
||||
@@ -466,6 +466,7 @@ function setVisibility() {
|
||||
isSilenced: $i.isSilenced,
|
||||
localOnly: localOnly.value,
|
||||
src: visibilityButton.value,
|
||||
...(props.reply ? { isReplyVisibilitySpecified: props.reply.visibility === 'specified' } : {}),
|
||||
}, {
|
||||
changeVisibility: v => {
|
||||
visibility.value = v;
|
||||
|
@@ -9,21 +9,21 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<div :class="[$style.label, $style.item]">
|
||||
{{ i18n.ts.visibility }}
|
||||
</div>
|
||||
<button key="public" :disabled="isSilenced" class="_button" :class="[$style.item, { [$style.active]: v === 'public' }]" data-index="1" @click="choose('public')">
|
||||
<button key="public" :disabled="isSilenced || isReplyVisibilitySpecified" class="_button" :class="[$style.item, { [$style.active]: v === 'public' }]" data-index="1" @click="choose('public')">
|
||||
<div :class="$style.icon"><i class="ti ti-world"></i></div>
|
||||
<div :class="$style.body">
|
||||
<span :class="$style.itemTitle">{{ i18n.ts._visibility.public }}</span>
|
||||
<span :class="$style.itemDescription">{{ i18n.ts._visibility.publicDescription }}</span>
|
||||
</div>
|
||||
</button>
|
||||
<button key="home" class="_button" :class="[$style.item, { [$style.active]: v === 'home' }]" data-index="2" @click="choose('home')">
|
||||
<button key="home" :disabled="isReplyVisibilitySpecified" class="_button" :class="[$style.item, { [$style.active]: v === 'home' }]" data-index="2" @click="choose('home')">
|
||||
<div :class="$style.icon"><i class="ti ti-home"></i></div>
|
||||
<div :class="$style.body">
|
||||
<span :class="$style.itemTitle">{{ i18n.ts._visibility.home }}</span>
|
||||
<span :class="$style.itemDescription">{{ i18n.ts._visibility.homeDescription }}</span>
|
||||
</div>
|
||||
</button>
|
||||
<button key="followers" class="_button" :class="[$style.item, { [$style.active]: v === 'followers' }]" data-index="3" @click="choose('followers')">
|
||||
<button key="followers" :disabled="isReplyVisibilitySpecified" class="_button" :class="[$style.item, { [$style.active]: v === 'followers' }]" data-index="3" @click="choose('followers')">
|
||||
<div :class="$style.icon"><i class="ti ti-lock"></i></div>
|
||||
<div :class="$style.body">
|
||||
<span :class="$style.itemTitle">{{ i18n.ts._visibility.followers }}</span>
|
||||
@@ -54,6 +54,7 @@ const props = withDefaults(defineProps<{
|
||||
isSilenced: boolean;
|
||||
localOnly: boolean;
|
||||
src?: HTMLElement;
|
||||
isReplyVisibilitySpecified?: boolean;
|
||||
}>(), {
|
||||
});
|
||||
|
||||
|
@@ -81,6 +81,7 @@ export const ROLE_POLICIES = [
|
||||
'canDeleteContent',
|
||||
'canUpdateAvatar',
|
||||
'canUpdateBanner',
|
||||
'mentionLimit',
|
||||
'canInvite',
|
||||
'inviteLimit',
|
||||
'inviteLimitCycle',
|
||||
|
@@ -9,7 +9,8 @@ import { Component, markRaw, Ref, ref, defineAsyncComponent } from 'vue';
|
||||
import { EventEmitter } from 'eventemitter3';
|
||||
import insertTextAtCursor from 'insert-text-at-cursor';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import type { ComponentProps } from 'vue-component-type-helpers';
|
||||
import type { ComponentProps as CP } from 'vue-component-type-helpers';
|
||||
import type { Form, GetFormResultType } from '@/scripts/form.js';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
@@ -29,15 +30,15 @@ import { showMovedDialog } from '@/scripts/show-moved-dialog.js';
|
||||
|
||||
export const openingWindowsCount = ref(0);
|
||||
|
||||
export const apiWithDialog = ((
|
||||
endpoint: string,
|
||||
data: Record<string, any> = {},
|
||||
export const apiWithDialog = (<E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints, P extends Misskey.Endpoints[E]['req'] = Misskey.Endpoints[E]['req']>(
|
||||
endpoint: E,
|
||||
data: P = {} as P,
|
||||
token?: string | null | undefined,
|
||||
) => {
|
||||
const promise = misskeyApi(endpoint, data, token);
|
||||
promiseDialog(promise, null, async (err) => {
|
||||
let title = null;
|
||||
let text = err.message + '\n' + (err as any).id;
|
||||
let title: string | undefined;
|
||||
let text = err.message + '\n' + err.id;
|
||||
if (err.code === 'INTERNAL_ERROR') {
|
||||
title = i18n.ts.internalServerError;
|
||||
text = i18n.ts.internalServerErrorDescription;
|
||||
@@ -86,21 +87,21 @@ export const apiWithDialog = ((
|
||||
return promise;
|
||||
}) as typeof misskeyApi;
|
||||
|
||||
export function promiseDialog<T extends Promise<any>>(
|
||||
promise: T,
|
||||
onSuccess?: ((res: any) => void) | null,
|
||||
onFailure?: ((err: Error) => void) | null,
|
||||
export function promiseDialog<T>(
|
||||
promise: Promise<T>,
|
||||
onSuccess?: ((res: T) => void) | null,
|
||||
onFailure?: ((err: Misskey.api.APIError) => void) | null,
|
||||
text?: string,
|
||||
): T {
|
||||
): Promise<T> {
|
||||
const showing = ref(true);
|
||||
const success = ref(false);
|
||||
const result = ref(false);
|
||||
|
||||
promise.then(res => {
|
||||
if (onSuccess) {
|
||||
showing.value = false;
|
||||
onSuccess(res);
|
||||
} else {
|
||||
success.value = true;
|
||||
result.value = true;
|
||||
window.setTimeout(() => {
|
||||
showing.value = false;
|
||||
}, 1000);
|
||||
@@ -119,7 +120,7 @@ export function promiseDialog<T extends Promise<any>>(
|
||||
|
||||
// NOTE: dynamic importすると挙動がおかしくなる(showingの変更が伝播しない)
|
||||
popup(MkWaitingDialog, {
|
||||
success: success,
|
||||
success: result,
|
||||
showing: showing,
|
||||
text: text,
|
||||
}, {}, 'closed');
|
||||
@@ -150,21 +151,37 @@ export function claimZIndex(priority: keyof typeof zIndexes = 'low'): number {
|
||||
// 使い物にならないので、代わりに ['$props'] から色々省くことで emit の型を生成する
|
||||
// FIXME: 何故か *.ts ファイルからだと型がうまく取れない?ことがあるのをなんとかしたい
|
||||
type ComponentEmit<T> = T extends new () => { $props: infer Props }
|
||||
? EmitsExtractor<Props>
|
||||
: never;
|
||||
? [keyof Pick<T, Extract<keyof T, `on${string}`>>] extends [never]
|
||||
? Record<string, unknown> // *.ts ファイルから型がうまく取れないとき用(これがないと {} になって型エラーがうるさい)
|
||||
: EmitsExtractor<Props>
|
||||
: T extends (...args: any) => any
|
||||
? ReturnType<T> extends { [x: string]: any; __ctx?: { [x: string]: any; props: infer Props } }
|
||||
? [keyof Pick<T, Extract<keyof T, `on${string}`>>] extends [never]
|
||||
? Record<string, unknown>
|
||||
: EmitsExtractor<Props>
|
||||
: never
|
||||
: never;
|
||||
|
||||
// props に ref を許可するようにする
|
||||
type ComponentProps<T extends Component> = { [K in keyof CP<T>]: CP<T>[K] | Ref<CP<T>[K]> };
|
||||
|
||||
type EmitsExtractor<T> = {
|
||||
[K in keyof T as K extends `onVnode${string}` ? never : K extends `on${infer E}` ? Uncapitalize<E> : K extends string ? never : K]: T[K];
|
||||
};
|
||||
|
||||
export async function popup<T extends Component>(component: T, props: ComponentProps<T>, events: ComponentEmit<T> = {} as ComponentEmit<T>, disposeEvent?: keyof ComponentEmit<T>) {
|
||||
export async function popup<T extends Component>(
|
||||
component: T,
|
||||
props: ComponentProps<T>,
|
||||
events: ComponentEmit<T> = {} as ComponentEmit<T>,
|
||||
disposeEvent?: keyof ComponentEmit<T>,
|
||||
): Promise<{ dispose: () => void }> {
|
||||
markRaw(component);
|
||||
|
||||
const id = ++popupIdCount;
|
||||
const dispose = () => {
|
||||
// このsetTimeoutが無いと挙動がおかしくなる(autocompleteが閉じなくなる)。Vueのバグ?
|
||||
window.setTimeout(() => {
|
||||
popups.value = popups.value.filter(popup => popup.id !== id);
|
||||
popups.value = popups.value.filter(item => item.id !== id);
|
||||
}, 0);
|
||||
};
|
||||
const state = {
|
||||
@@ -201,9 +218,9 @@ export function alert(props: {
|
||||
title?: string | null;
|
||||
text?: string | null;
|
||||
}): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise(resolve => {
|
||||
popup(MkDialog, props, {
|
||||
done: result => {
|
||||
done: () => {
|
||||
resolve();
|
||||
},
|
||||
}, 'closed');
|
||||
@@ -217,7 +234,7 @@ export function confirm(props: {
|
||||
okText?: string;
|
||||
cancelText?: string;
|
||||
}): Promise<{ canceled: boolean }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise(resolve => {
|
||||
popup(MkDialog, {
|
||||
...props,
|
||||
showCancelButton: true,
|
||||
@@ -241,10 +258,12 @@ export function actions<T extends {
|
||||
title?: string | null;
|
||||
text?: string | null;
|
||||
actions: T;
|
||||
}): Promise<{ canceled: true; result: undefined; } | {
|
||||
}): Promise<{
|
||||
canceled: true; result: undefined;
|
||||
} | {
|
||||
canceled: false; result: T[number]['value'];
|
||||
}> {
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise(resolve => {
|
||||
popup(MkDialog, {
|
||||
...props,
|
||||
actions: props.actions.map(a => ({
|
||||
@@ -263,6 +282,21 @@ export function actions<T extends {
|
||||
});
|
||||
}
|
||||
|
||||
// default が指定されていたら result は null になり得ないことを保証する overload function
|
||||
export function inputText(props: {
|
||||
type?: 'text' | 'email' | 'password' | 'url' | 'textarea';
|
||||
title?: string | null;
|
||||
text?: string | null;
|
||||
placeholder?: string | null;
|
||||
autocomplete?: string;
|
||||
default: string;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
}): Promise<{
|
||||
canceled: true; result: undefined;
|
||||
} | {
|
||||
canceled: false; result: string;
|
||||
}>;
|
||||
export function inputText(props: {
|
||||
type?: 'text' | 'email' | 'password' | 'url' | 'textarea';
|
||||
title?: string | null;
|
||||
@@ -272,18 +306,34 @@ export function inputText(props: {
|
||||
default?: string | null;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
}): Promise<{ canceled: true; result: undefined; } | {
|
||||
canceled: false; result: string;
|
||||
}): Promise<{
|
||||
canceled: true; result: undefined;
|
||||
} | {
|
||||
canceled: false; result: string | null;
|
||||
}>;
|
||||
export function inputText(props: {
|
||||
type?: 'text' | 'email' | 'password' | 'url' | 'textarea';
|
||||
title?: string | null;
|
||||
text?: string | null;
|
||||
placeholder?: string | null;
|
||||
autocomplete?: string;
|
||||
default?: string | null;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
}): Promise<{
|
||||
canceled: true; result: undefined;
|
||||
} | {
|
||||
canceled: false; result: string | null;
|
||||
}> {
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise(resolve => {
|
||||
popup(MkDialog, {
|
||||
title: props.title,
|
||||
text: props.text,
|
||||
title: props.title ?? undefined,
|
||||
text: props.text ?? undefined,
|
||||
input: {
|
||||
type: props.type,
|
||||
placeholder: props.placeholder,
|
||||
autocomplete: props.autocomplete,
|
||||
default: props.default,
|
||||
default: props.default ?? null,
|
||||
minLength: props.minLength,
|
||||
maxLength: props.maxLength,
|
||||
},
|
||||
@@ -295,24 +345,49 @@ export function inputText(props: {
|
||||
});
|
||||
}
|
||||
|
||||
// default が指定されていたら result は null になり得ないことを保証する overload function
|
||||
export function inputNumber(props: {
|
||||
title?: string | null;
|
||||
text?: string | null;
|
||||
placeholder?: string | null;
|
||||
autocomplete?: string;
|
||||
default: number;
|
||||
}): Promise<{
|
||||
canceled: true; result: undefined;
|
||||
} | {
|
||||
canceled: false; result: number;
|
||||
}>;
|
||||
export function inputNumber(props: {
|
||||
title?: string | null;
|
||||
text?: string | null;
|
||||
placeholder?: string | null;
|
||||
autocomplete?: string;
|
||||
default?: number | null;
|
||||
}): Promise<{ canceled: true; result: undefined; } | {
|
||||
canceled: false; result: number;
|
||||
}): Promise<{
|
||||
canceled: true; result: undefined;
|
||||
} | {
|
||||
canceled: false; result: number | null;
|
||||
}>;
|
||||
export function inputNumber(props: {
|
||||
title?: string | null;
|
||||
text?: string | null;
|
||||
placeholder?: string | null;
|
||||
autocomplete?: string;
|
||||
default?: number | null;
|
||||
}): Promise<{
|
||||
canceled: true; result: undefined;
|
||||
} | {
|
||||
canceled: false; result: number | null;
|
||||
}> {
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise(resolve => {
|
||||
popup(MkDialog, {
|
||||
title: props.title,
|
||||
text: props.text,
|
||||
title: props.title ?? undefined,
|
||||
text: props.text ?? undefined,
|
||||
input: {
|
||||
type: 'number',
|
||||
placeholder: props.placeholder,
|
||||
autocomplete: props.autocomplete,
|
||||
default: props.default,
|
||||
default: props.default ?? null,
|
||||
},
|
||||
}, {
|
||||
done: result => {
|
||||
@@ -326,31 +401,35 @@ export function inputDate(props: {
|
||||
title?: string | null;
|
||||
text?: string | null;
|
||||
placeholder?: string | null;
|
||||
default?: Date | null;
|
||||
}): Promise<{ canceled: true; result: undefined; } | {
|
||||
default?: string | null;
|
||||
}): Promise<{
|
||||
canceled: true; result: undefined;
|
||||
} | {
|
||||
canceled: false; result: Date;
|
||||
}> {
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise(resolve => {
|
||||
popup(MkDialog, {
|
||||
title: props.title,
|
||||
text: props.text,
|
||||
title: props.title ?? undefined,
|
||||
text: props.text ?? undefined,
|
||||
input: {
|
||||
type: 'date',
|
||||
placeholder: props.placeholder,
|
||||
default: props.default,
|
||||
default: props.default ?? null,
|
||||
},
|
||||
}, {
|
||||
done: result => {
|
||||
resolve(result ? { result: new Date(result.result), canceled: false } : { canceled: true });
|
||||
resolve(result ? { result: new Date(result.result), canceled: false } : { result: undefined, canceled: true });
|
||||
},
|
||||
}, 'closed');
|
||||
});
|
||||
}
|
||||
|
||||
export function authenticateDialog(): Promise<{ canceled: true; result: undefined; } | {
|
||||
export function authenticateDialog(): Promise<{
|
||||
canceled: true; result: undefined;
|
||||
} | {
|
||||
canceled: false; result: { password: string; token: string | null; };
|
||||
}> {
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise(resolve => {
|
||||
popup(MkPasswordDialog, {}, {
|
||||
done: result => {
|
||||
resolve(result ? { canceled: false, result } : { canceled: true, result: undefined });
|
||||
@@ -359,34 +438,53 @@ export function authenticateDialog(): Promise<{ canceled: true; result: undefine
|
||||
});
|
||||
}
|
||||
|
||||
// default が指定されていたら result は null になり得ないことを保証する overload function
|
||||
export function select<C = any>(props: {
|
||||
title?: string | null;
|
||||
text?: string | null;
|
||||
default?: string | null;
|
||||
} & ({
|
||||
default: string;
|
||||
items: {
|
||||
value: C;
|
||||
text: string;
|
||||
}[];
|
||||
}): Promise<{
|
||||
canceled: true; result: undefined;
|
||||
} | {
|
||||
groupedItems: {
|
||||
label: string;
|
||||
items: {
|
||||
value: C;
|
||||
text: string;
|
||||
}[];
|
||||
}[];
|
||||
})): Promise<{ canceled: true; result: undefined; } | {
|
||||
canceled: false; result: C;
|
||||
}>;
|
||||
export function select<C = any>(props: {
|
||||
title?: string | null;
|
||||
text?: string | null;
|
||||
default?: string | null;
|
||||
items: {
|
||||
value: C;
|
||||
text: string;
|
||||
}[];
|
||||
}): Promise<{
|
||||
canceled: true; result: undefined;
|
||||
} | {
|
||||
canceled: false; result: C | null;
|
||||
}>;
|
||||
export function select<C = any>(props: {
|
||||
title?: string | null;
|
||||
text?: string | null;
|
||||
default?: string | null;
|
||||
items: {
|
||||
value: C;
|
||||
text: string;
|
||||
}[];
|
||||
}): Promise<{
|
||||
canceled: true; result: undefined;
|
||||
} | {
|
||||
canceled: false; result: C | null;
|
||||
}> {
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise(resolve => {
|
||||
popup(MkDialog, {
|
||||
title: props.title,
|
||||
text: props.text,
|
||||
title: props.title ?? undefined,
|
||||
text: props.text ?? undefined,
|
||||
select: {
|
||||
items: props.items,
|
||||
groupedItems: props.groupedItems,
|
||||
default: props.default,
|
||||
default: props.default ?? null,
|
||||
},
|
||||
}, {
|
||||
done: result => {
|
||||
@@ -397,7 +495,7 @@ export function select<C = any>(props: {
|
||||
}
|
||||
|
||||
export function success(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise(resolve => {
|
||||
const showing = ref(true);
|
||||
window.setTimeout(() => {
|
||||
showing.value = false;
|
||||
@@ -412,7 +510,7 @@ export function success(): Promise<void> {
|
||||
}
|
||||
|
||||
export function waiting(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise(resolve => {
|
||||
const showing = ref(true);
|
||||
popup(MkWaitingDialog, {
|
||||
success: false,
|
||||
@@ -423,9 +521,13 @@ export function waiting(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
export function form(title, form) {
|
||||
return new Promise((resolve, reject) => {
|
||||
popup(defineAsyncComponent(() => import('@/components/MkFormDialog.vue')), { title, form }, {
|
||||
export function form<F extends Form>(title: string, f: F): Promise<{
|
||||
canceled: true; result: undefined;
|
||||
} | {
|
||||
canceled: false; result: GetFormResultType<F>;
|
||||
}> {
|
||||
return new Promise(resolve => {
|
||||
popup(defineAsyncComponent(() => import('@/components/MkFormDialog.vue')), { title, form: f }, {
|
||||
done: result => {
|
||||
resolve(result);
|
||||
},
|
||||
@@ -434,7 +536,7 @@ export function form(title, form) {
|
||||
}
|
||||
|
||||
export async function selectUser(opts: { includeSelf?: boolean; localOnly?: boolean; } = {}): Promise<Misskey.entities.UserDetailed> {
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise(resolve => {
|
||||
popup(defineAsyncComponent(() => import('@/components/MkUserSelectDialog.vue')), {
|
||||
includeSelf: opts.includeSelf,
|
||||
localOnly: opts.localOnly,
|
||||
@@ -447,7 +549,7 @@ export async function selectUser(opts: { includeSelf?: boolean; localOnly?: bool
|
||||
}
|
||||
|
||||
export async function selectDriveFile(multiple: boolean): Promise<Misskey.entities.DriveFile[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise(resolve => {
|
||||
popup(defineAsyncComponent(() => import('@/components/MkDriveSelectDialog.vue')), {
|
||||
type: 'file',
|
||||
multiple,
|
||||
@@ -461,23 +563,23 @@ export async function selectDriveFile(multiple: boolean): Promise<Misskey.entiti
|
||||
});
|
||||
}
|
||||
|
||||
export async function selectDriveFolder(multiple: boolean) {
|
||||
return new Promise((resolve, reject) => {
|
||||
export async function selectDriveFolder(multiple: boolean): Promise<Misskey.entities.DriveFolder[]> {
|
||||
return new Promise(resolve => {
|
||||
popup(defineAsyncComponent(() => import('@/components/MkDriveSelectDialog.vue')), {
|
||||
type: 'folder',
|
||||
multiple,
|
||||
}, {
|
||||
done: folders => {
|
||||
if (folders) {
|
||||
resolve(multiple ? folders : folders[0]);
|
||||
resolve(folders);
|
||||
}
|
||||
},
|
||||
}, 'closed');
|
||||
});
|
||||
}
|
||||
|
||||
export async function pickEmoji(src: HTMLElement | null, opts) {
|
||||
return new Promise((resolve, reject) => {
|
||||
export async function pickEmoji(src: HTMLElement, opts: ComponentProps<typeof MkEmojiPickerDialog>): Promise<string> {
|
||||
return new Promise(resolve => {
|
||||
popup(MkEmojiPickerDialog, {
|
||||
src,
|
||||
...opts,
|
||||
@@ -493,7 +595,7 @@ export async function cropImage(image: Misskey.entities.DriveFile, options: {
|
||||
aspectRatio: number;
|
||||
uploadFolder?: string | null;
|
||||
}): Promise<Misskey.entities.DriveFile> {
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise(resolve => {
|
||||
popup(defineAsyncComponent(() => import('@/components/MkCropperDialog.vue')), {
|
||||
file: image,
|
||||
aspectRatio: options.aspectRatio,
|
||||
@@ -512,26 +614,28 @@ type AwaitType<T> =
|
||||
T;
|
||||
let openingEmojiPicker: AwaitType<ReturnType<typeof popup>> | null = null;
|
||||
let activeTextarea: HTMLTextAreaElement | HTMLInputElement | null = null;
|
||||
export async function openEmojiPicker(src?: HTMLElement, opts, initialTextarea: typeof activeTextarea) {
|
||||
export async function openEmojiPicker(src: HTMLElement, opts: ComponentProps<typeof MkEmojiPickerWindow>, initialTextarea: typeof activeTextarea) {
|
||||
if (openingEmojiPicker) return;
|
||||
|
||||
activeTextarea = initialTextarea;
|
||||
|
||||
const textareas = document.querySelectorAll('textarea, input');
|
||||
for (const textarea of Array.from(textareas)) {
|
||||
for (const textarea of Array.from(
|
||||
document.querySelectorAll('textarea, input'),
|
||||
)) {
|
||||
textarea.addEventListener('focus', () => {
|
||||
activeTextarea = textarea;
|
||||
activeTextarea = textarea as HTMLTextAreaElement | HTMLInputElement;
|
||||
});
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(records => {
|
||||
for (const record of records) {
|
||||
for (const node of Array.from(record.addedNodes).filter(node => node instanceof HTMLElement) as HTMLElement[]) {
|
||||
const textareas = node.querySelectorAll('textarea, input') as NodeListOf<NonNullable<typeof activeTextarea>>;
|
||||
for (const textarea of Array.from(textareas).filter(textarea => textarea.dataset.preventEmojiInsert == null)) {
|
||||
if (document.activeElement === textarea) activeTextarea = textarea;
|
||||
for (const node of Array.from(record.addedNodes).filter(n => n instanceof HTMLElement) as HTMLElement[]) {
|
||||
for (const textarea of Array.from(
|
||||
node.querySelectorAll('textarea, input'),
|
||||
).filter(el => (el as HTMLTextAreaElement | HTMLInputElement).dataset.preventEmojiInsert == null)) {
|
||||
if (document.activeElement === textarea) activeTextarea = textarea as HTMLTextAreaElement | HTMLInputElement;
|
||||
textarea.addEventListener('focus', () => {
|
||||
activeTextarea = textarea;
|
||||
activeTextarea = textarea as HTMLTextAreaElement | HTMLInputElement;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -547,7 +651,7 @@ export async function openEmojiPicker(src?: HTMLElement, opts, initialTextarea:
|
||||
|
||||
openingEmojiPicker = await popup(MkEmojiPickerWindow, {
|
||||
src,
|
||||
pinnedEmojis: opts?.asReactionPicker ? defaultStore.reactiveState.reactions : defaultStore.reactiveState.pinnedEmojis,
|
||||
pinnedEmojis: opts.asReactionPicker ? defaultStore.reactiveState.reactions : defaultStore.reactiveState.pinnedEmojis,
|
||||
...opts,
|
||||
}, {
|
||||
chosen: emoji => {
|
||||
@@ -561,13 +665,13 @@ export async function openEmojiPicker(src?: HTMLElement, opts, initialTextarea:
|
||||
});
|
||||
}
|
||||
|
||||
export function popupMenu(items: MenuItem[] | Ref<MenuItem[]>, src?: HTMLElement | EventTarget | null, options?: {
|
||||
export function popupMenu(items: MenuItem[], src?: HTMLElement | EventTarget | null, options?: {
|
||||
align?: string;
|
||||
width?: number;
|
||||
viaKeyboard?: boolean;
|
||||
onClosing?: () => void;
|
||||
}): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise(resolve => {
|
||||
let dispose;
|
||||
popup(MkPopupMenu, {
|
||||
items,
|
||||
@@ -589,9 +693,9 @@ export function popupMenu(items: MenuItem[] | Ref<MenuItem[]>, src?: HTMLElement
|
||||
});
|
||||
}
|
||||
|
||||
export function contextMenu(items: MenuItem[] | Ref<MenuItem[]>, ev: MouseEvent): Promise<void> {
|
||||
export function contextMenu(items: MenuItem[], ev: MouseEvent): Promise<void> {
|
||||
ev.preventDefault();
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise(resolve => {
|
||||
let dispose;
|
||||
popup(MkContextMenu, {
|
||||
items,
|
||||
@@ -610,7 +714,7 @@ export function contextMenu(items: MenuItem[] | Ref<MenuItem[]>, ev: MouseEvent)
|
||||
export function post(props: Record<string, any> = {}): Promise<void> {
|
||||
showMovedDialog();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise(resolve => {
|
||||
// NOTE: MkPostFormDialogをdynamic importするとiOSでテキストエリアに自動フォーカスできない
|
||||
// NOTE: ただ、dynamic importしない場合、MkPostFormDialogインスタンスが使いまわされ、
|
||||
// Vueが渡されたコンポーネントに内部的に__propsというプロパティを生やす影響で、
|
||||
@@ -629,17 +733,3 @@ export function post(props: Record<string, any> = {}): Promise<void> {
|
||||
}
|
||||
|
||||
export const deckGlobalEvents = new EventEmitter();
|
||||
|
||||
/*
|
||||
export function checkExistence(fileData: ArrayBuffer): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const data = new FormData();
|
||||
data.append('md5', getMD5(fileData));
|
||||
|
||||
api('drive/files/find-by-hash', {
|
||||
md5: getMD5(fileData)
|
||||
}).then(resp => {
|
||||
resolve(resp.length > 0 ? resp[0] : null);
|
||||
});
|
||||
});
|
||||
}*/
|
||||
|
@@ -9,6 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<MkSelect v-model="type" :class="$style.typeSelect">
|
||||
<option value="isLocal">{{ i18n.ts._role._condition.isLocal }}</option>
|
||||
<option value="isRemote">{{ i18n.ts._role._condition.isRemote }}</option>
|
||||
<option value="roleAssignedTo">{{ i18n.ts._role._condition.roleAssignedTo }}</option>
|
||||
<option value="createdLessThan">{{ i18n.ts._role._condition.createdLessThan }}</option>
|
||||
<option value="createdMoreThan">{{ i18n.ts._role._condition.createdMoreThan }}</option>
|
||||
<option value="followersLessThanOrEq">{{ i18n.ts._role._condition.followersLessThanOrEq }}</option>
|
||||
@@ -51,6 +52,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
<MkInput v-else-if="['followersLessThanOrEq', 'followersMoreThanOrEq', 'followingLessThanOrEq', 'followingMoreThanOrEq', 'notesLessThanOrEq', 'notesMoreThanOrEq'].includes(type)" v-model="v.value" type="number">
|
||||
</MkInput>
|
||||
|
||||
<MkSelect v-else-if="type === 'roleAssignedTo'" v-model="v.roleId">
|
||||
<option v-for="role in roles.filter(r => r.target === 'manual')" :key="role.id" :value="role.id">{{ role.name }}</option>
|
||||
</MkSelect>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -62,6 +67,7 @@ import MkSelect from '@/components/MkSelect.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { deepClone } from '@/scripts/clone.js';
|
||||
import { rolesCache } from '@/cache.js';
|
||||
|
||||
const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
|
||||
|
||||
@@ -77,6 +83,8 @@ const props = defineProps<{
|
||||
|
||||
const v = ref(deepClone(props.modelValue));
|
||||
|
||||
const roles = await rolesCache.fetch();
|
||||
|
||||
watch(() => props.modelValue, () => {
|
||||
if (JSON.stringify(props.modelValue) === JSON.stringify(v.value)) return;
|
||||
v.value = deepClone(props.modelValue);
|
||||
@@ -92,6 +100,7 @@ const type = computed({
|
||||
if (t === 'and') v.value.values = [];
|
||||
if (t === 'or') v.value.values = [];
|
||||
if (t === 'not') v.value.value = { id: uuid(), type: 'isRemote' };
|
||||
if (t === 'roleAssignedTo') v.value.roleId = '';
|
||||
if (t === 'createdLessThan') v.value.sec = 86400;
|
||||
if (t === 'createdMoreThan') v.value.sec = 86400;
|
||||
if (t === 'followersLessThanOrEq') v.value.value = 10;
|
||||
|
@@ -280,6 +280,25 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder v-if="matchQuery([i18n.ts._role._options.mentionMax, 'mentionLimit'])">
|
||||
<template #label>{{ i18n.ts._role._options.mentionMax }}</template>
|
||||
<template #suffix>
|
||||
<span v-if="role.policies.mentionLimit.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
|
||||
<span v-else>{{ role.policies.mentionLimit.value }}</span>
|
||||
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.mentionLimit)"></i></span>
|
||||
</template>
|
||||
<div class="_gaps">
|
||||
<MkSwitch v-model="role.policies.mentionLimit.useDefault" :readonly="readonly">
|
||||
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
|
||||
</MkSwitch>
|
||||
<MkInput v-model="role.policies.mentionLimit.value" :disabled="role.policies.mentionLimit.useDefault" type="number" :readonly="readonly">
|
||||
</MkInput>
|
||||
<MkRange v-model="role.policies.mentionLimit.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
|
||||
<template #label>{{ i18n.ts._role.priority }}</template>
|
||||
</MkRange>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder v-if="matchQuery([i18n.ts._role._options.canInvite, 'canInvite'])">
|
||||
<template #label>{{ i18n.ts._role._options.canInvite }}</template>
|
||||
<template #suffix>
|
||||
|
@@ -96,6 +96,13 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</MkSwitch>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder v-if="matchQuery([i18n.ts._role._options.mentionMax, 'mentionLimit'])">
|
||||
<template #label>{{ i18n.ts._role._options.mentionMax }}</template>
|
||||
<template #suffix>{{ policies.mentionLimit }}</template>
|
||||
<MkInput v-model="policies.mentionLimit" type="number">
|
||||
</MkInput>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder v-if="matchQuery([i18n.ts._role._options.canInvite, 'canInvite'])">
|
||||
<template #label>{{ i18n.ts._role._options.canInvite }}</template>
|
||||
<template #suffix>{{ policies.canInvite ? i18n.ts.yes : i18n.ts.no }}</template>
|
||||
|
@@ -178,7 +178,7 @@ async function addRole(type: boolean) {
|
||||
const { canceled, result: role } = await os.select({
|
||||
items: roles.filter(r => r.isPublic).filter(r => !currentRoleIds.includes(r.id)).map(r => ({ text: r.name, value: r })),
|
||||
});
|
||||
if (canceled) return;
|
||||
if (canceled || role == null) return;
|
||||
|
||||
if (type) rolesThatCanBeUsedThisEmojiAsReaction.value.push(role);
|
||||
else rolesThatCanNotBeUsedThisEmojiAsReaction.value.push(role);
|
||||
|
@@ -52,7 +52,7 @@ const directNotesPagination = {
|
||||
function setFilter(ev) {
|
||||
const typeItems = notificationTypes.map(t => ({
|
||||
text: i18n.ts._notification._types[t],
|
||||
active: includeTypes.value && includeTypes.value.includes(t),
|
||||
active: (includeTypes.value && includeTypes.value.includes(t)) ?? false,
|
||||
action: () => {
|
||||
includeTypes.value = [t];
|
||||
},
|
||||
@@ -63,7 +63,7 @@ function setFilter(ev) {
|
||||
action: () => {
|
||||
includeTypes.value = null;
|
||||
},
|
||||
}, { type: 'divider' }, ...typeItems] : typeItems;
|
||||
}, { type: 'divider' as const }, ...typeItems] : typeItems;
|
||||
os.popupMenu(items, ev.currentTarget ?? ev.target);
|
||||
}
|
||||
|
||||
|
@@ -113,7 +113,7 @@ if (defaultStore.state.uploadFolder) {
|
||||
|
||||
function chooseUploadFolder() {
|
||||
os.selectDriveFolder(false).then(async folder => {
|
||||
defaultStore.set('uploadFolder', folder ? folder.id : null);
|
||||
defaultStore.set('uploadFolder', folder[0] ? folder[0].id : null);
|
||||
os.success();
|
||||
if (defaultStore.state.uploadFolder) {
|
||||
uploadFolder.value = await misskeyApi('drive/folders/show', {
|
||||
|
@@ -213,7 +213,7 @@ async function pickEmoji(itemsRef: Ref<string[]>, ev: MouseEvent) {
|
||||
os.pickEmoji(getHTMLElement(ev), {
|
||||
showPinned: false,
|
||||
}).then(it => {
|
||||
const emoji = it as string;
|
||||
const emoji = it;
|
||||
if (!itemsRef.value.includes(emoji)) {
|
||||
itemsRef.value.push(emoji);
|
||||
}
|
||||
|
@@ -10,6 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<option value="following">{{ i18n.ts.following }}</option>
|
||||
<option value="follower">{{ i18n.ts.followers }}</option>
|
||||
<option value="mutualFollow">{{ i18n.ts.mutualFollow }}</option>
|
||||
<option value="followingOrFollower">{{ i18n.ts.followingOrFollower }}</option>
|
||||
<option value="list">{{ i18n.ts.userList }}</option>
|
||||
<option value="never">{{ i18n.ts.none }}</option>
|
||||
</MkSelect>
|
||||
|
@@ -16,6 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
$i.notificationRecieveConfig[type]?.type === 'following' ? i18n.ts.following :
|
||||
$i.notificationRecieveConfig[type]?.type === 'follower' ? i18n.ts.followers :
|
||||
$i.notificationRecieveConfig[type]?.type === 'mutualFollow' ? i18n.ts.mutualFollow :
|
||||
$i.notificationRecieveConfig[type]?.type === 'followingOrFollower' ? i18n.ts.followingOrFollower :
|
||||
$i.notificationRecieveConfig[type]?.type === 'list' ? i18n.ts.userList :
|
||||
i18n.ts.all
|
||||
}}
|
||||
@@ -34,6 +35,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<FormSection>
|
||||
<div class="_gaps_m">
|
||||
<FormLink @click="testNotification">{{ i18n.ts._notification.sendTestNotification }}</FormLink>
|
||||
<FormLink @click="flushNotification">{{ i18n.ts._notification.flushNotification }}</FormLink>
|
||||
</div>
|
||||
</FormSection>
|
||||
<FormSection>
|
||||
@@ -113,6 +115,17 @@ function testNotification(): void {
|
||||
misskeyApi('notifications/test-notification');
|
||||
}
|
||||
|
||||
async function flushNotification() {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'warning',
|
||||
text: i18n.ts.resetAreYouSure,
|
||||
});
|
||||
|
||||
if (canceled) return;
|
||||
|
||||
os.apiWithDialog('notifications/flush');
|
||||
}
|
||||
|
||||
const headerActions = computed(() => []);
|
||||
|
||||
const headerTabs = computed(() => []);
|
||||
|
@@ -204,6 +204,7 @@ async function saveNew(): Promise<void> {
|
||||
|
||||
const { canceled, result: name } = await os.inputText({
|
||||
title: ts._preferencesBackups.inputName,
|
||||
default: '',
|
||||
});
|
||||
if (canceled) return;
|
||||
|
||||
@@ -372,6 +373,7 @@ async function rename(id: string): Promise<void> {
|
||||
|
||||
const { canceled: cancel1, result: name } = await os.inputText({
|
||||
title: ts._preferencesBackups.inputName,
|
||||
default: '',
|
||||
});
|
||||
if (cancel1 || profiles.value[id].name === name) return;
|
||||
|
||||
|
@@ -12,29 +12,37 @@ export type FormItem = {
|
||||
label?: string;
|
||||
type: 'string';
|
||||
default: string | null;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
hidden?: boolean;
|
||||
multiline?: boolean;
|
||||
treatAsMfm?: boolean;
|
||||
} | {
|
||||
label?: string;
|
||||
type: 'number';
|
||||
default: number | null;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
hidden?: boolean;
|
||||
step?: number;
|
||||
} | {
|
||||
label?: string;
|
||||
type: 'boolean';
|
||||
default: boolean | null;
|
||||
description?: string;
|
||||
hidden?: boolean;
|
||||
} | {
|
||||
label?: string;
|
||||
type: 'enum';
|
||||
default: string | null;
|
||||
required?: boolean;
|
||||
hidden?: boolean;
|
||||
enum: EnumItem[];
|
||||
} | {
|
||||
label?: string;
|
||||
type: 'radio';
|
||||
default: unknown | null;
|
||||
required?: boolean;
|
||||
hidden?: boolean;
|
||||
options: {
|
||||
label: string;
|
||||
@@ -44,9 +52,12 @@ export type FormItem = {
|
||||
label?: string;
|
||||
type: 'range';
|
||||
default: number | null;
|
||||
step: number;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
step?: number;
|
||||
min: number;
|
||||
max: number;
|
||||
textConverter?: (value: number) => string;
|
||||
} | {
|
||||
label?: string;
|
||||
type: 'object';
|
||||
@@ -57,6 +68,10 @@ export type FormItem = {
|
||||
type: 'array';
|
||||
default: unknown[] | null;
|
||||
hidden: boolean;
|
||||
} | {
|
||||
type: 'button';
|
||||
content?: string;
|
||||
action: (ev: MouseEvent, v: any) => void;
|
||||
};
|
||||
|
||||
export type Form = Record<string, FormItem>;
|
||||
|
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export type EmojiDef = {
|
||||
emoji: string;
|
||||
name: string;
|
||||
|
@@ -117,6 +117,7 @@ import XMentionsColumn from '@/ui/deck/mentions-column.vue';
|
||||
import XDirectColumn from '@/ui/deck/direct-column.vue';
|
||||
import XRoleTimelineColumn from '@/ui/deck/role-timeline-column.vue';
|
||||
import { mainRouter } from '@/router/main.js';
|
||||
import { MenuItem } from '@/types/menu.js';
|
||||
const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue'));
|
||||
const XAnnouncements = defineAsyncComponent(() => import('@/ui/_common_/announcements.vue'));
|
||||
|
||||
@@ -221,21 +222,19 @@ document.documentElement.style.scrollBehavior = 'auto';
|
||||
loadDeck();
|
||||
|
||||
function changeProfile(ev: MouseEvent) {
|
||||
const items = ref([{
|
||||
const items: MenuItem[] = [{
|
||||
text: deckStore.state.profile,
|
||||
active: true.valueOf,
|
||||
}]);
|
||||
active: true,
|
||||
action: () => {},
|
||||
}];
|
||||
getProfiles().then(profiles => {
|
||||
items.value = [{
|
||||
text: deckStore.state.profile,
|
||||
active: true.valueOf,
|
||||
}, ...(profiles.filter(k => k !== deckStore.state.profile).map(k => ({
|
||||
items.push(...(profiles.filter(k => k !== deckStore.state.profile).map(k => ({
|
||||
text: k,
|
||||
action: () => {
|
||||
deckStore.set('profile', k);
|
||||
unisonReload();
|
||||
},
|
||||
}))), { type: 'divider' }, {
|
||||
}))), { type: 'divider' as const }, {
|
||||
text: i18n.ts._deck.newProfile,
|
||||
icon: 'ti ti-plus',
|
||||
action: async () => {
|
||||
@@ -248,9 +247,10 @@ function changeProfile(ev: MouseEvent) {
|
||||
deckStore.set('profile', name);
|
||||
unisonReload();
|
||||
},
|
||||
}];
|
||||
});
|
||||
}).then(() => {
|
||||
os.popupMenu(items, ev.currentTarget ?? ev.target);
|
||||
});
|
||||
os.popupMenu(items, ev.currentTarget ?? ev.target);
|
||||
}
|
||||
|
||||
async function deleteProfile() {
|
||||
|
@@ -93,10 +93,10 @@ const fetch = () => {
|
||||
|
||||
const choose = () => {
|
||||
os.selectDriveFolder(false).then(folder => {
|
||||
if (folder == null) {
|
||||
if (folder[0] == null) {
|
||||
return;
|
||||
}
|
||||
widgetProps.folderId = folder.id;
|
||||
widgetProps.folderId = folder[0].id;
|
||||
save();
|
||||
fetch();
|
||||
});
|
||||
|
@@ -7,28 +7,28 @@ import { assert, describe, test } from 'vitest';
|
||||
import { searchEmoji } from '@/scripts/search-emoji.js';
|
||||
|
||||
describe('emoji autocomplete', () => {
|
||||
test('名前の完全一致は名前の前方一致より優先される', async () => {
|
||||
const result = searchEmoji('foooo', [{ emoji: ':foooo:', name: 'foooo' }, { emoji: ':foooobaaar:', name: 'foooobaaar' }]);
|
||||
assert.equal(result[0].emoji, ':foooo:');
|
||||
});
|
||||
test('名前の完全一致は名前の前方一致より優先される', async () => {
|
||||
const result = searchEmoji('foooo', [{ emoji: ':foooo:', name: 'foooo' }, { emoji: ':foooobaaar:', name: 'foooobaaar' }]);
|
||||
assert.equal(result[0].emoji, ':foooo:');
|
||||
});
|
||||
|
||||
test('名前の前方一致は名前の部分一致より優先される', async () => {
|
||||
const result = searchEmoji('baaa', [{ emoji: ':baaar:', name: 'baaar' }, { emoji: ':foooobaaar:', name: 'foooobaaar' }]);
|
||||
assert.equal(result[0].emoji, ':baaar:');
|
||||
});
|
||||
test('名前の前方一致は名前の部分一致より優先される', async () => {
|
||||
const result = searchEmoji('baaa', [{ emoji: ':baaar:', name: 'baaar' }, { emoji: ':foooobaaar:', name: 'foooobaaar' }]);
|
||||
assert.equal(result[0].emoji, ':baaar:');
|
||||
});
|
||||
|
||||
test('名前の完全一致はタグの完全一致より優先される', async () => {
|
||||
const result = searchEmoji('foooo', [{ emoji: ':foooo:', name: 'foooo' }, { emoji: ':baaar:', name: 'foooo', aliasOf: 'baaar' }]);
|
||||
assert.equal(result[0].emoji, ':foooo:');
|
||||
});
|
||||
test('名前の完全一致はタグの完全一致より優先される', async () => {
|
||||
const result = searchEmoji('foooo', [{ emoji: ':foooo:', name: 'foooo' }, { emoji: ':baaar:', name: 'foooo', aliasOf: 'baaar' }]);
|
||||
assert.equal(result[0].emoji, ':foooo:');
|
||||
});
|
||||
|
||||
test('名前の前方一致はタグの前方一致より優先される', async () => {
|
||||
const result = searchEmoji('foo', [{ emoji: ':foooo:', name: 'foooo' }, { emoji: ':baaar:', name: 'foooo', aliasOf: 'baaar' }]);
|
||||
assert.equal(result[0].emoji, ':foooo:');
|
||||
});
|
||||
test('名前の前方一致はタグの前方一致より優先される', async () => {
|
||||
const result = searchEmoji('foo', [{ emoji: ':foooo:', name: 'foooo' }, { emoji: ':baaar:', name: 'foooo', aliasOf: 'baaar' }]);
|
||||
assert.equal(result[0].emoji, ':foooo:');
|
||||
});
|
||||
|
||||
test('名前の部分一致はタグの部分一致より優先される', async () => {
|
||||
const result = searchEmoji('oooo', [{ emoji: ':foooo:', name: 'foooo' }, { emoji: ':baaar:', name: 'foooo', aliasOf: 'baaar' }]);
|
||||
assert.equal(result[0].emoji, ':foooo:');
|
||||
});
|
||||
test('名前の部分一致はタグの部分一致より優先される', async () => {
|
||||
const result = searchEmoji('oooo', [{ emoji: ':foooo:', name: 'foooo' }, { emoji: ':baaar:', name: 'foooo', aliasOf: 'baaar' }]);
|
||||
assert.equal(result[0].emoji, ':foooo:');
|
||||
});
|
||||
});
|
||||
|
@@ -9,6 +9,7 @@ import { onScrollBottom, onScrollTop } from '@/scripts/scroll.js';
|
||||
|
||||
describe('Scroll', () => {
|
||||
describe('onScrollTop', () => {
|
||||
/* 動作しない(happy-domのバグ?)
|
||||
test('Initial onScrollTop callback for connected elements', () => {
|
||||
const { document } = new Window();
|
||||
const div = document.createElement('div');
|
||||
@@ -21,6 +22,7 @@ describe('Scroll', () => {
|
||||
|
||||
assert.ok(called);
|
||||
});
|
||||
*/
|
||||
|
||||
test('No onScrollTop callback for disconnected elements', () => {
|
||||
const { document } = new Window();
|
||||
@@ -35,11 +37,11 @@ describe('Scroll', () => {
|
||||
});
|
||||
|
||||
describe('onScrollBottom', () => {
|
||||
/* 動作しない(happy-domのバグ?)
|
||||
test('Initial onScrollBottom callback for connected elements', () => {
|
||||
const { document } = new Window();
|
||||
const div = document.createElement('div');
|
||||
assert.strictEqual(div.scrollTop, 0);
|
||||
(div as any).scrollHeight = 100; // happy-dom has no scrollHeight
|
||||
|
||||
document.body.append(div);
|
||||
|
||||
@@ -48,12 +50,12 @@ describe('Scroll', () => {
|
||||
|
||||
assert.ok(called);
|
||||
});
|
||||
*/
|
||||
|
||||
test('No onScrollBottom callback for disconnected elements', () => {
|
||||
const { document } = new Window();
|
||||
const div = document.createElement('div');
|
||||
assert.strictEqual(div.scrollTop, 0);
|
||||
(div as any).scrollHeight = 100; // happy-dom has no scrollHeight
|
||||
|
||||
let called = false;
|
||||
onScrollBottom(div as any as HTMLElement, () => called = true);
|
||||
|
@@ -26,12 +26,12 @@
|
||||
"devDependencies": {
|
||||
"@misskey-dev/eslint-plugin": "1.0.0",
|
||||
"@types/matter-js": "0.19.6",
|
||||
"@types/node": "20.11.19",
|
||||
"@types/node": "20.11.24",
|
||||
"@types/seedrandom": "3.0.8",
|
||||
"@typescript-eslint/eslint-plugin": "7.0.2",
|
||||
"@typescript-eslint/parser": "7.0.2",
|
||||
"eslint": "8.56.0",
|
||||
"nodemon": "3.0.3",
|
||||
"@typescript-eslint/eslint-plugin": "7.1.0",
|
||||
"@typescript-eslint/parser": "7.1.0",
|
||||
"eslint": "8.57.0",
|
||||
"nodemon": "3.1.0",
|
||||
"typescript": "5.3.3"
|
||||
},
|
||||
"files": [
|
||||
|
@@ -569,6 +569,7 @@ export type Channels = {
|
||||
unreadNotification: (payload: Notification_2) => void;
|
||||
unreadMention: (payload: Note['id']) => void;
|
||||
readAllUnreadMentions: () => void;
|
||||
notificationFlushed: () => void;
|
||||
unreadSpecifiedNote: (payload: Note['id']) => void;
|
||||
readAllUnreadSpecifiedNotes: () => void;
|
||||
readAllAntennas: () => void;
|
||||
@@ -1774,6 +1775,7 @@ declare namespace entities {
|
||||
RoleCondFormulaLogics,
|
||||
RoleCondFormulaValueNot,
|
||||
RoleCondFormulaValueIsLocalOrRemote,
|
||||
RoleCondFormulaValueAssignedRole,
|
||||
RoleCondFormulaValueCreated,
|
||||
RoleCondFormulaFollowersOrFollowingOrNotes,
|
||||
RoleCondFormulaValue,
|
||||
@@ -2815,6 +2817,9 @@ type RoleCondFormulaLogics = components['schemas']['RoleCondFormulaLogics'];
|
||||
// @public (undocumented)
|
||||
type RoleCondFormulaValue = components['schemas']['RoleCondFormulaValue'];
|
||||
|
||||
// @public (undocumented)
|
||||
type RoleCondFormulaValueAssignedRole = components['schemas']['RoleCondFormulaValueAssignedRole'];
|
||||
|
||||
// @public (undocumented)
|
||||
type RoleCondFormulaValueCreated = components['schemas']['RoleCondFormulaValueCreated'];
|
||||
|
||||
|
@@ -9,10 +9,10 @@
|
||||
"devDependencies": {
|
||||
"@misskey-dev/eslint-plugin": "^1.0.0",
|
||||
"@readme/openapi-parser": "2.5.0",
|
||||
"@types/node": "20.11.19",
|
||||
"@typescript-eslint/eslint-plugin": "7.0.2",
|
||||
"@typescript-eslint/parser": "7.0.2",
|
||||
"eslint": "8.56.0",
|
||||
"@types/node": "20.11.24",
|
||||
"@typescript-eslint/eslint-plugin": "7.1.0",
|
||||
"@typescript-eslint/parser": "7.1.0",
|
||||
"eslint": "8.57.0",
|
||||
"openapi-types": "12.1.3",
|
||||
"openapi-typescript": "6.7.4",
|
||||
"ts-case-convert": "2.0.7",
|
||||
|
@@ -35,21 +35,21 @@
|
||||
"url": "git+https://github.com/misskey-dev/misskey.js.git"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@microsoft/api-extractor": "7.40.5",
|
||||
"@microsoft/api-extractor": "7.42.1",
|
||||
"@misskey-dev/eslint-plugin": "1.0.0",
|
||||
"@swc/jest": "0.2.36",
|
||||
"@types/jest": "29.5.12",
|
||||
"@types/node": "20.11.19",
|
||||
"@typescript-eslint/eslint-plugin": "7.0.2",
|
||||
"@typescript-eslint/parser": "7.0.2",
|
||||
"eslint": "8.56.0",
|
||||
"@types/node": "20.11.24",
|
||||
"@typescript-eslint/eslint-plugin": "7.1.0",
|
||||
"@typescript-eslint/parser": "7.1.0",
|
||||
"eslint": "8.57.0",
|
||||
"jest": "29.7.0",
|
||||
"jest-fetch-mock": "3.0.3",
|
||||
"jest-websocket-mock": "2.5.0",
|
||||
"mock-socket": "9.3.1",
|
||||
"ncp": "2.0.0",
|
||||
"nodemon": "3.0.3",
|
||||
"tsd": "0.30.5",
|
||||
"nodemon": "3.1.0",
|
||||
"tsd": "0.30.7",
|
||||
"typescript": "5.3.3"
|
||||
},
|
||||
"files": [
|
||||
|
@@ -3294,6 +3294,17 @@ declare module '../api.js' {
|
||||
credential?: string | null,
|
||||
): Promise<SwitchCaseResponseType<E, P>>;
|
||||
|
||||
/**
|
||||
* No description provided.
|
||||
*
|
||||
* **Credential required**: *Yes* / **Permission**: *write:notifications*
|
||||
*/
|
||||
request<E extends 'notifications/flush', P extends Endpoints[E]['req']>(
|
||||
endpoint: E,
|
||||
params: P,
|
||||
credential?: string | null,
|
||||
): Promise<SwitchCaseResponseType<E, P>>;
|
||||
|
||||
/**
|
||||
* No description provided.
|
||||
*
|
||||
|
@@ -865,6 +865,7 @@ export type Endpoints = {
|
||||
'notes/unrenote': { req: NotesUnrenoteRequest; res: EmptyResponse };
|
||||
'notes/user-list-timeline': { req: NotesUserListTimelineRequest; res: NotesUserListTimelineResponse };
|
||||
'notifications/create': { req: NotificationsCreateRequest; res: EmptyResponse };
|
||||
'notifications/flush': { req: EmptyRequest; res: EmptyResponse };
|
||||
'notifications/mark-all-as-read': { req: EmptyRequest; res: EmptyResponse };
|
||||
'notifications/test-notification': { req: EmptyRequest; res: EmptyResponse };
|
||||
'page-push': { req: PagePushRequest; res: EmptyResponse };
|
||||
|
@@ -43,6 +43,7 @@ export type Signin = components['schemas']['Signin'];
|
||||
export type RoleCondFormulaLogics = components['schemas']['RoleCondFormulaLogics'];
|
||||
export type RoleCondFormulaValueNot = components['schemas']['RoleCondFormulaValueNot'];
|
||||
export type RoleCondFormulaValueIsLocalOrRemote = components['schemas']['RoleCondFormulaValueIsLocalOrRemote'];
|
||||
export type RoleCondFormulaValueAssignedRole = components['schemas']['RoleCondFormulaValueAssignedRole'];
|
||||
export type RoleCondFormulaValueCreated = components['schemas']['RoleCondFormulaValueCreated'];
|
||||
export type RoleCondFormulaFollowersOrFollowingOrNotes = components['schemas']['RoleCondFormulaFollowersOrFollowingOrNotes'];
|
||||
export type RoleCondFormulaValue = components['schemas']['RoleCondFormulaValue'];
|
||||
|
@@ -2851,6 +2851,15 @@ export type paths = {
|
||||
*/
|
||||
post: operations['notifications/create'];
|
||||
};
|
||||
'/notifications/flush': {
|
||||
/**
|
||||
* notifications/flush
|
||||
* @description No description provided.
|
||||
*
|
||||
* **Credential required**: *Yes* / **Permission**: *write:notifications*
|
||||
*/
|
||||
post: operations['notifications/flush'];
|
||||
};
|
||||
'/notifications/mark-all-as-read': {
|
||||
/**
|
||||
* notifications/mark-all-as-read
|
||||
@@ -3790,7 +3799,7 @@ export type components = {
|
||||
notificationRecieveConfig: {
|
||||
note?: OneOf<[{
|
||||
/** @enum {string} */
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never';
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
|
||||
}, {
|
||||
/** @enum {string} */
|
||||
type: 'list';
|
||||
@@ -3799,7 +3808,7 @@ export type components = {
|
||||
}]>;
|
||||
follow?: OneOf<[{
|
||||
/** @enum {string} */
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never';
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
|
||||
}, {
|
||||
/** @enum {string} */
|
||||
type: 'list';
|
||||
@@ -3808,7 +3817,7 @@ export type components = {
|
||||
}]>;
|
||||
mention?: OneOf<[{
|
||||
/** @enum {string} */
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never';
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
|
||||
}, {
|
||||
/** @enum {string} */
|
||||
type: 'list';
|
||||
@@ -3817,7 +3826,7 @@ export type components = {
|
||||
}]>;
|
||||
reply?: OneOf<[{
|
||||
/** @enum {string} */
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never';
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
|
||||
}, {
|
||||
/** @enum {string} */
|
||||
type: 'list';
|
||||
@@ -3826,7 +3835,7 @@ export type components = {
|
||||
}]>;
|
||||
renote?: OneOf<[{
|
||||
/** @enum {string} */
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never';
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
|
||||
}, {
|
||||
/** @enum {string} */
|
||||
type: 'list';
|
||||
@@ -3835,7 +3844,7 @@ export type components = {
|
||||
}]>;
|
||||
quote?: OneOf<[{
|
||||
/** @enum {string} */
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never';
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
|
||||
}, {
|
||||
/** @enum {string} */
|
||||
type: 'list';
|
||||
@@ -3844,7 +3853,7 @@ export type components = {
|
||||
}]>;
|
||||
reaction?: OneOf<[{
|
||||
/** @enum {string} */
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never';
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
|
||||
}, {
|
||||
/** @enum {string} */
|
||||
type: 'list';
|
||||
@@ -3853,7 +3862,7 @@ export type components = {
|
||||
}]>;
|
||||
pollEnded?: OneOf<[{
|
||||
/** @enum {string} */
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never';
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
|
||||
}, {
|
||||
/** @enum {string} */
|
||||
type: 'list';
|
||||
@@ -3862,7 +3871,7 @@ export type components = {
|
||||
}]>;
|
||||
receiveFollowRequest?: OneOf<[{
|
||||
/** @enum {string} */
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never';
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
|
||||
}, {
|
||||
/** @enum {string} */
|
||||
type: 'list';
|
||||
@@ -3871,7 +3880,7 @@ export type components = {
|
||||
}]>;
|
||||
followRequestAccepted?: OneOf<[{
|
||||
/** @enum {string} */
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never';
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
|
||||
}, {
|
||||
/** @enum {string} */
|
||||
type: 'list';
|
||||
@@ -3880,7 +3889,7 @@ export type components = {
|
||||
}]>;
|
||||
roleAssigned?: OneOf<[{
|
||||
/** @enum {string} */
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never';
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
|
||||
}, {
|
||||
/** @enum {string} */
|
||||
type: 'list';
|
||||
@@ -3889,7 +3898,7 @@ export type components = {
|
||||
}]>;
|
||||
achievementEarned?: OneOf<[{
|
||||
/** @enum {string} */
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never';
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
|
||||
}, {
|
||||
/** @enum {string} */
|
||||
type: 'list';
|
||||
@@ -3898,7 +3907,7 @@ export type components = {
|
||||
}]>;
|
||||
app?: OneOf<[{
|
||||
/** @enum {string} */
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never';
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
|
||||
}, {
|
||||
/** @enum {string} */
|
||||
type: 'list';
|
||||
@@ -3907,7 +3916,7 @@ export type components = {
|
||||
}]>;
|
||||
test?: OneOf<[{
|
||||
/** @enum {string} */
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never';
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
|
||||
}, {
|
||||
/** @enum {string} */
|
||||
type: 'list';
|
||||
@@ -4723,6 +4732,16 @@ export type components = {
|
||||
/** @enum {string} */
|
||||
type: 'isLocal' | 'isRemote';
|
||||
};
|
||||
RoleCondFormulaValueAssignedRole: {
|
||||
id: string;
|
||||
/** @enum {string} */
|
||||
type: 'roleAssignedTo';
|
||||
/**
|
||||
* Format: id
|
||||
* @example xxxxxxxxxx
|
||||
*/
|
||||
roleId: string;
|
||||
};
|
||||
RoleCondFormulaValueCreated: {
|
||||
id: string;
|
||||
/** @enum {string} */
|
||||
@@ -4735,7 +4754,7 @@ export type components = {
|
||||
type: 'followersLessThanOrEq' | 'followersMoreThanOrEq' | 'followingLessThanOrEq' | 'followingMoreThanOrEq' | 'notesLessThanOrEq' | 'notesMoreThanOrEq';
|
||||
value: number;
|
||||
};
|
||||
RoleCondFormulaValue: components['schemas']['RoleCondFormulaLogics'] | components['schemas']['RoleCondFormulaValueNot'] | components['schemas']['RoleCondFormulaValueIsLocalOrRemote'] | components['schemas']['RoleCondFormulaValueCreated'] | components['schemas']['RoleCondFormulaFollowersOrFollowingOrNotes'];
|
||||
RoleCondFormulaValue: components['schemas']['RoleCondFormulaLogics'] | components['schemas']['RoleCondFormulaValueNot'] | components['schemas']['RoleCondFormulaValueIsLocalOrRemote'] | components['schemas']['RoleCondFormulaValueAssignedRole'] | components['schemas']['RoleCondFormulaValueCreated'] | components['schemas']['RoleCondFormulaFollowersOrFollowingOrNotes'];
|
||||
RoleLite: {
|
||||
/**
|
||||
* Format: id
|
||||
@@ -4790,6 +4809,7 @@ export type components = {
|
||||
canDeleteContent: boolean;
|
||||
canUpdateAvatar: boolean;
|
||||
canUpdateBanner: boolean;
|
||||
mentionLimit: number;
|
||||
canInvite: boolean;
|
||||
inviteLimit: number;
|
||||
inviteLimitCycle: number;
|
||||
@@ -9135,7 +9155,7 @@ export type operations = {
|
||||
notificationRecieveConfig: {
|
||||
note?: OneOf<[{
|
||||
/** @enum {string} */
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never';
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
|
||||
}, {
|
||||
/** @enum {string} */
|
||||
type: 'list';
|
||||
@@ -9144,7 +9164,7 @@ export type operations = {
|
||||
}]>;
|
||||
follow?: OneOf<[{
|
||||
/** @enum {string} */
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never';
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
|
||||
}, {
|
||||
/** @enum {string} */
|
||||
type: 'list';
|
||||
@@ -9153,7 +9173,7 @@ export type operations = {
|
||||
}]>;
|
||||
mention?: OneOf<[{
|
||||
/** @enum {string} */
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never';
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
|
||||
}, {
|
||||
/** @enum {string} */
|
||||
type: 'list';
|
||||
@@ -9162,7 +9182,7 @@ export type operations = {
|
||||
}]>;
|
||||
reply?: OneOf<[{
|
||||
/** @enum {string} */
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never';
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
|
||||
}, {
|
||||
/** @enum {string} */
|
||||
type: 'list';
|
||||
@@ -9171,7 +9191,7 @@ export type operations = {
|
||||
}]>;
|
||||
renote?: OneOf<[{
|
||||
/** @enum {string} */
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never';
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
|
||||
}, {
|
||||
/** @enum {string} */
|
||||
type: 'list';
|
||||
@@ -9180,7 +9200,7 @@ export type operations = {
|
||||
}]>;
|
||||
quote?: OneOf<[{
|
||||
/** @enum {string} */
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never';
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
|
||||
}, {
|
||||
/** @enum {string} */
|
||||
type: 'list';
|
||||
@@ -9189,7 +9209,7 @@ export type operations = {
|
||||
}]>;
|
||||
reaction?: OneOf<[{
|
||||
/** @enum {string} */
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never';
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
|
||||
}, {
|
||||
/** @enum {string} */
|
||||
type: 'list';
|
||||
@@ -9198,7 +9218,7 @@ export type operations = {
|
||||
}]>;
|
||||
pollEnded?: OneOf<[{
|
||||
/** @enum {string} */
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never';
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
|
||||
}, {
|
||||
/** @enum {string} */
|
||||
type: 'list';
|
||||
@@ -9207,7 +9227,7 @@ export type operations = {
|
||||
}]>;
|
||||
receiveFollowRequest?: OneOf<[{
|
||||
/** @enum {string} */
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never';
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
|
||||
}, {
|
||||
/** @enum {string} */
|
||||
type: 'list';
|
||||
@@ -9216,7 +9236,7 @@ export type operations = {
|
||||
}]>;
|
||||
followRequestAccepted?: OneOf<[{
|
||||
/** @enum {string} */
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never';
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
|
||||
}, {
|
||||
/** @enum {string} */
|
||||
type: 'list';
|
||||
@@ -9225,7 +9245,7 @@ export type operations = {
|
||||
}]>;
|
||||
roleAssigned?: OneOf<[{
|
||||
/** @enum {string} */
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never';
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
|
||||
}, {
|
||||
/** @enum {string} */
|
||||
type: 'list';
|
||||
@@ -9234,7 +9254,7 @@ export type operations = {
|
||||
}]>;
|
||||
achievementEarned?: OneOf<[{
|
||||
/** @enum {string} */
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never';
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
|
||||
}, {
|
||||
/** @enum {string} */
|
||||
type: 'list';
|
||||
@@ -9243,7 +9263,7 @@ export type operations = {
|
||||
}]>;
|
||||
app?: OneOf<[{
|
||||
/** @enum {string} */
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never';
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
|
||||
}, {
|
||||
/** @enum {string} */
|
||||
type: 'list';
|
||||
@@ -9252,7 +9272,7 @@ export type operations = {
|
||||
}]>;
|
||||
test?: OneOf<[{
|
||||
/** @enum {string} */
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never';
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
|
||||
}, {
|
||||
/** @enum {string} */
|
||||
type: 'list';
|
||||
@@ -18433,8 +18453,8 @@ export type operations = {
|
||||
untilId?: string;
|
||||
/** @default true */
|
||||
markAsRead?: boolean;
|
||||
includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
|
||||
excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
|
||||
includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'app' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'pollVote' | 'groupInvited')[];
|
||||
excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'app' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'pollVote' | 'groupInvited')[];
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -19532,7 +19552,7 @@ export type operations = {
|
||||
notificationRecieveConfig?: {
|
||||
note?: OneOf<[{
|
||||
/** @enum {string} */
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never';
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
|
||||
}, {
|
||||
/** @enum {string} */
|
||||
type: 'list';
|
||||
@@ -19541,7 +19561,7 @@ export type operations = {
|
||||
}]>;
|
||||
follow?: OneOf<[{
|
||||
/** @enum {string} */
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never';
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
|
||||
}, {
|
||||
/** @enum {string} */
|
||||
type: 'list';
|
||||
@@ -19550,7 +19570,7 @@ export type operations = {
|
||||
}]>;
|
||||
mention?: OneOf<[{
|
||||
/** @enum {string} */
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never';
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
|
||||
}, {
|
||||
/** @enum {string} */
|
||||
type: 'list';
|
||||
@@ -19559,7 +19579,7 @@ export type operations = {
|
||||
}]>;
|
||||
reply?: OneOf<[{
|
||||
/** @enum {string} */
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never';
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
|
||||
}, {
|
||||
/** @enum {string} */
|
||||
type: 'list';
|
||||
@@ -19568,7 +19588,7 @@ export type operations = {
|
||||
}]>;
|
||||
renote?: OneOf<[{
|
||||
/** @enum {string} */
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never';
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
|
||||
}, {
|
||||
/** @enum {string} */
|
||||
type: 'list';
|
||||
@@ -19577,7 +19597,7 @@ export type operations = {
|
||||
}]>;
|
||||
quote?: OneOf<[{
|
||||
/** @enum {string} */
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never';
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
|
||||
}, {
|
||||
/** @enum {string} */
|
||||
type: 'list';
|
||||
@@ -19586,7 +19606,7 @@ export type operations = {
|
||||
}]>;
|
||||
reaction?: OneOf<[{
|
||||
/** @enum {string} */
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never';
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
|
||||
}, {
|
||||
/** @enum {string} */
|
||||
type: 'list';
|
||||
@@ -19595,7 +19615,7 @@ export type operations = {
|
||||
}]>;
|
||||
pollEnded?: OneOf<[{
|
||||
/** @enum {string} */
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never';
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
|
||||
}, {
|
||||
/** @enum {string} */
|
||||
type: 'list';
|
||||
@@ -19604,7 +19624,7 @@ export type operations = {
|
||||
}]>;
|
||||
receiveFollowRequest?: OneOf<[{
|
||||
/** @enum {string} */
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never';
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
|
||||
}, {
|
||||
/** @enum {string} */
|
||||
type: 'list';
|
||||
@@ -19613,7 +19633,7 @@ export type operations = {
|
||||
}]>;
|
||||
followRequestAccepted?: OneOf<[{
|
||||
/** @enum {string} */
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never';
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
|
||||
}, {
|
||||
/** @enum {string} */
|
||||
type: 'list';
|
||||
@@ -19622,7 +19642,7 @@ export type operations = {
|
||||
}]>;
|
||||
roleAssigned?: OneOf<[{
|
||||
/** @enum {string} */
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never';
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
|
||||
}, {
|
||||
/** @enum {string} */
|
||||
type: 'list';
|
||||
@@ -19631,7 +19651,7 @@ export type operations = {
|
||||
}]>;
|
||||
achievementEarned?: OneOf<[{
|
||||
/** @enum {string} */
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never';
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
|
||||
}, {
|
||||
/** @enum {string} */
|
||||
type: 'list';
|
||||
@@ -19640,7 +19660,7 @@ export type operations = {
|
||||
}]>;
|
||||
app?: OneOf<[{
|
||||
/** @enum {string} */
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never';
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
|
||||
}, {
|
||||
/** @enum {string} */
|
||||
type: 'list';
|
||||
@@ -19649,7 +19669,7 @@ export type operations = {
|
||||
}]>;
|
||||
test?: OneOf<[{
|
||||
/** @enum {string} */
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'never';
|
||||
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
|
||||
}, {
|
||||
/** @enum {string} */
|
||||
type: 'list';
|
||||
@@ -22795,6 +22815,50 @@ export type operations = {
|
||||
};
|
||||
};
|
||||
};
|
||||
/**
|
||||
* notifications/flush
|
||||
* @description No description provided.
|
||||
*
|
||||
* **Credential required**: *Yes* / **Permission**: *write:notifications*
|
||||
*/
|
||||
'notifications/flush': {
|
||||
responses: {
|
||||
/** @description OK (without any results) */
|
||||
204: {
|
||||
content: never;
|
||||
};
|
||||
/** @description Client error */
|
||||
400: {
|
||||
content: {
|
||||
'application/json': components['schemas']['Error'];
|
||||
};
|
||||
};
|
||||
/** @description Authentication error */
|
||||
401: {
|
||||
content: {
|
||||
'application/json': components['schemas']['Error'];
|
||||
};
|
||||
};
|
||||
/** @description Forbidden error */
|
||||
403: {
|
||||
content: {
|
||||
'application/json': components['schemas']['Error'];
|
||||
};
|
||||
};
|
||||
/** @description I'm Ai */
|
||||
418: {
|
||||
content: {
|
||||
'application/json': components['schemas']['Error'];
|
||||
};
|
||||
};
|
||||
/** @description Internal server error */
|
||||
500: {
|
||||
content: {
|
||||
'application/json': components['schemas']['Error'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
/**
|
||||
* notifications/mark-all-as-read
|
||||
* @description No description provided.
|
||||
|
@@ -40,6 +40,7 @@ export type Channels = {
|
||||
unreadNotification: (payload: Notification) => void;
|
||||
unreadMention: (payload: Note['id']) => void;
|
||||
readAllUnreadMentions: () => void;
|
||||
notificationFlushed: () => void;
|
||||
unreadSpecifiedNote: (payload: Note['id']) => void;
|
||||
readAllUnreadSpecifiedNotes: () => void;
|
||||
readAllAntennas: () => void;
|
||||
|
@@ -25,11 +25,11 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@misskey-dev/eslint-plugin": "1.0.0",
|
||||
"@types/node": "20.11.19",
|
||||
"@typescript-eslint/eslint-plugin": "7.0.2",
|
||||
"@typescript-eslint/parser": "7.0.2",
|
||||
"eslint": "8.56.0",
|
||||
"nodemon": "3.0.3",
|
||||
"@types/node": "20.11.24",
|
||||
"@typescript-eslint/eslint-plugin": "7.1.0",
|
||||
"@typescript-eslint/parser": "7.1.0",
|
||||
"eslint": "8.57.0",
|
||||
"nodemon": "3.1.0",
|
||||
"typescript": "5.3.3"
|
||||
},
|
||||
"dependencies": {
|
||||
|
@@ -15,11 +15,11 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@misskey-dev/eslint-plugin": "1.0.0",
|
||||
"@typescript-eslint/parser": "7.0.2",
|
||||
"@typescript-eslint/parser": "7.1.0",
|
||||
"@typescript/lib-webworker": "npm:@types/serviceworker@0.0.67",
|
||||
"eslint": "8.56.0",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-plugin-import": "2.29.1",
|
||||
"nodemon": "3.0.3",
|
||||
"nodemon": "3.1.0",
|
||||
"typescript": "5.3.3"
|
||||
},
|
||||
"type": "module"
|
||||
|
Reference in New Issue
Block a user