> {
const game = typeof src === 'object' ? src : await this.reversiGamesRepository.findOneByOrFail({ id: src });
- const users = await Promise.all([
- this.userEntityService.pack(game.user1 ?? game.user1Id),
- this.userEntityService.pack(game.user2 ?? game.user2Id),
- ]);
+ const user1 = hint?.packedUser1 ?? await this.userEntityService.pack(game.user1 ?? game.user1Id);
+ const user2 = hint?.packedUser2 ?? await this.userEntityService.pack(game.user2 ?? game.user2Id);
return await awaitAll({
id: game.id,
@@ -94,10 +109,10 @@ export class ReversiGameEntityService {
isEnded: game.isEnded,
user1Id: game.user1Id,
user2Id: game.user2Id,
- user1: users[0],
- user2: users[1],
+ user1,
+ user2,
winnerId: game.winnerId,
- winner: game.winnerId ? users.find(u => u.id === game.winnerId)! : null,
+ winner: game.winnerId ? [user1, user2].find(u => u.id === game.winnerId)! : null,
surrenderedUserId: game.surrenderedUserId,
timeoutUserId: game.timeoutUserId,
black: game.black,
@@ -111,10 +126,21 @@ export class ReversiGameEntityService {
}
@bindThis
- public packLiteMany(
- xs: MiReversiGame[],
+ public async packLiteMany(
+ games: MiReversiGame[],
) {
- return Promise.all(xs.map(x => this.packLite(x)));
+ const _user1s = games.map(({ user1, user1Id }) => user1 ?? user1Id);
+ const _user2s = games.map(({ user2, user2Id }) => user2 ?? user2Id);
+ const _userMap = await this.userEntityService.packMany([..._user1s, ..._user2s])
+ .then(users => new Map(users.map(u => [u.id, u])));
+ return Promise.all(
+ games.map(game => {
+ return this.packLite(game, {
+ packedUser1: _userMap.get(game.user1Id),
+ packedUser2: _userMap.get(game.user2Id),
+ });
+ }),
+ );
}
}
diff --git a/packages/backend/src/core/entities/UserListEntityService.ts b/packages/backend/src/core/entities/UserListEntityService.ts
index 09cab24521..b77249c5cb 100644
--- a/packages/backend/src/core/entities/UserListEntityService.ts
+++ b/packages/backend/src/core/entities/UserListEntityService.ts
@@ -50,11 +50,14 @@ export class UserListEntityService {
public async packMembershipsMany(
memberships: MiUserListMembership[],
) {
+ const _users = memberships.map(({ user, userId }) => user ?? userId);
+ const _userMap = await this.userEntityService.packMany(_users)
+ .then(users => new Map(users.map(u => [u.id, u])));
return Promise.all(memberships.map(async x => ({
id: x.id,
createdAt: this.idService.parse(x.id).date.toISOString(),
userId: x.userId,
- user: await this.userEntityService.pack(x.userId),
+ user: _userMap.get(x.userId) ?? await this.userEntityService.pack(x.userId),
withReplies: x.withReplies,
})));
}
diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts
index a620d7c94b..41e5bfe9e4 100644
--- a/packages/backend/src/misc/json-schema.ts
+++ b/packages/backend/src/misc/json-schema.ts
@@ -228,7 +228,7 @@ export type SchemaTypeDef =
p['items']['allOf'] extends ReadonlyArray ? UnionToIntersection>>[] :
never
) :
- p['items'] extends NonNullable ? SchemaTypeDef[] :
+ p['items'] extends NonNullable ? SchemaType[] :
any[]
) :
p['anyOf'] extends ReadonlyArray ? UnionSchemaType & PartialIntersection> :
diff --git a/packages/backend/src/models/Antenna.ts b/packages/backend/src/models/Antenna.ts
index f5e819059e..33e6f48189 100644
--- a/packages/backend/src/models/Antenna.ts
+++ b/packages/backend/src/models/Antenna.ts
@@ -90,9 +90,6 @@ export class MiAntenna {
})
public expression: string | null;
- @Column('boolean')
- public notify: boolean;
-
@Index()
@Column('boolean', {
default: true,
diff --git a/packages/backend/src/models/Instance.ts b/packages/backend/src/models/Instance.ts
index 7dd4e5b10c..dd625f95d3 100644
--- a/packages/backend/src/models/Instance.ts
+++ b/packages/backend/src/models/Instance.ts
@@ -81,13 +81,22 @@ export class MiInstance {
public isNotResponding: boolean;
/**
- * このインスタンスへの配信を停止するか
+ * このインスタンスと不通になった日時
+ */
+ @Column('timestamp with time zone', {
+ nullable: true,
+ })
+ public notRespondingSince: Date | null;
+
+ /**
+ * このインスタンスへの配信状態
*/
@Index()
- @Column('boolean', {
- default: false,
+ @Column('enum', {
+ default: 'none',
+ enum: ['none', 'manuallySuspended', 'goneSuspended', 'autoSuspendedForNotResponding'],
})
- public isSuspended: boolean;
+ public suspensionState: 'none' | 'manuallySuspended' | 'goneSuspended' | 'autoSuspendedForNotResponding';
@Column('varchar', {
length: 64, nullable: true,
diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts
index 9b71cf6d57..fb021f0f6b 100644
--- a/packages/backend/src/models/Meta.ts
+++ b/packages/backend/src/models/Meta.ts
@@ -403,6 +403,12 @@ export class MiMeta {
})
public donationUrl: string | null;
+ @Column('varchar', {
+ length: 1024,
+ nullable: true,
+ })
+ public inquiryUrl: string | null;
+
@Column('varchar', {
length: 8192,
nullable: true,
diff --git a/packages/backend/src/models/Poll.ts b/packages/backend/src/models/Poll.ts
index c2693dbb19..ca985c8b24 100644
--- a/packages/backend/src/models/Poll.ts
+++ b/packages/backend/src/models/Poll.ts
@@ -8,6 +8,7 @@ import { noteVisibilities } from '@/types.js';
import { id } from './util/id.js';
import { MiNote } from './Note.js';
import type { MiUser } from './User.js';
+import type { MiChannel } from "@/models/Channel.js";
@Entity('poll')
export class MiPoll {
@@ -58,6 +59,14 @@ export class MiPoll {
comment: '[Denormalized]',
})
public userHost: string | null;
+
+ @Index()
+ @Column({
+ ...id(),
+ nullable: true,
+ comment: '[Denormalized]',
+ })
+ public channelId: MiChannel['id'] | null;
//#endregion
constructor(data: Partial) {
diff --git a/packages/backend/src/models/json-schema/antenna.ts b/packages/backend/src/models/json-schema/antenna.ts
index 78cf6d3ba2..b5b9a5b42c 100644
--- a/packages/backend/src/models/json-schema/antenna.ts
+++ b/packages/backend/src/models/json-schema/antenna.ts
@@ -72,10 +72,6 @@ export const packedAntennaSchema = {
optional: false, nullable: false,
default: false,
},
- notify: {
- type: 'boolean',
- optional: false, nullable: false,
- },
excludeBots: {
type: 'boolean',
optional: false, nullable: false,
@@ -99,5 +95,10 @@ export const packedAntennaSchema = {
optional: false, nullable: false,
default: false,
},
+ notify: {
+ type: 'boolean',
+ optional: false, nullable: false,
+ default: false,
+ },
},
} as const;
diff --git a/packages/backend/src/models/json-schema/federation-instance.ts b/packages/backend/src/models/json-schema/federation-instance.ts
index 7b8ab22831..a602126dc8 100644
--- a/packages/backend/src/models/json-schema/federation-instance.ts
+++ b/packages/backend/src/models/json-schema/federation-instance.ts
@@ -45,6 +45,11 @@ export const packedFederationInstanceSchema = {
type: 'boolean',
optional: false, nullable: false,
},
+ suspensionState: {
+ type: 'string',
+ nullable: false, optional: false,
+ enum: ['none', 'manuallySuspended', 'goneSuspended', 'autoSuspendedForNotResponding'],
+ },
isBlocked: {
type: 'boolean',
optional: false, nullable: false,
diff --git a/packages/backend/src/models/json-schema/meta.ts b/packages/backend/src/models/json-schema/meta.ts
index 47a9c48c1c..7edd877f80 100644
--- a/packages/backend/src/models/json-schema/meta.ts
+++ b/packages/backend/src/models/json-schema/meta.ts
@@ -243,6 +243,10 @@ export const packedMetaLiteSchema = {
type: 'string',
optional: false, nullable: true,
},
+ inquiryUrl: {
+ type: 'string',
+ optional: false, nullable: true,
+ },
serverRules: {
type: 'array',
optional: false, nullable: false,
diff --git a/packages/backend/src/queue/processors/DeliverProcessorService.ts b/packages/backend/src/queue/processors/DeliverProcessorService.ts
index 5fed070929..b73195afc3 100644
--- a/packages/backend/src/queue/processors/DeliverProcessorService.ts
+++ b/packages/backend/src/queue/processors/DeliverProcessorService.ts
@@ -5,6 +5,7 @@
import { Inject, Injectable } from '@nestjs/common';
import * as Bull from 'bullmq';
+import { Not } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { InstancesRepository } from '@/models/_.js';
import type Logger from '@/logger.js';
@@ -62,7 +63,7 @@ export class DeliverProcessorService {
if (suspendedHosts == null) {
suspendedHosts = await this.instancesRepository.find({
where: {
- isSuspended: true,
+ suspensionState: Not('none'),
},
});
this.suspendedHostsCache.set(suspendedHosts);
@@ -79,6 +80,7 @@ export class DeliverProcessorService {
if (i.isNotResponding) {
this.federatedInstanceService.update(i.id, {
isNotResponding: false,
+ notRespondingSince: null,
});
}
@@ -98,7 +100,15 @@ export class DeliverProcessorService {
if (!i.isNotResponding) {
this.federatedInstanceService.update(i.id, {
isNotResponding: true,
+ notRespondingSince: new Date(),
});
+ } else if (i.notRespondingSince) {
+ // 1週間以上不通ならサスペンド
+ if (i.suspensionState === 'none' && i.notRespondingSince.getTime() <= Date.now() - 1000 * 60 * 60 * 24 * 7) {
+ this.federatedInstanceService.update(i.id, {
+ suspensionState: 'autoSuspendedForNotResponding',
+ });
+ }
}
this.apRequestChart.deliverFail();
@@ -116,7 +126,7 @@ export class DeliverProcessorService {
if (job.data.isSharedInbox && res.statusCode === 410) {
this.federatedInstanceService.fetch(host).then(i => {
this.federatedInstanceService.update(i.id, {
- isSuspended: true,
+ suspensionState: 'goneSuspended',
});
});
throw new Bull.UnrecoverableError(`${host} is gone`);
diff --git a/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts b/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts
index 1d8e90f367..88c4ea29c0 100644
--- a/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts
+++ b/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts
@@ -84,7 +84,6 @@ export class ExportAntennasProcessorService {
excludeBots: antenna.excludeBots,
withReplies: antenna.withReplies,
withFile: antenna.withFile,
- notify: antenna.notify,
}));
if (antennas.length - 1 !== index) {
write(', ');
diff --git a/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts b/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts
index ff1c04de06..e5b7c5ac52 100644
--- a/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts
+++ b/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts
@@ -47,9 +47,8 @@ const validate = new Ajv().compile({
excludeBots: { type: 'boolean' },
withReplies: { type: 'boolean' },
withFile: { type: 'boolean' },
- notify: { type: 'boolean' },
},
- required: ['name', 'src', 'keywords', 'excludeKeywords', 'users', 'caseSensitive', 'withReplies', 'withFile', 'notify'],
+ required: ['name', 'src', 'keywords', 'excludeKeywords', 'users', 'caseSensitive', 'withReplies', 'withFile'],
});
@Injectable()
@@ -92,7 +91,6 @@ export class ImportAntennasProcessorService {
excludeBots: antenna.excludeBots,
withReplies: antenna.withReplies,
withFile: antenna.withFile,
- notify: antenna.notify,
}).then(x => this.antennasRepository.findOneByOrFail(x.identifiers[0]));
this.logger.succ('Antenna created: ' + result.id);
this.globalEventService.publishInternalEvent('antennaCreated', result);
diff --git a/packages/backend/src/queue/processors/InboxProcessorService.ts b/packages/backend/src/queue/processors/InboxProcessorService.ts
index ce32a482fd..641b8b8607 100644
--- a/packages/backend/src/queue/processors/InboxProcessorService.ts
+++ b/packages/backend/src/queue/processors/InboxProcessorService.ts
@@ -193,6 +193,8 @@ export class InboxProcessorService {
this.federatedInstanceService.update(i.id, {
latestRequestReceivedAt: new Date(),
isNotResponding: false,
+ // もしサーバーが死んでるために配信が止まっていた場合には自動的に復活させてあげる
+ suspensionState: i.suspensionState === 'autoSuspendedForNotResponding' ? 'none' : undefined,
});
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
@@ -207,13 +209,22 @@ export class InboxProcessorService {
// アクティビティを処理
try {
- await this.apInboxService.performActivity(authUser.user, activity);
+ const result = await this.apInboxService.performActivity(authUser.user, activity);
+ if (result && !result.startsWith('ok')) {
+ this.logger.warn(`inbox activity ignored (maybe): id=${activity.id} reason=${result}`);
+ return result;
+ }
} catch (e) {
if (e instanceof IdentifiableError) {
if (e.id === '689ee33f-f97c-479a-ac49-1b9f8140af99') {
return 'blocked notes with prohibited words';
}
- if (e.id === '85ab9bd7-3a41-4530-959d-f07073900109') return 'actor has been suspended';
+ if (e.id === '85ab9bd7-3a41-4530-959d-f07073900109') {
+ return 'actor has been suspended';
+ }
+ if (e.id === 'd450b8a9-48e4-4dab-ae36-f4db763fda7c') { // invalid Note
+ return e.message;
+ }
}
throw e;
}
diff --git a/packages/backend/src/server/HealthServerService.ts b/packages/backend/src/server/HealthServerService.ts
new file mode 100644
index 0000000000..2c3ed85925
--- /dev/null
+++ b/packages/backend/src/server/HealthServerService.ts
@@ -0,0 +1,54 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import * as Redis from 'ioredis';
+import { DataSource } from 'typeorm';
+import { bindThis } from '@/decorators.js';
+import { DI } from '@/di-symbols.js';
+import { readyRef } from '@/boot/ready.js';
+import type { FastifyInstance, FastifyPluginOptions } from 'fastify';
+import type { MeiliSearch } from 'meilisearch';
+
+@Injectable()
+export class HealthServerService {
+ constructor(
+ @Inject(DI.redis)
+ private redis: Redis.Redis,
+
+ @Inject(DI.redisForPub)
+ private redisForPub: Redis.Redis,
+
+ @Inject(DI.redisForSub)
+ private redisForSub: Redis.Redis,
+
+ @Inject(DI.redisForTimelines)
+ private redisForTimelines: Redis.Redis,
+
+ @Inject(DI.db)
+ private db: DataSource,
+
+ @Inject(DI.meilisearch)
+ private meilisearch: MeiliSearch | null,
+ ) {}
+
+ @bindThis
+ public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) {
+ fastify.get('/', async (request, reply) => {
+ reply.code(await Promise.all([
+ new Promise((resolve, reject) => readyRef.value ? resolve() : reject()),
+ this.redis.ping(),
+ this.redisForPub.ping(),
+ this.redisForSub.ping(),
+ this.redisForTimelines.ping(),
+ this.db.query('SELECT 1'),
+ ...(this.meilisearch ? [this.meilisearch.health()] : []),
+ ]).then(() => 200, () => 503));
+ reply.header('Cache-Control', 'no-store');
+ });
+
+ done();
+ }
+}
diff --git a/packages/backend/src/server/NodeinfoServerService.ts b/packages/backend/src/server/NodeinfoServerService.ts
index d4f4c8b752..716bb0944b 100644
--- a/packages/backend/src/server/NodeinfoServerService.ts
+++ b/packages/backend/src/server/NodeinfoServerService.ts
@@ -36,12 +36,12 @@ export class NodeinfoServerService {
@bindThis
public getLinks() {
return [{
- rel: 'http://nodeinfo.diaspora.software/ns/schema/2.1',
- href: this.config.url + nodeinfo2_1path
- }, {
- rel: 'http://nodeinfo.diaspora.software/ns/schema/2.0',
- href: this.config.url + nodeinfo2_0path,
- }];
+ rel: 'http://nodeinfo.diaspora.software/ns/schema/2.1',
+ href: this.config.url + nodeinfo2_1path,
+ }, {
+ rel: 'http://nodeinfo.diaspora.software/ns/schema/2.0',
+ href: this.config.url + nodeinfo2_0path,
+ }];
}
@bindThis
@@ -107,6 +107,7 @@ export class NodeinfoServerService {
langs: meta.langs,
tosUrl: meta.termsOfServiceUrl,
privacyPolicyUrl: meta.privacyPolicyUrl,
+ inquiryUrl: meta.inquiryUrl,
impressumUrl: meta.impressumUrl,
donationUrl: meta.donationUrl,
repositoryUrl: meta.repositoryUrl,
diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts
index e0c4768ffc..39c8f67b8e 100644
--- a/packages/backend/src/server/ServerModule.ts
+++ b/packages/backend/src/server/ServerModule.ts
@@ -8,6 +8,7 @@ import { EndpointsModule } from '@/server/api/EndpointsModule.js';
import { CoreModule } from '@/core/CoreModule.js';
import { ApiCallService } from './api/ApiCallService.js';
import { FileServerService } from './FileServerService.js';
+import { HealthServerService } from './HealthServerService.js';
import { NodeinfoServerService } from './NodeinfoServerService.js';
import { ServerService } from './ServerService.js';
import { WellKnownServerService } from './WellKnownServerService.js';
@@ -58,6 +59,7 @@ import { ReversiGameChannelService } from './api/stream/channels/reversi-game.js
ClientServerService,
ClientLoggerService,
FeedService,
+ HealthServerService,
UrlPreviewService,
ActivityPubServerService,
FileServerService,
diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts
index 2cf826deac..9eddf434f7 100644
--- a/packages/backend/src/server/ServerService.ts
+++ b/packages/backend/src/server/ServerService.ts
@@ -28,6 +28,7 @@ import { ApiServerService } from './api/ApiServerService.js';
import { StreamingApiServerService } from './api/StreamingApiServerService.js';
import { WellKnownServerService } from './WellKnownServerService.js';
import { FileServerService } from './FileServerService.js';
+import { HealthServerService } from './HealthServerService.js';
import { ClientServerService } from './web/ClientServerService.js';
import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
import { MastodonApiServerService } from './api/mastodon/MastodonApiServerService.js';
@@ -63,6 +64,7 @@ export class ServerService implements OnApplicationShutdown {
private wellKnownServerService: WellKnownServerService,
private nodeinfoServerService: NodeinfoServerService,
private fileServerService: FileServerService,
+ private healthServerService: HealthServerService,
private clientServerService: ClientServerService,
private globalEventService: GlobalEventService,
private loggerService: LoggerService,
@@ -110,6 +112,7 @@ export class ServerService implements OnApplicationShutdown {
fastify.register(this.nodeinfoServerService.createServer);
fastify.register(this.wellKnownServerService.createServer);
fastify.register(this.oauth2ProviderService.createServer, { prefix: '/oauth' });
+ fastify.register(this.healthServerService.createServer, { prefix: '/healthz' });
fastify.get<{ Params: { path: string }; Querystring: { static?: any; badge?: any; }; }>('/emoji/:path(.*)', async (request, reply) => {
const path = request.params.path;
diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts
index 9836689872..271ef80554 100644
--- a/packages/backend/src/server/api/ApiCallService.ts
+++ b/packages/backend/src/server/api/ApiCallService.ts
@@ -7,6 +7,7 @@ import { randomUUID } from 'node:crypto';
import * as fs from 'node:fs';
import * as stream from 'node:stream/promises';
import { Inject, Injectable } from '@nestjs/common';
+import * as Sentry from '@sentry/node';
import { DI } from '@/di-symbols.js';
import { getIpHash } from '@/misc/get-ip-hash.js';
import type { MiLocalUser, MiUser } from '@/models/User.js';
@@ -17,6 +18,7 @@ import { MetaService } from '@/core/MetaService.js';
import { createTemp } from '@/misc/create-temp.js';
import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
+import type { Config } from '@/config.js';
import { ApiError } from './error.js';
import { RateLimiterService } from './RateLimiterService.js';
import { ApiLoggerService } from './ApiLoggerService.js';
@@ -38,6 +40,9 @@ export class ApiCallService implements OnApplicationShutdown {
private userIpHistoriesClearIntervalId: NodeJS.Timeout;
constructor(
+ @Inject(DI.config)
+ private config: Config,
+
@Inject(DI.userIpsRepository)
private userIpsRepository: UserIpsRepository,
@@ -88,6 +93,48 @@ export class ApiCallService implements OnApplicationShutdown {
}
}
+ #onExecError(ep: IEndpoint, data: any, err: Error): void {
+ if (err instanceof ApiError || err instanceof AuthenticationError) {
+ throw err;
+ } else {
+ const errId = randomUUID();
+ this.logger.error(`Internal error occurred in ${ep.name}: ${err.message}`, {
+ ep: ep.name,
+ ps: data,
+ e: {
+ message: err.message,
+ code: err.name,
+ stack: err.stack,
+ id: errId,
+ },
+ });
+ console.error(err, errId);
+
+ if (this.config.sentryForBackend) {
+ Sentry.captureMessage(`Internal error occurred in ${ep.name}: ${err.message}`, {
+ extra: {
+ ep: ep.name,
+ ps: data,
+ e: {
+ message: err.message,
+ code: err.name,
+ stack: err.stack,
+ id: errId,
+ },
+ },
+ });
+ }
+
+ throw new ApiError(null, {
+ e: {
+ message: err.message,
+ code: err.name,
+ id: errId,
+ },
+ });
+ }
+ }
+
@bindThis
public handleRequest(
endpoint: IEndpoint & { exec: any },
@@ -362,31 +409,11 @@ export class ApiCallService implements OnApplicationShutdown {
}
// API invoking
- return await ep.exec(data, user, token, file, request.ip, request.headers).catch((err: Error) => {
- if (err instanceof ApiError || err instanceof AuthenticationError) {
- throw err;
- } else {
- const errId = randomUUID();
- this.logger.error(`Internal error occurred in ${ep.name}: ${err.message}`, {
- ep: ep.name,
- ps: data,
- e: {
- message: err.message,
- code: err.name,
- stack: err.stack,
- id: errId,
- },
- });
- console.error(err, errId);
- throw new ApiError(null, {
- e: {
- message: err.message,
- code: err.name,
- id: errId,
- },
- });
- }
- });
+ if (this.config.sentryForBackend) {
+ return await Sentry.startSpan({ name: 'API: ' + ep.name }, () => ep.exec(data, user, token, file, request.ip, request.headers).catch((err: Error) => this.#onExecError(ep, data, err)));
+ } else {
+ return await ep.exec(data, user, token, file, request.ip, request.headers).catch((err: Error) => this.#onExecError(ep, data, err));
+ }
}
@bindThis
diff --git a/packages/backend/src/server/api/ApiServerService.ts b/packages/backend/src/server/api/ApiServerService.ts
index e99244cdd0..4a5935f930 100644
--- a/packages/backend/src/server/api/ApiServerService.ts
+++ b/packages/backend/src/server/api/ApiServerService.ts
@@ -137,7 +137,7 @@ export class ApiServerService {
const instances = await this.instancesRepository.find({
select: ['host'],
where: {
- isSuspended: false,
+ suspensionState: 'none',
},
});
diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts
index f2945f477c..f44635fba0 100644
--- a/packages/backend/src/server/api/EndpointsModule.ts
+++ b/packages/backend/src/server/api/EndpointsModule.ts
@@ -88,6 +88,7 @@ import * as ep___admin_roles_unassign from './endpoints/admin/roles/unassign.js'
import * as ep___admin_roles_updateDefaultPolicies from './endpoints/admin/roles/update-default-policies.js';
import * as ep___admin_roles_users from './endpoints/admin/roles/users.js';
import * as ep___announcements from './endpoints/announcements.js';
+import * as ep___announcements_show from './endpoints/announcements/show.js';
import * as ep___antennas_create from './endpoints/antennas/create.js';
import * as ep___antennas_delete from './endpoints/antennas/delete.js';
import * as ep___antennas_list from './endpoints/antennas/list.js';
@@ -473,6 +474,7 @@ const $admin_roles_unassign: Provider = { provide: 'ep:admin/roles/unassign', us
const $admin_roles_updateDefaultPolicies: Provider = { provide: 'ep:admin/roles/update-default-policies', useClass: ep___admin_roles_updateDefaultPolicies.default };
const $admin_roles_users: Provider = { provide: 'ep:admin/roles/users', useClass: ep___admin_roles_users.default };
const $announcements: Provider = { provide: 'ep:announcements', useClass: ep___announcements.default };
+const $announcements_show: Provider = { provide: 'ep:announcements/show', useClass: ep___announcements_show.default };
const $antennas_create: Provider = { provide: 'ep:antennas/create', useClass: ep___antennas_create.default };
const $antennas_delete: Provider = { provide: 'ep:antennas/delete', useClass: ep___antennas_delete.default };
const $antennas_list: Provider = { provide: 'ep:antennas/list', useClass: ep___antennas_list.default };
@@ -862,6 +864,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
$admin_roles_updateDefaultPolicies,
$admin_roles_users,
$announcements,
+ $announcements_show,
$antennas_create,
$antennas_delete,
$antennas_list,
@@ -1245,6 +1248,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
$admin_roles_updateDefaultPolicies,
$admin_roles_users,
$announcements,
+ $announcements_show,
$antennas_create,
$antennas_delete,
$antennas_list,
diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts
index f83d2cacff..89f60933ef 100644
--- a/packages/backend/src/server/api/endpoints.ts
+++ b/packages/backend/src/server/api/endpoints.ts
@@ -88,6 +88,7 @@ import * as ep___admin_roles_unassign from './endpoints/admin/roles/unassign.js'
import * as ep___admin_roles_updateDefaultPolicies from './endpoints/admin/roles/update-default-policies.js';
import * as ep___admin_roles_users from './endpoints/admin/roles/users.js';
import * as ep___announcements from './endpoints/announcements.js';
+import * as ep___announcements_show from './endpoints/announcements/show.js';
import * as ep___antennas_create from './endpoints/antennas/create.js';
import * as ep___antennas_delete from './endpoints/antennas/delete.js';
import * as ep___antennas_list from './endpoints/antennas/list.js';
@@ -471,6 +472,7 @@ const eps = [
['admin/roles/update-default-policies', ep___admin_roles_updateDefaultPolicies],
['admin/roles/users', ep___admin_roles_users],
['announcements', ep___announcements],
+ ['announcements/show', ep___announcements_show],
['antennas/create', ep___antennas_create],
['antennas/delete', ep___antennas_delete],
['antennas/list', ep___antennas_list],
diff --git a/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts b/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts
index 4ababae9f2..9984441de7 100644
--- a/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts
+++ b/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts
@@ -47,13 +47,21 @@ export default class extends Endpoint { // eslint-
throw new Error('instance not found');
}
+ const isSuspendedBefore = instance.suspensionState !== 'none';
+ let suspensionState: undefined | 'manuallySuspended' | 'none';
+
+ if (ps.isSuspended != null && isSuspendedBefore !== ps.isSuspended) {
+ suspensionState = ps.isSuspended ? 'manuallySuspended' : 'none';
+ }
+
await this.federatedInstanceService.update(instance.id, {
isSuspended: ps.isSuspended,
+ suspensionState,
isNSFW: ps.isNSFW,
moderationNote: ps.moderationNote,
});
- if (ps.isSuspended != null && instance.isSuspended !== ps.isSuspended) {
+ if (ps.isSuspended != null && isSuspendedBefore !== ps.isSuspended) {
if (ps.isSuspended) {
this.moderationLogService.log(me, 'suspendRemoteInstance', {
id: instance.id,
diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts
index 1a4ae844b5..ca4d63b834 100644
--- a/packages/backend/src/server/api/endpoints/admin/meta.ts
+++ b/packages/backend/src/server/api/endpoints/admin/meta.ts
@@ -458,6 +458,10 @@ export const meta = {
type: 'string',
optional: false, nullable: true,
},
+ inquiryUrl: {
+ type: 'string',
+ optional: false, nullable: true,
+ },
repositoryUrl: {
type: 'string',
optional: false, nullable: true,
@@ -545,6 +549,7 @@ export default class extends Endpoint { // eslint-
impressumUrl: instance.impressumUrl,
donationUrl: instance.donationUrl,
privacyPolicyUrl: instance.privacyPolicyUrl,
+ inquiryUrl: instance.inquiryUrl,
disableRegistration: instance.disableRegistration,
emailRequiredForSignup: instance.emailRequiredForSignup,
approvalRequiredForSignup: instance.approvalRequiredForSignup,
diff --git a/packages/backend/src/server/api/endpoints/admin/roles/users.ts b/packages/backend/src/server/api/endpoints/admin/roles/users.ts
index 45758d4f50..198166bec2 100644
--- a/packages/backend/src/server/api/endpoints/admin/roles/users.ts
+++ b/packages/backend/src/server/api/endpoints/admin/roles/users.ts
@@ -89,10 +89,13 @@ export default class extends Endpoint { // eslint-
.limit(ps.limit)
.getMany();
+ const _users = assigns.map(({ user, userId }) => user ?? userId);
+ const _userMap = await this.userEntityService.packMany(_users, me, { schema: 'UserDetailed' })
+ .then(users => new Map(users.map(u => [u.id, u])));
return await Promise.all(assigns.map(async assign => ({
id: assign.id,
createdAt: this.idService.parse(assign.id).date.toISOString(),
- user: await this.userEntityService.pack(assign.user!, me, { schema: 'UserDetailed' }),
+ user: _userMap.get(assign.userId) ?? await this.userEntityService.pack(assign.user!, me, { schema: 'UserDetailed' }),
expiresAt: assign.expiresAt?.toISOString() ?? null,
})));
});
diff --git a/packages/backend/src/server/api/endpoints/admin/show-users.ts b/packages/backend/src/server/api/endpoints/admin/show-users.ts
index 685da928e3..5f16519403 100644
--- a/packages/backend/src/server/api/endpoints/admin/show-users.ts
+++ b/packages/backend/src/server/api/endpoints/admin/show-users.ts
@@ -16,7 +16,7 @@ export const meta = {
requireCredential: true,
requireModerator: true,
- kind: 'read:admin:show-users',
+ kind: 'read:admin:show-user',
res: {
type: 'array',
diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts
index 95dd7346e7..015a1e1f7c 100644
--- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts
+++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts
@@ -113,6 +113,7 @@ export const paramDef = {
impressumUrl: { type: 'string', nullable: true },
donationUrl: { type: 'string', nullable: true },
privacyPolicyUrl: { type: 'string', nullable: true },
+ inquiryUrl: { type: 'string', nullable: true },
useObjectStorage: { type: 'boolean' },
objectStorageBaseUrl: { type: 'string', nullable: true },
objectStorageBucket: { type: 'string', nullable: true },
@@ -430,6 +431,10 @@ export default class extends Endpoint { // eslint-
set.privacyPolicyUrl = ps.privacyPolicyUrl;
}
+ if (ps.inquiryUrl !== undefined) {
+ set.inquiryUrl = ps.inquiryUrl;
+ }
+
if (ps.useObjectStorage !== undefined) {
set.useObjectStorage = ps.useObjectStorage;
}
diff --git a/packages/backend/src/server/api/endpoints/announcements.ts b/packages/backend/src/server/api/endpoints/announcements.ts
index 3b12f5b62c..ff8dd73605 100644
--- a/packages/backend/src/server/api/endpoints/announcements.ts
+++ b/packages/backend/src/server/api/endpoints/announcements.ts
@@ -7,9 +7,9 @@ import { Inject, Injectable } from '@nestjs/common';
import { Brackets } from 'typeorm';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js';
-import { AnnouncementService } from '@/core/AnnouncementService.js';
+import { AnnouncementEntityService } from '@/core/entities/AnnouncementEntityService.js';
import { DI } from '@/di-symbols.js';
-import type { AnnouncementReadsRepository, AnnouncementsRepository } from '@/models/_.js';
+import type { AnnouncementsRepository } from '@/models/_.js';
export const meta = {
tags: ['meta'],
@@ -44,11 +44,8 @@ export default class extends Endpoint { // eslint-
@Inject(DI.announcementsRepository)
private announcementsRepository: AnnouncementsRepository,
- @Inject(DI.announcementReadsRepository)
- private announcementReadsRepository: AnnouncementReadsRepository,
-
private queryService: QueryService,
- private announcementService: AnnouncementService,
+ private announcementEntityService: AnnouncementEntityService,
) {
super(meta, paramDef, async (ps, me) => {
const query = this.queryService.makePaginationQuery(this.announcementsRepository.createQueryBuilder('announcement'), ps.sinceId, ps.untilId)
@@ -60,7 +57,7 @@ export default class extends Endpoint { // eslint-
const announcements = await query.limit(ps.limit).getMany();
- return this.announcementService.packMany(announcements, me);
+ return this.announcementEntityService.packMany(announcements, me);
});
}
}
diff --git a/packages/backend/src/server/api/endpoints/announcements/show.ts b/packages/backend/src/server/api/endpoints/announcements/show.ts
new file mode 100644
index 0000000000..6312a0a54c
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/announcements/show.ts
@@ -0,0 +1,54 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Injectable } from '@nestjs/common';
+import { EntityNotFoundError } from 'typeorm';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { AnnouncementService } from '@/core/AnnouncementService.js';
+import { ApiError } from '../../error.js';
+
+export const meta = {
+ tags: ['meta'],
+
+ requireCredential: false,
+
+ res: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'Announcement',
+ },
+
+ errors: {
+ noSuchAnnouncement: {
+ message: 'No such announcement.',
+ code: 'NO_SUCH_ANNOUNCEMENT',
+ id: 'b57b5e1d-4f49-404a-9edb-46b00268f121',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ announcementId: { type: 'string', format: 'misskey:id' },
+ },
+ required: ['announcementId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint { // eslint-disable-line import/no-default-export
+ constructor(
+ private announcementService: AnnouncementService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ try {
+ return await this.announcementService.getAnnouncement(ps.announcementId, me);
+ } catch (err) {
+ if (err instanceof EntityNotFoundError) throw new ApiError(meta.errors.noSuchAnnouncement);
+ throw err;
+ }
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/antennas/create.ts b/packages/backend/src/server/api/endpoints/antennas/create.ts
index 57c8eb4958..6b7bacb054 100644
--- a/packages/backend/src/server/api/endpoints/antennas/create.ts
+++ b/packages/backend/src/server/api/endpoints/antennas/create.ts
@@ -67,9 +67,8 @@ export const paramDef = {
excludeBots: { type: 'boolean' },
withReplies: { type: 'boolean' },
withFile: { type: 'boolean' },
- notify: { type: 'boolean' },
},
- required: ['name', 'src', 'keywords', 'excludeKeywords', 'users', 'caseSensitive', 'withReplies', 'withFile', 'notify'],
+ required: ['name', 'src', 'keywords', 'excludeKeywords', 'users', 'caseSensitive', 'withReplies', 'withFile'],
} as const;
@Injectable()
@@ -128,7 +127,6 @@ export default class extends Endpoint { // eslint-
excludeBots: ps.excludeBots,
withReplies: ps.withReplies,
withFile: ps.withFile,
- notify: ps.notify,
}).then(x => this.antennasRepository.findOneByOrFail(x.identifiers[0]));
this.globalEventService.publishInternalEvent('antennaCreated', antenna);
diff --git a/packages/backend/src/server/api/endpoints/antennas/update.ts b/packages/backend/src/server/api/endpoints/antennas/update.ts
index e6720aacf8..0c30bca9e0 100644
--- a/packages/backend/src/server/api/endpoints/antennas/update.ts
+++ b/packages/backend/src/server/api/endpoints/antennas/update.ts
@@ -66,7 +66,6 @@ export const paramDef = {
excludeBots: { type: 'boolean' },
withReplies: { type: 'boolean' },
withFile: { type: 'boolean' },
- notify: { type: 'boolean' },
},
required: ['antennaId'],
} as const;
@@ -124,7 +123,6 @@ export default class extends Endpoint { // eslint-
excludeBots: ps.excludeBots,
withReplies: ps.withReplies,
withFile: ps.withFile,
- notify: ps.notify,
isActive: true,
lastUsedAt: new Date(),
});
diff --git a/packages/backend/src/server/api/endpoints/drive/files/find.ts b/packages/backend/src/server/api/endpoints/drive/files/find.ts
index 595a6957b2..502d42f9e0 100644
--- a/packages/backend/src/server/api/endpoints/drive/files/find.ts
+++ b/packages/backend/src/server/api/endpoints/drive/files/find.ts
@@ -54,7 +54,7 @@ export default class extends Endpoint { // eslint-
folderId: ps.folderId ?? IsNull(),
});
- return await Promise.all(files.map(file => this.driveFileEntityService.pack(file, { self: true })));
+ return await this.driveFileEntityService.packMany(files, { self: true });
});
}
}
diff --git a/packages/backend/src/server/api/endpoints/following/requests/list.ts b/packages/backend/src/server/api/endpoints/following/requests/list.ts
index 88f559138b..fa59e38976 100644
--- a/packages/backend/src/server/api/endpoints/following/requests/list.ts
+++ b/packages/backend/src/server/api/endpoints/following/requests/list.ts
@@ -71,7 +71,7 @@ export default class extends Endpoint { // eslint-
.limit(ps.limit)
.getMany();
- return await Promise.all(requests.map(req => this.followRequestEntityService.pack(req)));
+ return await this.followRequestEntityService.packMany(requests, me);
});
}
}
diff --git a/packages/backend/src/server/api/endpoints/i/notifications.ts b/packages/backend/src/server/api/endpoints/i/notifications.ts
index 594e8b95c8..5e97b90f99 100644
--- a/packages/backend/src/server/api/endpoints/i/notifications.ts
+++ b/packages/backend/src/server/api/endpoints/i/notifications.ts
@@ -7,7 +7,7 @@ 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 { FilterUnionByProperty, notificationTypes, obsoleteNotificationTypes } from '@/types.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { NoteReadService } from '@/core/NoteReadService.js';
import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js';
@@ -84,27 +84,51 @@ export default class extends Endpoint { // eslint-
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 limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1
- const notificationsRes = await this.redisClient.xrevrange(
- `notificationTimeline:${me.id}`,
- ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+',
- ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : '-',
- 'COUNT', limit);
+ let sinceTime = ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime().toString() : null;
+ let untilTime = ps.untilId ? this.idService.parse(ps.untilId).date.getTime().toString() : null;
- if (notificationsRes.length === 0) {
- return [];
- }
+ let notifications: MiNotification[];
+ for (;;) {
+ let notificationsRes: [id: string, fields: string[]][];
- let notifications = notificationsRes.map(x => JSON.parse(x[1][1])).filter(x => x.id !== ps.untilId && x !== ps.sinceId) as MiNotification[];
+ // sinceidのみの場合は古い順、そうでない場合は新しい順。 QueryService.makePaginationQueryも参照
+ if (sinceTime && !untilTime) {
+ notificationsRes = await this.redisClient.xrange(
+ `notificationTimeline:${me.id}`,
+ '(' + sinceTime,
+ '+',
+ 'COUNT', ps.limit);
+ } else {
+ notificationsRes = await this.redisClient.xrevrange(
+ `notificationTimeline:${me.id}`,
+ untilTime ? '(' + untilTime : '+',
+ sinceTime ? '(' + sinceTime : '-',
+ 'COUNT', ps.limit);
+ }
- if (includeTypes && includeTypes.length > 0) {
- notifications = notifications.filter(notification => includeTypes.includes(notification.type));
- } else if (excludeTypes && excludeTypes.length > 0) {
- notifications = notifications.filter(notification => !excludeTypes.includes(notification.type));
- }
+ if (notificationsRes.length === 0) {
+ return [];
+ }
- if (notifications.length === 0) {
- return [];
+ notifications = notificationsRes.map(x => JSON.parse(x[1][1])) as MiNotification[];
+
+ if (includeTypes && includeTypes.length > 0) {
+ notifications = notifications.filter(notification => includeTypes.includes(notification.type));
+ } else if (excludeTypes && excludeTypes.length > 0) {
+ notifications = notifications.filter(notification => !excludeTypes.includes(notification.type));
+ }
+
+ if (notifications.length !== 0) {
+ // 通知が1件以上ある場合は返す
+ break;
+ }
+
+ // フィルタしたことで通知が0件になった場合、次のページを取得する
+ if (ps.sinceId && !ps.untilId) {
+ sinceTime = notificationsRes[notificationsRes.length - 1][0];
+ } else {
+ untilTime = notificationsRes[notificationsRes.length - 1][0];
+ }
}
// Mark all as read
diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts
index 06edb28578..aa2f85845f 100644
--- a/packages/backend/src/server/api/endpoints/i/update.ts
+++ b/packages/backend/src/server/api/endpoints/i/update.ts
@@ -530,26 +530,32 @@ export default class extends Endpoint { // eslint-
private async verifyLink(url: string, user: MiLocalUser) {
if (!safeForSql(url)) return;
- const html = await this.httpRequestService.getHtml(url);
+ try {
+ const html = await this.httpRequestService.getHtml(url);
- const { window } = new JSDOM(html);
- const doc = window.document;
+ const { window } = new JSDOM(html);
+ const doc = window.document;
- const myLink = `${this.config.url}/@${user.username}`;
+ const myLink = `${this.config.url}/@${user.username}`;
- const aEls = Array.from(doc.getElementsByTagName('a'));
- const linkEls = Array.from(doc.getElementsByTagName('link'));
+ const aEls = Array.from(doc.getElementsByTagName('a'));
+ const linkEls = Array.from(doc.getElementsByTagName('link'));
- const includesMyLink = aEls.some(a => a.href === myLink);
- const includesRelMeLinks = [...aEls, ...linkEls].some(link => link.rel === 'me' && link.href === myLink);
+ const includesMyLink = aEls.some(a => a.href === myLink);
+ const includesRelMeLinks = [...aEls, ...linkEls].some(link => link.rel === 'me' && link.href === myLink);
- if (includesMyLink || includesRelMeLinks) {
- await this.userProfilesRepository.createQueryBuilder('profile').update()
- .where('userId = :userId', { userId: user.id })
- .set({
- verifiedLinks: () => `array_append("verifiedLinks", '${url}')`, // ここでSQLインジェクションされそうなのでとりあえず safeForSql で弾いている
- })
- .execute();
+ if (includesMyLink || includesRelMeLinks) {
+ await this.userProfilesRepository.createQueryBuilder('profile').update()
+ .where('userId = :userId', { userId: user.id })
+ .set({
+ verifiedLinks: () => `array_append("verifiedLinks", '${url}')`, // ここでSQLインジェクションされそうなのでとりあえず safeForSql で弾いている
+ })
+ .execute();
+ }
+
+ window.close();
+ } catch (err) {
+ // なにもしない
}
}
}
diff --git a/packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts b/packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts
index ba38573065..4fd6f8682d 100644
--- a/packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts
+++ b/packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts
@@ -32,6 +32,7 @@ export const paramDef = {
properties: {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
offset: { type: 'integer', default: 0 },
+ excludeChannels: { type: 'boolean', default: false },
},
required: [],
} as const;
@@ -86,6 +87,12 @@ export default class extends Endpoint { // eslint-
query.setParameters(mutingQuery.getParameters());
//#endregion
+ //#region exclude channels
+ if (ps.excludeChannels) {
+ query.andWhere('poll.channelId IS NULL');
+ }
+ //#endregion
+
const polls = await query
.orderBy('poll.noteId', 'DESC')
.limit(ps.limit)
diff --git a/packages/backend/src/server/api/endpoints/notes/reactions.ts b/packages/backend/src/server/api/endpoints/notes/reactions.ts
index 3beb5064ae..7e334df93e 100644
--- a/packages/backend/src/server/api/endpoints/notes/reactions.ts
+++ b/packages/backend/src/server/api/endpoints/notes/reactions.ts
@@ -79,7 +79,7 @@ export default class extends Endpoint { // eslint-
const reactions = await query.limit(ps.limit).getMany();
- return await Promise.all(reactions.map(reaction => this.noteReactionEntityService.pack(reaction, me)));
+ return await this.noteReactionEntityService.packMany(reactions, me);
});
}
}
diff --git a/packages/backend/src/server/api/endpoints/roles/users.ts b/packages/backend/src/server/api/endpoints/roles/users.ts
index 85d100ce1c..48d350af59 100644
--- a/packages/backend/src/server/api/endpoints/roles/users.ts
+++ b/packages/backend/src/server/api/endpoints/roles/users.ts
@@ -92,9 +92,12 @@ export default class extends Endpoint { // eslint-
.limit(ps.limit)
.getMany();
+ const _users = assigns.map(({ user, userId }) => user ?? userId);
+ const _userMap = await this.userEntityService.packMany(_users, me, { schema: 'UserDetailed' })
+ .then(users => new Map(users.map(u => [u.id, u])));
return await Promise.all(assigns.map(async assign => ({
id: assign.id,
- user: await this.userEntityService.pack(assign.user!, me, { schema: 'UserDetailed' }),
+ user: _userMap.get(assign.userId) ?? await this.userEntityService.pack(assign.user!, me, { schema: 'UserDetailed' }),
})));
});
}
diff --git a/packages/backend/src/server/api/endpoints/users/get-frequently-replied-users.ts b/packages/backend/src/server/api/endpoints/users/get-frequently-replied-users.ts
index 02aa037466..9248a2fa68 100644
--- a/packages/backend/src/server/api/endpoints/users/get-frequently-replied-users.ts
+++ b/packages/backend/src/server/api/endpoints/users/get-frequently-replied-users.ts
@@ -118,12 +118,14 @@ export default class extends Endpoint { // eslint-
const repliedUsersSorted = Object.keys(repliedUsers).sort((a, b) => repliedUsers[b] - repliedUsers[a]);
// Extract top replied users
- const topRepliedUsers = repliedUsersSorted.slice(0, ps.limit);
+ const topRepliedUserIds = repliedUsersSorted.slice(0, ps.limit);
// Make replies object (includes weights)
- const repliesObj = await Promise.all(topRepliedUsers.map(async (user) => ({
- user: await this.userEntityService.pack(user, me, { schema: 'UserDetailed' }),
- weight: repliedUsers[user] / peak,
+ const _userMap = await this.userEntityService.packMany(topRepliedUserIds, me, { schema: 'UserDetailed' })
+ .then(users => new Map(users.map(u => [u.id, u])));
+ const repliesObj = await Promise.all(topRepliedUserIds.map(async (userId) => ({
+ user: _userMap.get(userId) ?? await this.userEntityService.pack(userId, me, { schema: 'UserDetailed' }),
+ weight: repliedUsers[userId] / peak,
})));
return repliesObj;
diff --git a/packages/backend/src/server/api/endpoints/users/show.ts b/packages/backend/src/server/api/endpoints/users/show.ts
index bd81989cb9..062326e28d 100644
--- a/packages/backend/src/server/api/endpoints/users/show.ts
+++ b/packages/backend/src/server/api/endpoints/users/show.ts
@@ -110,14 +110,16 @@ export default class extends Endpoint { // eslint-
});
// リクエストされた通りに並べ替え
+ // 順番は保持されるけど数は減ってる可能性がある
const _users: MiUser[] = [];
for (const id of ps.userIds) {
- _users.push(users.find(x => x.id === id)!);
+ const user = users.find(x => x.id === id);
+ if (user != null) _users.push(user);
}
- return await Promise.all(_users.map(u => this.userEntityService.pack(u, me, {
- schema: 'UserDetailed',
- })));
+ const _userMap = await this.userEntityService.packMany(_users, me, { schema: 'UserDetailed' })
+ .then(users => new Map(users.map(u => [u.id, u])));
+ return _users.map(u => _userMap.get(u.id)!);
} else {
// Lookup user
if (typeof ps.host === 'string' && typeof ps.username === 'string') {
diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts
index 84c10370f6..702e306feb 100644
--- a/packages/backend/src/server/web/ClientServerService.ts
+++ b/packages/backend/src/server/web/ClientServerService.ts
@@ -456,7 +456,7 @@ export class ClientServerService {
//#endregion
- const renderBase = async (reply: FastifyReply) => {
+ const renderBase = async (reply: FastifyReply, data: { [key: string]: any } = {}) => {
const meta = await this.metaService.fetch();
reply.header('Cache-Control', 'public, max-age=30');
return await reply.view('base', {
@@ -465,6 +465,7 @@ export class ClientServerService {
title: meta.name ?? 'Misskey',
desc: meta.description,
...await this.generateCommonPugData(meta),
+ ...data,
});
};
@@ -483,7 +484,9 @@ export class ClientServerService {
};
// Atom
- fastify.get<{ Params: { user: string; } }>('/@:user.atom', async (request, reply) => {
+ fastify.get<{ Params: { user?: string; } }>('/@:user.atom', async (request, reply) => {
+ if (request.params.user == null) return await renderBase(reply);
+
const feed = await getFeed(request.params.user);
if (feed) {
@@ -496,7 +499,9 @@ export class ClientServerService {
});
// RSS
- fastify.get<{ Params: { user: string; } }>('/@:user.rss', async (request, reply) => {
+ fastify.get<{ Params: { user?: string; } }>('/@:user.rss', async (request, reply) => {
+ if (request.params.user == null) return await renderBase(reply);
+
const feed = await getFeed(request.params.user);
if (feed) {
@@ -509,7 +514,9 @@ export class ClientServerService {
});
// JSON
- fastify.get<{ Params: { user: string; } }>('/@:user.json', async (request, reply) => {
+ fastify.get<{ Params: { user?: string; } }>('/@:user.json', async (request, reply) => {
+ if (request.params.user == null) return await renderBase(reply);
+
const feed = await getFeed(request.params.user);
if (feed) {
@@ -762,6 +769,18 @@ export class ClientServerService {
});
//#endregion
+ //region noindex pages
+ // Tags
+ fastify.get<{ Params: { clip: string; } }>('/tags/:tag', async (request, reply) => {
+ return await renderBase(reply, { noindex: true });
+ });
+
+ // User with Tags
+ fastify.get<{ Params: { clip: string; } }>('/user-tags/:tag', async (request, reply) => {
+ return await renderBase(reply, { noindex: true });
+ });
+ //endregion
+
fastify.get('/_info_card_', async (request, reply) => {
const meta = await this.metaService.fetch(true);
diff --git a/packages/backend/src/server/web/views/base.pug b/packages/backend/src/server/web/views/base.pug
index 0f555e0637..c1e38717c8 100644
--- a/packages/backend/src/server/web/views/base.pug
+++ b/packages/backend/src/server/web/views/base.pug
@@ -55,6 +55,9 @@ html
block title
= title || 'Sharkey'
+ if noindex
+ meta(name='robots' content='noindex')
+
block desc
meta(name='description' content= desc || '✨🌎✨ A interplanetary communication platform ✨🚀✨')
diff --git a/packages/backend/test/e2e/antennas.ts b/packages/backend/test/e2e/antennas.ts
index cf5c7dd130..101238b601 100644
--- a/packages/backend/test/e2e/antennas.ts
+++ b/packages/backend/test/e2e/antennas.ts
@@ -38,7 +38,6 @@ describe('アンテナ', () => {
excludeKeywords: [['']],
keywords: [['keyword']],
name: 'test',
- notify: false,
src: 'all' as const,
userListId: null,
users: [''],
@@ -151,7 +150,6 @@ describe('アンテナ', () => {
isActive: true,
keywords: [['keyword']],
name: 'test',
- notify: false,
src: 'all',
userListId: null,
users: [''],
@@ -159,6 +157,7 @@ describe('アンテナ', () => {
withReplies: false,
excludeBots: false,
localOnly: false,
+ notify: false,
};
assert.deepStrictEqual(response, expected);
});
@@ -219,8 +218,6 @@ describe('アンテナ', () => {
{ parameters: () => ({ withReplies: true }) },
{ parameters: () => ({ withFile: false }) },
{ parameters: () => ({ withFile: true }) },
- { parameters: () => ({ notify: false }) },
- { parameters: () => ({ notify: true }) },
];
test.each(antennaParamPattern)('を作成できること($#)', async ({ parameters }) => {
const response = await successfulApiCall({
diff --git a/packages/backend/test/e2e/move.ts b/packages/backend/test/e2e/move.ts
index 4e5306da97..35050130dc 100644
--- a/packages/backend/test/e2e/move.ts
+++ b/packages/backend/test/e2e/move.ts
@@ -191,7 +191,6 @@ describe('Account Move', () => {
localOnly: false,
withReplies: false,
withFile: false,
- notify: false,
}, alice);
antennaId = antenna.body.id;
@@ -435,7 +434,6 @@ describe('Account Move', () => {
localOnly: false,
withReplies: false,
withFile: false,
- notify: false,
}, alice);
assert.strictEqual(res.status, 403);
diff --git a/packages/backend/test/unit/AnnouncementService.ts b/packages/backend/test/unit/AnnouncementService.ts
index aa082ff2f2..81da0fac31 100644
--- a/packages/backend/test/unit/AnnouncementService.ts
+++ b/packages/backend/test/unit/AnnouncementService.ts
@@ -10,6 +10,7 @@ import { ModuleMocker } from 'jest-mock';
import { Test } from '@nestjs/testing';
import { GlobalModule } from '@/GlobalModule.js';
import { AnnouncementService } from '@/core/AnnouncementService.js';
+import { AnnouncementEntityService } from '@/core/entities/AnnouncementEntityService.js';
import type {
AnnouncementReadsRepository,
AnnouncementsRepository,
@@ -67,6 +68,7 @@ describe('AnnouncementService', () => {
],
providers: [
AnnouncementService,
+ AnnouncementEntityService,
CacheService,
IdService,
],
diff --git a/packages/frontend/.storybook/preview-head.html b/packages/frontend/.storybook/preview-head.html
index 4722fe7f5f..ae42fd49bc 100644
--- a/packages/frontend/.storybook/preview-head.html
+++ b/packages/frontend/.storybook/preview-head.html
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-
+
diff --git a/packages/frontend/src/components/MkFormDialog.vue b/packages/frontend/src/components/MkFormDialog.vue
index deedc5badb..124f114111 100644
--- a/packages/frontend/src/components/MkFormDialog.vue
+++ b/packages/frontend/src/components/MkFormDialog.vue
@@ -21,8 +21,9 @@ SPDX-License-Identifier: AGPL-3.0-only
-
-
+
+
+
({{ i18n.ts.optional }})
{{ v.description }}
@@ -53,6 +54,12 @@ SPDX-License-Identifier: AGPL-3.0-only
+ values[k] = f"
+ />
@@ -72,6 +79,7 @@ import MkSelect from './MkSelect.vue';
import MkRange from './MkRange.vue';
import MkButton from './MkButton.vue';
import MkRadios from './MkRadios.vue';
+import XFile from './MkFormDialog.file.vue';
import type { Form } from '@/scripts/form.js';
import MkModalWindow from '@/components/MkModalWindow.vue';
import { i18n } from '@/i18n.js';
diff --git a/packages/frontend/src/components/MkModal.vue b/packages/frontend/src/components/MkModal.vue
index eb240da759..9e69ab2207 100644
--- a/packages/frontend/src/components/MkModal.vue
+++ b/packages/frontend/src/components/MkModal.vue
@@ -276,8 +276,11 @@ const align = () => {
const onOpened = () => {
emit('opened');
+ // NOTE: Chromatic テストの際に undefined になる場合がある
+ if (content.value == null) return;
+
// モーダルコンテンツにマウスボタンが押され、コンテンツ外でマウスボタンが離されたときにモーダルバックグラウンドクリックと判定させないためにマウスイベントを監視しフラグ管理する
- const el = content.value!.children[0];
+ const el = content.value.children[0];
el.addEventListener('mousedown', ev => {
contentClicking = true;
window.addEventListener('mouseup', ev => {
diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue
index caadfa7ca7..c6631fe1f4 100644
--- a/packages/frontend/src/components/MkPostForm.vue
+++ b/packages/frontend/src/components/MkPostForm.vue
@@ -192,7 +192,7 @@ const localOnly = ref(props.initialLocalOnly ?? (defaultStore.state.rememberNote
const visibility = ref(props.initialVisibility ?? (defaultStore.state.rememberNoteVisibility ? defaultStore.state.visibility : defaultStore.state.defaultNoteVisibility));
const visibleUsers = ref([]);
if (props.initialVisibleUsers) {
- props.initialVisibleUsers.forEach(pushVisibleUser);
+ props.initialVisibleUsers.forEach(u => pushVisibleUser(u));
}
const reactionAcceptance = ref(defaultStore.state.reactionAcceptance);
const autocomplete = ref(null);
@@ -338,7 +338,7 @@ if (props.reply && ['home', 'followers', 'specified'].includes(props.reply.visib
misskeyApi('users/show', {
userIds: props.reply.visibleUserIds.filter(uid => uid !== $i.id && uid !== props.reply?.userId),
}).then(users => {
- users.forEach(pushVisibleUser);
+ users.forEach(u => pushVisibleUser(u));
});
}
@@ -618,6 +618,23 @@ async function onPaste(ev: ClipboardEvent) {
quoteId.value = paste.substring(url.length).match(/^\/notes\/(.+?)\/?$/)?.[1] ?? null;
});
}
+
+ if (paste.length > 1000) {
+ ev.preventDefault();
+ os.confirm({
+ type: 'info',
+ text: i18n.ts.attachAsFileQuestion,
+ }).then(({ canceled }) => {
+ if (canceled) {
+ insertTextAtCursor(textareaEl.value, paste);
+ return;
+ }
+
+ const fileName = formatTimeString(new Date(), defaultStore.state.pastedFileName).replace(/{{number}}/g, "0");
+ const file = new File([paste], `${fileName}.txt`, { type: "text/plain" });
+ upload(file, `${fileName}.txt`);
+ });
+ }
}
function onDragover(ev) {
@@ -1004,11 +1021,7 @@ onMounted(() => {
}
if (draft.data.visibleUserIds) {
misskeyApi('users/show', { userIds: draft.data.visibleUserIds }).then(users => {
- for (let i = 0; i < users.length; i++) {
- if (users[i].id === draft.data.visibleUserIds[i]) {
- pushVisibleUser(users[i]);
- }
- }
+ users.forEach(u => pushVisibleUser(u));
});
}
}
diff --git a/packages/frontend/src/components/global/MkAd.stories.impl.ts b/packages/frontend/src/components/global/MkAd.stories.impl.ts
index a1d274382f..aef26ab92d 100644
--- a/packages/frontend/src/components/global/MkAd.stories.impl.ts
+++ b/packages/frontend/src/components/global/MkAd.stories.impl.ts
@@ -11,6 +11,10 @@ import { i18n } from '@/i18n.js';
let lock: Promise | undefined;
+function sleep(ms: number) {
+ return new Promise(resolve => setTimeout(resolve, ms));
+}
+
const common = {
render(args) {
return {
@@ -43,6 +47,8 @@ const common = {
lock = new Promise(r => resolve = r);
try {
+ // NOTE: sleep しないと何故か落ちる
+ await sleep(100);
const canvas = within(canvasElement);
const a = canvas.getByRole('link');
// await expect(a.href).toMatch(/^https?:\/\/.*#test$/);
@@ -53,7 +59,7 @@ const common = {
const i = buttons[0];
await expect(i).toBeInTheDocument();
await userEvent.click(i);
- // await expect(canvasElement).toHaveTextContent(i18n.ts._ad.back);
+ await expect(canvasElement).toHaveTextContent(i18n.ts._ad.back);
await expect(a).not.toBeInTheDocument();
await expect(i).not.toBeInTheDocument();
buttons = canvas.getAllByRole('button');
diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts
index fc73622d6b..c728b014a2 100644
--- a/packages/frontend/src/os.ts
+++ b/packages/frontend/src/os.ts
@@ -525,7 +525,7 @@ export function waiting(): Promise {
});
}
-export function form(title: string, f: F): Promise<{ canceled: true } | { result: GetFormResultType }> {
+export function form(title: string, f: F): Promise<{ canceled: true, result?: undefined } | { canceled?: false, result: GetFormResultType }> {
return new Promise(resolve => {
popup(defineAsyncComponent(() => import('@/components/MkFormDialog.vue')), { title, form: f }, {
done: result => {
diff --git a/packages/frontend/src/pages/admin/federation.vue b/packages/frontend/src/pages/admin/federation.vue
index db9211929f..c1f958cece 100644
--- a/packages/frontend/src/pages/admin/federation.vue
+++ b/packages/frontend/src/pages/admin/federation.vue
@@ -59,6 +59,7 @@ SPDX-License-Identifier: AGPL-3.0-only
+
+
diff --git a/packages/frontend/src/pages/announcements.vue b/packages/frontend/src/pages/announcements.vue
index 4f5abdb385..07bbc46ffc 100644
--- a/packages/frontend/src/pages/announcements.vue
+++ b/packages/frontend/src/pages/announcements.vue
@@ -21,14 +21,19 @@ SPDX-License-Identifier: AGPL-3.0-only
- {{ announcement.title }}
+ {{ announcement.title }}
-
-
-
+
+
+ {{ i18n.ts.createdAt }}:
+
+
+ {{ i18n.ts.updatedAt }}:
+
+
{{ i18n.ts.gotIt }}
@@ -73,24 +78,24 @@ const paginationEl = ref
>();
const tab = ref('current');
-async function read(announcement) {
- if (announcement.needConfirmationToRead) {
+async function read(target) {
+ if (target.needConfirmationToRead) {
const confirm = await os.confirm({
type: 'question',
title: i18n.ts._announcement.readConfirmTitle,
- text: i18n.tsx._announcement.readConfirmText({ title: announcement.title }),
+ text: i18n.tsx._announcement.readConfirmText({ title: target.title }),
});
if (confirm.canceled) return;
}
if (!paginationEl.value) return;
- paginationEl.value.updateItem(announcement.id, a => {
+ paginationEl.value.updateItem(target.id, a => {
a.isRead = true;
return a;
});
- misskeyApi('i/read-announcement', { announcementId: announcement.id });
+ misskeyApi('i/read-announcement', { announcementId: target.id });
updateAccount({
- unreadAnnouncements: $i!.unreadAnnouncements.filter(a => a.id !== announcement.id),
+ unreadAnnouncements: $i!.unreadAnnouncements.filter(a => a.id !== target.id),
});
}
diff --git a/packages/frontend/src/pages/channel.vue b/packages/frontend/src/pages/channel.vue
index ee081d07ee..e49a16fecd 100644
--- a/packages/frontend/src/pages/channel.vue
+++ b/packages/frontend/src/pages/channel.vue
@@ -83,6 +83,7 @@ import { definePageMetadata } from '@/scripts/page-metadata.js';
import { deviceKind } from '@/scripts/device-kind.js';
import MkNotes from '@/components/MkNotes.vue';
import { url } from '@/config.js';
+import { favoritedChannelsCache } from '@/cache.js';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import { defaultStore } from '@/store.js';
@@ -170,6 +171,7 @@ function favorite() {
channelId: channel.value.id,
}).then(() => {
favorited.value = true;
+ favoritedChannelsCache.delete();
});
}
@@ -185,6 +187,7 @@ async function unfavorite() {
channelId: channel.value.id,
}).then(() => {
favorited.value = false;
+ favoritedChannelsCache.delete();
});
}
diff --git a/packages/frontend/src/pages/contact.vue b/packages/frontend/src/pages/contact.vue
index 33083c0f40..b6cfebf229 100644
--- a/packages/frontend/src/pages/contact.vue
+++ b/packages/frontend/src/pages/contact.vue
@@ -7,7 +7,21 @@ SPDX-License-Identifier: AGPL-3.0-only
- {{ instance.maintainerEmail }}
+
+
+ {{ i18n.ts.inquiry }}
+
+ {{ instance.inquiryUrl }}
+
+
+
+
+ {{ i18n.ts.email }}
+
+ {{ instance.maintainerEmail }}
+
+
+
@@ -16,6 +30,8 @@ SPDX-License-Identifier: AGPL-3.0-only
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { instance } from '@/instance.js';
+import MkKeyValue from '@/components/MkKeyValue.vue';
+import MkLink from '@/components/MkLink.vue';
definePageMetadata(() => ({
title: i18n.ts.inquiry,
diff --git a/packages/frontend/src/pages/explore.featured.vue b/packages/frontend/src/pages/explore.featured.vue
index b5c8e70166..cfdb235d3a 100644
--- a/packages/frontend/src/pages/explore.featured.vue
+++ b/packages/frontend/src/pages/explore.featured.vue
@@ -29,6 +29,9 @@ const paginationForPolls = {
endpoint: 'notes/polls/recommendation' as const,
limit: 10,
offsetMode: true,
+ params: {
+ excludeChannels: true,
+ },
};
const tab = ref('notes');
diff --git a/packages/frontend/src/pages/instance-info.vue b/packages/frontend/src/pages/instance-info.vue
index 4099e2bac9..0cc4c1a48a 100644
--- a/packages/frontend/src/pages/instance-info.vue
+++ b/packages/frontend/src/pages/instance-info.vue
@@ -35,7 +35,16 @@ SPDX-License-Identifier: AGPL-3.0-only
Moderation
- {{ i18n.ts.stopActivityDelivery }}
+
+
+ {{ i18n.ts._delivery.status }}
+
+
+ {{ i18n.ts._delivery._type[suspensionState] }}
+
+
+ {{ i18n.ts._delivery.stop }}
+ {{ i18n.ts._delivery.resume }}
{{ i18n.ts.blockThisInstance }}
{{ i18n.ts.silenceThisInstance }}
Mark as NSFW
@@ -156,7 +165,7 @@ const tab = ref('overview');
const chartSrc = ref('instance-requests');
const meta = ref(null);
const instance = ref(null);
-const suspended = ref(false);
+const suspensionState = ref<'none' | 'manuallySuspended' | 'goneSuspended' | 'autoSuspendedForNotResponding'>('none');
const isBlocked = ref(false);
const isSilenced = ref(false);
const isNSFW = ref(false);
@@ -185,7 +194,7 @@ async function fetch(): Promise {
instance.value = await misskeyApi('federation/show-instance', {
host: props.host,
});
- suspended.value = instance.value?.isSuspended ?? false;
+ suspensionState.value = instance.value?.suspensionState ?? 'none';
isBlocked.value = instance.value?.isBlocked ?? false;
isSilenced.value = instance.value?.isSilenced ?? false;
isNSFW.value = instance.value?.isNSFW ?? false;
@@ -212,11 +221,21 @@ async function toggleSilenced(): Promise {
});
}
-async function toggleSuspend(): Promise {
+async function stopDelivery(): Promise {
if (!instance.value) throw new Error('No instance?');
+ suspensionState.value = 'manuallySuspended';
await misskeyApi('admin/federation/update-instance', {
host: instance.value.host,
- isSuspended: suspended.value,
+ isSuspended: true,
+ });
+}
+
+async function resumeDelivery(): Promise {
+ if (!instance.value) throw new Error('No instance?');
+ suspensionState.value = 'none';
+ await misskeyApi('admin/federation/update-instance', {
+ host: instance.value.host,
+ isSuspended: false,
});
}
diff --git a/packages/frontend/src/pages/my-antennas/editor.vue b/packages/frontend/src/pages/my-antennas/editor.vue
index 07fc459688..51dbb3b08f 100644
--- a/packages/frontend/src/pages/my-antennas/editor.vue
+++ b/packages/frontend/src/pages/my-antennas/editor.vue
@@ -39,7 +39,6 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ i18n.ts.localOnly }}
{{ i18n.ts.caseSensitive }}
{{ i18n.ts.withFileAntenna }}
- {{ i18n.ts.notifyAntenna }}
{{ i18n.ts.save }}
@@ -82,7 +81,6 @@ const localOnly = ref
(props.antenna.localOnly);
const excludeBots = ref(props.antenna.excludeBots);
const withReplies = ref(props.antenna.withReplies);
const withFile = ref(props.antenna.withFile);
-const notify = ref(props.antenna.notify);
const userLists = ref(null);
watch(() => src.value, async () => {
@@ -99,7 +97,6 @@ async function saveAntenna() {
excludeBots: excludeBots.value,
withReplies: withReplies.value,
withFile: withFile.value,
- notify: notify.value,
caseSensitive: caseSensitive.value,
localOnly: localOnly.value,
users: users.value.trim().split('\n').map(x => x.trim()),
diff --git a/packages/frontend/src/pages/settings/general.vue b/packages/frontend/src/pages/settings/general.vue
index e772b8c167..3b4f000e61 100644
--- a/packages/frontend/src/pages/settings/general.vue
+++ b/packages/frontend/src/pages/settings/general.vue
@@ -50,13 +50,17 @@ SPDX-License-Identifier: AGPL-3.0-only
- {{ i18n.ts.showNoteActionsOnlyHover }}
- {{ i18n.ts.showClipButtonInNoteFooter }}
+
+ {{ i18n.ts.collapseRenotes }}
+ {{ i18n.ts.collapseRenotesDescription }}
+
{{ i18n.ts.collapseRenotes }}
{{ i18n.ts.collapseFiles }}
Uncollapse CWs on notes
- {{ i18n.ts.autoloadConversation }}
Always expand long notes
+ {{ i18n.ts.showNoteActionsOnlyHover }}
+ {{ i18n.ts.showClipButtonInNoteFooter }}
+ {{ i18n.ts.autoloadConversation }}
{{ i18n.ts.enableAdvancedMfm }}
{{ i18n.ts.enableAnimatedMfm }}
{{ i18n.ts.enableQuickAddMfmFunction }}
diff --git a/packages/frontend/src/pages/share.vue b/packages/frontend/src/pages/share.vue
index 1eeeb587eb..8d974b17fb 100644
--- a/packages/frontend/src/pages/share.vue
+++ b/packages/frontend/src/pages/share.vue
@@ -64,7 +64,34 @@ async function init() {
// Googleニュース対策
if (text?.startsWith(`${title.value}.\n`)) noteText += text.replace(`${title.value}.\n`, '');
else if (text && title.value !== text) noteText += `${text}\n`;
- if (url) noteText += `${url}`;
+ if (url) {
+ try {
+ // Normalize the URL to URL-encoded and puny-coded from with the URL constructor.
+ //
+ // It's common to use unicode characters in the URL for better visibility of URL
+ // like: https://ja.wikipedia.org/wiki/ミスキー
+ // or like: https://藍.moe/
+ // However, in the MFM, the unicode characters must be URL-encoded to be parsed as `url` node
+ // like: https://ja.wikipedia.org/wiki/%E3%83%9F%E3%82%B9%E3%82%AD%E3%83%BC
+ // or like: https://xn--931a.moe/
+ // Therefore, we need to normalize the URL to URL-encoded form.
+ //
+ // The URL constructor will parse the URL and normalize unicode characters
+ // in the host to punycode and in the path component to URL-encoded form.
+ // (see url.spec.whatwg.org)
+ //
+ // In addition, the current MFM renderer decodes the URL-encoded path and / punycode encoded host name so
+ // this normalization doesn't make the visible URL ugly.
+ // (see MkUrl.vue)
+
+ noteText += new URL(url).href;
+ } catch {
+ // fallback to original URL if the URL is invalid.
+ // note that this is extremely rare since the `url` parameter is designed to share a URL and
+ // the URL constructor will throw TypeError only if failure, which means the URL is not valid.
+ noteText += url;
+ }
+ }
initialText.value = noteText.trim();
if (visibility.value === 'specified') {
diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue
index a9f7a163f6..1e5a244dd4 100644
--- a/packages/frontend/src/pages/timeline.vue
+++ b/packages/frontend/src/pages/timeline.vue
@@ -49,7 +49,7 @@ import { i18n } from '@/i18n.js';
import { instance } from '@/instance.js';
import { $i } from '@/account.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
-import { antennasCache, userListsCache } from '@/cache.js';
+import { antennasCache, userListsCache, favoritedChannelsCache } from '@/cache.js';
import { deviceKind } from '@/scripts/device-kind.js';
import { deepMerge } from '@/scripts/merge.js';
import { MenuItem } from '@/types/menu.js';
@@ -180,9 +180,7 @@ async function chooseAntenna(ev: MouseEvent): Promise {
}
async function chooseChannel(ev: MouseEvent): Promise {
- const channels = await misskeyApi('channels/my-favorites', {
- limit: 100,
- });
+ const channels = await favoritedChannelsCache.fetch();
const items: MenuItem[] = [
...channels.map(channel => {
const lastReadedAt = miLocalStorage.getItemAsJson(`channelLastReadedAt:${channel.id}`) ?? null;
diff --git a/packages/frontend/src/router/definition.ts b/packages/frontend/src/router/definition.ts
index e411a145c1..f18dac2a44 100644
--- a/packages/frontend/src/router/definition.ts
+++ b/packages/frontend/src/router/definition.ts
@@ -193,6 +193,9 @@ const routes: RouteDef[] = [{
}, {
path: '/announcements',
component: page(() => import('@/pages/announcements.vue')),
+}, {
+ path: '/announcements/:announcementId',
+ component: page(() => import('@/pages/announcement.vue')),
}, {
path: '/about',
component: page(() => import('@/pages/about.vue')),
diff --git a/packages/frontend/src/scripts/lookup-user.ts b/packages/frontend/src/scripts/admin-lookup.ts
similarity index 72%
rename from packages/frontend/src/scripts/lookup-user.ts
rename to packages/frontend/src/scripts/admin-lookup.ts
index efc9132e75..1b57b853c9 100644
--- a/packages/frontend/src/scripts/lookup-user.ts
+++ b/packages/frontend/src/scripts/admin-lookup.ts
@@ -63,3 +63,26 @@ export async function lookupUserByEmail() {
}
}
}
+
+export async function lookupFile() {
+ const { canceled, result: q } = await os.inputText({
+ title: i18n.ts.fileIdOrUrl,
+ minLength: 1,
+ });
+ if (canceled) return;
+
+ const show = (file) => {
+ os.pageWindow(`/admin/file/${file.id}`);
+ };
+
+ misskeyApi('admin/drive/show-file', q.startsWith('http://') || q.startsWith('https://') ? { url: q.trim() } : { fileId: q.trim() }).then(file => {
+ show(file);
+ }).catch(err => {
+ if (err.code === 'NO_SUCH_FILE') {
+ os.alert({
+ type: 'error',
+ text: i18n.ts.notFound,
+ });
+ }
+ });
+}
diff --git a/packages/frontend/src/scripts/collapsed.ts b/packages/frontend/src/scripts/collapsed.ts
index 237bd37c7a..4ec88a3c65 100644
--- a/packages/frontend/src/scripts/collapsed.ts
+++ b/packages/frontend/src/scripts/collapsed.ts
@@ -6,15 +6,16 @@
import * as Misskey from 'misskey-js';
export function shouldCollapsed(note: Misskey.entities.Note, urls: string[]): boolean {
- const collapsed = note.cw == null && note.text != null && (
- (note.text.includes('$[x2')) ||
- (note.text.includes('$[x3')) ||
- (note.text.includes('$[x4')) ||
- (note.text.includes('$[scale')) ||
- (note.text.split('\n').length > 9) ||
- (note.text.length > 500) ||
- (note.files.length >= 5) ||
- (urls.length >= 4)
+ const collapsed = note.cw == null && (
+ note.text != null && (
+ (note.text.includes('$[x2')) ||
+ (note.text.includes('$[x3')) ||
+ (note.text.includes('$[x4')) ||
+ (note.text.includes('$[scale')) ||
+ (note.text.split('\n').length > 9) ||
+ (note.text.length > 500) ||
+ (urls.length >= 4)
+ ) || note.files.length >= 5
);
return collapsed;
diff --git a/packages/frontend/src/scripts/form.ts b/packages/frontend/src/scripts/form.ts
index b0db404f28..242a504c3b 100644
--- a/packages/frontend/src/scripts/form.ts
+++ b/packages/frontend/src/scripts/form.ts
@@ -3,18 +3,22 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
+import * as Misskey from 'misskey-js';
+
type EnumItem = string | {
label: string;
value: string;
};
+type Hidden = boolean | ((v: any) => boolean);
+
export type FormItem = {
label?: string;
type: 'string';
default: string | null;
description?: string;
required?: boolean;
- hidden?: boolean;
+ hidden?: Hidden;
multiline?: boolean;
treatAsMfm?: boolean;
} | {
@@ -23,27 +27,27 @@ export type FormItem = {
default: number | null;
description?: string;
required?: boolean;
- hidden?: boolean;
+ hidden?: Hidden;
step?: number;
} | {
label?: string;
type: 'boolean';
default: boolean | null;
description?: string;
- hidden?: boolean;
+ hidden?: Hidden;
} | {
label?: string;
type: 'enum';
default: string | null;
required?: boolean;
- hidden?: boolean;
+ hidden?: Hidden;
enum: EnumItem[];
} | {
label?: string;
type: 'radio';
default: unknown | null;
required?: boolean;
- hidden?: boolean;
+ hidden?: Hidden;
options: {
label: string;
value: unknown;
@@ -58,20 +62,27 @@ export type FormItem = {
min: number;
max: number;
textConverter?: (value: number) => string;
+ hidden?: Hidden;
} | {
label?: string;
type: 'object';
default: Record | null;
- hidden: boolean;
+ hidden: Hidden;
} | {
label?: string;
type: 'array';
default: unknown[] | null;
- hidden: boolean;
+ hidden: Hidden;
} | {
type: 'button';
content?: string;
+ hidden?: Hidden;
action: (ev: MouseEvent, v: any) => void;
+} | {
+ type: 'drive-file';
+ defaultFileId?: string | null;
+ hidden?: Hidden;
+ validate?: (v: Misskey.entities.DriveFile) => Promise;
};
export type Form = Record;
@@ -84,8 +95,9 @@ type GetItemType- =
Item['type'] extends 'range' ? number :
Item['type'] extends 'enum' ? string :
Item['type'] extends 'array' ? unknown[] :
- Item['type'] extends 'object' ? Record
- : never;
+ Item['type'] extends 'object' ? Record :
+ Item['type'] extends 'drive-file' ? Misskey.entities.DriveFile | undefined :
+ never;
export type GetFormResultType = {
[P in keyof F]: GetItemType;
diff --git a/packages/frontend/src/scripts/get-note-menu.ts b/packages/frontend/src/scripts/get-note-menu.ts
index c0db701114..3d3653b84f 100644
--- a/packages/frontend/src/scripts/get-note-menu.ts
+++ b/packages/frontend/src/scripts/get-note-menu.ts
@@ -16,7 +16,7 @@ import { url } from '@/config.js';
import { defaultStore, noteActions } from '@/store.js';
import { miLocalStorage } from '@/local-storage.js';
import { getUserMenu } from '@/scripts/get-user-menu.js';
-import { clipsCache } from '@/cache.js';
+import { clipsCache, favoritedChannelsCache } from '@/cache.js';
import { MenuItem } from '@/types/menu.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
import { isSupportShare } from '@/scripts/navigator.js';
@@ -552,6 +552,7 @@ export function getRenoteMenu(props: {
const channelRenoteItems: MenuItem[] = [];
const normalRenoteItems: MenuItem[] = [];
+ const normalExternalChannelRenoteItems: MenuItem[] = [];
if (appearNote.channel) {
channelRenoteItems.push(...[{
@@ -630,12 +631,47 @@ export function getRenoteMenu(props: {
});
},
}]);
+
+ normalExternalChannelRenoteItems.push({
+ type: 'parent',
+ icon: 'ti ti-repeat',
+ text: appearNote.channel ? i18n.ts.renoteToOtherChannel : i18n.ts.renoteToChannel,
+ children: async () => {
+ const channels = await favoritedChannelsCache.fetch();
+ return channels.filter((channel) => {
+ if (!appearNote.channelId) return true;
+ return channel.id !== appearNote.channelId;
+ }).map((channel) => ({
+ text: channel.name,
+ action: () => {
+ const el = props.renoteButton.value;
+ if (el) {
+ const rect = el.getBoundingClientRect();
+ const x = rect.left + (el.offsetWidth / 2);
+ const y = rect.top + (el.offsetHeight / 2);
+ os.popup(MkRippleEffect, { x, y }, {}, 'end');
+ }
+
+ if (!props.mock) {
+ misskeyApi('notes/create', {
+ renoteId: appearNote.id,
+ channelId: channel.id,
+ }).then(() => {
+ os.toast(i18n.tsx.renotedToX({ name: channel.name }));
+ });
+ }
+ },
+ }));
+ },
+ });
}
const renoteItems = [
...normalRenoteItems,
...(channelRenoteItems.length > 0 && normalRenoteItems.length > 0) ? [{ type: 'divider' }] as MenuItem[] : [],
...channelRenoteItems,
+ ...(normalExternalChannelRenoteItems.length > 0 && (normalRenoteItems.length > 0 || channelRenoteItems.length > 0)) ? [{ type: 'divider' }] as MenuItem[] : [],
+ ...normalExternalChannelRenoteItems,
];
return {
diff --git a/packages/frontend/src/ui/_common_/announcements.vue b/packages/frontend/src/ui/_common_/announcements.vue
index b49eff9148..37d89f682f 100644
--- a/packages/frontend/src/ui/_common_/announcements.vue
+++ b/packages/frontend/src/ui/_common_/announcements.vue
@@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
v-for="announcement in $i.unreadAnnouncements.filter(x => x.display === 'banner')"
:key="announcement.id"
:class="$style.item"
- to="/announcements"
+ :to="`/announcements/${announcement.id}`"
>
diff --git a/packages/frontend/src/ui/deck/antenna-column.vue b/packages/frontend/src/ui/deck/antenna-column.vue
index 79c7c48073..2df8b9ead7 100644
--- a/packages/frontend/src/ui/deck/antenna-column.vue
+++ b/packages/frontend/src/ui/deck/antenna-column.vue
@@ -9,18 +9,22 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ column.name }}
-
+
diff --git a/packages/frontend/src/ui/deck/deck-store.ts b/packages/frontend/src/ui/deck/deck-store.ts
index 6c4e2fd52b..1a4f7c5e17 100644
--- a/packages/frontend/src/ui/deck/deck-store.ts
+++ b/packages/frontend/src/ui/deck/deck-store.ts
@@ -9,6 +9,7 @@ import { notificationTypes } from 'misskey-js';
import { Storage } from '@/pizzax.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { deepClone } from '@/scripts/clone.js';
+import { SoundStore } from '@/store.js';
type ColumnWidget = {
name: string;
@@ -33,6 +34,7 @@ export type Column = {
withRenotes?: boolean;
withReplies?: boolean;
onlyFiles?: boolean;
+ soundSetting: SoundStore;
};
export const deckStore = markRaw(new Storage('deck', {
diff --git a/packages/frontend/src/ui/deck/list-column.vue b/packages/frontend/src/ui/deck/list-column.vue
index f7988ed1b7..14f3e5fcd9 100644
--- a/packages/frontend/src/ui/deck/list-column.vue
+++ b/packages/frontend/src/ui/deck/list-column.vue
@@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ column.name }}
-
+
@@ -21,6 +21,10 @@ import MkTimeline from '@/components/MkTimeline.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js';
+import { MenuItem } from '@/types/menu.js';
+import { SoundStore } from '@/store.js';
+import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js';
+import * as sound from '@/scripts/sound.js';
const props = defineProps<{
column: Column;
@@ -30,6 +34,7 @@ const props = defineProps<{
const timeline = shallowRef>();
const withRenotes = ref(props.column.withRenotes ?? true);
const onlyFiles = ref(props.column.onlyFiles ?? false);
+const soundSetting = ref(props.column.soundSetting ?? { type: null, volume: 1 });
if (props.column.listId == null) {
setList();
@@ -47,6 +52,10 @@ watch(onlyFiles, v => {
});
});
+watch(soundSetting, v => {
+ updateColumn(props.column.id, { soundSetting: v });
+});
+
async function setList() {
const lists = await misskeyApi('users/lists/list');
const { canceled, result: list } = await os.select({
@@ -66,7 +75,11 @@ function editList() {
os.pageWindow('my/lists/' + props.column.listId);
}
-const menu = [
+function onNote() {
+ sound.playMisskeySfxFile(soundSetting.value);
+}
+
+const menu: MenuItem[] = [
{
icon: 'ph-pencil-simple ph-bold ph-lg',
text: i18n.ts.selectList,
@@ -87,5 +100,10 @@ const menu = [
text: i18n.ts.fileAttachedOnly,
ref: onlyFiles,
},
+ {
+ icon: 'ti ti-bell',
+ text: i18n.ts._deck.newNoteNotificationSettings,
+ action: () => soundSettingsButton(soundSetting),
+ },
];
diff --git a/packages/frontend/src/ui/deck/role-timeline-column.vue b/packages/frontend/src/ui/deck/role-timeline-column.vue
index 1a673a1753..2fe53918ff 100644
--- a/packages/frontend/src/ui/deck/role-timeline-column.vue
+++ b/packages/frontend/src/ui/deck/role-timeline-column.vue
@@ -9,18 +9,22 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ column.name }}
-
+