summaryrefslogtreecommitdiff
path: root/packages
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2023-03-06 11:54:12 +0900
committerGitHub <noreply@github.com>2023-03-06 11:54:12 +0900
commitae517a99a76d322c0fe803e56721c5770df4c439 (patch)
tree2ce3725392fbcbbfca77737ba96176e2fdbee94a /packages
parentMerge pull request #10181 from misskey-dev/develop (diff)
parent[ci skip] chore(client): showNoteActionsOnlyHover変更時にリロードダ... (diff)
downloadmisskey-ae517a99a76d322c0fe803e56721c5770df4c439.tar.gz
misskey-ae517a99a76d322c0fe803e56721c5770df4c439.tar.bz2
misskey-ae517a99a76d322c0fe803e56721c5770df4c439.zip
Merge pull request #10218 from misskey-dev/develop
Release: 13.9.2
Diffstat (limited to 'packages')
-rw-r--r--packages/backend/package.json1
-rw-r--r--packages/backend/src/core/DownloadService.ts20
-rw-r--r--packages/backend/src/core/DriveService.ts44
-rw-r--r--packages/backend/src/core/entities/DriveFileEntityService.ts28
-rw-r--r--packages/backend/src/core/entities/GalleryPostEntityService.ts3
-rw-r--r--packages/backend/src/core/entities/NoteEntityService.ts24
-rw-r--r--packages/backend/src/core/entities/UserEntityService.ts14
-rw-r--r--packages/backend/src/misc/correct-filename.ts15
-rw-r--r--packages/backend/src/misc/is-mime-image.ts2
-rw-r--r--packages/backend/src/server/FileServerService.ts63
-rw-r--r--packages/backend/src/server/web/boot.js7
-rw-r--r--packages/backend/test/e2e/endpoints.ts10
-rw-r--r--packages/backend/test/e2e/note.ts118
-rw-r--r--packages/backend/test/unit/misc/others.ts42
-rw-r--r--packages/frontend/src/components/MkFolder.vue90
-rw-r--r--packages/frontend/src/components/MkPagination.vue4
-rw-r--r--packages/frontend/src/components/MkPostForm.vue9
-rw-r--r--packages/frontend/src/components/MkSignup.vue1
-rw-r--r--packages/frontend/src/components/form/section.vue42
-rw-r--r--packages/frontend/src/pages/about-misskey.vue7
-rw-r--r--packages/frontend/src/pages/admin/roles.edit.vue3
-rw-r--r--packages/frontend/src/pages/admin/roles.role.vue2
-rw-r--r--packages/frontend/src/pages/admin/roles.vue16
-rw-r--r--packages/frontend/src/pages/channel.vue197
-rw-r--r--packages/frontend/src/pages/clip.vue13
-rw-r--r--packages/frontend/src/pages/explore.roles.vue2
-rw-r--r--packages/frontend/src/pages/settings/email.vue7
-rw-r--r--packages/frontend/src/pages/settings/general.vue3
-rw-r--r--packages/frontend/src/pages/settings/preferences-backups.vue1
-rw-r--r--packages/frontend/src/pages/user/home.vue5
-rw-r--r--packages/frontend/src/store.ts6
-rw-r--r--packages/frontend/src/style.scss6
-rw-r--r--packages/frontend/src/ui/deck/channel-column.vue1
33 files changed, 561 insertions, 245 deletions
diff --git a/packages/backend/package.json b/packages/backend/package.json
index 42efb881e2..35e8dc5c60 100644
--- a/packages/backend/package.json
+++ b/packages/backend/package.json
@@ -124,6 +124,7 @@
"seedrandom": "3.0.5",
"semver": "7.3.8",
"sharp": "0.31.3",
+ "sharp-read-bmp": "github:misskey-dev/sharp-read-bmp",
"strict-event-emitter-types": "2.0.0",
"stringz": "2.1.0",
"summaly": "github:misskey-dev/summaly",
diff --git a/packages/backend/src/core/DownloadService.ts b/packages/backend/src/core/DownloadService.ts
index 852c1f32e3..bd999c67da 100644
--- a/packages/backend/src/core/DownloadService.ts
+++ b/packages/backend/src/core/DownloadService.ts
@@ -6,6 +6,7 @@ import IPCIDR from 'ip-cidr';
import PrivateIp from 'private-ip';
import chalk from 'chalk';
import got, * as Got from 'got';
+import { parse } from 'content-disposition';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { HttpRequestService } from '@/core/HttpRequestService.js';
@@ -32,13 +33,18 @@ export class DownloadService {
}
@bindThis
- public async downloadUrl(url: string, path: string): Promise<void> {
+ public async downloadUrl(url: string, path: string): Promise<{
+ filename: string;
+ }> {
this.logger.info(`Downloading ${chalk.cyan(url)} to ${chalk.cyanBright(path)} ...`);
const timeout = 30 * 1000;
const operationTimeout = 60 * 1000;
const maxSize = this.config.maxFileSize ?? 262144000;
+ const urlObj = new URL(url);
+ let filename = urlObj.pathname.split('/').pop() ?? 'untitled';
+
const req = got.stream(url, {
headers: {
'User-Agent': this.config.userAgent,
@@ -77,6 +83,14 @@ export class DownloadService {
req.destroy();
}
}
+
+ const contentDisposition = res.headers['content-disposition'];
+ if (contentDisposition != null) {
+ const parsed = parse(contentDisposition);
+ if (parsed.parameters.filename) {
+ filename = parsed.parameters.filename;
+ }
+ }
}).on('downloadProgress', (progress: Got.Progress) => {
if (progress.transferred > maxSize) {
this.logger.warn(`maxSize exceeded (${progress.transferred} > ${maxSize}) on downloadProgress`);
@@ -95,6 +109,10 @@ export class DownloadService {
}
this.logger.succ(`Download finished: ${chalk.cyan(url)}`);
+
+ return {
+ filename,
+ };
}
@bindThis
diff --git a/packages/backend/src/core/DriveService.ts b/packages/backend/src/core/DriveService.ts
index b15c967c85..f4a06faebb 100644
--- a/packages/backend/src/core/DriveService.ts
+++ b/packages/backend/src/core/DriveService.ts
@@ -34,6 +34,7 @@ import { FileInfoService } from '@/core/FileInfoService.js';
import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
import type S3 from 'aws-sdk/clients/s3.js';
+import { correctFilename } from '@/misc/correct-filename.js';
type AddFileArgs = {
/** User who wish to add file */
@@ -168,7 +169,7 @@ export class DriveService {
//#region Uploads
this.registerLogger.info(`uploading original: ${key}`);
const uploads = [
- this.upload(key, fs.createReadStream(path), type, name),
+ this.upload(key, fs.createReadStream(path), type, ext, name),
];
if (alts.webpublic) {
@@ -176,7 +177,7 @@ export class DriveService {
webpublicUrl = `${ baseUrl }/${ webpublicKey }`;
this.registerLogger.info(`uploading webpublic: ${webpublicKey}`);
- uploads.push(this.upload(webpublicKey, alts.webpublic.data, alts.webpublic.type, name));
+ uploads.push(this.upload(webpublicKey, alts.webpublic.data, alts.webpublic.type, alts.webpublic.ext, name));
}
if (alts.thumbnail) {
@@ -184,7 +185,7 @@ export class DriveService {
thumbnailUrl = `${ baseUrl }/${ thumbnailKey }`;
this.registerLogger.info(`uploading thumbnail: ${thumbnailKey}`);
- uploads.push(this.upload(thumbnailKey, alts.thumbnail.data, alts.thumbnail.type));
+ uploads.push(this.upload(thumbnailKey, alts.thumbnail.data, alts.thumbnail.type, alts.thumbnail.ext));
}
await Promise.all(uploads);
@@ -360,7 +361,7 @@ export class DriveService {
* Upload to ObjectStorage
*/
@bindThis
- private async upload(key: string, stream: fs.ReadStream | Buffer, type: string, filename?: string) {
+ private async upload(key: string, stream: fs.ReadStream | Buffer, type: string, ext?: string | null, filename?: string) {
if (type === 'image/apng') type = 'image/png';
if (!FILE_TYPE_BROWSERSAFE.includes(type)) type = 'application/octet-stream';
@@ -374,7 +375,12 @@ export class DriveService {
CacheControl: 'max-age=31536000, immutable',
} as S3.PutObjectRequest;
- if (filename) params.ContentDisposition = contentDisposition('inline', filename);
+ if (filename) params.ContentDisposition = contentDisposition(
+ 'inline',
+ // 拡張子からContent-Typeを設定してそうな挙動を示すオブジェクトストレージ (upcloud?) も存在するので、
+ // 許可されているファイル形式でしか拡張子をつけない
+ ext ? correctFilename(filename, ext) : filename,
+ );
if (meta.objectStorageSetPublicRead) params.ACL = 'public-read';
const s3 = this.s3Service.getS3(meta);
@@ -466,7 +472,12 @@ export class DriveService {
//}
// detect name
- const detectedName = name ?? (info.type.ext ? `untitled.${info.type.ext}` : 'untitled');
+ const detectedName = correctFilename(
+ // DriveFile.nameは256文字, validateFileNameは200文字制限であるため、
+ // extを付加してデータベースの文字数制限に当たることはまずない
+ (name && this.driveFileEntityService.validateFileName(name)) ? name : 'untitled',
+ info.type.ext
+ );
if (user && !force) {
// Check if there is a file with the same hash
@@ -736,24 +747,19 @@ export class DriveService {
requestIp = null,
requestHeaders = null,
}: UploadFromUrlArgs): Promise<DriveFile> {
- let name = new URL(url).pathname.split('/').pop() ?? null;
- if (name == null || !this.driveFileEntityService.validateFileName(name)) {
- name = null;
- }
-
- // If the comment is same as the name, skip comment
- // (image.name is passed in when receiving attachment)
- if (comment !== null && name === comment) {
- comment = null;
- }
-
// Create temp file
const [path, cleanup] = await createTemp();
try {
// write content at URL to temp file
- await this.downloadService.downloadUrl(url, path);
-
+ const { filename: name } = await this.downloadService.downloadUrl(url, path);
+
+ // If the comment is same as the name, skip comment
+ // (image.name is passed in when receiving attachment)
+ if (comment !== null && name === comment) {
+ comment = null;
+ }
+
const driveFile = await this.addFile({ user, path, name, comment, folderId, force, isLink, url, uri, sensitive, requestIp, requestHeaders });
this.downloaderLogger.succ(`Got: ${driveFile.id}`);
return driveFile!;
diff --git a/packages/backend/src/core/entities/DriveFileEntityService.ts b/packages/backend/src/core/entities/DriveFileEntityService.ts
index 158fafa9d5..f769ddd5e9 100644
--- a/packages/backend/src/core/entities/DriveFileEntityService.ts
+++ b/packages/backend/src/core/entities/DriveFileEntityService.ts
@@ -1,5 +1,5 @@
import { forwardRef, Inject, Injectable } from '@nestjs/common';
-import { DataSource } from 'typeorm';
+import { DataSource, In } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { NotesRepository, DriveFilesRepository } from '@/models/index.js';
import type { Config } from '@/config.js';
@@ -21,6 +21,7 @@ type PackOptions = {
};
import { bindThis } from '@/decorators.js';
import { isMimeImage } from '@/misc/is-mime-image.js';
+import { isNotNull } from '@/misc/is-not-null.js';
@Injectable()
export class DriveFileEntityService {
@@ -255,10 +256,33 @@ export class DriveFileEntityService {
@bindThis
public async packMany(
- files: (DriveFile['id'] | DriveFile)[],
+ files: DriveFile[],
options?: PackOptions,
): Promise<Packed<'DriveFile'>[]> {
const items = await Promise.all(files.map(f => this.packNullable(f, options)));
return items.filter((x): x is Packed<'DriveFile'> => x != null);
}
+
+ @bindThis
+ public async packManyByIdsMap(
+ fileIds: DriveFile['id'][],
+ options?: PackOptions,
+ ): Promise<Map<Packed<'DriveFile'>['id'], Packed<'DriveFile'> | null>> {
+ const files = await this.driveFilesRepository.findBy({ id: In(fileIds) });
+ const packedFiles = await this.packMany(files, options);
+ const map = new Map<Packed<'DriveFile'>['id'], Packed<'DriveFile'> | null>(packedFiles.map(f => [f.id, f]));
+ for (const id of fileIds) {
+ if (!map.has(id)) map.set(id, null);
+ }
+ return map;
+ }
+
+ @bindThis
+ public async packManyByIds(
+ fileIds: DriveFile['id'][],
+ options?: PackOptions,
+ ): Promise<Packed<'DriveFile'>[]> {
+ const filesMap = await this.packManyByIdsMap(fileIds, options);
+ return fileIds.map(id => filesMap.get(id)).filter(isNotNull);
+ }
}
diff --git a/packages/backend/src/core/entities/GalleryPostEntityService.ts b/packages/backend/src/core/entities/GalleryPostEntityService.ts
index ab29e7dba1..fb147ae181 100644
--- a/packages/backend/src/core/entities/GalleryPostEntityService.ts
+++ b/packages/backend/src/core/entities/GalleryPostEntityService.ts
@@ -41,7 +41,8 @@ export class GalleryPostEntityService {
title: post.title,
description: post.description,
fileIds: post.fileIds,
- files: this.driveFileEntityService.packMany(post.fileIds),
+ // TODO: packMany causes N+1 queries
+ files: this.driveFileEntityService.packManyByIds(post.fileIds),
tags: post.tags.length > 0 ? post.tags : undefined,
isSensitive: post.isSensitive,
likedCount: post.likedCount,
diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts
index 2ffe5f1c21..4ec10df9a6 100644
--- a/packages/backend/src/core/entities/NoteEntityService.ts
+++ b/packages/backend/src/core/entities/NoteEntityService.ts
@@ -11,6 +11,7 @@ import type { Note } from '@/models/entities/Note.js';
import type { NoteReaction } from '@/models/entities/NoteReaction.js';
import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository, DriveFilesRepository } from '@/models/index.js';
import { bindThis } from '@/decorators.js';
+import { isNotNull } from '@/misc/is-not-null.js';
import type { OnModuleInit } from '@nestjs/common';
import type { CustomEmojiService } from '../CustomEmojiService.js';
import type { ReactionService } from '../ReactionService.js';
@@ -249,6 +250,21 @@ export class NoteEntityService implements OnModuleInit {
}
@bindThis
+ public async packAttachedFiles(fileIds: Note['fileIds'], packedFiles: Map<Note['fileIds'][number], Packed<'DriveFile'> | null>): Promise<Packed<'DriveFile'>[]> {
+ const missingIds = [];
+ for (const id of fileIds) {
+ if (!packedFiles.has(id)) missingIds.push(id);
+ }
+ if (missingIds.length) {
+ const additionalMap = await this.driveFileEntityService.packManyByIdsMap(missingIds);
+ for (const [k, v] of additionalMap) {
+ packedFiles.set(k, v);
+ }
+ }
+ return fileIds.map(id => packedFiles.get(id)).filter(isNotNull);
+ }
+
+ @bindThis
public async pack(
src: Note['id'] | Note,
me?: { id: User['id'] } | null | undefined,
@@ -257,6 +273,7 @@ export class NoteEntityService implements OnModuleInit {
skipHide?: boolean;
_hint_?: {
myReactions: Map<Note['id'], NoteReaction | null>;
+ packedFiles: Map<Note['fileIds'][number], Packed<'DriveFile'> | null>;
};
},
): Promise<Packed<'Note'>> {
@@ -284,6 +301,7 @@ export class NoteEntityService implements OnModuleInit {
const reactionEmojiNames = Object.keys(note.reactions)
.filter(x => x.startsWith(':') && x.includes('@') && !x.includes('@.')) // リモートカスタム絵文字のみ
.map(x => this.reactionService.decodeReaction(x).reaction.replaceAll(':', ''));
+ const packedFiles = options?._hint_?.packedFiles;
const packed: Packed<'Note'> = await awaitAll({
id: note.id,
@@ -304,7 +322,7 @@ export class NoteEntityService implements OnModuleInit {
emojis: host != null ? this.customEmojiService.populateEmojis(note.emojis, host) : undefined,
tags: note.tags.length > 0 ? note.tags : undefined,
fileIds: note.fileIds,
- files: this.driveFileEntityService.packMany(note.fileIds),
+ files: packedFiles != null ? this.packAttachedFiles(note.fileIds, packedFiles) : this.driveFileEntityService.packManyByIds(note.fileIds),
replyId: note.replyId,
renoteId: note.renoteId,
channelId: note.channelId ?? undefined,
@@ -388,11 +406,15 @@ export class NoteEntityService implements OnModuleInit {
}
await this.customEmojiService.prefetchEmojis(this.customEmojiService.aggregateNoteEmojis(notes));
+ // TODO: 本当は renote とか reply がないのに renoteId とか replyId があったらここで解決しておく
+ const fileIds = notes.map(n => [n.fileIds, n.renote?.fileIds, n.reply?.fileIds]).flat(2).filter(isNotNull);
+ const packedFiles = await this.driveFileEntityService.packManyByIdsMap(fileIds);
return await Promise.all(notes.map(n => this.pack(n, me, {
...options,
_hint_: {
myReactions: myReactionsMap,
+ packedFiles,
},
})));
}
diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts
index 8c36e47f1b..3635643218 100644
--- a/packages/backend/src/core/entities/UserEntityService.ts
+++ b/packages/backend/src/core/entities/UserEntityService.ts
@@ -278,27 +278,27 @@ export class UserEntityService implements OnModuleInit {
@bindThis
public async getAvatarUrl(user: User): Promise<string> {
if (user.avatar) {
- return this.driveFileEntityService.getPublicUrl(user.avatar, 'avatar') ?? this.getIdenticonUrl(user.id);
+ return this.driveFileEntityService.getPublicUrl(user.avatar, 'avatar') ?? this.getIdenticonUrl(user);
} else if (user.avatarId) {
const avatar = await this.driveFilesRepository.findOneByOrFail({ id: user.avatarId });
- return this.driveFileEntityService.getPublicUrl(avatar, 'avatar') ?? this.getIdenticonUrl(user.id);
+ return this.driveFileEntityService.getPublicUrl(avatar, 'avatar') ?? this.getIdenticonUrl(user);
} else {
- return this.getIdenticonUrl(user.id);
+ return this.getIdenticonUrl(user);
}
}
@bindThis
public getAvatarUrlSync(user: User): string {
if (user.avatar) {
- return this.driveFileEntityService.getPublicUrl(user.avatar, 'avatar') ?? this.getIdenticonUrl(user.id);
+ return this.driveFileEntityService.getPublicUrl(user.avatar, 'avatar') ?? this.getIdenticonUrl(user);
} else {
- return this.getIdenticonUrl(user.id);
+ return this.getIdenticonUrl(user);
}
}
@bindThis
- public getIdenticonUrl(userId: User['id']): string {
- return `${this.config.url}/identicon/${userId}`;
+ public getIdenticonUrl(user: User): string {
+ return `${this.config.url}/identicon/${user.username.toLowerCase()}@${user.host ?? this.config.host}`;
}
public async pack<ExpectsMe extends boolean | null = null, D extends boolean = false>(
diff --git a/packages/backend/src/misc/correct-filename.ts b/packages/backend/src/misc/correct-filename.ts
new file mode 100644
index 0000000000..3357d8c1bd
--- /dev/null
+++ b/packages/backend/src/misc/correct-filename.ts
@@ -0,0 +1,15 @@
+// 与えられた拡張子とファイル名が一致しているかどうかを確認し、
+// 一致していない場合は拡張子を付与して返す
+export function correctFilename(filename: string, ext: string | null) {
+ const dotExt = ext ? ext.startsWith('.') ? ext : `.${ext}` : '.unknown';
+ if (filename.endsWith(dotExt)) {
+ return filename;
+ }
+ if (ext === 'jpg' && filename.endsWith('.jpeg')) {
+ return filename;
+ }
+ if (ext === 'tif' && filename.endsWith('.tiff')) {
+ return filename;
+ }
+ return `${filename}${dotExt}`;
+}
diff --git a/packages/backend/src/misc/is-mime-image.ts b/packages/backend/src/misc/is-mime-image.ts
index acf5c1ede3..0b6d147dc1 100644
--- a/packages/backend/src/misc/is-mime-image.ts
+++ b/packages/backend/src/misc/is-mime-image.ts
@@ -4,6 +4,8 @@ const dictionary = {
'safe-file': FILE_TYPE_BROWSERSAFE,
'sharp-convertible-image': ['image/jpeg', 'image/png', 'image/gif', 'image/apng', 'image/vnd.mozilla.apng', 'image/webp', 'image/avif', 'image/svg+xml'],
'sharp-animation-convertible-image': ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/svg+xml'],
+ 'sharp-convertible-image-with-bmp': ['image/jpeg', 'image/png', 'image/gif', 'image/apng', 'image/vnd.mozilla.apng', 'image/webp', 'image/avif', 'image/svg+xml', 'image/x-icon', 'image/bmp'],
+ 'sharp-animation-convertible-image-with-bmp': ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/svg+xml', 'image/x-icon', 'image/bmp'],
};
export const isMimeImage = (mime: string, type: keyof typeof dictionary): boolean => dictionary[type].includes(mime);
diff --git a/packages/backend/src/server/FileServerService.ts b/packages/backend/src/server/FileServerService.ts
index e5eefac1fa..835657b625 100644
--- a/packages/backend/src/server/FileServerService.ts
+++ b/packages/backend/src/server/FileServerService.ts
@@ -22,6 +22,8 @@ import { bindThis } from '@/decorators.js';
import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify';
import { isMimeImage } from '@/misc/is-mime-image.js';
import sharp from 'sharp';
+import { sharpBmp } from 'sharp-read-bmp';
+import { correctFilename } from '@/misc/correct-filename.js';
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
@@ -52,15 +54,6 @@ export class FileServerService {
}
@bindThis
- public commonReadableHandlerGenerator(reply: FastifyReply) {
- return (err: Error): void => {
- this.logger.error(err);
- reply.code(500);
- reply.header('Cache-Control', 'max-age=300');
- };
- }
-
- @bindThis
public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) {
fastify.addHook('onRequest', (request, reply, done) => {
reply.header('Content-Security-Policy', 'default-src \'none\'; img-src \'self\'; media-src \'self\'; style-src \'unsafe-inline\'');
@@ -140,7 +133,7 @@ export class FileServerService {
let image: IImageStreamable | null = null;
if (file.fileRole === 'thumbnail') {
- if (isMimeImage(file.mime, 'sharp-convertible-image')) {
+ if (isMimeImage(file.mime, 'sharp-convertible-image-with-bmp')) {
reply.header('Cache-Control', 'max-age=31536000, immutable');
const url = new URL(`${this.config.mediaProxy}/static.webp`);
@@ -190,13 +183,19 @@ export class FileServerService {
}
reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(image.type) ? image.type : 'application/octet-stream');
+ reply.header('Content-Disposition',
+ contentDisposition(
+ 'inline',
+ correctFilename(file.filename, image.ext)
+ )
+ );
return image.data;
}
if (file.fileRole !== 'original') {
- const filename = rename(file.file.name, {
+ const filename = rename(file.filename, {
suffix: file.fileRole === 'thumbnail' ? '-thumb' : '-web',
- extname: file.ext ? `.${file.ext}` : undefined,
+ extname: file.ext ? `.${file.ext}` : '.unknown',
}).toString();
reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.mime) ? file.mime : 'application/octet-stream');
@@ -204,12 +203,10 @@ export class FileServerService {
reply.header('Content-Disposition', contentDisposition('inline', filename));
return fs.createReadStream(file.path);
} else {
- const stream = fs.createReadStream(file.path);
- stream.on('error', this.commonReadableHandlerGenerator(reply));
reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.file.type) ? file.file.type : 'application/octet-stream');
reply.header('Cache-Control', 'max-age=31536000, immutable');
- reply.header('Content-Disposition', contentDisposition('inline', file.file.name));
- return stream;
+ reply.header('Content-Disposition', contentDisposition('inline', file.filename));
+ return fs.createReadStream(file.path);
}
} catch (e) {
if ('cleanup' in file) file.cleanup();
@@ -261,8 +258,8 @@ export class FileServerService {
}
try {
- const isConvertibleImage = isMimeImage(file.mime, 'sharp-convertible-image');
- const isAnimationConvertibleImage = isMimeImage(file.mime, 'sharp-animation-convertible-image');
+ const isConvertibleImage = isMimeImage(file.mime, 'sharp-convertible-image-with-bmp');
+ const isAnimationConvertibleImage = isMimeImage(file.mime, 'sharp-animation-convertible-image-with-bmp');
if (
'emoji' in request.query ||
@@ -286,7 +283,7 @@ export class FileServerService {
type: file.mime,
};
} else {
- const data = sharp(file.path, { animated: !('static' in request.query) })
+ const data = (await sharpBmp(file.path, file.mime, { animated: !('static' in request.query) }))
.resize({
height: 'emoji' in request.query ? 128 : 320,
withoutEnlargement: true,
@@ -300,11 +297,11 @@ export class FileServerService {
};
}
} else if ('static' in request.query) {
- image = this.imageProcessingService.convertToWebpStream(file.path, 498, 280);
+ image = this.imageProcessingService.convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 498, 280);
} else if ('preview' in request.query) {
- image = this.imageProcessingService.convertToWebpStream(file.path, 200, 200);
+ image = this.imageProcessingService.convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 200, 200);
} else if ('badge' in request.query) {
- const mask = sharp(file.path)
+ const mask = (await sharpBmp(file.path, file.mime))
.resize(96, 96, {
fit: 'inside',
withoutEnlargement: false,
@@ -360,6 +357,12 @@ export class FileServerService {
reply.header('Content-Type', image.type);
reply.header('Cache-Control', 'max-age=31536000, immutable');
+ reply.header('Content-Disposition',
+ contentDisposition(
+ 'inline',
+ correctFilename(file.filename, image.ext)
+ )
+ );
return image.data;
} catch (e) {
if ('cleanup' in file) file.cleanup();
@@ -369,8 +372,8 @@ export class FileServerService {
@bindThis
private async getStreamAndTypeFromUrl(url: string): Promise<
- { state: 'remote'; fileRole?: 'thumbnail' | 'webpublic' | 'original'; file?: DriveFile; mime: string; ext: string | null; path: string; cleanup: () => void; }
- | { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; mime: string; ext: string | null; path: string; }
+ { state: 'remote'; fileRole?: 'thumbnail' | 'webpublic' | 'original'; file?: DriveFile; mime: string; ext: string | null; path: string; cleanup: () => void; filename: string; }
+ | { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; filename: string; mime: string; ext: string | null; path: string; }
| '404'
| '204'
> {
@@ -386,11 +389,11 @@ export class FileServerService {
@bindThis
private async downloadAndDetectTypeFromUrl(url: string): Promise<
- { state: 'remote' ; mime: string; ext: string | null; path: string; cleanup: () => void; }
+ { state: 'remote' ; mime: string; ext: string | null; path: string; cleanup: () => void; filename: string; }
> {
const [path, cleanup] = await createTemp();
try {
- await this.downloadService.downloadUrl(url, path);
+ const { filename } = await this.downloadService.downloadUrl(url, path);
const { mime, ext } = await this.fileInfoService.detectType(path);
@@ -398,6 +401,7 @@ export class FileServerService {
state: 'remote',
mime, ext,
path, cleanup,
+ filename,
};
} catch (e) {
cleanup();
@@ -407,8 +411,8 @@ export class FileServerService {
@bindThis
private async getFileFromKey(key: string): Promise<
- { state: 'remote'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; url: string; mime: string; ext: string | null; path: string; cleanup: () => void; }
- | { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; mime: string; ext: string | null; path: string; }
+ { state: 'remote'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; filename: string; url: string; mime: string; ext: string | null; path: string; cleanup: () => void; }
+ | { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; filename: string; mime: string; ext: string | null; path: string; }
| '404'
| '204'
> {
@@ -432,6 +436,7 @@ export class FileServerService {
url: file.uri,
fileRole: isThumbnail ? 'thumbnail' : isWebpublic ? 'webpublic' : 'original',
file,
+ filename: file.name,
};
}
@@ -443,6 +448,7 @@ export class FileServerService {
state: 'stored_internal',
fileRole: isThumbnail ? 'thumbnail' : 'webpublic',
file,
+ filename: file.name,
mime, ext,
path,
};
@@ -452,6 +458,7 @@ export class FileServerService {
state: 'stored_internal',
fileRole: 'original',
file,
+ filename: file.name,
mime: file.type,
ext: null,
path,
diff --git a/packages/backend/src/server/web/boot.js b/packages/backend/src/server/web/boot.js
index c6cb25e43a..fd7f54da54 100644
--- a/packages/backend/src/server/web/boot.js
+++ b/packages/backend/src/server/web/boot.js
@@ -61,6 +61,13 @@
renderError('META_FETCH_V');
return;
}
+
+ // for https://github.com/misskey-dev/misskey/issues/10202
+ if (lang == null || lang.toString == null || lang.toString() === 'null') {
+ console.error('invalid lang value detected!!!', typeof lang, lang);
+ lang = 'en-US';
+ }
+
const localRes = await window.fetch(`/assets/locales/${lang}.${v}.json`);
if (localRes.status === 200) {
localStorage.setItem('lang', lang);
diff --git a/packages/backend/test/e2e/endpoints.ts b/packages/backend/test/e2e/endpoints.ts
index e864eab6cb..42bdc5f24d 100644
--- a/packages/backend/test/e2e/endpoints.ts
+++ b/packages/backend/test/e2e/endpoints.ts
@@ -410,11 +410,19 @@ describe('Endpoints', () => {
});
test('ファイルに名前を付けられる', async () => {
+ const res = await uploadFile(alice, { name: 'Belmond.jpg' });
+
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
+ assert.strictEqual(res.body.name, 'Belmond.jpg');
+ });
+
+ test('ファイルに名前を付けられるが、拡張子は正しいものになる', async () => {
const res = await uploadFile(alice, { name: 'Belmond.png' });
assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
- assert.strictEqual(res.body.name, 'Belmond.png');
+ assert.strictEqual(res.body.name, 'Belmond.png.jpg');
});
test('ファイル無しで怒られる', async () => {
diff --git a/packages/backend/test/e2e/note.ts b/packages/backend/test/e2e/note.ts
index 98ee34d8d1..1b5f9580d5 100644
--- a/packages/backend/test/e2e/note.ts
+++ b/packages/backend/test/e2e/note.ts
@@ -2,7 +2,7 @@ process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import { Note } from '@/models/entities/Note.js';
-import { signup, post, uploadUrl, startServer, initTestDb, api } from '../utils.js';
+import { signup, post, uploadUrl, startServer, initTestDb, api, uploadFile } from '../utils.js';
import type { INestApplicationContext } from '@nestjs/common';
describe('Note', () => {
@@ -213,6 +213,122 @@ describe('Note', () => {
assert.deepStrictEqual(noteDoc.mentions, [bob.id]);
});
+ describe('添付ファイル情報', () => {
+ test('ファイルを添付した場合、投稿成功時にファイル情報入りのレスポンスが帰ってくる', async () => {
+ const file = await uploadFile(alice);
+ const res = await api('/notes/create', {
+ fileIds: [file.body.id],
+ }, alice);
+
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
+ assert.strictEqual(res.body.createdNote.files.length, 1);
+ assert.strictEqual(res.body.createdNote.files[0].id, file.body.id);
+ });
+
+ test('ファイルを添付した場合、タイムラインでファイル情報入りのレスポンスが帰ってくる', async () => {
+ const file = await uploadFile(alice);
+ const createdNote = await api('/notes/create', {
+ fileIds: [file.body.id],
+ }, alice);
+
+ assert.strictEqual(createdNote.status, 200);
+
+ const res = await api('/notes', {
+ withFiles: true,
+ }, alice);
+
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(Array.isArray(res.body), true);
+ const myNote = res.body.find((note: { id: string; files: { id: string }[] }) => note.id === createdNote.body.createdNote.id);
+ assert.notEqual(myNote, null);
+ assert.strictEqual(myNote.files.length, 1);
+ assert.strictEqual(myNote.files[0].id, file.body.id);
+ });
+
+ test('ファイルが添付されたノートをリノートした場合、タイムラインでファイル情報入りのレスポンスが帰ってくる', async () => {
+ const file = await uploadFile(alice);
+ const createdNote = await api('/notes/create', {
+ fileIds: [file.body.id],
+ }, alice);
+
+ assert.strictEqual(createdNote.status, 200);
+
+ const renoted = await api('/notes/create', {
+ renoteId: createdNote.body.createdNote.id,
+ }, alice);
+ assert.strictEqual(renoted.status, 200);
+
+ const res = await api('/notes', {
+ renote: true,
+ }, alice);
+
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(Array.isArray(res.body), true);
+ const myNote = res.body.find((note: { id: string }) => note.id === renoted.body.createdNote.id);
+ assert.notEqual(myNote, null);
+ assert.strictEqual(myNote.renote.files.length, 1);
+ assert.strictEqual(myNote.renote.files[0].id, file.body.id);
+ });
+
+ test('ファイルが添付されたノートに返信した場合、タイムラインでファイル情報入りのレスポンスが帰ってくる', async () => {
+ const file = await uploadFile(alice);
+ const createdNote = await api('/notes/create', {
+ fileIds: [file.body.id],
+ }, alice);
+
+ assert.strictEqual(createdNote.status, 200);
+
+ const reply = await api('/notes/create', {
+ replyId: createdNote.body.createdNote.id,
+ text: 'this is reply',
+ }, alice);
+ assert.strictEqual(reply.status, 200);
+
+ const res = await api('/notes', {
+ reply: true,
+ }, alice);
+
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(Array.isArray(res.body), true);
+ const myNote = res.body.find((note: { id: string }) => note.id === reply.body.createdNote.id);
+ assert.notEqual(myNote, null);
+ assert.strictEqual(myNote.reply.files.length, 1);
+ assert.strictEqual(myNote.reply.files[0].id, file.body.id);
+ });
+
+ test('ファイルが添付されたノートへの返信をリノートした場合、タイムラインでファイル情報入りのレスポンスが帰ってくる', async () => {
+ const file = await uploadFile(alice);
+ const createdNote = await api('/notes/create', {
+ fileIds: [file.body.id],
+ }, alice);
+
+ assert.strictEqual(createdNote.status, 200);
+
+ const reply = await api('/notes/create', {
+ replyId: createdNote.body.createdNote.id,
+ text: 'this is reply',
+ }, alice);
+ assert.strictEqual(reply.status, 200);
+
+ const renoted = await api('/notes/create', {
+ renoteId: reply.body.createdNote.id,
+ }, alice);
+ assert.strictEqual(renoted.status, 200);
+
+ const res = await api('/notes', {
+ renote: true,
+ }, alice);
+
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(Array.isArray(res.body), true);
+ const myNote = res.body.find((note: { id: string }) => note.id === renoted.body.createdNote.id);
+ assert.notEqual(myNote, null);
+ assert.strictEqual(myNote.renote.reply.files.length, 1);
+ assert.strictEqual(myNote.renote.reply.files[0].id, file.body.id);
+ });
+ });
+
describe('notes/create', () => {
test('投票を添付できる', async () => {
const res = await api('/notes/create', {
diff --git a/packages/backend/test/unit/misc/others.ts b/packages/backend/test/unit/misc/others.ts
new file mode 100644
index 0000000000..c476aef33b
--- /dev/null
+++ b/packages/backend/test/unit/misc/others.ts
@@ -0,0 +1,42 @@
+import { describe, test, expect } from '@jest/globals';
+import { contentDisposition } from '@/misc/content-disposition.js';
+import { correctFilename } from '@/misc/correct-filename.js';
+
+describe('misc:content-disposition', () => {
+ test('inline', () => {
+ expect(contentDisposition('inline', 'foo bar')).toBe('inline; filename=\"foo_bar\"; filename*=UTF-8\'\'foo%20bar');
+ });
+ test('attachment', () => {
+ expect(contentDisposition('attachment', 'foo bar')).toBe('attachment; filename=\"foo_bar\"; filename*=UTF-8\'\'foo%20bar');
+ });
+ test('non ascii', () => {
+ expect(contentDisposition('attachment', 'ファイル名')).toBe('attachment; filename=\"_____\"; filename*=UTF-8\'\'%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E5%90%8D');
+ });
+});
+
+describe('misc:correct-filename', () => {
+ test('simple', () => {
+ expect(correctFilename('filename', 'jpg')).toBe('filename.jpg');
+ });
+ test('with same ext', () => {
+ expect(correctFilename('filename.jpg', 'jpg')).toBe('filename.jpg');
+ });
+ test('.ext', () => {
+ expect(correctFilename('filename.jpg', '.jpg')).toBe('filename.jpg');
+ });
+ test('with different ext', () => {
+ expect(correctFilename('filename.webp', 'jpg')).toBe('filename.webp.jpg');
+ });
+ test('non ascii with space', () => {
+ expect(correctFilename('ファイル 名前', 'jpg')).toBe('ファイル 名前.jpg');
+ });
+ test('jpeg', () => {
+ expect(correctFilename('filename.jpeg', 'jpg')).toBe('filename.jpeg');
+ });
+ test('tiff', () => {
+ expect(correctFilename('filename.tiff', 'tif')).toBe('filename.tiff');
+ });
+ test('null ext', () => {
+ expect(correctFilename('filename', null)).toBe('filename.unknown');
+ });
+});
diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue
index a54a1c2305..2748a9e491 100644
--- a/packages/frontend/src/components/MkFolder.vue
+++ b/packages/frontend/src/components/MkFolder.vue
@@ -1,41 +1,46 @@
<template>
-<div ref="rootEl" :class="[$style.root, { [$style.opened]: opened }]">
- <div :class="$style.header" class="_button" @click="toggle">
- <div :class="$style.headerIcon"><slot name="icon"></slot></div>
- <div :class="$style.headerText">
- <div :class="$style.headerTextMain">
- <slot name="label"></slot>
- </div>
- <div :class="$style.headerTextSub">
- <slot name="caption"></slot>
+<div ref="rootEl" :class="$style.root">
+ <MkStickyContainer>
+ <template #header>
+ <div :class="[$style.header, { [$style.opened]: opened }]" class="_button" @click="toggle">
+ <div :class="$style.headerIcon"><slot name="icon"></slot></div>
+ <div :class="$style.headerText">
+ <div :class="$style.headerTextMain">
+ <slot name="label"></slot>
+ </div>
+ <div :class="$style.headerTextSub">
+ <slot name="caption"></slot>
+ </div>
+ </div>
+ <div :class="$style.headerRight">
+ <span :class="$style.headerRightText"><slot name="suffix"></slot></span>
+ <i v-if="opened" class="ti ti-chevron-up icon"></i>
+ <i v-else class="ti ti-chevron-down icon"></i>
+ </div>
</div>
+ </template>
+
+ <div v-if="openedAtLeastOnce" :class="[$style.body, { [$style.bgSame]: bgSame }]" :style="{ maxHeight: maxHeight ? `${maxHeight}px` : null, overflow: maxHeight ? `auto` : null }">
+ <Transition
+ :enter-active-class="$store.state.animation ? $style.transition_toggle_enterActive : ''"
+ :leave-active-class="$store.state.animation ? $style.transition_toggle_leaveActive : ''"
+ :enter-from-class="$store.state.animation ? $style.transition_toggle_enterFrom : ''"
+ :leave-to-class="$store.state.animation ? $style.transition_toggle_leaveTo : ''"
+ @enter="enter"
+ @after-enter="afterEnter"
+ @leave="leave"
+ @after-leave="afterLeave"
+ >
+ <KeepAlive>
+ <div v-show="opened">
+ <MkSpacer :margin-min="14" :margin-max="22">
+ <slot></slot>
+ </MkSpacer>
+ </div>
+ </KeepAlive>
+ </Transition>
</div>
- <div :class="$style.headerRight">
- <span :class="$style.headerRightText"><slot name="suffix"></slot></span>
- <i v-if="opened" class="ti ti-chevron-up icon"></i>
- <i v-else class="ti ti-chevron-down icon"></i>
- </div>
- </div>
- <div v-if="openedAtLeastOnce" :class="[$style.body, { [$style.bgSame]: bgSame }]" :style="{ maxHeight: maxHeight ? `${maxHeight}px` : null }">
- <Transition
- :enter-active-class="$store.state.animation ? $style.transition_toggle_enterActive : ''"
- :leave-active-class="$store.state.animation ? $style.transition_toggle_leaveActive : ''"
- :enter-from-class="$store.state.animation ? $style.transition_toggle_enterFrom : ''"
- :leave-to-class="$store.state.animation ? $style.transition_toggle_leaveTo : ''"
- @enter="enter"
- @after-enter="afterEnter"
- @leave="leave"
- @after-leave="afterLeave"
- >
- <KeepAlive>
- <div v-show="opened">
- <MkSpacer :margin-min="14" :margin-max="22">
- <slot></slot>
- </MkSpacer>
- </div>
- </KeepAlive>
- </Transition>
- </div>
+ </MkStickyContainer>
</div>
</template>
@@ -117,12 +122,6 @@ onMounted(() => {
.root {
display: block;
-
- &.opened {
- > .header {
- border-radius: 6px 6px 0 0;
- }
- }
}
.header {
@@ -132,6 +131,8 @@ onMounted(() => {
box-sizing: border-box;
padding: 9px 12px 9px 12px;
background: var(--buttonBg);
+ -webkit-backdrop-filter: var(--blur, blur(15px));
+ backdrop-filter: var(--blur, blur(15px));
border-radius: 6px;
transition: border-radius 0.3s;
@@ -144,6 +145,10 @@ onMounted(() => {
color: var(--accent);
background: var(--buttonHoverBg);
}
+
+ &.opened {
+ border-radius: 6px 6px 0 0;
+ }
}
.headerUpper {
@@ -153,7 +158,7 @@ onMounted(() => {
.headerLower {
color: var(--fgTransparentWeak);
- font-size: .85em;
+ font-size: .85em;
padding-left: 4px;
}
@@ -202,7 +207,6 @@ onMounted(() => {
background: var(--panel);
border-radius: 0 0 6px 6px;
container-type: inline-size;
- overflow: auto;
&.bgSame {
background: var(--bg);
diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue
index 378d0ac020..a1a61a6fd6 100644
--- a/packages/frontend/src/components/MkPagination.vue
+++ b/packages/frontend/src/components/MkPagination.vue
@@ -21,14 +21,14 @@
<div v-else ref="rootEl">
<div v-show="pagination.reversed && more" key="_more_" class="_margin">
- <MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? fetchMoreAhead : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary @click="fetchMoreAhead">
+ <MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? fetchMoreAhead : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary rounded @click="fetchMoreAhead">
{{ i18n.ts.loadMore }}
</MkButton>
<MkLoading v-else class="loading"/>
</div>
<slot :items="items" :fetching="fetching || moreFetching"></slot>
<div v-show="!pagination.reversed && more" key="_more_" class="_margin">
- <MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? fetchMore : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary @click="fetchMore">
+ <MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? fetchMore : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary rounded @click="fetchMore">
{{ i18n.ts.loadMore }}
</MkButton>
<MkLoading v-else class="loading"/>
diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue
index 2b3e2c8646..09f672be7b 100644
--- a/packages/frontend/src/components/MkPostForm.vue
+++ b/packages/frontend/src/components/MkPostForm.vue
@@ -658,7 +658,14 @@ async function post(ev?: MouseEvent) {
if ((text.includes('love') || text.includes('❤')) && text.includes('misskey')) {
claimAchievement('iLoveMisskey');
}
- if (text.includes('Efrlqw8ytg4'.toLowerCase()) || text.includes('XVCwzwxdHuA'.toLowerCase())) {
+ if (
+ text.includes('https://youtu.be/Efrlqw8ytg4'.toLowerCase()) ||
+ text.includes('https://www.youtube.com/watch?v=Efrlqw8ytg4'.toLowerCase()) ||
+ text.includes('https://m.youtube.com/watch?v=Efrlqw8ytg4'.toLowerCase()) ||
+ text.includes('https://youtu.be/XVCwzwxdHuA'.toLowerCase()) ||
+ text.includes('https://www.youtube.com/watch?v=XVCwzwxdHuA'.toLowerCase()) ||
+ text.includes('https://m.youtube.com/watch?v=XVCwzwxdHuA'.toLowerCase())
+ ) {
claimAchievement('brainDiver');
}
diff --git a/packages/frontend/src/components/MkSignup.vue b/packages/frontend/src/components/MkSignup.vue
index d8703a0b1b..62ada6b736 100644
--- a/packages/frontend/src/components/MkSignup.vue
+++ b/packages/frontend/src/components/MkSignup.vue
@@ -9,6 +9,7 @@
<template #prefix>@</template>
<template #suffix>@{{ host }}</template>
<template #caption>
+ <div><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.cannotBeChangedLater }}</div>
<span v-if="usernameState === 'wait'" style="color:#999"><MkLoading :em="true"/> {{ i18n.ts.checking }}</span>
<span v-else-if="usernameState === 'ok'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.available }}</span>
<span v-else-if="usernameState === 'unavailable'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.unavailable }}</span>
diff --git a/packages/frontend/src/components/form/section.vue b/packages/frontend/src/components/form/section.vue
index a838164978..55308b9c80 100644
--- a/packages/frontend/src/components/form/section.vue
+++ b/packages/frontend/src/components/form/section.vue
@@ -1,7 +1,7 @@
<template>
-<div class="vrtktovh" :class="{ first }">
- <div class="label"><slot name="label"></slot></div>
- <div class="main">
+<div :class="[$style.root, { [$style.rootFirst]: first }]">
+ <div :class="[$style.label, { [$style.labelFirst]: first }]"><slot name="label"></slot></div>
+ <div :class="$style.main">
<slot></slot>
</div>
</div>
@@ -13,31 +13,31 @@ defineProps<{
}>();
</script>
-<style lang="scss" scoped>
-.vrtktovh {
+<style lang="scss" module>
+.root {
border-top: solid 0.5px var(--divider);
//border-bottom: solid 0.5px var(--divider);
+}
- > .label {
- font-weight: bold;
- padding: 1.5em 0 0 0;
- margin: 0 0 16px 0;
+.rootFirst {
+ border-top: none;
+}
- &:empty {
- display: none;
- }
- }
+.label {
+ font-weight: bold;
+ padding: 1.5em 0 0 0;
+ margin: 0 0 16px 0;
- > .main {
- margin: 1.5em 0 0 0;
+ &:empty {
+ display: none;
}
+}
- &.first {
- border-top: none;
+.labelFirst {
+ padding-top: 0;
+}
- > .label {
- padding-top: 0;
- }
- }
+.main {
+ margin: 1.5em 0 0 0;
}
</style>
diff --git a/packages/frontend/src/pages/about-misskey.vue b/packages/frontend/src/pages/about-misskey.vue
index 51c3de43fe..3c073fc7c4 100644
--- a/packages/frontend/src/pages/about-misskey.vue
+++ b/packages/frontend/src/pages/about-misskey.vue
@@ -84,6 +84,12 @@
</div>
<p>{{ i18n.ts._aboutMisskey.morePatrons }}</p>
</FormSection>
+ <FormSection>
+ <template #label>Special thanks</template>
+ <div style="text-align: center;">
+ <a style="display: inline-block;" class="dcadvirth" title="DC Advirth" href="https://www.dotchain.ltd/advirth" target="_blank"><img width="200" src="https://misskey-hub.net/sponsors/dcadvirth.png" alt="DC Advirth"></a>
+ </div>
+ </FormSection>
</div>
</MkSpacer>
</div>
@@ -203,6 +209,7 @@ const patrons = [
'pixeldesu',
'あめ玉',
'氷月氷華里',
+ 'Ebise Lutica',
];
let thereIsTreasure = $ref($i && !claimedAchievements.includes('foundTreasure'));
diff --git a/packages/frontend/src/pages/admin/roles.edit.vue b/packages/frontend/src/pages/admin/roles.edit.vue
index 2a65a75187..ac6cca84c1 100644
--- a/packages/frontend/src/pages/admin/roles.edit.vue
+++ b/packages/frontend/src/pages/admin/roles.edit.vue
@@ -46,7 +46,8 @@ if (props.id) {
data = {
name: 'New Role',
description: '',
- rolePermission: 'normal',
+ isAdministrator: false,
+ isModerator: false,
color: null,
iconUrl: null,
target: 'manual',
diff --git a/packages/frontend/src/pages/admin/roles.role.vue b/packages/frontend/src/pages/admin/roles.role.vue
index e09f22e345..6eac902577 100644
--- a/packages/frontend/src/pages/admin/roles.role.vue
+++ b/packages/frontend/src/pages/admin/roles.role.vue
@@ -11,7 +11,7 @@
<MkFolder>
<template #icon><i class="ti ti-info-circle"></i></template>
<template #label>{{ i18n.ts.info }}</template>
- <XEditor v-model="role" readonly/>
+ <XEditor :model-value="role" readonly/>
</MkFolder>
<MkFolder v-if="role.target === 'manual'" default-open>
<template #icon><i class="ti ti-users"></i></template>
diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue
index d89f0d2a7d..25d8f3ad6e 100644
--- a/packages/frontend/src/pages/admin/roles.vue
+++ b/packages/frontend/src/pages/admin/roles.vue
@@ -4,7 +4,6 @@
<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :content-max="700">
<div class="_gaps">
- <MkButton primary rounded @click="create"><i class="ti ti-plus"></i> {{ i18n.ts._role.new }}</MkButton>
<MkFolder>
<template #label>{{ i18n.ts._role.baseRole }}</template>
<div class="_gaps_s">
@@ -132,8 +131,20 @@
<MkButton primary rounded @click="updateBaseRole">{{ i18n.ts.save }}</MkButton>
</div>
</MkFolder>
+ <MkButton primary rounded @click="create"><i class="ti ti-plus"></i> {{ i18n.ts._role.new }}</MkButton>
<div class="_gaps_s">
- <MkRolePreview v-for="role in roles" :key="role.id" :role="role" :for-moderation="true"/>
+ <MkFoldableSection>
+ <template #header>Manual roles</template>
+ <div class="_gaps_s">
+ <MkRolePreview v-for="role in roles.filter(x => x.target === 'manual')" :key="role.id" :role="role" :for-moderation="true"/>
+ </div>
+ </MkFoldableSection>
+ <MkFoldableSection>
+ <template #header>Conditional roles</template>
+ <div class="_gaps_s">
+ <MkRolePreview v-for="role in roles.filter(x => x.target === 'conditional')" :key="role.id" :role="role" :for-moderation="true"/>
+ </div>
+ </MkFoldableSection>
</div>
</div>
</MkSpacer>
@@ -155,6 +166,7 @@ import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
import { instance } from '@/instance';
import { useRouter } from '@/router';
+import MkFoldableSection from '@/components/MkFoldableSection.vue';
const ROLE_POLICIES = [
'gtlAvailable',
diff --git a/packages/frontend/src/pages/channel.vue b/packages/frontend/src/pages/channel.vue
index 6b4fcb32f8..65edb97e83 100644
--- a/packages/frontend/src/pages/channel.vue
+++ b/packages/frontend/src/pages/channel.vue
@@ -1,30 +1,25 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer :content-max="700">
- <div v-if="channel && tab === 'timeline'" class="_gaps">
- <div class="wpgynlbz _panel" :class="{ hide: !showBanner }">
- <XChannelFollowButton :channel="channel" :full="true" class="subscribe"/>
- <button class="_button toggle" @click="() => showBanner = !showBanner">
- <template v-if="showBanner"><i class="ti ti-chevron-up"></i></template>
- <template v-else><i class="ti ti-chevron-down"></i></template>
- </button>
- <div v-if="!showBanner" class="hideOverlay">
- </div>
- <div :style="{ backgroundImage: channel.bannerUrl ? `url(${channel.bannerUrl})` : null }" class="banner">
- <div class="status">
+ <MkSpacer :content-max="700" :class="$style.main">
+ <div v-if="channel && tab === 'overview'" class="_gaps">
+ <div class="_panel" :class="$style.bannerContainer">
+ <XChannelFollowButton :channel="channel" :full="true" :class="$style.subscribe"/>
+ <div :style="{ backgroundImage: channel.bannerUrl ? `url(${channel.bannerUrl})` : null }" :class="$style.banner">
+ <div :class="$style.bannerStatus">
<div><i class="ti ti-users ti-fw"></i><I18n :src="i18n.ts._channel.usersCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.usersCount }}</b></template></I18n></div>
<div><i class="ti ti-pencil ti-fw"></i><I18n :src="i18n.ts._channel.notesCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.notesCount }}</b></template></I18n></div>
</div>
- <div class="fade"></div>
+ <div :class="$style.bannerFade"></div>
</div>
- <div v-if="channel.description" class="description">
+ <div v-if="channel.description" :class="$style.description">
<Mfm :text="channel.description" :is-note="false" :i="$i"/>
</div>
</div>
-
+ </div>
+ <div v-if="channel && tab === 'timeline'" class="_gaps">
<!-- スマホ・タブレットの場合、キーボードが表示されると投稿が見づらくなるので、デスクトップ場合のみ自動でフォーカスを当てる -->
- <MkPostForm v-if="$i" :channel="channel" class="post-form _panel" fixed :autofocus="deviceKind === 'desktop'"/>
+ <MkPostForm v-if="$i && defaultStore.reactiveState.showFixedPostFormInChannel.value" :channel="channel" class="post-form _panel" fixed :autofocus="deviceKind === 'desktop'"/>
<MkTimeline :key="channelId" src="channel" :channel="channelId" @before="before" @after="after"/>
</div>
@@ -32,6 +27,15 @@
<MkNotes :pagination="featuredPagination"/>
</div>
</MkSpacer>
+ <template #footer>
+ <div :class="$style.footer">
+ <MkSpacer :content-max="700" :margin-min="16" :margin-max="16">
+ <div class="_buttonsCenter">
+ <MkButton inline rounded primary gradate @click="openPostForm()"><i class="ti ti-pencil"></i> {{ i18n.ts.postToTheChannel }}</MkButton>
+ </div>
+ </MkSpacer>
+ </div>
+ </template>
</MkStickyContainer>
</template>
@@ -47,6 +51,9 @@ import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
import { deviceKind } from '@/scripts/device-kind';
import MkNotes from '@/components/MkNotes.vue';
+import { url } from '@/config';
+import MkButton from '@/components/MkButton.vue';
+import { defaultStore } from '@/store';
const router = useRouter();
@@ -56,7 +63,6 @@ const props = defineProps<{
let tab = $ref('timeline');
let channel = $ref(null);
-let showBanner = $ref(true);
const featuredPagination = $computed(() => ({
endpoint: 'notes/featured' as const,
limit: 10,
@@ -76,13 +82,35 @@ function edit() {
router.push(`/channels/${channel.id}/edit`);
}
+function openPostForm() {
+ os.post({
+ channel: {
+ id: channel.id,
+ },
+ });
+}
+
const headerActions = $computed(() => channel && channel.userId ? [{
+ icon: 'ti ti-share',
+ text: i18n.ts.share,
+ handler: async (): Promise<void> => {
+ navigator.share({
+ title: channel.name,
+ text: channel.description,
+ url: `${url}/channels/${channel.id}`,
+ });
+ },
+}, {
icon: 'ti ti-settings',
text: i18n.ts.edit,
handler: edit,
}] : null);
const headerTabs = $computed(() => [{
+ key: 'overview',
+ title: i18n.ts.overview,
+ icon: 'ti ti-info-circle',
+}, {
key: 'timeline',
title: i18n.ts.timeline,
icon: 'ti ti-home',
@@ -98,102 +126,57 @@ definePageMetadata(computed(() => channel ? {
} : null));
</script>
-<style lang="scss" scoped>
-.wpgynlbz {
- position: relative;
-
- > .subscribe {
- position: absolute;
- z-index: 1;
- top: 16px;
- left: 16px;
- }
-
- > .toggle {
- position: absolute;
- z-index: 2;
- top: 8px;
- right: 8px;
- font-size: 1.2em;
- width: 48px;
- height: 48px;
- color: #fff;
- background: rgba(0, 0, 0, 0.5);
- border-radius: 100%;
-
- > i {
- vertical-align: middle;
- }
- }
-
- > .banner {
- position: relative;
- height: 200px;
- background-position: center;
- background-size: cover;
-
- > .fade {
- position: absolute;
- bottom: 0;
- left: 0;
- width: 100%;
- height: 64px;
- background: linear-gradient(0deg, var(--panel), var(--X15));
- }
-
- > .status {
- position: absolute;
- z-index: 1;
- bottom: 16px;
- right: 16px;
- padding: 8px 12px;
- font-size: 80%;
- background: rgba(0, 0, 0, 0.7);
- border-radius: 6px;
- color: #fff;
- }
- }
+<style lang="scss" module>
+.main {
+ min-height: calc(var(--containerHeight) - (var(--stickyTop, 0px) + var(--stickyBottom, 0px)));
+}
- > .description {
- padding: 16px;
- }
+.footer {
+ -webkit-backdrop-filter: var(--blur, blur(15px));
+ backdrop-filter: var(--blur, blur(15px));
+ border-top: solid 0.5px var(--divider);
+}
- > .hideOverlay {
- position: absolute;
- z-index: 1;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- -webkit-backdrop-filter: var(--blur, blur(16px));
- backdrop-filter: var(--blur, blur(16px));
- background: rgba(0, 0, 0, 0.3);
- }
+.bannerContainer {
+ position: relative;
+}
- &.hide {
- > .subscribe {
- display: none;
- }
+.subscribe {
+ position: absolute;
+ z-index: 1;
+ top: 16px;
+ left: 16px;
+}
- > .toggle {
- top: 0;
- right: 0;
- height: 100%;
- background: transparent;
- }
+.banner {
+ position: relative;
+ height: 200px;
+ background-position: center;
+ background-size: cover;
+}
- > .banner {
- height: 42px;
- filter: blur(8px);
+.bannerFade {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ width: 100%;
+ height: 64px;
+ background: linear-gradient(0deg, var(--panel), var(--X15));
+}
- > * {
- display: none;
- }
- }
+.bannerStatus {
+ position: absolute;
+ z-index: 1;
+ bottom: 16px;
+ right: 16px;
+ padding: 8px 12px;
+ font-size: 80%;
+ background: rgba(0, 0, 0, 0.7);
+ border-radius: 6px;
+ color: #fff;
+}
- > .description {
- display: none;
- }
- }
+.description {
+ padding: 16px;
}
</style>
diff --git a/packages/frontend/src/pages/clip.vue b/packages/frontend/src/pages/clip.vue
index d4e8f27005..d66088d33a 100644
--- a/packages/frontend/src/pages/clip.vue
+++ b/packages/frontend/src/pages/clip.vue
@@ -26,6 +26,7 @@ import { $i } from '@/account';
import { i18n } from '@/i18n';
import * as os from '@/os';
import { definePageMetadata } from '@/scripts/page-metadata';
+import { url } from '@/config';
const props = defineProps<{
clipId: string,
@@ -82,7 +83,17 @@ const headerActions = $computed(() => clip && isOwned ? [{
...result,
});
},
-}, {
+}, ...(clip.isPublic ? [{
+ icon: 'ti ti-share',
+ text: i18n.ts.share,
+ handler: async (): Promise<void> => {
+ navigator.share({
+ title: clip.name,
+ text: clip.description,
+ url: `${url}/clips/${clip.id}`,
+ });
+ },
+}] : []), {
icon: 'ti ti-trash',
text: i18n.ts.delete,
danger: true,
diff --git a/packages/frontend/src/pages/explore.roles.vue b/packages/frontend/src/pages/explore.roles.vue
index 8be11008c2..51177d079c 100644
--- a/packages/frontend/src/pages/explore.roles.vue
+++ b/packages/frontend/src/pages/explore.roles.vue
@@ -16,7 +16,7 @@ let roles = $ref();
os.api('roles/list', {
limit: 30,
}).then(res => {
- roles = res;
+ roles = res.filter(x => x.target === 'manual');
});
</script>
diff --git a/packages/frontend/src/pages/settings/email.vue b/packages/frontend/src/pages/settings/email.vue
index 1734dcfe42..b1e6f223b6 100644
--- a/packages/frontend/src/pages/settings/email.vue
+++ b/packages/frontend/src/pages/settings/email.vue
@@ -1,5 +1,5 @@
<template>
-<div class="_gaps_m">
+<div v-if="instance.enableEmail" class="_gaps_m">
<FormSection first>
<template #label>{{ i18n.ts.emailAddress }}</template>
<MkInput v-model="emailAddress" type="email" manual-save>
@@ -37,17 +37,22 @@
</div>
</FormSection>
</div>
+<div v-if="!instance.enableEmail" class="_gaps_m">
+ <MkInfo>{{ i18n.ts.emailNotSupported }}</MkInfo>
+</div>
</template>
<script lang="ts" setup>
import { onMounted, ref, watch } from 'vue';
import FormSection from '@/components/form/section.vue';
+import MkInfo from '@/components/MkInfo.vue';
import MkInput from '@/components/MkInput.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import * as os from '@/os';
import { $i } from '@/account';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
+import { instance } from '@/instance';
const emailAddress = ref($i!.email);
diff --git a/packages/frontend/src/pages/settings/general.vue b/packages/frontend/src/pages/settings/general.vue
index a578c5c747..2e2c456c07 100644
--- a/packages/frontend/src/pages/settings/general.vue
+++ b/packages/frontend/src/pages/settings/general.vue
@@ -21,6 +21,7 @@
</MkRadios>
<MkSwitch v-model="showFixedPostForm">{{ i18n.ts.showFixedPostForm }}</MkSwitch>
+ <MkSwitch v-model="showFixedPostFormInChannel">{{ i18n.ts.showFixedPostFormInChannel }}</MkSwitch>
<FormSection>
<template #label>{{ i18n.ts.behavior }}</template>
@@ -156,6 +157,7 @@ const loadRawImages = computed(defaultStore.makeGetterSetter('loadRawImages'));
const imageNewTab = computed(defaultStore.makeGetterSetter('imageNewTab'));
const nsfw = computed(defaultStore.makeGetterSetter('nsfw'));
const showFixedPostForm = computed(defaultStore.makeGetterSetter('showFixedPostForm'));
+const showFixedPostFormInChannel = computed(defaultStore.makeGetterSetter('showFixedPostFormInChannel'));
const numberOfPageCache = computed(defaultStore.makeGetterSetter('numberOfPageCache'));
const instanceTicker = computed(defaultStore.makeGetterSetter('instanceTicker'));
const enableInfiniteScroll = computed(defaultStore.makeGetterSetter('enableInfiniteScroll'));
@@ -191,6 +193,7 @@ watch([
enableInfiniteScroll,
squareAvatars,
aiChanMode,
+ showNoteActionsOnlyHover,
showGapBetweenNotesInTimeline,
instanceTicker,
overridedDeviceKind,
diff --git a/packages/frontend/src/pages/settings/preferences-backups.vue b/packages/frontend/src/pages/settings/preferences-backups.vue
index e3cf1aefb0..28e39236f7 100644
--- a/packages/frontend/src/pages/settings/preferences-backups.vue
+++ b/packages/frontend/src/pages/settings/preferences-backups.vue
@@ -73,6 +73,7 @@ const defaultStoreSaveKeys: (keyof typeof defaultStore['state'])[] = [
'useBlurEffectForModal',
'useBlurEffect',
'showFixedPostForm',
+ 'showFixedPostFormInChannel',
'enableInfiniteScroll',
'useReactionPickerForContextMenu',
'showGapBetweenNotesInTimeline',
diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue
index 441b19440c..02794175ae 100644
--- a/packages/frontend/src/pages/user/home.vue
+++ b/packages/frontend/src/pages/user/home.vue
@@ -352,6 +352,9 @@ onUnmounted(() => {
> .roles {
padding: 24px 24px 0 154px;
font-size: 0.95em;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
> .role {
border: solid 1px var(--color, var(--divider));
@@ -493,7 +496,7 @@ onUnmounted(() => {
> .roles {
padding: 16px 16px 0 16px;
- text-align: center;
+ justify-content: center;
}
> .description {
diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts
index a6ad1774ff..2766b434fc 100644
--- a/packages/frontend/src/store.ts
+++ b/packages/frontend/src/store.ts
@@ -197,6 +197,10 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'device',
default: false,
},
+ showFixedPostFormInChannel: {
+ where: 'device',
+ default: false,
+ },
enableInfiniteScroll: {
where: 'device',
default: true,
@@ -271,7 +275,7 @@ export const defaultStore = markRaw(new Storage('base', {
},
numberOfPageCache: {
where: 'device',
- default: 5,
+ default: 3,
},
showNoteActionsOnlyHover: {
where: 'device',
diff --git a/packages/frontend/src/style.scss b/packages/frontend/src/style.scss
index 5a465d7873..3634e02745 100644
--- a/packages/frontend/src/style.scss
+++ b/packages/frontend/src/style.scss
@@ -285,6 +285,12 @@ hr {
flex-wrap: wrap;
}
+._buttonsCenter {
+ @extend ._buttons;
+
+ justify-content: center;
+}
+
._borderButton {
@extend ._button;
display: block;
diff --git a/packages/frontend/src/ui/deck/channel-column.vue b/packages/frontend/src/ui/deck/channel-column.vue
index 4c6b41e42e..b81d6729e6 100644
--- a/packages/frontend/src/ui/deck/channel-column.vue
+++ b/packages/frontend/src/ui/deck/channel-column.vue
@@ -61,7 +61,6 @@ function post() {
channel: {
id: props.column.channelId,
},
- instant: true,
});
}