diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2023-03-06 11:54:12 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2023-03-06 11:54:12 +0900 |
| commit | ae517a99a76d322c0fe803e56721c5770df4c439 (patch) | |
| tree | 2ce3725392fbcbbfca77737ba96176e2fdbee94a /packages | |
| parent | Merge pull request #10181 from misskey-dev/develop (diff) | |
| parent | [ci skip] chore(client): showNoteActionsOnlyHover変更時にリロードダ... (diff) | |
| download | misskey-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')
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, }); } |