summaryrefslogtreecommitdiff
path: root/packages
diff options
context:
space:
mode:
authordakkar <dakkar@thenautilus.net>2024-03-24 11:53:52 +0000
committerdakkar <dakkar@thenautilus.net>2024-03-24 11:53:52 +0000
commitbc531ac414deab044d18ce41da1863138b2906a5 (patch)
tree500d323a47044216c4505695d4732d7dad3b08b2 /packages
parentMerge branch 'develop' into future-2024-03-23 (diff)
parenttest(backend): fix streaming test error when replying to followers-only note ... (diff)
downloadsharkey-bc531ac414deab044d18ce41da1863138b2906a5.tar.gz
sharkey-bc531ac414deab044d18ce41da1863138b2906a5.tar.bz2
sharkey-bc531ac414deab044d18ce41da1863138b2906a5.zip
Merge remote-tracking branch 'misskey/develop' into future-2024-03-23
Diffstat (limited to 'packages')
-rw-r--r--packages/backend/migration/1710512074000-url-preview-meta.js42
-rw-r--r--packages/backend/migration/1710919614510-antenna-exclude-bots.js16
-rw-r--r--packages/backend/package.json2
-rw-r--r--packages/backend/src/core/AntennaService.ts6
-rw-r--r--packages/backend/src/core/chart/core.ts14
-rw-r--r--packages/backend/src/core/entities/AntennaEntityService.ts1
-rw-r--r--packages/backend/src/core/entities/MetaEntityService.ts1
-rw-r--r--packages/backend/src/models/Antenna.ts5
-rw-r--r--packages/backend/src/models/Meta.ts38
-rw-r--r--packages/backend/src/models/json-schema/antenna.ts5
-rw-r--r--packages/backend/src/models/json-schema/meta.ts4
-rw-r--r--packages/backend/src/queue/processors/ExportAntennasProcessorService.ts1
-rw-r--r--packages/backend/src/queue/processors/ImportAntennasProcessorService.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/admin/meta.ts34
-rw-r--r--packages/backend/src/server/api/endpoints/admin/update-meta.ts41
-rw-r--r--packages/backend/src/server/api/endpoints/antennas/create.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/antennas/update.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/flash/create.ts2
-rw-r--r--packages/backend/src/server/api/openapi/gen-spec.ts2
-rw-r--r--packages/backend/src/server/web/UrlPreviewService.ts67
-rw-r--r--packages/backend/src/server/web/views/note.pug4
-rw-r--r--packages/backend/src/server/web/views/user.pug2
-rw-r--r--packages/backend/test/e2e/antennas.ts2
-rw-r--r--packages/backend/test/e2e/streaming.ts6
-rw-r--r--packages/frontend/package.json2
-rw-r--r--packages/frontend/src/boot/common.ts3
-rw-r--r--packages/frontend/src/components/MkCode.core.vue10
-rw-r--r--packages/frontend/src/components/MkInput.vue2
-rw-r--r--packages/frontend/src/components/MkLink.vue17
-rw-r--r--packages/frontend/src/components/MkNote.vue32
-rw-r--r--packages/frontend/src/components/MkNoteDetailed.vue30
-rw-r--r--packages/frontend/src/components/MkPasswordDialog.vue26
-rw-r--r--packages/frontend/src/components/MkSignin.vue11
-rw-r--r--packages/frontend/src/components/MkUrlPreview.vue11
-rw-r--r--packages/frontend/src/components/global/MkUrl.vue3
-rw-r--r--packages/frontend/src/components/page/page.image.vue24
-rw-r--r--packages/frontend/src/components/page/page.note.vue13
-rw-r--r--packages/frontend/src/components/page/page.text.vue13
-rw-r--r--packages/frontend/src/components/page/page.vue2
-rw-r--r--packages/frontend/src/index.html2
-rw-r--r--packages/frontend/src/instance.ts2
-rw-r--r--packages/frontend/src/pages/admin/security.vue16
-rw-r--r--packages/frontend/src/pages/admin/settings.vue74
-rw-r--r--packages/frontend/src/pages/flash/flash-edit.vue14
-rw-r--r--packages/frontend/src/pages/my-antennas/create.vue1
-rw-r--r--packages/frontend/src/pages/my-antennas/editor.vue3
-rw-r--r--packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue2
-rw-r--r--packages/frontend/src/pages/page.vue374
-rw-r--r--packages/frontend/src/pages/settings/2fa.qrdialog.vue2
-rw-r--r--packages/frontend/src/pages/settings/2fa.vue6
-rw-r--r--packages/frontend/src/scripts/code-highlighter.ts18
-rw-r--r--packages/frontend/src/scripts/theme.ts4
-rw-r--r--packages/frontend/src/store.ts1
-rw-r--r--packages/frontend/src/stream.ts22
-rw-r--r--packages/frontend/src/style.scss11
-rw-r--r--packages/frontend/vite.config.ts29
-rw-r--r--packages/misskey-js/generator/src/generator.ts2
-rw-r--r--packages/misskey-js/src/autogen/types.ts29
58 files changed, 845 insertions, 267 deletions
diff --git a/packages/backend/migration/1710512074000-url-preview-meta.js b/packages/backend/migration/1710512074000-url-preview-meta.js
new file mode 100644
index 0000000000..8af521bbf4
--- /dev/null
+++ b/packages/backend/migration/1710512074000-url-preview-meta.js
@@ -0,0 +1,42 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export class UrlPreviewMeta1710512074000 {
+ name = 'UrlPreviewMeta1710512074000'
+
+ async up(queryRunner) {
+ await queryRunner.query(`
+ alter table meta
+ rename column "summalyProxy" to "urlPreviewSummaryProxyUrl";
+ alter table meta
+ add "urlPreviewEnabled" boolean default true not null;
+ alter table meta
+ add "urlPreviewTimeout" integer default 10000 not null;
+ alter table meta
+ add "urlPreviewMaximumContentLength" bigint default 10485760 not null;
+ alter table meta
+ add "urlPreviewRequireContentLength" boolean default false not null;
+ alter table meta
+ add "urlPreviewUserAgent" varchar(1024) default null;
+ `);
+ }
+
+ async down(queryRunner) {
+ await queryRunner.query(`
+ alter table meta
+ rename column "urlPreviewSummaryProxyUrl" to "summalyProxy";
+ alter table meta
+ drop column "urlPreviewEnabled";
+ alter table meta
+ drop column "urlPreviewTimeout";
+ alter table meta
+ drop column "urlPreviewMaximumContentLength";
+ alter table meta
+ drop column "urlPreviewRequireContentLength";
+ alter table meta
+ drop column "urlPreviewUserAgent";
+ `);
+ }
+}
diff --git a/packages/backend/migration/1710919614510-antenna-exclude-bots.js b/packages/backend/migration/1710919614510-antenna-exclude-bots.js
new file mode 100644
index 0000000000..fac84317cc
--- /dev/null
+++ b/packages/backend/migration/1710919614510-antenna-exclude-bots.js
@@ -0,0 +1,16 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export class AntennaExcludeBots1710919614510 {
+ name = 'AntennaExcludeBots1710919614510'
+
+ async up(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "antenna" ADD "excludeBots" boolean NOT NULL DEFAULT false`);
+ }
+
+ async down(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "antenna" DROP COLUMN "excludeBots"`);
+ }
+}
diff --git a/packages/backend/package.json b/packages/backend/package.json
index 2ddb067afe..937180eedb 100644
--- a/packages/backend/package.json
+++ b/packages/backend/package.json
@@ -78,7 +78,7 @@
"@fastify/static": "6.12.0",
"@fastify/view": "8.2.0",
"@misskey-dev/sharp-read-bmp": "1.2.0",
- "@misskey-dev/summaly": "5.0.3",
+ "@misskey-dev/summaly": "5.1.0",
"@nestjs/common": "10.3.3",
"@nestjs/core": "10.3.3",
"@nestjs/testing": "10.3.3",
diff --git a/packages/backend/src/core/AntennaService.ts b/packages/backend/src/core/AntennaService.ts
index 4f956a43ed..793d8974b3 100644
--- a/packages/backend/src/core/AntennaService.ts
+++ b/packages/backend/src/core/AntennaService.ts
@@ -92,7 +92,7 @@ export class AntennaService implements OnApplicationShutdown {
}
@bindThis
- public async addNoteToAntennas(note: MiNote, noteUser: { id: MiUser['id']; username: string; host: string | null; }): Promise<void> {
+ public async addNoteToAntennas(note: MiNote, noteUser: { id: MiUser['id']; username: string; host: string | null; isBot: boolean; }): Promise<void> {
const antennas = await this.getAntennas();
const antennasWithMatchResult = await Promise.all(antennas.map(antenna => this.checkHitAntenna(antenna, note, noteUser).then(hit => [antenna, hit] as const)));
const matchedAntennas = antennasWithMatchResult.filter(([, hit]) => hit).map(([antenna]) => antenna);
@@ -110,10 +110,12 @@ export class AntennaService implements OnApplicationShutdown {
// NOTE: フォローしているユーザーのノート、リストのユーザーのノート、グループのユーザーのノート指定はパフォーマンス上の理由で無効になっている
@bindThis
- public async checkHitAntenna(antenna: MiAntenna, note: (MiNote | Packed<'Note'>), noteUser: { id: MiUser['id']; username: string; host: string | null; }): Promise<boolean> {
+ public async checkHitAntenna(antenna: MiAntenna, note: (MiNote | Packed<'Note'>), noteUser: { id: MiUser['id']; username: string; host: string | null; isBot: boolean; }): Promise<boolean> {
if (note.visibility === 'specified') return false;
if (note.visibility === 'followers') return false;
+ if (antenna.excludeBots && noteUser.isBot) return false;
+
if (antenna.localOnly && noteUser.host != null) return false;
if (!antenna.withReplies && note.replyId != null) return false;
diff --git a/packages/backend/src/core/chart/core.ts b/packages/backend/src/core/chart/core.ts
index aa0cb9dc2b..f10e30ef10 100644
--- a/packages/backend/src/core/chart/core.ts
+++ b/packages/backend/src/core/chart/core.ts
@@ -459,13 +459,15 @@ export default abstract class Chart<T extends Schema> {
}
}
- // bake unique count
+ // bake cardinality
for (const [k, v] of Object.entries(finalDiffs)) {
if (this.schema[k].uniqueIncrement) {
const name = COLUMN_PREFIX + k.replaceAll('.', COLUMN_DELIMITER) as keyof Columns<T>;
const tempColumnName = UNIQUE_TEMP_COLUMN_PREFIX + k.replaceAll('.', COLUMN_DELIMITER) as keyof TempColumnsForUnique<T>;
- queryForHour[name] = new Set([...(v as string[]), ...(logHour[tempColumnName] as unknown as string[])]).size;
- queryForDay[name] = new Set([...(v as string[]), ...(logDay[tempColumnName] as unknown as string[])]).size;
+ const cardinalityOfHour = new Set([...(v as string[]), ...(logHour[tempColumnName] as unknown as string[])]).size;
+ const cardinalityOfDay = new Set([...(v as string[]), ...(logDay[tempColumnName] as unknown as string[])]).size;
+ queryForHour[name] = cardinalityOfHour;
+ queryForDay[name] = cardinalityOfDay;
}
}
@@ -637,7 +639,7 @@ export default abstract class Chart<T extends Schema> {
// 要求された範囲にログがひとつもなかったら
if (logs.length === 0) {
// もっとも新しいログを持ってくる
- // (すくなくともひとつログが無いと隙間埋めできないため)
+ // (すくなくともひとつログが無いと補間できないため)
const recentLog = await repository.findOne({
where: group ? {
group: group,
@@ -654,7 +656,7 @@ export default abstract class Chart<T extends Schema> {
// 要求された範囲の最も古い箇所に位置するログが存在しなかったら
} else if (!isTimeSame(new Date(logs.at(-1)!.date * 1000), gt)) {
// 要求された範囲の最も古い箇所時点での最も新しいログを持ってきて末尾に追加する
- // (隙間埋めできないため)
+ // (補間できないため)
const outdatedLog = await repository.findOne({
where: {
date: LessThan(Chart.dateToTimestamp(gt)),
@@ -683,7 +685,7 @@ export default abstract class Chart<T extends Schema> {
if (log) {
chart.unshift(this.convertRawRecord(log));
} else {
- // 隙間埋め
+ // 補間
const latest = logs.find(l => isTimeBefore(new Date(l.date * 1000), current));
const data = latest ? this.convertRawRecord(latest) : null;
chart.unshift(this.getNewLog(data));
diff --git a/packages/backend/src/core/entities/AntennaEntityService.ts b/packages/backend/src/core/entities/AntennaEntityService.ts
index 64d6a3c978..3ec8efa6bf 100644
--- a/packages/backend/src/core/entities/AntennaEntityService.ts
+++ b/packages/backend/src/core/entities/AntennaEntityService.ts
@@ -39,6 +39,7 @@ export class AntennaEntityService {
caseSensitive: antenna.caseSensitive,
localOnly: antenna.localOnly,
notify: antenna.notify,
+ excludeBots: antenna.excludeBots,
withReplies: antenna.withReplies,
withFile: antenna.withFile,
isActive: antenna.isActive,
diff --git a/packages/backend/src/core/entities/MetaEntityService.ts b/packages/backend/src/core/entities/MetaEntityService.ts
index 7e3926c431..fa643e45a7 100644
--- a/packages/backend/src/core/entities/MetaEntityService.ts
+++ b/packages/backend/src/core/entities/MetaEntityService.ts
@@ -114,6 +114,7 @@ export class MetaEntityService {
policies: { ...DEFAULT_POLICIES, ...instance.policies },
mediaProxy: this.config.mediaProxy,
+ enableUrlPreview: instance.urlPreviewEnabled,
};
return packed;
diff --git a/packages/backend/src/models/Antenna.ts b/packages/backend/src/models/Antenna.ts
index 332a899768..f5e819059e 100644
--- a/packages/backend/src/models/Antenna.ts
+++ b/packages/backend/src/models/Antenna.ts
@@ -75,6 +75,11 @@ export class MiAntenna {
@Column('boolean', {
default: false,
})
+ public excludeBots: boolean;
+
+ @Column('boolean', {
+ default: false,
+ })
public withReplies: boolean;
@Column('boolean')
diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts
index dd2e78cde2..9b71cf6d57 100644
--- a/packages/backend/src/models/Meta.ts
+++ b/packages/backend/src/models/Meta.ts
@@ -287,12 +287,6 @@ export class MiMeta {
})
public enableBotTrending: boolean;
- @Column('varchar', {
- length: 1024,
- nullable: true,
- })
- public summalyProxy: string | null;
-
@Column('boolean', {
default: false,
})
@@ -631,4 +625,36 @@ export class MiMeta {
length: 256, array: true, default: '{}',
})
public bubbleInstances: string[];
+
+ @Column('boolean', {
+ default: true,
+ })
+ public urlPreviewEnabled: boolean;
+
+ @Column('integer', {
+ default: 10000,
+ })
+ public urlPreviewTimeout: number;
+
+ @Column('bigint', {
+ default: 1024 * 1024 * 10,
+ })
+ public urlPreviewMaximumContentLength: number;
+
+ @Column('boolean', {
+ default: true,
+ })
+ public urlPreviewRequireContentLength: boolean;
+
+ @Column('varchar', {
+ length: 1024,
+ nullable: true,
+ })
+ public urlPreviewSummaryProxyUrl: string | null;
+
+ @Column('varchar', {
+ length: 1024,
+ nullable: true,
+ })
+ public urlPreviewUserAgent: string | null;
}
diff --git a/packages/backend/src/models/json-schema/antenna.ts b/packages/backend/src/models/json-schema/antenna.ts
index 74622b6193..78cf6d3ba2 100644
--- a/packages/backend/src/models/json-schema/antenna.ts
+++ b/packages/backend/src/models/json-schema/antenna.ts
@@ -76,6 +76,11 @@ export const packedAntennaSchema = {
type: 'boolean',
optional: false, nullable: false,
},
+ excludeBots: {
+ type: 'boolean',
+ optional: false, nullable: false,
+ default: false,
+ },
withReplies: {
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 9db3f7f809..47a9c48c1c 100644
--- a/packages/backend/src/models/json-schema/meta.ts
+++ b/packages/backend/src/models/json-schema/meta.ts
@@ -223,6 +223,10 @@ export const packedMetaLiteSchema = {
type: 'string',
optional: false, nullable: false,
},
+ enableUrlPreview: {
+ type: 'boolean',
+ optional: false, nullable: false,
+ },
backgroundImageUrl: {
type: 'string',
optional: false, nullable: true,
diff --git a/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts b/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts
index af48bad417..1d8e90f367 100644
--- a/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts
+++ b/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts
@@ -81,6 +81,7 @@ export class ExportAntennasProcessorService {
}) : null,
caseSensitive: antenna.caseSensitive,
localOnly: antenna.localOnly,
+ excludeBots: antenna.excludeBots,
withReplies: antenna.withReplies,
withFile: antenna.withFile,
notify: antenna.notify,
diff --git a/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts b/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts
index 951b560597..ff1c04de06 100644
--- a/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts
+++ b/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts
@@ -44,6 +44,7 @@ const validate = new Ajv().compile({
} },
caseSensitive: { type: 'boolean' },
localOnly: { type: 'boolean' },
+ excludeBots: { type: 'boolean' },
withReplies: { type: 'boolean' },
withFile: { type: 'boolean' },
notify: { type: 'boolean' },
@@ -88,6 +89,7 @@ export class ImportAntennasProcessorService {
users: (antenna.src === 'list' && antenna.userListAccts !== null ? antenna.userListAccts : antenna.users).filter(Boolean),
caseSensitive: antenna.caseSensitive,
localOnly: antenna.localOnly,
+ excludeBots: antenna.excludeBots,
withReplies: antenna.withReplies,
withFile: antenna.withFile,
notify: antenna.notify,
diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts
index 34454c276e..1a4ae844b5 100644
--- a/packages/backend/src/server/api/endpoints/admin/meta.ts
+++ b/packages/backend/src/server/api/endpoints/admin/meta.ts
@@ -465,6 +465,8 @@ export const meta = {
summalyProxy: {
type: 'string',
optional: false, nullable: true,
+ deprecated: true,
+ description: '[Deprecated] Use "urlPreviewSummaryProxyUrl" instead.',
},
themeColor: {
type: 'string',
@@ -482,6 +484,30 @@ export const meta = {
type: 'string',
optional: false, nullable: false,
},
+ urlPreviewEnabled: {
+ type: 'boolean',
+ optional: false, nullable: false,
+ },
+ urlPreviewTimeout: {
+ type: 'number',
+ optional: false, nullable: false,
+ },
+ urlPreviewMaximumContentLength: {
+ type: 'number',
+ optional: false, nullable: false,
+ },
+ urlPreviewRequireContentLength: {
+ type: 'boolean',
+ optional: false, nullable: false,
+ },
+ urlPreviewUserAgent: {
+ type: 'string',
+ optional: false, nullable: true,
+ },
+ urlPreviewSummaryProxyUrl: {
+ type: 'string',
+ optional: false, nullable: true,
+ },
},
},
} as const;
@@ -569,7 +595,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
enableSensitiveMediaDetectionForVideos: instance.enableSensitiveMediaDetectionForVideos,
enableBotTrending: instance.enableBotTrending,
proxyAccountId: instance.proxyAccountId,
- summalyProxy: instance.summalyProxy,
email: instance.email,
smtpSecure: instance.smtpSecure,
smtpHost: instance.smtpHost,
@@ -616,6 +641,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
perUserHomeTimelineCacheMax: instance.perUserHomeTimelineCacheMax,
perUserListTimelineCacheMax: instance.perUserListTimelineCacheMax,
notesPerOneAd: instance.notesPerOneAd,
+ summalyProxy: instance.urlPreviewSummaryProxyUrl,
+ urlPreviewEnabled: instance.urlPreviewEnabled,
+ urlPreviewTimeout: instance.urlPreviewTimeout,
+ urlPreviewMaximumContentLength: instance.urlPreviewMaximumContentLength,
+ urlPreviewRequireContentLength: instance.urlPreviewRequireContentLength,
+ urlPreviewUserAgent: instance.urlPreviewUserAgent,
+ urlPreviewSummaryProxyUrl: instance.urlPreviewSummaryProxyUrl,
};
});
}
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 7fea7d969e..95dd7346e7 100644
--- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts
+++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts
@@ -93,7 +93,6 @@ export const paramDef = {
type: 'string',
},
},
- summalyProxy: { type: 'string', nullable: true },
deeplAuthKey: { type: 'string', nullable: true },
deeplIsPro: { type: 'boolean' },
deeplFreeMode: { type: 'boolean' },
@@ -158,6 +157,16 @@ export const paramDef = {
type: 'string',
},
},
+ summalyProxy: {
+ type: 'string', nullable: true,
+ description: '[Deprecated] Use "urlPreviewSummaryProxyUrl" instead.',
+ },
+ urlPreviewEnabled: { type: 'boolean' },
+ urlPreviewTimeout: { type: 'integer' },
+ urlPreviewMaximumContentLength: { type: 'integer' },
+ urlPreviewRequireContentLength: { type: 'boolean' },
+ urlPreviewUserAgent: { type: 'string', nullable: true },
+ urlPreviewSummaryProxyUrl: { type: 'string', nullable: true },
},
required: [],
} as const;
@@ -357,10 +366,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
set.langs = ps.langs.filter(Boolean);
}
- if (ps.summalyProxy !== undefined) {
- set.summalyProxy = ps.summalyProxy;
- }
-
if (ps.enableEmail !== undefined) {
set.enableEmail = ps.enableEmail;
}
@@ -609,6 +614,32 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
set.bannedEmailDomains = ps.bannedEmailDomains;
}
+ if (ps.urlPreviewEnabled !== undefined) {
+ set.urlPreviewEnabled = ps.urlPreviewEnabled;
+ }
+
+ if (ps.urlPreviewTimeout !== undefined) {
+ set.urlPreviewTimeout = ps.urlPreviewTimeout;
+ }
+
+ if (ps.urlPreviewMaximumContentLength !== undefined) {
+ set.urlPreviewMaximumContentLength = ps.urlPreviewMaximumContentLength;
+ }
+
+ if (ps.urlPreviewRequireContentLength !== undefined) {
+ set.urlPreviewRequireContentLength = ps.urlPreviewRequireContentLength;
+ }
+
+ if (ps.urlPreviewUserAgent !== undefined) {
+ const value = (ps.urlPreviewUserAgent ?? '').trim();
+ set.urlPreviewUserAgent = value === '' ? null : ps.urlPreviewUserAgent;
+ }
+
+ if (ps.summalyProxy !== undefined || ps.urlPreviewSummaryProxyUrl !== undefined) {
+ const value = ((ps.urlPreviewSummaryProxyUrl ?? ps.summalyProxy) ?? '').trim();
+ set.urlPreviewSummaryProxyUrl = value === '' ? null : value;
+ }
+
const before = await this.metaService.fetch(true);
await this.metaService.update(set);
diff --git a/packages/backend/src/server/api/endpoints/antennas/create.ts b/packages/backend/src/server/api/endpoints/antennas/create.ts
index 191de8f833..57c8eb4958 100644
--- a/packages/backend/src/server/api/endpoints/antennas/create.ts
+++ b/packages/backend/src/server/api/endpoints/antennas/create.ts
@@ -64,6 +64,7 @@ export const paramDef = {
} },
caseSensitive: { type: 'boolean' },
localOnly: { type: 'boolean' },
+ excludeBots: { type: 'boolean' },
withReplies: { type: 'boolean' },
withFile: { type: 'boolean' },
notify: { type: 'boolean' },
@@ -124,6 +125,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
users: ps.users,
caseSensitive: ps.caseSensitive,
localOnly: ps.localOnly,
+ excludeBots: ps.excludeBots,
withReplies: ps.withReplies,
withFile: ps.withFile,
notify: ps.notify,
diff --git a/packages/backend/src/server/api/endpoints/antennas/update.ts b/packages/backend/src/server/api/endpoints/antennas/update.ts
index 76a34924a0..e6720aacf8 100644
--- a/packages/backend/src/server/api/endpoints/antennas/update.ts
+++ b/packages/backend/src/server/api/endpoints/antennas/update.ts
@@ -63,6 +63,7 @@ export const paramDef = {
} },
caseSensitive: { type: 'boolean' },
localOnly: { type: 'boolean' },
+ excludeBots: { type: 'boolean' },
withReplies: { type: 'boolean' },
withFile: { type: 'boolean' },
notify: { type: 'boolean' },
@@ -120,6 +121,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
users: ps.users,
caseSensitive: ps.caseSensitive,
localOnly: ps.localOnly,
+ excludeBots: ps.excludeBots,
withReplies: ps.withReplies,
withFile: ps.withFile,
notify: ps.notify,
diff --git a/packages/backend/src/server/api/endpoints/flash/create.ts b/packages/backend/src/server/api/endpoints/flash/create.ts
index 584d167a29..361496e17e 100644
--- a/packages/backend/src/server/api/endpoints/flash/create.ts
+++ b/packages/backend/src/server/api/endpoints/flash/create.ts
@@ -44,6 +44,7 @@ export const paramDef = {
permissions: { type: 'array', items: {
type: 'string',
} },
+ visibility: { type: 'string', enum: ['public', 'private'], default: 'public' },
},
required: ['title', 'summary', 'script', 'permissions'],
} as const;
@@ -66,6 +67,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
summary: ps.summary,
script: ps.script,
permissions: ps.permissions,
+ visibility: ps.visibility,
}).then(x => this.flashsRepository.findOneByOrFail(x.identifiers[0]));
return await this.flashEntityService.pack(flash);
diff --git a/packages/backend/src/server/api/openapi/gen-spec.ts b/packages/backend/src/server/api/openapi/gen-spec.ts
index b5f91ff542..436f9a44bb 100644
--- a/packages/backend/src/server/api/openapi/gen-spec.ts
+++ b/packages/backend/src/server/api/openapi/gen-spec.ts
@@ -93,7 +93,7 @@ export function genOpenapiSpec(config: Config, includeSelfRef = false) {
const hasBody = (schema.type === 'object' && schema.properties && Object.keys(schema.properties).length >= 1);
const info = {
- operationId: endpoint.name,
+ operationId: endpoint.name.replaceAll('/', '___'), // NOTE: スラッシュは使えない
summary: endpoint.name,
description: desc,
externalDocs: {
diff --git a/packages/backend/src/server/web/UrlPreviewService.ts b/packages/backend/src/server/web/UrlPreviewService.ts
index c6a96e94cb..8f8f08a305 100644
--- a/packages/backend/src/server/web/UrlPreviewService.ts
+++ b/packages/backend/src/server/web/UrlPreviewService.ts
@@ -5,6 +5,7 @@
import { Inject, Injectable } from '@nestjs/common';
import { summaly } from '@misskey-dev/summaly';
+import { SummalyResult } from '@misskey-dev/summaly/built/summary.js';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { MetaService } from '@/core/MetaService.js';
@@ -14,6 +15,7 @@ import { query } from '@/misc/prelude/url.js';
import { LoggerService } from '@/core/LoggerService.js';
import { bindThis } from '@/decorators.js';
import { ApiError } from '@/server/api/error.js';
+import { MiMeta } from '@/models/Meta.js';
import type { FastifyRequest, FastifyReply } from 'fastify';
@Injectable()
@@ -62,24 +64,25 @@ export class UrlPreviewService {
const meta = await this.metaService.fetch();
- this.logger.info(meta.summalyProxy
+ if (!meta.urlPreviewEnabled) {
+ reply.code(403);
+ return {
+ error: new ApiError({
+ message: 'URL preview is disabled',
+ code: 'URL_PREVIEW_DISABLED',
+ id: '58b36e13-d2f5-0323-b0c6-76aa9dabefb8',
+ }),
+ };
+ }
+
+ this.logger.info(meta.urlPreviewSummaryProxyUrl
? `(Proxy) Getting preview of ${url}@${lang} ...`
: `Getting preview of ${url}@${lang} ...`);
+
try {
- const summary = meta.summalyProxy ?
- await this.httpRequestService.getJson<ReturnType<typeof summaly>>(`${meta.summalyProxy}?${query({
- url: url,
- lang: lang ?? 'ja-JP',
- })}`)
- :
- await summaly(url, {
- followRedirects: false,
- lang: lang ?? 'ja-JP',
- agent: this.config.proxy ? {
- http: this.httpRequestService.httpAgent,
- https: this.httpRequestService.httpsAgent,
- } : undefined,
- });
+ const summary = meta.urlPreviewSummaryProxyUrl
+ ? await this.fetchSummaryFromProxy(url, meta, lang)
+ : await this.fetchSummary(url, meta, lang);
this.logger.succ(`Got preview of ${url}: ${summary.title}`);
@@ -100,6 +103,7 @@ export class UrlPreviewService {
return summary;
} catch (err) {
this.logger.warn(`Failed to get preview of ${url}: ${err}`);
+
reply.code(422);
reply.header('Cache-Control', 'max-age=86400, immutable');
return {
@@ -111,4 +115,37 @@ export class UrlPreviewService {
};
}
}
+
+ private fetchSummary(url: string, meta: MiMeta, lang?: string): Promise<SummalyResult> {
+ const agent = this.config.proxy
+ ? {
+ http: this.httpRequestService.httpAgent,
+ https: this.httpRequestService.httpsAgent,
+ }
+ : undefined;
+
+ return summaly(url, {
+ followRedirects: false,
+ lang: lang ?? 'ja-JP',
+ agent: agent,
+ userAgent: meta.urlPreviewUserAgent ?? undefined,
+ operationTimeout: meta.urlPreviewTimeout,
+ contentLengthLimit: meta.urlPreviewMaximumContentLength,
+ contentLengthRequired: meta.urlPreviewRequireContentLength,
+ });
+ }
+
+ private fetchSummaryFromProxy(url: string, meta: MiMeta, lang?: string): Promise<SummalyResult> {
+ const proxy = meta.urlPreviewSummaryProxyUrl!;
+ const queryStr = query({
+ url: url,
+ lang: lang ?? 'ja-JP',
+ userAgent: meta.urlPreviewUserAgent ?? undefined,
+ operationTimeout: meta.urlPreviewTimeout,
+ contentLengthLimit: meta.urlPreviewMaximumContentLength,
+ contentLengthRequired: meta.urlPreviewRequireContentLength,
+ });
+
+ return this.httpRequestService.getJson<SummalyResult>(`${proxy}?${queryStr}`);
+ }
}
diff --git a/packages/backend/src/server/web/views/note.pug b/packages/backend/src/server/web/views/note.pug
index 9bc652b6a1..fb659ce171 100644
--- a/packages/backend/src/server/web/views/note.pug
+++ b/packages/backend/src/server/web/views/note.pug
@@ -2,7 +2,7 @@ extends ./base
block vars
- const user = note.user;
- - const title = user.name ? `${user.name} (@${user.username})` : `@${user.username}`;
+ - const title = user.name ? `${user.name} (@${user.username}${user.host ? `@${user.host}` : ''})` : `@${user.username}${user.host ? `@${user.host}` : ''}`;
- const url = `${config.url}/notes/${note.id}`;
- const isRenote = note.renote && note.text == null && note.fileIds.length == 0 && note.poll == null;
- const images = (note.files || []).filter(file => file.type.startsWith('image/') && !file.isSensitive)
@@ -28,7 +28,7 @@ block og
// FIXME: add embed player for Twitter
if images.length
meta(property='twitter:card' content='summary_large_image')
- each image in images
+ each image in images
meta(property='og:image' content= image.url)
else
meta(property='twitter:card' content='summary')
diff --git a/packages/backend/src/server/web/views/user.pug b/packages/backend/src/server/web/views/user.pug
index 83d57349a6..2b0a7bab5c 100644
--- a/packages/backend/src/server/web/views/user.pug
+++ b/packages/backend/src/server/web/views/user.pug
@@ -1,7 +1,7 @@
extends ./base
block vars
- - const title = user.name ? `${user.name} (@${user.username})` : `@${user.username}`;
+ - const title = user.name ? `${user.name} (@${user.username}${user.host ? `@${user.host}` : ''})` : `@${user.username}${user.host ? `@${user.host}` : ''}`;
- const url = `${config.url}/@${(user.host ? `${user.username}@${user.host}` : user.username)}`;
block title
diff --git a/packages/backend/test/e2e/antennas.ts b/packages/backend/test/e2e/antennas.ts
index 7370b1963c..cf5c7dd130 100644
--- a/packages/backend/test/e2e/antennas.ts
+++ b/packages/backend/test/e2e/antennas.ts
@@ -44,6 +44,7 @@ describe('アンテナ', () => {
users: [''],
withFile: false,
withReplies: false,
+ excludeBots: false,
};
let root: User;
@@ -156,6 +157,7 @@ describe('アンテナ', () => {
users: [''],
withFile: false,
withReplies: false,
+ excludeBots: false,
localOnly: false,
};
assert.deepStrictEqual(response, expected);
diff --git a/packages/backend/test/e2e/streaming.ts b/packages/backend/test/e2e/streaming.ts
index edb930617f..26bb68ec6c 100644
--- a/packages/backend/test/e2e/streaming.ts
+++ b/packages/backend/test/e2e/streaming.ts
@@ -158,19 +158,17 @@ describe('Streaming', () => {
assert.strictEqual(fired, true);
});
- /* なんか失敗する
test('フォローしているユーザーの visibility: followers な投稿への返信が流れる', async () => {
- const note = await api('notes/create', { text: 'foo', visibility: 'followers' }, kyoko);
+ const note = await post(kyoko, { text: 'foo', visibility: 'followers' });
const fired = await waitFire(
ayano, 'homeTimeline', // ayano:home
- () => api('notes/create', { text: 'bar', visibility: 'followers', replyId: note.body.id }, kyoko), // kyoko posts
+ () => api('notes/create', { text: 'bar', visibility: 'followers', replyId: note.id }, kyoko), // kyoko posts
msg => msg.type === 'note' && msg.body.userId === kyoko.id && msg.body.reply.text === 'foo',
);
assert.strictEqual(fired, true);
});
- */
test('フォローしているユーザーのフォローしていないユーザーの visibility: followers な投稿への返信が流れない', async () => {
const chitoseNote = await post(chitose, { text: 'followers-only post', visibility: 'followers' });
diff --git a/packages/frontend/package.json b/packages/frontend/package.json
index 72b26961c3..bdccf4aba6 100644
--- a/packages/frontend/package.json
+++ b/packages/frontend/package.json
@@ -61,7 +61,7 @@
"rollup": "4.12.0",
"sanitize-html": "2.12.1",
"sass": "1.71.1",
- "shiki": "1.1.7",
+ "shiki": "1.2.0",
"strict-event-emitter-types": "2.0.0",
"textarea-caret": "3.1.0",
"three": "0.162.0",
diff --git a/packages/frontend/src/boot/common.ts b/packages/frontend/src/boot/common.ts
index 9694a5b627..94040c6413 100644
--- a/packages/frontend/src/boot/common.ts
+++ b/packages/frontend/src/boot/common.ts
@@ -145,8 +145,11 @@ export async function common(createVue: () => App<Element>) {
// NOTE: この処理は必ずクライアント更新チェック処理より後に来ること(テーマ再構築のため)
watch(defaultStore.reactiveState.darkMode, (darkMode) => {
applyTheme(darkMode ? ColdDeviceStorage.get('darkTheme') : ColdDeviceStorage.get('lightTheme'));
+ document.documentElement.dataset.colorMode = darkMode ? 'dark' : 'light';
}, { immediate: miLocalStorage.getItem('theme') == null });
+ document.documentElement.dataset.colorMode = defaultStore.state.darkMode ? 'dark' : 'light';
+
const darkTheme = computed(ColdDeviceStorage.makeGetterSetter('darkTheme'));
const lightTheme = computed(ColdDeviceStorage.makeGetterSetter('lightTheme'));
diff --git a/packages/frontend/src/components/MkCode.core.vue b/packages/frontend/src/components/MkCode.core.vue
index a23b4dc3b2..9e54420034 100644
--- a/packages/frontend/src/components/MkCode.core.vue
+++ b/packages/frontend/src/components/MkCode.core.vue
@@ -9,9 +9,9 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
-import { ref, computed, watch } from 'vue';
-import { bundledLanguagesInfo } from 'shiki';
-import type { BuiltinLanguage } from 'shiki';
+import { computed, ref, watch } from 'vue';
+import { bundledLanguagesInfo } from 'shiki/langs';
+import type { BundledLanguage } from 'shiki/langs';
import { getHighlighter, getTheme } from '@/scripts/code-highlighter.js';
import { defaultStore } from '@/store.js';
@@ -23,7 +23,7 @@ const props = defineProps<{
const highlighter = await getHighlighter();
const darkMode = defaultStore.reactiveState.darkMode;
-const codeLang = ref<BuiltinLanguage | 'aiscript'>('js');
+const codeLang = ref<BundledLanguage | 'aiscript'>('js');
const [lightThemeName, darkThemeName] = await Promise.all([
getTheme('light', true),
@@ -42,7 +42,7 @@ const html = computed(() => highlighter.codeToHtml(props.code, {
}));
async function fetchLanguage(to: string): Promise<void> {
- const language = to as BuiltinLanguage;
+ const language = to as BundledLanguage;
// Check for the loaded languages, and load the language if it's not loaded yet.
if (!highlighter.getLoadedLanguages().includes(language)) {
diff --git a/packages/frontend/src/components/MkInput.vue b/packages/frontend/src/components/MkInput.vue
index b026903b66..201b409a05 100644
--- a/packages/frontend/src/components/MkInput.vue
+++ b/packages/frontend/src/components/MkInput.vue
@@ -22,6 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:autocomplete="autocomplete"
:autocapitalize="autocapitalize"
:spellcheck="spellcheck"
+ :inputmode="inputmode"
:step="step"
:list="id"
:min="min"
@@ -63,6 +64,7 @@ const props = defineProps<{
mfmAutocomplete?: boolean | SuggestionType[],
autocapitalize?: string;
spellcheck?: boolean;
+ inputmode?: 'none' | 'text' | 'search' | 'email' | 'url' | 'numeric' | 'tel' | 'decimal';
step?: any;
datalist?: string[];
min?: number;
diff --git a/packages/frontend/src/components/MkLink.vue b/packages/frontend/src/components/MkLink.vue
index 3194afa649..ae55f30350 100644
--- a/packages/frontend/src/components/MkLink.vue
+++ b/packages/frontend/src/components/MkLink.vue
@@ -19,6 +19,7 @@ import { defineAsyncComponent, ref } from 'vue';
import { url as local } from '@/config.js';
import { useTooltip } from '@/scripts/use-tooltip.js';
import * as os from '@/os.js';
+import { isEnabledUrlPreview } from '@/instance.js';
const props = withDefaults(defineProps<{
url: string;
@@ -32,13 +33,15 @@ const target = self ? null : '_blank';
const el = ref<HTMLElement | { $el: HTMLElement }>();
-useTooltip(el, (showing) => {
- os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), {
- showing,
- url: props.url,
- source: el.value instanceof HTMLElement ? el.value : el.value?.$el,
- }, {}, 'closed');
-});
+if (isEnabledUrlPreview.value) {
+ useTooltip(el, (showing) => {
+ os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), {
+ showing,
+ url: props.url,
+ source: el.value instanceof HTMLElement ? el.value : el.value?.$el,
+ }, {}, 'closed');
+ });
+}
</script>
<style lang="scss" module>
diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue
index 6c926e50c4..1957913606 100644
--- a/packages/frontend/src/components/MkNote.vue
+++ b/packages/frontend/src/components/MkNote.vue
@@ -86,7 +86,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkMediaList :mediaList="appearNote.files" @click.stop/>
</div>
<MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll" @click.stop/>
- <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :class="$style.urlPreview" @click.stop/>
+ <div v-if="isEnabledUrlPreview">
+ <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :class="$style.urlPreview" @click.stop/>
+ </div>
<div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div>
<button v-if="isLong && collapsed" :class="$style.collapsed" class="_button" @click.stop @click="collapsed = false">
<span :class="$style.collapsedLabel">{{ i18n.ts.showMore }}</span>
@@ -184,6 +186,7 @@ import MkNoteSub from '@/components/MkNoteSub.vue';
import MkNoteHeader from '@/components/MkNoteHeader.vue';
import MkNoteSimple from '@/components/MkNoteSimple.vue';
import MkReactionsViewer from '@/components/MkReactionsViewer.vue';
+import MkReactionsViewerDetails from '@/components/MkReactionsViewer.details.vue';
import MkMediaList from '@/components/MkMediaList.vue';
import MkCwButton from '@/components/MkCwButton.vue';
import MkPoll from '@/components/MkPoll.vue';
@@ -198,7 +201,7 @@ import { userPage } from '@/filters/user.js';
import number from '@/filters/number.js';
import * as os from '@/os.js';
import * as sound from '@/scripts/sound.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js';
import { defaultStore, noteViewInterruptors } from '@/store.js';
import { reactionPicker } from '@/scripts/reaction-picker.js';
import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js';
@@ -218,6 +221,7 @@ import { showMovedDialog } from '@/scripts/show-moved-dialog.js';
import { shouldCollapsed } from '@/scripts/collapsed.js';
import { useRouter } from '@/router/supplier.js';
import { boostMenuItems, type Visibility } from '@/scripts/boost-quote.js';
+import { isEnabledUrlPreview } from '@/instance.js';
const props = withDefaults(defineProps<{
note: Misskey.entities.Note;
@@ -306,7 +310,7 @@ const renoteCollapsed = ref(
defaultStore.state.collapseRenotes && isRenote && (
($i && ($i.id === note.value.userId || $i.id === appearNote.value.userId)) || // `||` must be `||`! See https://github.com/misskey-dev/misskey/issues/13131
(appearNote.value.myReaction != null)
- )
+ ),
);
const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null);
const animated = computed(() => parsed.value ? checkAnimationFromMfm(parsed.value) : null);
@@ -407,6 +411,28 @@ if (!props.mock) {
renoted.value = res.length > 0;
});
}
+
+ if (appearNote.value.reactionAcceptance === 'likeOnly') {
+ useTooltip(reactButton, async (showing) => {
+ const reactions = await misskeyApiGet('notes/reactions', {
+ noteId: appearNote.value.id,
+ limit: 10,
+ _cacheKey_: appearNote.value.reactionCount,
+ });
+
+ const users = reactions.map(x => x.user);
+
+ if (users.length < 1) return;
+
+ os.popup(MkReactionsViewerDetails, {
+ showing,
+ reaction: '❤️',
+ users,
+ count: appearNote.value.reactionCount,
+ targetElement: reactButton.value!,
+ }, {}, 'closed');
+ });
+ }
}
function boostVisibility() {
diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue
index ab0793418b..028add9dde 100644
--- a/packages/frontend/src/components/MkNoteDetailed.vue
+++ b/packages/frontend/src/components/MkNoteDetailed.vue
@@ -99,7 +99,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkMediaList :mediaList="appearNote.files"/>
</div>
<MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll"/>
- <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" style="margin-top: 6px;"/>
+ <div v-if="isEnabledUrlPreview">
+ <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" style="margin-top: 6px;"/>
+ </div>
<div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote" :expandAllCws="props.expandAllCws"/></div>
</div>
<MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ph-television ph-bold ph-lg"></i> {{ appearNote.channel.name }}</MkA>
@@ -226,6 +228,7 @@ import * as Misskey from 'misskey-js';
import MkNoteSub from '@/components/MkNoteSub.vue';
import MkNoteSimple from '@/components/MkNoteSimple.vue';
import MkReactionsViewer from '@/components/MkReactionsViewer.vue';
+import MkReactionsViewerDetails from '@/components/MkReactionsViewer.details.vue';
import MkMediaList from '@/components/MkMediaList.vue';
import MkCwButton from '@/components/MkCwButton.vue';
import MkPoll from '@/components/MkPoll.vue';
@@ -238,7 +241,7 @@ import { userPage } from '@/filters/user.js';
import { notePage } from '@/filters/note.js';
import number from '@/filters/number.js';
import * as os from '@/os.js';
-import { misskeyApi } from '@/scripts/misskey-api.js';
+import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js';
import * as sound from '@/scripts/sound.js';
import { defaultStore, noteViewInterruptors } from '@/store.js';
import { reactionPicker } from '@/scripts/reaction-picker.js';
@@ -259,6 +262,7 @@ import MkPagination, { type Paging } from '@/components/MkPagination.vue';
import MkReactionIcon from '@/components/MkReactionIcon.vue';
import MkButton from '@/components/MkButton.vue';
import { boostMenuItems, type Visibility } from '@/scripts/boost-quote.js';
+import { isEnabledUrlPreview } from '@/instance.js';
const props = defineProps<{
note: Misskey.entities.Note;
@@ -439,6 +443,28 @@ function boostVisibility() {
}
}
+if (appearNote.value.reactionAcceptance === 'likeOnly') {
+ useTooltip(reactButton, async (showing) => {
+ const reactions = await misskeyApiGet('notes/reactions', {
+ noteId: appearNote.value.id,
+ limit: 10,
+ _cacheKey_: appearNote.value.reactionCount,
+ });
+
+ const users = reactions.map(x => x.user);
+
+ if (users.length < 1) return;
+
+ os.popup(MkReactionsViewerDetails, {
+ showing,
+ reaction: '❤️',
+ users,
+ count: appearNote.value.reactionCount,
+ targetElement: reactButton.value!,
+ }, {}, 'closed');
+ });
+}
+
function renote(visibility: Visibility, localOnly: boolean = false) {
pleaseLogin();
showMovedDialog();
diff --git a/packages/frontend/src/components/MkPasswordDialog.vue b/packages/frontend/src/components/MkPasswordDialog.vue
index 3c0cdaa786..1cc5066846 100644
--- a/packages/frontend/src/components/MkPasswordDialog.vue
+++ b/packages/frontend/src/components/MkPasswordDialog.vue
@@ -19,18 +19,21 @@ SPDX-License-Identifier: AGPL-3.0-only
<div style="margin-top: 16px;">{{ i18n.ts.authenticationRequiredToContinue }}</div>
</div>
- <div class="_gaps">
- <MkInput ref="passwordInput" v-model="password" :placeholder="i18n.ts.password" type="password" autocomplete="current-password webauthn" :withPasswordToggle="true">
- <template #prefix><i class="ph-password ph-bold ph-lg"></i></template>
- </MkInput>
+ <form @submit.prevent="done">
+ <div class="_gaps">
+ <MkInput ref="passwordInput" v-model="password" :placeholder="i18n.ts.password" type="password" autocomplete="current-password webauthn" required :withPasswordToggle="true">
+ <template #prefix><i class="ph-password ph-bold ph-lg"></i></template>
+ </MkInput>
- <MkInput v-if="$i.twoFactorEnabled" v-model="token" type="text" pattern="^([0-9]{6}|[A-Z0-9]{32})$" autocomplete="one-time-code" :spellcheck="false">
- <template #label>{{ i18n.ts.token }} ({{ i18n.ts['2fa'] }})</template>
- <template #prefix><i class="ph-keyhole ph-bold ph-lg"></i></template>
- </MkInput>
+ <MkInput v-if="$i.twoFactorEnabled" v-model="token" type="text" :pattern="isBackupCode ? '^[A-Z0-9]{32}$' :'^[0-9]{6}$'" autocomplete="one-time-code" required :spellcheck="false" :inputmode="isBackupCode ? undefined : 'numeric'">
+ <template #label>{{ i18n.ts.token }} ({{ i18n.ts['2fa'] }})</template>
+ <template #prefix><i v-if="isBackupCode" class="ph-keyhole ph-bold ph-lg"></i><i v-else class="ph-math-operations ph-bold ph-lg"></i></template>
+ <template #caption><button class="_textButton" type="button" @click="isBackupCode = !isBackupCode">{{ isBackupCode ? i18n.ts.useTotp : i18n.ts.useBackupCode }}</button></template>
+ </MkInput>
- <MkButton :disabled="(password ?? '') == '' || ($i.twoFactorEnabled && (token ?? '') == '')" primary rounded style="margin: 0 auto;" @click="done"><i class="ph-lock ph-bold ph-lg-open"></i> {{ i18n.ts.continue }}</MkButton>
- </div>
+ <MkButton :disabled="(password ?? '') == '' || ($i.twoFactorEnabled && (token ?? '') == '')" type="submit" primary rounded style="margin: 0 auto;"><i class="ph-lock ph-bold ph-lg-open"></i> {{ i18n.ts.continue }}</MkButton>
+ </div>
+ </form>
</MkSpacer>
</MkModalWindow>
</template>
@@ -54,6 +57,7 @@ const emit = defineEmits<{
const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
const passwordInput = shallowRef<InstanceType<typeof MkInput>>();
const password = ref('');
+const isBackupCode = ref(false);
const token = ref<string | null>(null);
function onClose() {
@@ -61,7 +65,7 @@ function onClose() {
if (dialog.value) dialog.value.close();
}
-function done(res) {
+function done() {
emit('done', { password: password.value, token: token.value });
if (dialog.value) dialog.value.close();
}
diff --git a/packages/frontend/src/components/MkSignin.vue b/packages/frontend/src/components/MkSignin.vue
index dc68a99593..ebfb150832 100644
--- a/packages/frontend/src/components/MkSignin.vue
+++ b/packages/frontend/src/components/MkSignin.vue
@@ -31,15 +31,15 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="user && user.securityKeys" class="or-hr">
<p class="or-msg">{{ i18n.ts.or }}</p>
</div>
- <div class="twofa-group totp-group">
- <p style="margin-bottom:0;">{{ i18n.ts['2fa'] }}</p>
+ <div class="twofa-group totp-group _gaps">
<MkInput v-if="user && user.usePasswordLessLogin" v-model="password" type="password" autocomplete="current-password" :withPasswordToggle="true" required>
<template #label>{{ i18n.ts.password }}</template>
<template #prefix><i class="ph-lock ph-bold ph-lg"></i></template>
</MkInput>
- <MkInput v-model="token" type="text" pattern="^([0-9]{6}|[A-Z0-9]{32})$" autocomplete="one-time-code" :spellcheck="false" required>
- <template #label>{{ i18n.ts.token }}</template>
- <template #prefix><i class="ph-keyhole ph-bold ph-lg"></i></template>
+ <MkInput v-model="token" type="text" :pattern="isBackupCode ? '^[A-Z0-9]{32}$' :'^[0-9]{6}$'" autocomplete="one-time-code" required :spellcheck="false" :inputmode="isBackupCode ? undefined : 'numeric'">
+ <template #label>{{ i18n.ts.token }} ({{ i18n.ts['2fa'] }})</template>
+ <template #prefix><i v-if="isBackupCode" class="ph-keyhole ph-bold ph-lg"></i><i v-else class="ph-math-operations ph-bold ph-lg"></i></template>
+ <template #caption><button class="_textButton" type="button" @click="isBackupCode = !isBackupCode">{{ isBackupCode ? i18n.ts.useTotp : i18n.ts.useBackupCode }}</button></template>
</MkInput>
<MkButton type="submit" :disabled="signing" large primary rounded style="margin: 0 auto;">{{ signing ? i18n.ts.loggingIn : i18n.ts.login }}</MkButton>
</div>
@@ -70,6 +70,7 @@ const password = ref('');
const token = ref('');
const host = ref(toUnicode(configHost));
const totpLogin = ref(false);
+const isBackupCode = ref(false);
const queryingKey = ref(false);
const credentialRequest = ref<CredentialRequestOptions | null>(null);
diff --git a/packages/frontend/src/components/MkUrlPreview.vue b/packages/frontend/src/components/MkUrlPreview.vue
index 10ba137b94..2e069fcdd2 100644
--- a/packages/frontend/src/components/MkUrlPreview.vue
+++ b/packages/frontend/src/components/MkUrlPreview.vue
@@ -152,15 +152,16 @@ requestUrl.hash = '';
window.fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${versatileLang}`)
.then(res => {
if (!res.ok) {
- fetching.value = false;
- unknownUrl.value = true;
- return;
+ if (_DEV_) {
+ console.warn(`[HTTP${res.status}] Failed to fetch url preview`);
+ }
+ return null;
}
return res.json();
})
- .then((info: SummalyResult) => {
- if (info.url == null) {
+ .then((info: SummalyResult | null) => {
+ if (!info || info.url == null) {
fetching.value = false;
unknownUrl.value = true;
return;
diff --git a/packages/frontend/src/components/global/MkUrl.vue b/packages/frontend/src/components/global/MkUrl.vue
index 665cfcf1ab..1104e0ae89 100644
--- a/packages/frontend/src/components/global/MkUrl.vue
+++ b/packages/frontend/src/components/global/MkUrl.vue
@@ -31,6 +31,7 @@ import { url as local } from '@/config.js';
import * as os from '@/os.js';
import { useTooltip } from '@/scripts/use-tooltip.js';
import { safeURIDecode } from '@/scripts/safe-uri-decode.js';
+import { isEnabledUrlPreview } from '@/instance.js';
const props = withDefaults(defineProps<{
url: string;
@@ -45,7 +46,7 @@ const url = new URL(props.url);
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('invalid url');
const el = ref();
-if (props.showUrlPreview) {
+if (props.showUrlPreview && isEnabledUrlPreview.value) {
useTooltip(el, (showing) => {
os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), {
showing,
diff --git a/packages/frontend/src/components/page/page.image.vue b/packages/frontend/src/components/page/page.image.vue
index ced02943db..fc1ce9fc7b 100644
--- a/packages/frontend/src/components/page/page.image.vue
+++ b/packages/frontend/src/components/page/page.image.vue
@@ -4,19 +4,15 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
-<div>
- <MediaImage
- v-if="image"
- :image="image"
- :disableImageLink="true"
- />
+<div :class="$style.root">
+ <MkMediaList v-if="image" :mediaList="[image]" :class="$style.mediaList"/>
</div>
</template>
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import * as Misskey from 'misskey-js';
-import MediaImage from '@/components/MkMediaImage.vue';
+import MkMediaList from '@/components/MkMediaList.vue';
const props = defineProps<{
block: Misskey.entities.PageBlock,
@@ -28,5 +24,17 @@ const image = ref<Misskey.entities.DriveFile | null>(null);
onMounted(() => {
image.value = props.page.attachedFiles.find(x => x.id === props.block.fileId) ?? null;
});
-
</script>
+
+<style lang="scss" module>
+.root {
+ border: 1px solid var(--divider);
+ border-radius: var(--radius);
+ overflow: hidden;
+}
+.mediaList {
+ // MkMediaList 内の上部マージン 4px
+ margin-top: -4px;
+ height: calc(100% + 4px);
+}
+</style>
diff --git a/packages/frontend/src/components/page/page.note.vue b/packages/frontend/src/components/page/page.note.vue
index 7b56494a6e..b5ba407806 100644
--- a/packages/frontend/src/components/page/page.note.vue
+++ b/packages/frontend/src/components/page/page.note.vue
@@ -4,9 +4,9 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
-<div style="margin: 1em 0;">
- <MkNote v-if="note && !block.detailed" :key="note.id + ':normal'" v-model:note="note"/>
- <MkNoteDetailed v-if="note && block.detailed" :key="note.id + ':detail'" v-model:note="note"/>
+<div :class="$style.root">
+ <MkNote v-if="note && !block.detailed" :key="note.id + ':normal'" :note="note"/>
+ <MkNoteDetailed v-if="note && block.detailed" :key="note.id + ':detail'" :note="note"/>
</div>
</template>
@@ -32,3 +32,10 @@ onMounted(() => {
});
});
</script>
+
+<style lang="scss" module>
+.root {
+ border: 1px solid var(--divider);
+ border-radius: var(--radius);
+}
+</style>
diff --git a/packages/frontend/src/components/page/page.text.vue b/packages/frontend/src/components/page/page.text.vue
index 6a9415e137..cac67afd0b 100644
--- a/packages/frontend/src/components/page/page.text.vue
+++ b/packages/frontend/src/components/page/page.text.vue
@@ -4,9 +4,11 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
-<div class="_gaps">
+<div class="_gaps" :class="$style.textRoot">
<Mfm :text="block.text ?? ''" :isNote="false"/>
- <MkUrlPreview v-for="url in urls" :key="url" :url="url"/>
+ <div v-if="isEnabledUrlPreview">
+ <MkUrlPreview v-for="url in urls" :key="url" :url="url"/>
+ </div>
</div>
</template>
@@ -15,6 +17,7 @@ import { defineAsyncComponent } from 'vue';
import * as mfm from '@transfem-org/sfm-js';
import * as Misskey from 'misskey-js';
import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js';
+import { isEnabledUrlPreview } from '@/instance.js';
const MkUrlPreview = defineAsyncComponent(() => import('@/components/MkUrlPreview.vue'));
@@ -25,3 +28,9 @@ const props = defineProps<{
const urls = props.block.text ? extractUrlFromMfm(mfm.parse(props.block.text)) : [];
</script>
+
+<style lang="scss" module>
+.textRoot {
+ font-size: 1.1rem;
+}
+</style>
diff --git a/packages/frontend/src/components/page/page.vue b/packages/frontend/src/components/page/page.vue
index 53c70b01f4..a31c5eff28 100644
--- a/packages/frontend/src/components/page/page.vue
+++ b/packages/frontend/src/components/page/page.vue
@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
-<div :class="{ [$style.center]: page.alignCenter, [$style.serif]: page.font === 'serif' }" class="_gaps_s">
+<div :class="{ [$style.center]: page.alignCenter, [$style.serif]: page.font === 'serif' }" class="_gaps">
<XBlock v-for="child in page.content" :key="child.id" :page="page" :block="child" :h="2"/>
</div>
</template>
diff --git a/packages/frontend/src/index.html b/packages/frontend/src/index.html
index 54059bfaf4..51a9cdce79 100644
--- a/packages/frontend/src/index.html
+++ b/packages/frontend/src/index.html
@@ -18,7 +18,7 @@
http-equiv="Content-Security-Policy"
content="default-src 'self' https://newassets.hcaptcha.com/ https://challenges.cloudflare.com/ http://localhost:7493/;
worker-src 'self';
- script-src 'self' 'unsafe-eval' https://*.hcaptcha.com https://challenges.cloudflare.com;
+ script-src 'self' 'unsafe-eval' https://*.hcaptcha.com https://challenges.cloudflare.com https://esm.sh;
style-src 'self' 'unsafe-inline';
img-src 'self' data: blob: www.google.com xn--931a.moe launcher.moe localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000 activitypub.software secure.gravatar.com avatars.githubusercontent.com;
media-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000;
diff --git a/packages/frontend/src/instance.ts b/packages/frontend/src/instance.ts
index 4232cbcd78..22337e7eb9 100644
--- a/packages/frontend/src/instance.ts
+++ b/packages/frontend/src/instance.ts
@@ -36,6 +36,8 @@ export const infoImageUrl = computed(() => instance.infoImageUrl ?? DEFAULT_INFO
export const notFoundImageUrl = computed(() => instance.notFoundImageUrl ?? DEFAULT_NOT_FOUND_IMAGE_URL);
+export const isEnabledUrlPreview = computed(() => instance.enableUrlPreview ?? true);
+
export async function fetchInstance(force = false): Promise<void> {
if (!force) {
const cachedAt = miLocalStorage.getItem('instanceCachedAt') ? parseInt(miLocalStorage.getItem('instanceCachedAt')!) : 0;
diff --git a/packages/frontend/src/pages/admin/security.vue b/packages/frontend/src/pages/admin/security.vue
index 8e75975209..887d3c1446 100644
--- a/packages/frontend/src/pages/admin/security.vue
+++ b/packages/frontend/src/pages/admin/security.vue
@@ -75,19 +75,6 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSwitch>
</div>
</MkFolder>
-
- <MkFolder>
- <template #label>Summaly Proxy</template>
-
- <div class="_gaps_m">
- <MkInput v-model="summalyProxy">
- <template #prefix><i class="ph-link ph-bold ph-lg"></i></template>
- <template #label>Summaly Proxy URL</template>
- </MkInput>
-
- <MkButton primary @click="save"><i class="ph-floppy-disk ph-bold ph-lg"></i> {{ i18n.ts.save }}</MkButton>
- </div>
- </MkFolder>
</div>
</FormSuspense>
</MkSpacer>
@@ -112,7 +99,6 @@ import { fetchInstance } from '@/instance.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
-const summalyProxy = ref<string>('');
const enableHcaptcha = ref<boolean>(false);
const enableMcaptcha = ref<boolean>(false);
const enableRecaptcha = ref<boolean>(false);
@@ -128,7 +114,6 @@ const bannedEmailDomains = ref<string>('');
async function init() {
const meta = await misskeyApi('admin/meta');
- summalyProxy.value = meta.summalyProxy;
enableHcaptcha.value = meta.enableHcaptcha;
enableMcaptcha.value = meta.enableMcaptcha;
enableRecaptcha.value = meta.enableRecaptcha;
@@ -145,7 +130,6 @@ async function init() {
function save() {
os.apiWithDialog('admin/update-meta', {
- summalyProxy: summalyProxy.value,
enableIpLogging: enableIpLogging.value,
enableActiveEmailValidation: enableActiveEmailValidation.value,
enableVerifymailApi: enableVerifymailApi.value,
diff --git a/packages/frontend/src/pages/admin/settings.vue b/packages/frontend/src/pages/admin/settings.vue
index 887ac6fb4c..ebbe252cb7 100644
--- a/packages/frontend/src/pages/admin/settings.vue
+++ b/packages/frontend/src/pages/admin/settings.vue
@@ -148,6 +148,53 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
</FormSection>
+
+ <FormSection>
+ <template #label>{{ i18n.ts._urlPreviewSetting.title }}</template>
+
+ <div class="_gaps_m">
+ <MkSwitch v-model="urlPreviewEnabled">
+ <template #label>{{ i18n.ts._urlPreviewSetting.enable }}</template>
+ </MkSwitch>
+
+ <MkSwitch v-model="urlPreviewRequireContentLength">
+ <template #label>{{ i18n.ts._urlPreviewSetting.requireContentLength }}</template>
+ <template #caption>{{ i18n.ts._urlPreviewSetting.requireContentLengthDescription }}</template>
+ </MkSwitch>
+
+ <MkInput v-model="urlPreviewMaximumContentLength" type="number">
+ <template #label>{{ i18n.ts._urlPreviewSetting.maximumContentLength }}</template>
+ <template #caption>{{ i18n.ts._urlPreviewSetting.maximumContentLengthDescription }}</template>
+ </MkInput>
+
+ <MkInput v-model="urlPreviewTimeout" type="number">
+ <template #label>{{ i18n.ts._urlPreviewSetting.timeout }}</template>
+ <template #caption>{{ i18n.ts._urlPreviewSetting.timeoutDescription }}</template>
+ </MkInput>
+
+ <MkInput v-model="urlPreviewUserAgent" type="text">
+ <template #label>{{ i18n.ts._urlPreviewSetting.userAgent }}</template>
+ <template #caption>{{ i18n.ts._urlPreviewSetting.userAgentDescription }}</template>
+ </MkInput>
+
+ <div>
+ <MkInput v-model="urlPreviewSummaryProxyUrl" type="text">
+ <template #label>{{ i18n.ts._urlPreviewSetting.summaryProxy }}</template>
+ <template #caption>[{{ i18n.ts.notUsePleaseLeaveBlank }}] {{ i18n.ts._urlPreviewSetting.summaryProxyDescription }}</template>
+ </MkInput>
+
+ <div :class="$style.subCaption">
+ {{ i18n.ts._urlPreviewSetting.summaryProxyDescription2 }}
+ <ul style="padding-left: 20px; margin: 4px 0">
+ <li>{{ i18n.ts._urlPreviewSetting.timeout }} / key:timeout</li>
+ <li>{{ i18n.ts._urlPreviewSetting.maximumContentLength }} / key:contentLengthLimit</li>
+ <li>{{ i18n.ts._urlPreviewSetting.requireContentLength }} / key:contentLengthRequired</li>
+ <li>{{ i18n.ts._urlPreviewSetting.userAgent }} / key:userAgent</li>
+ </ul>
+ </div>
+ </div>
+ </div>
+ </FormSection>
</div>
</FormSuspense>
</MkSpacer>
@@ -178,6 +225,8 @@ import { fetchInstance, instance } from '@/instance.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import MkButton from '@/components/MkButton.vue';
+import MkFolder from '@/components/MkFolder.vue';
+import MkSelect from '@/components/MkSelect.vue';
const name = ref<string | null>(null);
const shortName = ref<string | null>(null);
@@ -200,6 +249,12 @@ const perRemoteUserUserTimelineCacheMax = ref<number>(0);
const perUserHomeTimelineCacheMax = ref<number>(0);
const perUserListTimelineCacheMax = ref<number>(0);
const notesPerOneAd = ref<number>(0);
+const urlPreviewEnabled = ref<boolean>(true);
+const urlPreviewTimeout = ref<number>(10000);
+const urlPreviewMaximumContentLength = ref<number>(1024 * 1024 * 10);
+const urlPreviewRequireContentLength = ref<boolean>(true);
+const urlPreviewUserAgent = ref<string | null>(null);
+const urlPreviewSummaryProxyUrl = ref<string | null>(null);
async function init(): Promise<void> {
const meta = await misskeyApi('admin/meta');
@@ -224,9 +279,15 @@ async function init(): Promise<void> {
perUserHomeTimelineCacheMax.value = meta.perUserHomeTimelineCacheMax;
perUserListTimelineCacheMax.value = meta.perUserListTimelineCacheMax;
notesPerOneAd.value = meta.notesPerOneAd;
+ urlPreviewEnabled.value = meta.urlPreviewEnabled;
+ urlPreviewTimeout.value = meta.urlPreviewTimeout;
+ urlPreviewMaximumContentLength.value = meta.urlPreviewMaximumContentLength;
+ urlPreviewRequireContentLength.value = meta.urlPreviewRequireContentLength;
+ urlPreviewUserAgent.value = meta.urlPreviewUserAgent;
+ urlPreviewSummaryProxyUrl.value = meta.urlPreviewSummaryProxyUrl;
}
-async function save(): void {
+async function save() {
await os.apiWithDialog('admin/update-meta', {
name: name.value,
shortName: shortName.value === '' ? null : shortName.value,
@@ -249,6 +310,12 @@ async function save(): void {
perUserHomeTimelineCacheMax: perUserHomeTimelineCacheMax.value,
perUserListTimelineCacheMax: perUserListTimelineCacheMax.value,
notesPerOneAd: notesPerOneAd.value,
+ urlPreviewEnabled: urlPreviewEnabled.value,
+ urlPreviewTimeout: urlPreviewTimeout.value,
+ urlPreviewMaximumContentLength: urlPreviewMaximumContentLength.value,
+ urlPreviewRequireContentLength: urlPreviewRequireContentLength.value,
+ urlPreviewUserAgent: urlPreviewUserAgent.value,
+ urlPreviewSummaryProxyUrl: urlPreviewSummaryProxyUrl.value,
});
fetchInstance(true);
@@ -267,4 +334,9 @@ definePageMetadata(() => ({
-webkit-backdrop-filter: var(--blur, blur(15px));
backdrop-filter: var(--blur, blur(15px));
}
+
+.subCaption {
+ font-size: 0.85em;
+ color: var(--fgTransparentWeak);
+}
</style>
diff --git a/packages/frontend/src/pages/flash/flash-edit.vue b/packages/frontend/src/pages/flash/flash-edit.vue
index 53c8c78914..c2f86d2285 100644
--- a/packages/frontend/src/pages/flash/flash-edit.vue
+++ b/packages/frontend/src/pages/flash/flash-edit.vue
@@ -18,16 +18,17 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkCodeEditor v-model="script" lang="is">
<template #label>{{ i18n.ts._play.script }}</template>
</MkCodeEditor>
- <div class="_buttons">
- <MkButton primary @click="save"><i class="ph-check ph-bold ph-lg"></i> {{ i18n.ts.save }}</MkButton>
- <MkButton @click="show"><i class="ph-eye ph-bold ph-lg"></i> {{ i18n.ts.show }}</MkButton>
- <MkButton v-if="flash" danger @click="del"><i class="ph-trash ph-bold ph-lg"></i> {{ i18n.ts.delete }}</MkButton>
- </div>
<MkSelect v-model="visibility">
<template #label>{{ i18n.ts.visibility }}</template>
+ <template #caption>{{ i18n.ts._play.visibilityDescription }}</template>
<option :key="'public'" :value="'public'">{{ i18n.ts.public }}</option>
<option :key="'private'" :value="'private'">{{ i18n.ts.private }}</option>
</MkSelect>
+ <div class="_buttons">
+ <MkButton primary @click="save"><i class="ph-check ph-bold ph-lg"></i> {{ i18n.ts.save }}</MkButton>
+ <MkButton @click="show"><i class="ph-eye ph-bold ph-lg"></i> {{ i18n.ts.show }}</MkButton>
+ <MkButton v-if="flash" danger @click="del"><i class="ph-trash ph-bold ph-lg"></i> {{ i18n.ts.delete }}</MkButton>
+ </div>
</div>
</MkSpacer>
</MkStickyContainer>
@@ -367,7 +368,7 @@ const props = defineProps<{
}>();
const flash = ref<Misskey.entities.Flash | null>(null);
-const visibility = ref<Misskey.entities.FlashUpdateRequest['visibility']>('public');
+const visibility = ref<'private' | 'public'>('public');
if (props.id) {
flash.value = await misskeyApi('flash/show', {
@@ -420,6 +421,7 @@ async function save() {
summary: summary.value,
permissions: permissions.value,
script: script.value,
+ visibility: visibility.value,
});
router.push('/play/' + created.id + '/edit');
}
diff --git a/packages/frontend/src/pages/my-antennas/create.vue b/packages/frontend/src/pages/my-antennas/create.vue
index f511c48a06..ee2330a4f7 100644
--- a/packages/frontend/src/pages/my-antennas/create.vue
+++ b/packages/frontend/src/pages/my-antennas/create.vue
@@ -26,6 +26,7 @@ const draft = ref({
users: [],
keywords: [],
excludeKeywords: [],
+ excludeBots: false,
withReplies: false,
caseSensitive: false,
localOnly: false,
diff --git a/packages/frontend/src/pages/my-antennas/editor.vue b/packages/frontend/src/pages/my-antennas/editor.vue
index 2d29e2d375..07fc459688 100644
--- a/packages/frontend/src/pages/my-antennas/editor.vue
+++ b/packages/frontend/src/pages/my-antennas/editor.vue
@@ -26,6 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ i18n.ts.users }}</template>
<template #caption>{{ i18n.ts.antennaUsersDescription }} <button class="_textButton" @click="addUser">{{ i18n.ts.addUser }}</button></template>
</MkTextarea>
+ <MkSwitch v-model="excludeBots">{{ i18n.ts.antennaExcludeBots }}</MkSwitch>
<MkSwitch v-model="withReplies">{{ i18n.ts.withReplies }}</MkSwitch>
<MkTextarea v-model="keywords">
<template #label>{{ i18n.ts.antennaKeywords }}</template>
@@ -78,6 +79,7 @@ const keywords = ref<string>(props.antenna.keywords.map(x => x.join(' ')).join('
const excludeKeywords = ref<string>(props.antenna.excludeKeywords.map(x => x.join(' ')).join('\n'));
const caseSensitive = ref<boolean>(props.antenna.caseSensitive);
const localOnly = ref<boolean>(props.antenna.localOnly);
+const excludeBots = ref<boolean>(props.antenna.excludeBots);
const withReplies = ref<boolean>(props.antenna.withReplies);
const withFile = ref<boolean>(props.antenna.withFile);
const notify = ref<boolean>(props.antenna.notify);
@@ -94,6 +96,7 @@ async function saveAntenna() {
name: name.value,
src: src.value,
userListId: userListId.value,
+ excludeBots: excludeBots.value,
withReplies: withReplies.value,
withFile: withFile.value,
notify: notify.value,
diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue
index 978d03c1cd..0bbe256cbf 100644
--- a/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue
+++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue
@@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<XContainer :draggable="true" @remove="() => $emit('remove')">
<template #header><i class="ph-note ph-bold ph-lg"></i> {{ i18n.ts._pages.blocks.note }}</template>
- <section style="padding: 0 16px 0 16px;">
+ <section style="padding: 16px;" class="_gaps_s">
<MkInput v-model="id">
<template #label>{{ i18n.ts._pages.blocks._note.id }}</template>
<template #caption>{{ i18n.ts._pages.blocks._note.idDescription }}</template>
diff --git a/packages/frontend/src/pages/page.vue b/packages/frontend/src/pages/page.vue
index dc47f20bee..3ff270926f 100644
--- a/packages/frontend/src/pages/page.vue
+++ b/packages/frontend/src/pages/page.vue
@@ -6,48 +6,73 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :displayBackButton="true" :tabs="headerTabs"/></template>
- <MkSpacer :contentMax="700">
- <Transition :name="defaultStore.state.animation ? 'fade' : ''" mode="out-in">
- <div v-if="page" :key="page.id" class="xcukqgmh">
- <div class="main">
- <!--
- <div class="header">
- <h1>{{ page.title }}</h1>
- </div>
- -->
- <div class="banner">
- <MkMediaImage
- v-if="page.eyeCatchingImageId"
- :image="page.eyeCatchingImage"
- :cover="true"
- :disableImageLink="true"
- class="thumbnail"
- />
+ <MkSpacer :contentMax="800">
+ <Transition
+ :enterActiveClass="defaultStore.state.animation ? $style.fadeEnterActive : ''"
+ :leaveActiveClass="defaultStore.state.animation ? $style.fadeLeaveActive : ''"
+ :enterFromClass="defaultStore.state.animation ? $style.fadeEnterFrom : ''"
+ :leaveToClass="defaultStore.state.animation ? $style.fadeLeaveTo : ''"
+ >
+ <div v-if="page" :key="page.id" class="_gaps">
+ <div :class="$style.pageMain">
+ <div :class="$style.pageBanner">
+ <div :class="$style.pageBannerBgRoot">
+ <MkImgWithBlurhash
+ v-if="page.eyeCatchingImageId"
+ :class="$style.pageBannerBg"
+ :hash="page.eyeCatchingImage?.blurhash"
+ :cover="true"
+ :forceBlurhash="true"
+ />
+ <img
+ v-else-if="instance.backgroundImageUrl || instance.bannerUrl"
+ :class="[$style.pageBannerBg, $style.pageBannerBgFallback1]"
+ :src="getStaticImageUrl(instance.backgroundImageUrl ?? instance.bannerUrl!)"
+ />
+ <div v-else :class="[$style.pageBannerBg, $style.pageBannerBgFallback2]"></div>
+ </div>
+ <div v-if="page.eyeCatchingImageId" :class="$style.pageBannerImage">
+ <MkMediaImage
+ :image="page.eyeCatchingImage!"
+ :cover="true"
+ :disableImageLink="true"
+ :class="$style.thumbnail"
+ />
+ </div>
+ <div :class="$style.pageBannerTitle" class="_gaps_s">
+ <h1>{{ page.title || page.name }}</h1>
+ <div v-if="page.user" :class="$style.pageBannerTitleUser">
+ <MkAvatar :user="page.user" :class="$style.avatar" indicator link preview/> <MkA :to="`/@${username}`"><MkUserName :user="page.user" :nowrap="false"/></MkA>
+ </div>
+ </div>
</div>
- <div class="content">
+ <div :class="$style.pageContent">
<XPage :page="page"/>
</div>
- <div class="actions">
- <div class="like">
+ <div :class="$style.pageActions">
+ <div>
<MkButton v-if="page.isLiked" v-tooltip="i18n.ts._pages.unlike" class="button" asLike primary @click="unlike()"><i class="ph-heart-break ph-bold ph-lg"></i><span v-if="page.likedCount > 0" class="count">{{ page.likedCount }}</span></MkButton>
<MkButton v-else v-tooltip="i18n.ts._pages.like" class="button" asLike @click="like()"><i class="ph-heart ph-bold ph-lg"></i><span v-if="page.likedCount > 0" class="count">{{ page.likedCount }}</span></MkButton>
</div>
- <div class="other">
- <button v-tooltip="i18n.ts.shareWithNote" v-click-anime class="_button" @click="shareWithNote"><i class="ph-rocket-launch ph-bold ph-lg ti-fw"></i></button>
- <button v-tooltip="i18n.ts.copyLink" v-click-anime class="_button" @click="copyLink"><i class="ph-share-network ph-bold ph-lg ti-fw"></i></button>
- <button v-if="isSupportShare()" v-tooltip="i18n.ts.share" v-click-anime class="_button" @click="share"><i class="ph-share-network ph-bold ph-lg ti-fw"></i></button>
+ <div :class="$style.other">
+ <button v-tooltip="i18n.ts.copyLink" class="_button" :class="$style.generalActionButton" @click="copyLink"><i class="ph-link ph-bold ph-lg ti-fw"></i></button>
+ <button v-tooltip="i18n.ts.share" class="_button" :class="$style.generalActionButton" @click="share"><i class="ph-share-network ph-bold ph-lg ti-fw"></i></button>
</div>
</div>
- <div class="user">
- <MkAvatar :user="page.user" class="avatar" link preview/>
- <div class="name">
- <MkUserName :user="page.user" style="display: block;"/>
- <MkAcct :user="page.user"/>
- </div>
- <MkFollowButton v-if="!$i || $i.id != page.user.id" :user="page.user" :inline="true" :transparent="false" :full="true" large class="koudoku"/>
+ <div :class="$style.pageUser">
+ <MkAvatar :user="page.user" :class="$style.avatar" link preview/>
+ <MkA :to="`/@${username}`">
+ <MkUserName :user="page.user" :class="$style.name"/>
+ <MkAcct :user="page.user" :class="$style.acct"/>
+ </MkA>
+ <MkFollowButton v-if="!$i || $i.id != page.user.id" :user="page.user!" :inline="true" :transparent="false" :full="true" :class="$style.follow"/>
</div>
- <div class="links">
- <MkA :to="`/@${username}/pages/${pageName}/view-source`" class="link">{{ i18n.ts._pages.viewSource }}</MkA>
+ <div :class="$style.pageDate">
+ <div><i class="ti ti-clock"></i> {{ i18n.ts.createdAt }}: <MkTime :time="page.createdAt" mode="detail"/></div>
+ <div v-if="page.createdAt != page.updatedAt"><i class="ti ti-clock-edit"></i> {{ i18n.ts.updatedAt }}: <MkTime :time="page.updatedAt" mode="detail"/></div>
+ </div>
+ <div :class="$style.pageLinks">
+ <MkA v-if="!$i || $i.id !== page.userId" :to="`/@${username}/pages/${pageName}/view-source`" class="link">{{ i18n.ts._pages.viewSource }}</MkA>
<template v-if="$i && $i.id === page.userId">
<MkA :to="`/pages/edit/${page.id}`" class="link">{{ i18n.ts._pages.editThisPage }}</MkA>
<button v-if="$i.pinnedPageId === page.id" class="link _textButton" @click="pin(false)">{{ i18n.ts.unpin }}</button>
@@ -55,10 +80,6 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
</div>
</div>
- <div class="footer">
- <div><i class="ph-clock ph-bold ph-lg"></i> {{ i18n.ts.createdAt }}: <MkTime :time="page.createdAt" mode="detail"/></div>
- <div v-if="page.createdAt != page.updatedAt"><i class="ph-clock ph-bold ph-lg"></i> {{ i18n.ts.updatedAt }}: <MkTime :time="page.updatedAt" mode="detail"/></div>
- </div>
<MkAd :prefer="['horizontal', 'horizontal-big']"/>
<MkContainer :max-height="300" :foldable="true" class="other">
<template #icon><i class="ph-clock ph-bold ph-lg"></i></template>
@@ -84,6 +105,7 @@ import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { url } from '@/config.js';
import MkMediaImage from '@/components/MkMediaImage.vue';
+import MkImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
import MkFollowButton from '@/components/MkFollowButton.vue';
import MkContainer from '@/components/MkContainer.vue';
import MkPagination from '@/components/MkPagination.vue';
@@ -94,6 +116,8 @@ import { pageViewInterruptors, defaultStore } from '@/store.js';
import { deepClone } from '@/scripts/clone.js';
import { $i } from '@/account.js';
import { isSupportShare } from '@/scripts/navigator.js';
+import { instance } from '@/instance.js';
+import { getStaticImageUrl } from '@/scripts/media-proxy.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
const props = defineProps<{
@@ -133,35 +157,63 @@ function fetchPage() {
});
}
-function share() {
- navigator.share({
- title: page.value.title ?? page.value.name,
- text: page.value.summary,
- url: `${url}/@${page.value.user.username}/pages/${page.value.name}`,
- });
+function share(ev: MouseEvent) {
+ if (!page.value) return;
+
+ os.popupMenu([
+ {
+ text: i18n.ts.shareWithNote,
+ icon: 'ti ti-pencil',
+ action: shareWithNote,
+ },
+ ...(isSupportShare() ? [{
+ text: i18n.ts.share,
+ icon: 'ti ti-share',
+ action: shareWithNavigator,
+ }] : []),
+ ], ev.currentTarget ?? ev.target);
}
function copyLink() {
+ if (!page.value) return;
+
copyToClipboard(`${url}/@${page.value.user.username}/pages/${page.value.name}`);
os.success();
}
function shareWithNote() {
+ if (!page.value) return;
+
os.post({
- initialText: `${page.value.title || page.value.name} ${url}/@${page.value.user.username}/pages/${page.value.name}`,
+ initialText: `${page.value.title || page.value.name}\n${url}/@${page.value.user.username}/pages/${page.value.name}`,
+ instant: true,
+ });
+}
+
+function shareWithNavigator() {
+ if (!page.value) return;
+
+ navigator.share({
+ title: page.value.title ?? page.value.name,
+ text: page.value.summary ?? undefined,
+ url: `${url}/@${page.value.user.username}/pages/${page.value.name}`,
});
}
function like() {
+ if (!page.value) return;
+
os.apiWithDialog('pages/like', {
pageId: page.value.id,
}).then(() => {
- page.value.isLiked = true;
- page.value.likedCount++;
+ page.value!.isLiked = true;
+ page.value!.likedCount++;
});
}
async function unlike() {
+ if (!page.value) return;
+
const confirm = await os.confirm({
type: 'warning',
text: i18n.ts.unlikeConfirm,
@@ -170,12 +222,14 @@ async function unlike() {
os.apiWithDialog('pages/unlike', {
pageId: page.value.id,
}).then(() => {
- page.value.isLiked = false;
- page.value.likedCount--;
+ page.value!.isLiked = false;
+ page.value!.likedCount--;
});
}
function pin(pin) {
+ if (!page.value) return;
+
os.apiWithDialog('i/update', {
pinnedPageId: pin ? page.value.id : null,
});
@@ -200,109 +254,185 @@ definePageMetadata(() => ({
}));
</script>
-<style lang="scss" scoped>
-.fade-enter-active,
-.fade-leave-active {
+<style lang="scss" module>
+.fadeEnterActive,
+.fadeLeaveActive {
transition: opacity 0.125s ease;
}
-.fade-enter-from,
-.fade-leave-to {
+.fadeEnterFrom,
+.fadeLeaveTo {
opacity: 0;
}
-.xcukqgmh {
- > .main {
- padding: 32px;
+.generalActionButton {
+ height: 2.5rem;
+ width: 2.5rem;
+ text-align: center;
+ border-radius: 99rem;
- > .header {
- padding: 16px;
+ & :global(.ti) {
+ line-height: 2.5rem;
+ }
- > h1 {
- margin: 0;
- }
- }
+ &:hover,
+ &:focus-visible {
+ background-color: var(--accentedBg);
+ color: var(--accent);
+ text-decoration: none;
+ }
+}
- > .banner {
- > .thumbnail {
- // TODO: 良い感じのアスペクト比で表示
- display: block;
- width: 100%;
- height: auto;
- aspect-ratio: 3/1;
- border-radius: var(--radius);
- overflow: hidden;
- object-fit: cover;
- }
+.pageMain {
+ border-radius: var(--radius);
+ padding: 2rem;
+ background: var(--panel);
+ box-sizing: border-box;
+}
+
+.pageBanner {
+ width: calc(100% + 4rem);
+ margin: -2rem -2rem 1.5rem;
+ border-radius: var(--radius) var(--radius) 0 0;
+ overflow: hidden;
+ position: relative;
+
+ > .pageBannerBgRoot {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+
+ .pageBannerBg {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ opacity: .2;
+ filter: brightness(1.2);
}
- > .content {
- margin-top: 16px;
- padding: 16px 0 0 0;
+ .pageBannerBgFallback1 {
+ filter: blur(20px);
}
- > .actions {
- display: flex;
- align-items: center;
- margin-top: 16px;
- padding: 16px 0 0 0;
- border-top: solid 0.5px var(--divider);
+ .pageBannerBgFallback2 {
+ background-color: var(--accentedBg);
+ }
- > .other {
- margin-left: auto;
+ &::after {
+ content: '';
+ position: absolute;
+ left: 0;
+ bottom: 0;
+ width: 100%;
+ height: 100px;
+ background: linear-gradient(0deg, var(--panel), transparent);
+ }
+ }
- > button {
- padding: 8px;
- margin: 0 8px;
+ > .pageBannerImage {
+ position: relative;
+ padding-top: 56.25%;
- &:hover {
- color: var(--fgHighlighted);
- }
- }
- }
+ > .thumbnail {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
}
+ }
- > .user {
- margin-top: 16px;
- padding: 16px 0 0 0;
- border-top: solid 0.5px var(--divider);
- display: flex;
- align-items: center;
+ > .pageBannerTitle {
+ position: relative;
+ padding: 1.5rem 2rem;
- > .avatar {
- width: 52px;
- height: 52px;
- }
+ h1 {
+ font-size: 2rem;
+ font-weight: 700;
+ color: var(--fg);
+ margin: 0;
+ }
- > .name {
- margin: 0 0 0 12px;
- font-size: 90%;
- }
+ .pageBannerTitleUser {
+ --height: 32px;
- > .koudoku {
- margin-left: auto;
+ .avatar {
+ height: var(--height);
+ width: var(--height);
}
+
+ line-height: var(--height);
}
+ }
+}
+
+.pageContent {
+ margin-bottom: 1.5rem;
+}
- > .links {
- margin-top: 16px;
- padding: 24px 0 0 0;
- border-top: solid 0.5px var(--divider);
+.pageActions {
+ display: flex;
+ align-items: center;
- > .link {
- margin-right: 0.75em;
- }
- }
+ border-top: 1px solid var(--divider);
+ padding-top: 1.5rem;
+ margin-bottom: 1.5rem;
+
+ > .other {
+ margin-left: auto;
+ display: flex;
+ gap: var(--marginHalf);
+ }
+}
+
+.pageUser {
+ display: flex;
+ align-items: center;
+
+ border-top: 1px solid var(--divider);
+ padding-top: 1.5rem;
+ margin-bottom: 1.5rem;
+
+ .avatar,
+ .name,
+ .acct {
+ display: block;
+ }
+
+ .avatar {
+ width: 4rem;
+ height: 4rem;
+ margin-right: 1rem;
+ }
+
+ .name {
+ font-size: 110%;
+ font-weight: 700;
+ }
+
+ .acct {
+ font-size: 90%;
+ opacity: 0.7;
}
- > .footer {
- margin: var(--margin) 0 var(--margin) 0;
- font-size: 85%;
- opacity: 0.75;
+ .follow {
+ margin-left: auto;
}
}
-</style>
-<style module>
+.pageDate {
+ margin-bottom: 1.5rem;
+}
+
+.pageLinks {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: var(--marginHalf);
+}
+
.relatedPagesRoot {
padding: var(--margin);
}
diff --git a/packages/frontend/src/pages/settings/2fa.qrdialog.vue b/packages/frontend/src/pages/settings/2fa.qrdialog.vue
index 13f475c2f2..3e1df89173 100644
--- a/packages/frontend/src/pages/settings/2fa.qrdialog.vue
+++ b/packages/frontend/src/pages/settings/2fa.qrdialog.vue
@@ -52,7 +52,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSpacer :marginMin="20" :marginMax="28">
<div class="_gaps">
<div>{{ i18n.ts._2fa.step3Title }}</div>
- <MkInput v-model="token" autocomplete="one-time-code"></MkInput>
+ <MkInput v-model="token" autocomplete="one-time-code" inputmode="numeric"></MkInput>
<div>{{ i18n.ts._2fa.step3 }}</div>
</div>
<div class="_buttonsCenter" style="margin-top: 16px;">
diff --git a/packages/frontend/src/pages/settings/2fa.vue b/packages/frontend/src/pages/settings/2fa.vue
index ba85a43084..2b246c0e52 100644
--- a/packages/frontend/src/pages/settings/2fa.vue
+++ b/packages/frontend/src/pages/settings/2fa.vue
@@ -80,7 +80,7 @@ import MkSwitch from '@/components/MkSwitch.vue';
import FormSection from '@/components/form/section.vue';
import MkFolder from '@/components/MkFolder.vue';
import * as os from '@/os.js';
-import { signinRequired } from '@/account.js';
+import { signinRequired, updateAccount } from '@/account.js';
import { i18n } from '@/i18n.js';
const $i = signinRequired();
@@ -116,6 +116,10 @@ async function unregisterTOTP(): Promise<void> {
os.apiWithDialog('i/2fa/unregister', {
password: auth.result.password,
token: auth.result.token,
+ }).then(res => {
+ updateAccount({
+ twoFactorEnabled: false,
+ });
}).catch(error => {
os.alert({
type: 'error',
diff --git a/packages/frontend/src/scripts/code-highlighter.ts b/packages/frontend/src/scripts/code-highlighter.ts
index 5dd0a3be78..e94027d302 100644
--- a/packages/frontend/src/scripts/code-highlighter.ts
+++ b/packages/frontend/src/scripts/code-highlighter.ts
@@ -3,18 +3,19 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import { bundledThemesInfo } from 'shiki';
import { getHighlighterCore, loadWasm } from 'shiki/core';
import darkPlus from 'shiki/themes/dark-plus.mjs';
+import { bundledThemesInfo } from 'shiki/themes';
+import { bundledLanguagesInfo } from 'shiki/langs';
import { unique } from './array.js';
import { deepClone } from './clone.js';
import { deepMerge } from './merge.js';
-import type { Highlighter, LanguageRegistration, ThemeRegistration, ThemeRegistrationRaw } from 'shiki';
+import type { HighlighterCore, LanguageRegistration, ThemeRegistration, ThemeRegistrationRaw } from 'shiki/core';
import { ColdDeviceStorage } from '@/store.js';
import lightTheme from '@/themes/_light.json5';
import darkTheme from '@/themes/_dark.json5';
-let _highlighter: Highlighter | null = null;
+let _highlighter: HighlighterCore | null = null;
export async function getTheme(mode: 'light' | 'dark', getName: true): Promise<string>;
export async function getTheme(mode: 'light' | 'dark', getName?: false): Promise<ThemeRegistration | ThemeRegistrationRaw>;
@@ -51,16 +52,14 @@ export async function getTheme(mode: 'light' | 'dark', getName = false): Promise
return darkPlus;
}
-export async function getHighlighter(): Promise<Highlighter> {
+export async function getHighlighter(): Promise<HighlighterCore> {
if (!_highlighter) {
return await initHighlighter();
}
return _highlighter;
}
-export async function initHighlighter() {
- const aiScriptGrammar = await import('aiscript-vscode/aiscript/syntaxes/aiscript.tmLanguage.json');
-
+async function initHighlighter() {
await loadWasm(import('shiki/onig.wasm?init'));
// テーマの重複を消す
@@ -69,11 +68,12 @@ export async function initHighlighter() {
...(await Promise.all([getTheme('light'), getTheme('dark')])),
]);
+ const jsLangInfo = bundledLanguagesInfo.find(t => t.id === 'javascript');
const highlighter = await getHighlighterCore({
themes,
langs: [
- import('shiki/langs/javascript.mjs'),
- aiScriptGrammar.default as unknown as LanguageRegistration,
+ ...(jsLangInfo ? [async () => await jsLangInfo.import()] : []),
+ async () => (await import('aiscript-vscode/aiscript/syntaxes/aiscript.tmLanguage.json')).default as unknown as LanguageRegistration,
],
});
diff --git a/packages/frontend/src/scripts/theme.ts b/packages/frontend/src/scripts/theme.ts
index c49593ed42..e59643b09c 100644
--- a/packages/frontend/src/scripts/theme.ts
+++ b/packages/frontend/src/scripts/theme.ts
@@ -6,7 +6,7 @@
import { ref } from 'vue';
import tinycolor from 'tinycolor2';
import { deepClone } from './clone.js';
-import type { BuiltinTheme } from 'shiki';
+import type { BundledTheme } from 'shiki/themes';
import { globalEvents } from '@/events.js';
import lightTheme from '@/themes/_light.json5';
import darkTheme from '@/themes/_dark.json5';
@@ -20,7 +20,7 @@ export type Theme = {
base?: 'dark' | 'light';
props: Record<string, string>;
codeHighlighter?: {
- base: BuiltinTheme;
+ base: BundledTheme;
overrides?: Record<string, any>;
} | {
base: '_none_';
diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts
index bef937d45c..d705fea07c 100644
--- a/packages/frontend/src/store.ts
+++ b/packages/frontend/src/store.ts
@@ -7,7 +7,6 @@ import { markRaw, ref } from 'vue';
import * as Misskey from 'misskey-js';
import { miLocalStorage } from './local-storage.js';
import type { SoundType } from '@/scripts/sound.js';
-import type { BuiltinTheme as ShikiBuiltinTheme } from 'shiki';
import { Storage } from '@/pizzax.js';
import { hemisphere } from '@/scripts/intl-const.js';
diff --git a/packages/frontend/src/stream.ts b/packages/frontend/src/stream.ts
index 0c5ee06197..0d5bd78b09 100644
--- a/packages/frontend/src/stream.ts
+++ b/packages/frontend/src/stream.ts
@@ -8,7 +8,12 @@ import { markRaw } from 'vue';
import { $i } from '@/account.js';
import { wsOrigin } from '@/config.js';
+// heart beat interval in ms
+const HEART_BEAT_INTERVAL = 1000 * 60;
+
let stream: Misskey.Stream | null = null;
+let timeoutHeartBeat: ReturnType<typeof setTimeout> | null = null;
+let lastHeartbeatCall = 0;
export function useStream(): Misskey.Stream {
if (stream) return stream;
@@ -17,7 +22,18 @@ export function useStream(): Misskey.Stream {
token: $i.token,
} : null));
- window.setTimeout(heartbeat, 1000 * 60);
+ if (timeoutHeartBeat) window.clearTimeout(timeoutHeartBeat);
+ timeoutHeartBeat = window.setTimeout(heartbeat, HEART_BEAT_INTERVAL);
+
+ // send heartbeat right now when last send time is over HEART_BEAT_INTERVAL
+ document.addEventListener('visibilitychange', () => {
+ if (
+ !stream
+ || document.visibilityState !== 'visible'
+ || Date.now() - lastHeartbeatCall < HEART_BEAT_INTERVAL
+ ) return;
+ heartbeat();
+ });
return stream;
}
@@ -26,5 +42,7 @@ function heartbeat(): void {
if (stream != null && document.visibilityState === 'visible') {
stream.heartbeat();
}
- window.setTimeout(heartbeat, 1000 * 60);
+ lastHeartbeatCall = Date.now();
+ if (timeoutHeartBeat) window.clearTimeout(timeoutHeartBeat);
+ timeoutHeartBeat = window.setTimeout(heartbeat, HEART_BEAT_INTERVAL);
}
diff --git a/packages/frontend/src/style.scss b/packages/frontend/src/style.scss
index e5a18134ee..4d9b2a77dc 100644
--- a/packages/frontend/src/style.scss
+++ b/packages/frontend/src/style.scss
@@ -465,12 +465,13 @@ rt {
border-radius: 10px;
--bg: #F1E8DC;
- --panel: #fff;
--fg: #693410;
- --switchOffBg: rgba(0, 0, 0, 0.1);
- --switchOffFg: rgb(255, 255, 255);
- --switchOnBg: var(--accent);
- --switchOnFg: rgb(255, 255, 255);
+}
+
+html[data-color-mode=dark] ._woodenFrame {
+ --bg: #1d0c02;
+ --fg: #F1E8DC;
+ --panel: #192320;
}
._woodenFrameH {
diff --git a/packages/frontend/vite.config.ts b/packages/frontend/vite.config.ts
index 657f6002c6..7d6a9e9313 100644
--- a/packages/frontend/vite.config.ts
+++ b/packages/frontend/vite.config.ts
@@ -5,11 +5,30 @@ import { type UserConfig, defineConfig } from 'vite';
import locales from '../../locales/index.js';
import meta from '../../package.json';
+import packageInfo from './package.json' assert { type: 'json' };
import pluginUnwindCssModuleClassName from './lib/rollup-plugin-unwind-css-module-class-name.js';
import pluginJson5 from './vite.json5.js';
const extensions = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.json', '.json5', '.svg', '.sass', '.scss', '.css', '.vue'];
+/**
+ * Misskeyのフロントエンドにバンドルせず、CDNなどから別途読み込むリソースを記述する。
+ * CDNを使わずにバンドルしたい場合、以下の配列から該当要素を削除orコメントアウトすればOK
+ */
+const externalPackages = [
+ // shiki(コードブロックのシンタックスハイライトで使用中)はテーマ・言語の定義の容量が大きいため、それらはCDNから読み込む
+ {
+ name: 'shiki',
+ match: /^shiki\/(?<subPkg>(langs|themes))$/,
+ path(id: string, pattern: RegExp): string {
+ const match = pattern.exec(id)?.groups;
+ return match
+ ? `https://esm.sh/shiki@${packageInfo.dependencies.shiki}/${match['subPkg']}`
+ : id;
+ },
+ },
+];
+
const hash = (str: string, seed = 0): number => {
let h1 = 0xdeadbeef ^ seed,
h2 = 0x41c6ce57 ^ seed;
@@ -110,6 +129,7 @@ export function getConfig(): UserConfig {
input: {
app: './src/_boot_.ts',
},
+ external: externalPackages.map(p => p.match),
output: {
manualChunks: {
vue: ['vue'],
@@ -117,6 +137,15 @@ export function getConfig(): UserConfig {
},
chunkFileNames: process.env.NODE_ENV === 'production' ? '[hash:8].js' : '[name]-[hash:8].js',
assetFileNames: process.env.NODE_ENV === 'production' ? '[hash:8][extname]' : '[name]-[hash:8][extname]',
+ paths(id) {
+ for (const p of externalPackages) {
+ if (p.match.test(id)) {
+ return p.path(id, p.match);
+ }
+ }
+
+ return id;
+ },
},
},
cssCodeSplit: true,
diff --git a/packages/misskey-js/generator/src/generator.ts b/packages/misskey-js/generator/src/generator.ts
index f091e599a9..49dcd4c1ed 100644
--- a/packages/misskey-js/generator/src/generator.ts
+++ b/packages/misskey-js/generator/src/generator.ts
@@ -98,6 +98,8 @@ async function generateEndpoints(
const entitiesOutputLine: string[] = [];
+ entitiesOutputLine.push('/* eslint @typescript-eslint/naming-convention: 0 */');
+
entitiesOutputLine.push(`import { operations } from '${toImportPath(typeFileName)}';`);
entitiesOutputLine.push('');
diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts
index 2cc9aef1df..ff314dec68 100644
--- a/packages/misskey-js/src/autogen/types.ts
+++ b/packages/misskey-js/src/autogen/types.ts
@@ -4577,6 +4577,8 @@ export type components = {
localOnly: boolean;
notify: boolean;
/** @default false */
+ excludeBots: boolean;
+ /** @default false */
withReplies: boolean;
withFile: boolean;
isActive: boolean;
@@ -4957,6 +4959,7 @@ export type components = {
enableServiceWorker: boolean;
translatorAvailable: boolean;
mediaProxy: string;
+ enableUrlPreview: boolean;
backgroundImageUrl: string | null;
impressumUrl: string | null;
logoImageUrl: string | null;
@@ -5116,11 +5119,21 @@ export type operations = {
objectStorageS3ForcePathStyle: boolean;
privacyPolicyUrl: string | null;
repositoryUrl: string | null;
+ /**
+ * @deprecated
+ * @description [Deprecated] Use "urlPreviewSummaryProxyUrl" instead.
+ */
summalyProxy: string | null;
themeColor: string | null;
tosUrl: string | null;
uri: string;
version: string;
+ urlPreviewEnabled: boolean;
+ urlPreviewTimeout: number;
+ urlPreviewMaximumContentLength: number;
+ urlPreviewRequireContentLength: boolean;
+ urlPreviewUserAgent: string | null;
+ urlPreviewSummaryProxyUrl: string | null;
};
};
};
@@ -9280,7 +9293,6 @@ export type operations = {
maintainerName?: string | null;
maintainerEmail?: string | null;
langs?: string[];
- summalyProxy?: string | null;
deeplAuthKey?: string | null;
deeplIsPro?: boolean;
deeplFreeMode?: boolean;
@@ -9339,6 +9351,14 @@ export type operations = {
perUserListTimelineCacheMax?: number;
notesPerOneAd?: number;
silencedHosts?: string[] | null;
+ /** @description [Deprecated] Use "urlPreviewSummaryProxyUrl" instead. */
+ summalyProxy?: string | null;
+ urlPreviewEnabled?: boolean;
+ urlPreviewTimeout?: number;
+ urlPreviewMaximumContentLength?: number;
+ urlPreviewRequireContentLength?: boolean;
+ urlPreviewUserAgent?: string | null;
+ urlPreviewSummaryProxyUrl?: string | null;
};
};
};
@@ -10079,6 +10099,7 @@ export type operations = {
users: string[];
caseSensitive: boolean;
localOnly?: boolean;
+ excludeBots?: boolean;
withReplies: boolean;
withFile: boolean;
notify: boolean;
@@ -10360,6 +10381,7 @@ export type operations = {
users?: string[];
caseSensitive?: boolean;
localOnly?: boolean;
+ excludeBots?: boolean;
withReplies?: boolean;
withFile?: boolean;
notify?: boolean;
@@ -23570,6 +23592,11 @@ export type operations = {
summary: string;
script: string;
permissions: string[];
+ /**
+ * @default public
+ * @enum {string}
+ */
+ visibility?: 'public' | 'private';
};
};
};