diff --git a/.github/workflows/docker-develop.yml b/.github/workflows/docker-develop.yml index d2cf4bf629..63dc940e24 100644 --- a/.github/workflows/docker-develop.yml +++ b/.github/workflows/docker-develop.yml @@ -31,3 +31,5 @@ jobs: push: true tags: misskey/misskey:develop labels: develop + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 48e2b19d6a..9135b4f60a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -109,8 +109,12 @@ jobs: # https://github.com/cypress-io/cypress/issues/4351#issuecomment-559489091 - name: ALSA Env run: echo -e 'pcm.!default {\n type hw\n card 0\n}\n\nctl.!default {\n type hw\n card 0\n}' > ~/.asoundrc + # XXX: This tries reinstalling Cypress if the binary is not cached + # Remove this when the cache issue is fixed + - name: Cypress install + run: pnpm exec cypress install - name: Cypress run - uses: cypress-io/github-action@v4 + uses: cypress-io/github-action@v5 with: install: false start: pnpm start:test diff --git a/.node-version b/.node-version index e44a38e080..0e9dc6b586 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -v18.12.1 +v18.13.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index b0dbb6e240..e767c15df4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,19 @@ You should also include the user name that made the change. --> +## 13.2.4 (2023/01/27) +### Improvements +- リモートカスタム絵文字表示時のパフォーマンスを改善 +- Default to `animation: false` when prefers-reduced-motion is set +- リアクション履歴が公開なら、ログインしていなくても表示できるように +- tweak blur setting +- tweak custom emoji cache + +### Bugfixes +- fix aggregation of retention +- ダッシュボードでオンラインユーザー数が表示されない問題を修正 +- フォロー申請・フォローのボタンが、通知から消えている問題を修正 + ## 13.2.3 (2023/01/26) ### Improvements - カスタム絵文字の更新をリアルタイムで反映するように diff --git a/Dockerfile b/Dockerfile index 47fe31bca7..3876b5f6ce 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,8 +2,12 @@ ARG NODE_VERSION=18.13.0-bullseye FROM node:${NODE_VERSION} AS builder -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt,sharing=locked \ + rm -f /etc/apt/apt.conf.d/docker-clean \ + ; echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache \ + && apt-get update \ + && apt-get install -yqq --no-install-recommends \ build-essential RUN corepack enable @@ -16,7 +20,8 @@ COPY ["packages/backend/package.json", "./packages/backend/"] COPY ["packages/frontend/package.json", "./packages/frontend/"] COPY ["packages/sw/package.json", "./packages/sw/"] -RUN pnpm i --frozen-lockfile +RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \ + pnpm i --frozen-lockfile --aggregate-output COPY . ./ @@ -30,11 +35,13 @@ FROM node:${NODE_VERSION}-slim AS runner ARG UID="991" ARG GID="991" -RUN apt-get update \ +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt,sharing=locked \ + rm -f /etc/apt/apt.conf.d/docker-clean \ + ; echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache \ + && apt-get update \ && apt-get install -y --no-install-recommends \ ffmpeg tini \ - && apt-get -y clean \ - && rm -rf /var/lib/apt/lists/* \ && corepack enable \ && groupadd -g "${GID}" misskey \ && useradd -l -u "${UID}" -g "${GID}" -m -d /misskey misskey diff --git a/locales/uk-UA.yml b/locales/uk-UA.yml index a852339969..a6ba27e4fe 100644 --- a/locales/uk-UA.yml +++ b/locales/uk-UA.yml @@ -688,7 +688,7 @@ pageLikesCount: "Кількість отриманих вподобань сто pageLikedCount: "Кількість вподобаних сторінок" contact: "Контакт" useSystemFont: "Використовувати стандартний шрифт системи" -clips: "Добірка" +clips: "Добірки" experimentalFeatures: "Експериментальні функції" developer: "Розробник" makeExplorable: "Зробіть обліковий запис видимим у розділі \"Огляд\"" @@ -1003,9 +1003,19 @@ _achievements: title: "Майстер нотаток III" description: "1000 днів користування загально" flavor: "Дякуємо, що користуєтеся Misskey!" + _noteClipped1: + title: "Не можна не зберегти" + description: "Перша нотатка у добірці" + _noteFavorited1: + title: "Дивитися на зірки" _myNoteFavorited1: title: "У пошуках зірок" + _profileFilled: + title: "Повна готовність" + description: "Профіль заповнено" _markedAsCat: + title: "Я кіт" + description: "Позначено як акаунт кота" flavor: "Я дам тобі ім'я пізніше" _following1: title: "Перша підписка" @@ -1034,6 +1044,7 @@ _achievements: _followers300: description: "Кількість підписників досягла 300" _followers500: + title: "Радіовежа" description: "Кількість підписників досягла 500" _followers1000: title: "Інфлюенсер" @@ -1047,6 +1058,8 @@ _achievements: description: "Минуло 3 роки з моменту створення акаунта" _loggedInOnBirthday: title: "З Днем народження!" + _loggedInOnNewYearsDay: + description: "Увійшли в перший день року" _brainDiver: title: "Brain Diver" flavor: "Misskey-Misskey La-Tu-Ma" @@ -1586,6 +1599,7 @@ _notification: youReceivedFollowRequest: "Ви отримали запит на підписку" yourFollowRequestAccepted: "Запит на підписку прийнято" youWereInvitedToGroup: "Запрошення до групи" + achievementEarned: "Досягнення відкрито" _types: all: "Все" follow: "Підписки" diff --git a/package.json b/package.json index c5a556aead..06ec191b5c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "misskey", - "version": "13.2.3", + "version": "13.2.4", "codename": "nasubi", "repository": { "type": "git", @@ -57,7 +57,7 @@ "@typescript-eslint/eslint-plugin": "5.49.0", "@typescript-eslint/parser": "5.49.0", "cross-env": "7.0.3", - "cypress": "12.3.0", + "cypress": "12.4.0", "eslint": "^8.32.0", "start-server-and-test": "1.15.3" }, diff --git a/packages/backend/package.json b/packages/backend/package.json index c3b45f6bf4..f68fde8b4c 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -32,8 +32,8 @@ "@fastify/cors": "8.2.0", "@fastify/http-proxy": "^8.4.0", "@fastify/multipart": "7.4.0", - "@fastify/static": "6.6.1", - "@fastify/view": "7.4.0", + "@fastify/static": "6.7.0", + "@fastify/view": "7.4.1", "@nestjs/common": "9.2.1", "@nestjs/core": "9.2.1", "@nestjs/testing": "9.2.1", @@ -110,7 +110,7 @@ "stringz": "2.1.0", "summaly": "2.7.0", "syslog-pro": "git+https://github.com/misskey-dev/SyslogPro#0.2.9-misskey.2", - "systeminformation": "5.17.3", + "systeminformation": "5.17.4", "tinycolor2": "1.5.2", "tmp": "0.2.1", "tsc-alias": "1.8.2", @@ -131,7 +131,7 @@ "devDependencies": { "@redocly/openapi-core": "1.0.0-beta.120", "@swc/cli": "^0.1.59", - "@swc/core": "1.3.27", + "@swc/core": "1.3.29", "@swc/jest": "0.2.24", "@types/accepts": "1.3.5", "@types/archiver": "5.3.1", @@ -143,11 +143,11 @@ "@types/escape-regexp": "0.0.1", "@types/fluent-ffmpeg": "2.1.20", "@types/ioredis": "4.28.10", - "@types/jest": "29.2.6", + "@types/jest": "29.4.0", "@types/js-yaml": "4.0.5", "@types/jsdom": "20.0.1", "@types/jsonld": "1.5.8", - "@types/jsrsasign": "10.5.4", + "@types/jsrsasign": "10.5.5", "@types/mime-types": "2.1.1", "@types/node": "18.11.18", "@types/node-fetch": "3.0.3", @@ -181,7 +181,7 @@ "eslint": "8.32.0", "eslint-plugin-import": "2.27.5", "execa": "6.1.0", - "jest": "29.3.1", - "jest-mock": "^29.3.1" + "jest": "29.4.1", + "jest-mock": "^29.4.1" } } diff --git a/packages/backend/src/core/CustomEmojiService.ts b/packages/backend/src/core/CustomEmojiService.ts index 1f0b214159..39814e1be6 100644 --- a/packages/backend/src/core/CustomEmojiService.ts +++ b/packages/backend/src/core/CustomEmojiService.ts @@ -6,22 +6,35 @@ import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import type { DriveFile } from '@/models/entities/DriveFile.js'; import type { Emoji } from '@/models/entities/Emoji.js'; -import type { EmojisRepository } from '@/models/index.js'; +import type { EmojisRepository, Note } from '@/models/index.js'; import { bindThis } from '@/decorators.js'; +import { Cache } from '@/misc/cache.js'; +import { UtilityService } from '@/core/UtilityService.js'; +import type { Config } from '@/config.js'; +import { ReactionService } from '@/core/ReactionService.js'; +import { query } from '@/misc/prelude/url.js'; @Injectable() export class CustomEmojiService { + private cache: Cache; + constructor( + @Inject(DI.config) + private config: Config, + @Inject(DI.db) private db: DataSource, @Inject(DI.emojisRepository) private emojisRepository: EmojisRepository, + private utilityService: UtilityService, private idService: IdService, private emojiEntityService: EmojiEntityService, private globalEventService: GlobalEventService, + private reactionService: ReactionService, ) { + this.cache = new Cache(1000 * 60 * 60 * 12); } @bindThis @@ -44,12 +57,135 @@ export class CustomEmojiService { type: data.driveFile.webpublicType ?? data.driveFile.type, }).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0])); - await this.db.queryResultCache!.remove(['meta_emojis']); + if (data.host == null) { + await this.db.queryResultCache!.remove(['meta_emojis']); - this.globalEventService.publishBroadcastStream('emojiAdded', { - emoji: await this.emojiEntityService.pack(emoji.id), - }); + this.globalEventService.publishBroadcastStream('emojiAdded', { + emoji: await this.emojiEntityService.pack(emoji.id), + }); + } return emoji; } + + @bindThis + private normalizeHost(src: string | undefined, noteUserHost: string | null): string | null { + // クエリに使うホスト + let host = src === '.' ? null // .はローカルホスト (ここがマッチするのはリアクションのみ) + : src === undefined ? noteUserHost // ノートなどでホスト省略表記の場合はローカルホスト (ここがリアクションにマッチすることはない) + : this.utilityService.isSelfHost(src) ? null // 自ホスト指定 + : (src || noteUserHost); // 指定されたホスト || ノートなどの所有者のホスト (こっちがリアクションにマッチすることはない) + + host = this.utilityService.toPunyNullable(host); + + return host; + } + + @bindThis + private parseEmojiStr(emojiName: string, noteUserHost: string | null) { + const match = emojiName.match(/^(\w+)(?:@([\w.-]+))?$/); + if (!match) return { name: null, host: null }; + + const name = match[1]; + + // ホスト正規化 + const host = this.utilityService.toPunyNullable(this.normalizeHost(match[2], noteUserHost)); + + return { name, host }; + } + + /** + * 添付用(リモート)カスタム絵文字URLを解決する + * @param emojiName ノートやユーザープロフィールに添付された、またはリアクションのカスタム絵文字名 (:は含めない, リアクションでローカルホストの場合は@.を付ける (これはdecodeReactionで可能)) + * @param noteUserHost ノートやユーザープロフィールの所有者のホスト + * @returns URL, nullは未マッチを意味する + */ + @bindThis + public async populateEmoji(emojiName: string, noteUserHost: string | null): Promise { + const { name, host } = this.parseEmojiStr(emojiName, noteUserHost); + if (name == null) return null; + if (host == null) return null; + + const queryOrNull = async () => (await this.emojisRepository.findOneBy({ + name, + host: host ?? IsNull(), + })) ?? null; + + const emoji = await this.cache.fetch(`${name} ${host}`, queryOrNull); + + if (emoji == null) return null; + + const isLocal = emoji.host == null; + const emojiUrl = emoji.publicUrl || emoji.originalUrl; // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ) + const url = isLocal + ? emojiUrl + : this.config.proxyRemoteFiles + ? `${this.config.url}/proxy/${encodeURIComponent((new URL(emojiUrl)).pathname)}?${query({ url: emojiUrl })}` + : emojiUrl; + + return url; + } + + /** + * 複数の添付用(リモート)カスタム絵文字URLを解決する (キャシュ付き, 存在しないものは結果から除外される) + */ + @bindThis + public async populateEmojis(emojiNames: string[], noteUserHost: string | null): Promise> { + const emojis = await Promise.all(emojiNames.map(x => this.populateEmoji(x, noteUserHost))); + const res = {} as any; + for (let i = 0; i < emojiNames.length; i++) { + if (emojis[i] != null) { + res[emojiNames[i]] = emojis[i]; + } + } + return res; + } + + @bindThis + public aggregateNoteEmojis(notes: Note[]) { + let emojis: { name: string | null; host: string | null; }[] = []; + for (const note of notes) { + emojis = emojis.concat(note.emojis + .map(e => this.parseEmojiStr(e, note.userHost))); + if (note.renote) { + emojis = emojis.concat(note.renote.emojis + .map(e => this.parseEmojiStr(e, note.renote!.userHost))); + if (note.renote.user) { + emojis = emojis.concat(note.renote.user.emojis + .map(e => this.parseEmojiStr(e, note.renote!.userHost))); + } + } + const customReactions = Object.keys(note.reactions).map(x => this.reactionService.decodeReaction(x)).filter(x => x.name != null) as typeof emojis; + emojis = emojis.concat(customReactions); + if (note.user) { + emojis = emojis.concat(note.user.emojis + .map(e => this.parseEmojiStr(e, note.userHost))); + } + } + return emojis.filter(x => x.name != null && x.host != null) as { name: string; host: string; }[]; + } + + /** + * 与えられた絵文字のリストをデータベースから取得し、キャッシュに追加します + */ + @bindThis + public async prefetchEmojis(emojis: { name: string; host: string | null; }[]): Promise { + const notCachedEmojis = emojis.filter(emoji => this.cache.get(`${emoji.name} ${emoji.host}`) == null); + const emojisQuery: any[] = []; + const hosts = new Set(notCachedEmojis.map(e => e.host)); + for (const host of hosts) { + if (host == null) continue; + emojisQuery.push({ + name: In(notCachedEmojis.filter(e => e.host === host).map(e => e.name)), + host: host, + }); + } + const _emojis = emojisQuery.length > 0 ? await this.emojisRepository.find({ + where: emojisQuery, + select: ['name', 'host', 'originalUrl', 'publicUrl'], + }) : []; + for (const emoji of _emojis) { + this.cache.set(`${emoji.name} ${emoji.host}`, emoji); + } + } } diff --git a/packages/backend/src/core/ImageProcessingService.ts b/packages/backend/src/core/ImageProcessingService.ts index 312189eea4..fbc02f504b 100644 --- a/packages/backend/src/core/ImageProcessingService.ts +++ b/packages/backend/src/core/ImageProcessingService.ts @@ -9,6 +9,14 @@ export type IImage = { type: string; }; +export type IImageStream = { + data: Readable; + ext: string | null; + type: string; +}; + +export type IImageStreamable = IImage | IImageStream; + export const webpDefault: sharp.WebpOptions = { quality: 85, alphaQuality: 95, @@ -19,6 +27,7 @@ export const webpDefault: sharp.WebpOptions = { }; import { bindThis } from '@/decorators.js'; +import { Readable } from 'node:stream'; @Injectable() export class ImageProcessingService { @@ -64,7 +73,7 @@ export class ImageProcessingService { */ @bindThis public async convertToWebp(path: string, width: number, height: number, options: sharp.WebpOptions = webpDefault): Promise { - return this.convertSharpToWebp(await sharp(path), width, height, options); + return this.convertSharpToWebp(sharp(path), width, height, options); } @bindThis @@ -85,6 +94,27 @@ export class ImageProcessingService { }; } + @bindThis + public convertToWebpStream(path: string, width: number, height: number, options: sharp.WebpOptions = webpDefault): IImageStream { + return this.convertSharpToWebpStream(sharp(path), width, height, options); + } + + @bindThis + public convertSharpToWebpStream(sharp: sharp.Sharp, width: number, height: number, options: sharp.WebpOptions = webpDefault): IImageStream { + const data = sharp + .resize(width, height, { + fit: 'inside', + withoutEnlargement: true, + }) + .rotate() + .webp(options) + + return { + data, + ext: 'webp', + type: 'image/webp', + }; + } /** * Convert to PNG * with resize, remove metadata, resolve orientation, stop animation diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index 2b179643f3..bd6971adb3 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -282,7 +282,9 @@ export class NoteEntityService implements OnModuleInit { : await this.channelsRepository.findOneBy({ id: note.channelId }) : null; - const reactionEmojiNames = Object.keys(note.reactions).filter(x => x.startsWith(':')).map(x => this.reactionService.decodeReaction(x).reaction).map(x => x.replace(/:/g, '')); + const reactionEmojiNames = Object.keys(note.reactions) + .filter(x => x.startsWith(':') && x.includes('@') && !x.includes('@.')) // リモートカスタム絵文字のみ + .map(x => this.reactionService.decodeReaction(x).reaction.replaceAll(':', '')); const packed: Packed<'Note'> = await awaitAll({ id: note.id, @@ -299,6 +301,8 @@ export class NoteEntityService implements OnModuleInit { renoteCount: note.renoteCount, repliesCount: note.repliesCount, reactions: this.reactionService.convertLegacyReactions(note.reactions), + reactionEmojis: this.customEmojiService.populateEmojis(reactionEmojiNames, host), + 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), @@ -384,6 +388,8 @@ export class NoteEntityService implements OnModuleInit { } } + await this.customEmojiService.prefetchEmojis(this.customEmojiService.aggregateNoteEmojis(notes)); + return await Promise.all(notes.map(n => this.pack(n, me, { ...options, _hint_: { diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts index a8210eea02..ded1b512a1 100644 --- a/packages/backend/src/core/entities/NotificationEntityService.ts +++ b/packages/backend/src/core/entities/NotificationEntityService.ts @@ -146,6 +146,8 @@ export class NotificationEntityService implements OnModuleInit { myReactionsMap.set(target, myReactions.find(reaction => reaction.noteId === target) ?? null); } + await this.customEmojiService.prefetchEmojis(this.customEmojiService.aggregateNoteEmojis(notes)); + return await Promise.all(notifications.map(x => this.pack(x, { _hintForEachNotes_: { myReactions: myReactionsMap, diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index f532b5bf6e..546e61a26e 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -413,6 +413,7 @@ export class UserEntityService implements OnModuleInit { faviconUrl: instance.faviconUrl, themeColor: instance.themeColor, } : undefined) : undefined, + emojis: this.customEmojiService.populateEmojis(user.emojis, user.host), onlineStatus: this.getOnlineStatus(user), ...(opts.detail ? { diff --git a/packages/backend/src/logger.ts b/packages/backend/src/logger.ts index e7d7051630..5d275bc7b2 100644 --- a/packages/backend/src/logger.ts +++ b/packages/backend/src/logger.ts @@ -68,6 +68,7 @@ export default class Logger { if (envOption.withLogTime) log = chalk.gray(time) + ' ' + log; console.log(important ? chalk.bold(log) : log); + if (level === 'error' && data) console.log(data); if (store) { if (this.syslogClient) { diff --git a/packages/backend/src/queue/processors/AggregateRetentionProcessorService.ts b/packages/backend/src/queue/processors/AggregateRetentionProcessorService.ts index 4650da76bb..da4ae88557 100644 --- a/packages/backend/src/queue/processors/AggregateRetentionProcessorService.ts +++ b/packages/backend/src/queue/processors/AggregateRetentionProcessorService.ts @@ -57,8 +57,15 @@ export class AggregateRetentionProcessorService { usersCount: targetUserIds.length, }); + // 今日活動したユーザーを全て取得 + const activeUsers = await this.usersRepository.findBy({ + host: IsNull(), + lastActiveDate: MoreThan(new Date(Date.now() - (1000 * 60 * 60 * 24))), + }); + const activeUsersIds = activeUsers.map(u => u.id); + for (const record of pastRecords) { - const retention = record.userIds.filter(id => targetUserIds.includes(id)).length; + const retention = record.userIds.filter(id => activeUsersIds.includes(id)).length; const data = deepClone(record.data); data[dateKey] = retention; diff --git a/packages/backend/src/server/FileServerService.ts b/packages/backend/src/server/FileServerService.ts index 134b3df327..40024270ae 100644 --- a/packages/backend/src/server/FileServerService.ts +++ b/packages/backend/src/server/FileServerService.ts @@ -5,14 +5,14 @@ import { Inject, Injectable } from '@nestjs/common'; import fastifyStatic from '@fastify/static'; import rename from 'rename'; import type { Config } from '@/config.js'; -import type { DriveFilesRepository } from '@/models/index.js'; +import type { DriveFile, DriveFilesRepository } from '@/models/index.js'; import { DI } from '@/di-symbols.js'; import { createTemp } from '@/misc/create-temp.js'; import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; import { StatusError } from '@/misc/status-error.js'; import type Logger from '@/logger.js'; import { DownloadService } from '@/core/DownloadService.js'; -import { ImageProcessingService } from '@/core/ImageProcessingService.js'; +import { IImageStreamable, ImageProcessingService, webpDefault } from '@/core/ImageProcessingService.js'; import { VideoProcessingService } from '@/core/VideoProcessingService.js'; import { InternalStorageService } from '@/core/InternalStorageService.js'; import { contentDisposition } from '@/misc/content-disposition.js'; @@ -20,6 +20,8 @@ import { FileInfoService } from '@/core/FileInfoService.js'; import { LoggerService } from '@/core/LoggerService.js'; 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'; const _filename = fileURLToPath(import.meta.url); const _dirname = dirname(_filename); @@ -57,7 +59,7 @@ export class FileServerService { reply.header('Cache-Control', 'max-age=300'); }; } - + @bindThis public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) { fastify.addHook('onRequest', (request, reply, done) => { @@ -70,23 +72,309 @@ export class FileServerService { serve: false, }); - fastify.get('/app-default.jpg', (request, reply) => { + fastify.get('/files/app-default.jpg', (request, reply) => { const file = fs.createReadStream(`${_dirname}/assets/dummy.png`); reply.header('Content-Type', 'image/jpeg'); reply.header('Cache-Control', 'max-age=31536000, immutable'); return reply.send(file); }); - fastify.get<{ Params: { key: string; } }>('/:key', async (request, reply) => await this.sendDriveFile(request, reply)); - fastify.get<{ Params: { key: string; } }>('/:key/*', async (request, reply) => await this.sendDriveFile(request, reply)); + fastify.get<{ Params: { key: string; } }>('/files/:key', async (request, reply) => { + return await this.sendDriveFile(request, reply) + .catch(err => this.errorHandler(request, reply, err)); + }); + fastify.get<{ Params: { key: string; } }>('/files/:key/*', async (request, reply) => { + return await this.sendDriveFile(request, reply) + .catch(err => this.errorHandler(request, reply, err)); + }); + + fastify.get<{ + Params: { url: string; }; + Querystring: { url?: string; }; + }>('/proxy/:url*', async (request, reply) => { + return await this.proxyHandler(request, reply) + .catch(err => this.errorHandler(request, reply, err)); + }); done(); } + @bindThis + private async errorHandler(request: FastifyRequest<{ Params?: { [x: string]: any }; Querystring?: { [x: string]: any }; }>, reply: FastifyReply, err?: any) { + this.logger.error(`${err}`); + + reply.header('Cache-Control', 'max-age=300'); + + if (request.query && 'fallback' in request.query) { + return reply.sendFile('/dummy.png', assets); + } + + if (err instanceof StatusError && (err.statusCode === 302 || err.isClientError)) { + reply.code(err.statusCode); + return; + } + + reply.code(500); + return; + } + @bindThis private async sendDriveFile(request: FastifyRequest<{ Params: { key: string; } }>, reply: FastifyReply) { const key = request.params.key; + const file = await this.getFileFromKey(key).then(); + if (file === '404') { + reply.code(404); + reply.header('Cache-Control', 'max-age=86400'); + return reply.sendFile('/dummy.png', assets); + } + + if (file === '204') { + reply.code(204); + reply.header('Cache-Control', 'max-age=86400'); + return; + } + + try { + if (file.state === 'remote') { + const convertFile = async () => { + if (file.fileRole === 'thumbnail') { + if (['image/jpeg', 'image/webp', 'image/avif', 'image/png', 'image/svg+xml'].includes(file.mime)) { + return this.imageProcessingService.convertToWebpStream( + file.path, + 498, + 280 + ); + } else if (file.mime.startsWith('video/')) { + return await this.videoProcessingService.generateVideoThumbnail(file.path); + } + } + + if (file.fileRole === 'webpublic') { + if (['image/svg+xml'].includes(file.mime)) { + return this.imageProcessingService.convertToWebpStream( + file.path, + 2048, + 2048, + { ...webpDefault, lossless: true } + ) + } + } + + return { + data: fs.createReadStream(file.path), + ext: file.ext, + type: file.mime, + }; + }; + + const image = await convertFile(); + + if ('pipe' in image.data && typeof image.data.pipe === 'function') { + // image.dataがstreamなら、stream終了後にcleanup + image.data.on('end', file.cleanup); + image.data.on('close', file.cleanup); + } else { + // image.dataがstreamでないなら直ちにcleanup + file.cleanup(); + } + + reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(image.type) ? image.type : 'application/octet-stream'); + reply.header('Cache-Control', 'max-age=31536000, immutable'); + return image.data; + } + + if (file.fileRole !== 'original') { + const filename = rename(file.file.name, { + suffix: file.fileRole === 'thumbnail' ? '-thumb' : '-web', + extname: file.ext ? `.${file.ext}` : undefined, + }).toString(); + + reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.mime) ? file.mime : 'application/octet-stream'); + reply.header('Cache-Control', 'max-age=31536000, immutable'); + 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; + } + } catch (e) { + if ('cleanup' in file) file.cleanup(); + throw e; + } + } + + @bindThis + private async proxyHandler(request: FastifyRequest<{ Params: { url: string; }; Querystring: { url?: string; }; }>, reply: FastifyReply) { + const url = 'url' in request.query ? request.query.url : 'https://' + request.params.url; + + if (typeof url !== 'string') { + reply.code(400); + return; + } + + // Create temp file + const file = await this.getStreamAndTypeFromUrl(url); + if (file === '404') { + reply.code(404); + reply.header('Cache-Control', 'max-age=86400'); + return reply.sendFile('/dummy.png', assets); + } + + if (file === '204') { + reply.code(204); + reply.header('Cache-Control', 'max-age=86400'); + return; + } + + try { + const isConvertibleImage = isMimeImage(file.mime, 'sharp-convertible-image'); + const isAnimationConvertibleImage = isMimeImage(file.mime, 'sharp-animation-convertible-image'); + + let image: IImageStreamable | null = null; + if ('emoji' in request.query && isConvertibleImage) { + if (!isAnimationConvertibleImage && !('static' in request.query)) { + image = { + data: fs.createReadStream(file.path), + ext: file.ext, + type: file.mime, + }; + } else { + const data = sharp(file.path, { animated: !('static' in request.query) }) + .resize({ + height: 128, + withoutEnlargement: true, + }) + .webp(webpDefault); + + image = { + data, + ext: 'webp', + type: 'image/webp', + }; + } + } else if ('static' in request.query && isConvertibleImage) { + image = this.imageProcessingService.convertToWebpStream(file.path, 498, 280); + } else if ('preview' in request.query && isConvertibleImage) { + image = this.imageProcessingService.convertToWebpStream(file.path, 200, 200); + } else if ('badge' in request.query) { + if (!isConvertibleImage) { + // 画像でないなら404でお茶を濁す + throw new StatusError('Unexpected mime', 404); + } + + const mask = sharp(file.path) + .resize(96, 96, { + fit: 'inside', + withoutEnlargement: false, + }) + .greyscale() + .normalise() + .linear(1.75, -(128 * 1.75) + 128) // 1.75x contrast + .flatten({ background: '#000' }) + .toColorspace('b-w'); + + const stats = await mask.clone().stats(); + + if (stats.entropy < 0.1) { + // エントロピーがあまりない場合は404にする + throw new StatusError('Skip to provide badge', 404); + } + + const data = sharp({ + create: { width: 96, height: 96, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } }, + }) + .pipelineColorspace('b-w') + .boolean(await mask.png().toBuffer(), 'eor'); + + image = { + data: await data.png().toBuffer(), + ext: 'png', + type: 'image/png', + }; + } else if (file.mime === 'image/svg+xml') { + image = this.imageProcessingService.convertToWebpStream(file.path, 2048, 2048); + } else if (!file.mime.startsWith('image/') || !FILE_TYPE_BROWSERSAFE.includes(file.mime)) { + throw new StatusError('Rejected type', 403, 'Rejected type'); + } + + if (!image) { + image = { + data: fs.createReadStream(file.path), + ext: file.ext, + type: file.mime, + }; + } + + if ('cleanup' in file) { + if ('pipe' in image.data && typeof image.data.pipe === 'function') { + // image.dataがstreamなら、stream終了後にcleanup + image.data.on('end', file.cleanup); + image.data.on('close', file.cleanup); + } else { + // image.dataがstreamでないなら直ちにcleanup + file.cleanup(); + } + } + + reply.header('Content-Type', image.type); + reply.header('Cache-Control', 'max-age=31536000, immutable'); + return image.data; + } catch (e) { + if ('cleanup' in file) file.cleanup(); + throw e; + } + } + + @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; } + | '404' + | '204' + > { + if (url.startsWith(`${this.config.url}/files/`)) { + const key = url.replace(`${this.config.url}/files/`, '').split('/').shift(); + if (!key) throw new StatusError('Invalid File Key', 400, 'Invalid File Key'); + + return await this.getFileFromKey(key); + } + + return await this.downloadAndDetectTypeFromUrl(url); + } + + @bindThis + private async downloadAndDetectTypeFromUrl(url: string): Promise< + { state: 'remote' ; mime: string; ext: string | null; path: string; cleanup: () => void; } + > { + const [path, cleanup] = await createTemp(); + try { + await this.downloadService.downloadUrl(url, path); + + const { mime, ext } = await this.fileInfoService.detectType(path); + + return { + state: 'remote', + mime, ext, + path, cleanup, + } + } catch (e) { + cleanup(); + throw e; + } + } + + @bindThis + private async getFileFromKey(key: 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; } + | '404' + | '204' + > { // Fetch drive file const file = await this.driveFilesRepository.createQueryBuilder('file') .where('file.accessKey = :accessKey', { accessKey: key }) @@ -94,89 +382,41 @@ export class FileServerService { .orWhere('file.webpublicAccessKey = :webpublicAccessKey', { webpublicAccessKey: key }) .getOne(); - if (file == null) { - reply.code(404); - reply.header('Cache-Control', 'max-age=86400'); - return reply.sendFile('/dummy.png', assets); - } + if (file == null) return '404'; const isThumbnail = file.thumbnailAccessKey === key; const isWebpublic = file.webpublicAccessKey === key; if (!file.storedInternal) { - if (file.isLink && file.uri) { // 期限切れリモートファイル - const [path, cleanup] = await createTemp(); - - try { - await this.downloadService.downloadUrl(file.uri, path); - - const { mime, ext } = await this.fileInfoService.detectType(path); - - const convertFile = async () => { - if (isThumbnail) { - if (['image/jpeg', 'image/webp', 'image/avif', 'image/png', 'image/svg+xml'].includes(mime)) { - return await this.imageProcessingService.convertToWebp(path, 498, 280); - } else if (mime.startsWith('video/')) { - return await this.videoProcessingService.generateVideoThumbnail(path); - } - } - - if (isWebpublic) { - if (['image/svg+xml'].includes(mime)) { - return await this.imageProcessingService.convertToPng(path, 2048, 2048); - } - } - - return { - data: fs.readFileSync(path), - ext, - type: mime, - }; - }; - - const image = await convertFile(); - reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(image.type) ? image.type : 'application/octet-stream'); - reply.header('Cache-Control', 'max-age=31536000, immutable'); - return image.data; - } catch (err) { - this.logger.error(`${err}`); - - if (err instanceof StatusError && err.isClientError) { - reply.code(err.statusCode); - reply.header('Cache-Control', 'max-age=86400'); - } else { - reply.code(500); - reply.header('Cache-Control', 'max-age=300'); - } - } finally { - cleanup(); - } - return; + if (!(file.isLink && file.uri)) return '204'; + const result = await this.downloadAndDetectTypeFromUrl(file.uri); + return { + ...result, + fileRole: isThumbnail ? 'thumbnail' : isWebpublic ? 'webpublic' : 'original', + file, } - - reply.code(204); - reply.header('Cache-Control', 'max-age=86400'); - return; } - if (isThumbnail || isWebpublic) { - const { mime, ext } = await this.fileInfoService.detectType(this.internalStorageService.resolvePath(key)); - const filename = rename(file.name, { - suffix: isThumbnail ? '-thumb' : '-web', - extname: ext ? `.${ext}` : undefined, - }).toString(); + const path = this.internalStorageService.resolvePath(key); - reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(mime) ? mime : 'application/octet-stream'); - reply.header('Cache-Control', 'max-age=31536000, immutable'); - reply.header('Content-Disposition', contentDisposition('inline', filename)); - return this.internalStorageService.read(key); - } else { - const readable = this.internalStorageService.read(file.accessKey!); - readable.on('error', this.commonReadableHandlerGenerator(reply)); - reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.type) ? file.type : 'application/octet-stream'); - reply.header('Cache-Control', 'max-age=31536000, immutable'); - reply.header('Content-Disposition', contentDisposition('inline', file.name)); - return readable; + if (isThumbnail || isWebpublic) { + const { mime, ext } = await this.fileInfoService.detectType(path); + return { + state: 'stored_internal', + fileRole: isThumbnail ? 'thumbnail' : 'webpublic', + file, + mime, ext, + path, + }; + } + + return { + state: 'stored_internal', + fileRole: 'original', + file, + mime: file.type, + ext: null, + path, } } } diff --git a/packages/backend/src/server/MediaProxyServerService.ts b/packages/backend/src/server/MediaProxyServerService.ts deleted file mode 100644 index 5b76f15020..0000000000 --- a/packages/backend/src/server/MediaProxyServerService.ts +++ /dev/null @@ -1,177 +0,0 @@ -import * as fs from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { dirname } from 'node:path'; -import { Inject, Injectable } from '@nestjs/common'; -import sharp from 'sharp'; -import fastifyStatic from '@fastify/static'; -import { DI } from '@/di-symbols.js'; -import type { Config } from '@/config.js'; -import { isMimeImage } from '@/misc/is-mime-image.js'; -import { createTemp } from '@/misc/create-temp.js'; -import { DownloadService } from '@/core/DownloadService.js'; -import { ImageProcessingService, webpDefault } from '@/core/ImageProcessingService.js'; -import type { IImage } from '@/core/ImageProcessingService.js'; -import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; -import { StatusError } from '@/misc/status-error.js'; -import type Logger from '@/logger.js'; -import { FileInfoService } from '@/core/FileInfoService.js'; -import { LoggerService } from '@/core/LoggerService.js'; -import { bindThis } from '@/decorators.js'; -import type { FastifyInstance, FastifyPluginOptions, FastifyReply, FastifyRequest } from 'fastify'; - -const _filename = fileURLToPath(import.meta.url); -const _dirname = dirname(_filename); - -const assets = `${_dirname}/../../src/server/assets/`; - -@Injectable() -export class MediaProxyServerService { - private logger: Logger; - - constructor( - @Inject(DI.config) - private config: Config, - - private fileInfoService: FileInfoService, - private downloadService: DownloadService, - private imageProcessingService: ImageProcessingService, - private loggerService: LoggerService, - ) { - this.logger = this.loggerService.getLogger('server', 'gray', false); - - //this.createServer = this.createServer.bind(this); - } - - @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\''); - done(); - }); - - fastify.register(fastifyStatic, { - root: _dirname, - serve: false, - }); - - fastify.get<{ - Params: { url: string; }; - Querystring: { url?: string; }; - }>('/:url*', async (request, reply) => await this.handler(request, reply)); - - done(); - } - - @bindThis - private async handler(request: FastifyRequest<{ Params: { url: string; }; Querystring: { url?: string; }; }>, reply: FastifyReply) { - const url = 'url' in request.query ? request.query.url : 'https://' + request.params.url; - - if (typeof url !== 'string') { - reply.code(400); - return; - } - - // Create temp file - const [path, cleanup] = await createTemp(); - - try { - await this.downloadService.downloadUrl(url, path); - - const { mime, ext } = await this.fileInfoService.detectType(path); - const isConvertibleImage = isMimeImage(mime, 'sharp-convertible-image'); - const isAnimationConvertibleImage = isMimeImage(mime, 'sharp-animation-convertible-image'); - - let image: IImage; - if ('emoji' in request.query && isConvertibleImage) { - if (!isAnimationConvertibleImage && !('static' in request.query)) { - image = { - data: fs.readFileSync(path), - ext, - type: mime, - }; - } else { - const data = await sharp(path, { animated: !('static' in request.query) }) - .resize({ - height: 128, - withoutEnlargement: true, - }) - .webp(webpDefault) - .toBuffer(); - - image = { - data, - ext: 'webp', - type: 'image/webp', - }; - } - } else if ('static' in request.query && isConvertibleImage) { - image = await this.imageProcessingService.convertToWebp(path, 498, 280); - } else if ('preview' in request.query && isConvertibleImage) { - image = await this.imageProcessingService.convertToWebp(path, 200, 200); - } else if ('badge' in request.query) { - if (!isConvertibleImage) { - // 画像でないなら404でお茶を濁す - throw new StatusError('Unexpected mime', 404); - } - - const mask = sharp(path) - .resize(96, 96, { - fit: 'inside', - withoutEnlargement: false, - }) - .greyscale() - .normalise() - .linear(1.75, -(128 * 1.75) + 128) // 1.75x contrast - .flatten({ background: '#000' }) - .toColorspace('b-w'); - - const stats = await mask.clone().stats(); - - if (stats.entropy < 0.1) { - // エントロピーがあまりない場合は404にする - throw new StatusError('Skip to provide badge', 404); - } - - const data = sharp({ - create: { width: 96, height: 96, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } }, - }) - .pipelineColorspace('b-w') - .boolean(await mask.png().toBuffer(), 'eor'); - - image = { - data: await data.png().toBuffer(), - ext: 'png', - type: 'image/png', - }; - } else if (mime === 'image/svg+xml') { - image = await this.imageProcessingService.convertToWebp(path, 2048, 2048, webpDefault); - } else if (!mime.startsWith('image/') || !FILE_TYPE_BROWSERSAFE.includes(mime)) { - throw new StatusError('Rejected type', 403, 'Rejected type'); - } else { - image = { - data: fs.readFileSync(path), - ext, - type: mime, - }; - } - - reply.header('Content-Type', image.type); - reply.header('Cache-Control', 'max-age=31536000, immutable'); - return image.data; - } catch (err) { - this.logger.error(`${err}`); - - if ('fallback' in request.query) { - return reply.sendFile('/dummy.png', assets); - } - - if (err instanceof StatusError && (err.statusCode === 302 || err.isClientError)) { - reply.code(err.statusCode); - } else { - reply.code(500); - } - } finally { - cleanup(); - } - } -} diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts index 474edafe41..9dc1527698 100644 --- a/packages/backend/src/server/ServerModule.ts +++ b/packages/backend/src/server/ServerModule.ts @@ -3,7 +3,6 @@ import { EndpointsModule } from '@/server/api/EndpointsModule.js'; import { CoreModule } from '@/core/CoreModule.js'; import { ApiCallService } from './api/ApiCallService.js'; import { FileServerService } from './FileServerService.js'; -import { MediaProxyServerService } from './MediaProxyServerService.js'; import { NodeinfoServerService } from './NodeinfoServerService.js'; import { ServerService } from './ServerService.js'; import { WellKnownServerService } from './WellKnownServerService.js'; @@ -51,7 +50,6 @@ import { UserListChannelService } from './api/stream/channels/user-list.js'; UrlPreviewService, ActivityPubServerService, FileServerService, - MediaProxyServerService, NodeinfoServerService, ServerService, WellKnownServerService, diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts index eb6a3795eb..beb3a34ecd 100644 --- a/packages/backend/src/server/ServerService.ts +++ b/packages/backend/src/server/ServerService.ts @@ -20,7 +20,6 @@ import { NodeinfoServerService } from './NodeinfoServerService.js'; import { ApiServerService } from './api/ApiServerService.js'; import { StreamingApiServerService } from './api/StreamingApiServerService.js'; import { WellKnownServerService } from './WellKnownServerService.js'; -import { MediaProxyServerService } from './MediaProxyServerService.js'; import { FileServerService } from './FileServerService.js'; import { ClientServerService } from './web/ClientServerService.js'; @@ -48,7 +47,6 @@ export class ServerService { private wellKnownServerService: WellKnownServerService, private nodeinfoServerService: NodeinfoServerService, private fileServerService: FileServerService, - private mediaProxyServerService: MediaProxyServerService, private clientServerService: ClientServerService, private globalEventService: GlobalEventService, private loggerService: LoggerService, @@ -73,8 +71,7 @@ export class ServerService { } fastify.register(this.apiServerService.createServer, { prefix: '/api' }); - fastify.register(this.fileServerService.createServer, { prefix: '/files' }); - fastify.register(this.mediaProxyServerService.createServer, { prefix: '/proxy' }); + fastify.register(this.fileServerService.createServer); fastify.register(this.activityPubServerService.createServer); fastify.register(this.nodeinfoServerService.createServer); fastify.register(this.wellKnownServerService.createServer); diff --git a/packages/backend/src/server/api/endpoints/users/reactions.ts b/packages/backend/src/server/api/endpoints/users/reactions.ts index 9ec911f322..ac401a60ee 100644 --- a/packages/backend/src/server/api/endpoints/users/reactions.ts +++ b/packages/backend/src/server/api/endpoints/users/reactions.ts @@ -61,7 +61,7 @@ export default class extends Endpoint { super(meta, paramDef, async (ps, me) => { const profile = await this.userProfilesRepository.findOneByOrFail({ userId: ps.userId }); - if (me == null || (me.id !== ps.userId && !profile.publicReactions)) { + if ((me == null || me.id !== ps.userId) && !profile.publicReactions) { throw new ApiError(meta.errors.reactionsNotPublic); } diff --git a/packages/frontend/package.json b/packages/frontend/package.json index da568a9eac..12f890aa53 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -12,7 +12,7 @@ "@rollup/plugin-json": "6.0.0", "@rollup/pluginutils": "5.0.2", "@syuilo/aiscript": "0.12.2", - "@tabler/icons-webfont": "^2.0.0", + "@tabler/icons-webfont": "^2.1.2", "@vitejs/plugin-vue": "4.0.0", "@vue/compiler-sfc": "3.2.45", "autobind-decorator": "2.4.0", @@ -44,7 +44,7 @@ "punycode": "2.3.0", "querystring": "0.2.1", "rndstr": "1.0.0", - "rollup": "3.10.1", + "rollup": "3.11.0", "s-age": "1.1.2", "sanitize-html": "^2.8.1", "sass": "1.57.1", @@ -53,7 +53,7 @@ "stringz": "2.1.0", "syuilo-password-strength": "0.0.1", "textarea-caret": "3.1.0", - "three": "0.148.0", + "three": "0.149.0", "throttle-debounce": "5.0.0", "tinycolor2": "1.5.2", "tsc-alias": "1.8.2", @@ -86,7 +86,7 @@ "@typescript-eslint/parser": "5.49.0", "@vue/runtime-core": "3.2.45", "cross-env": "7.0.3", - "cypress": "12.3.0", + "cypress": "12.4.0", "eslint": "8.32.0", "eslint-plugin-import": "2.27.5", "eslint-plugin-vue": "9.9.0", diff --git a/packages/frontend/src/components/MkAutocomplete.vue b/packages/frontend/src/components/MkAutocomplete.vue index 2cb3aeb3d8..27997eb330 100644 --- a/packages/frontend/src/components/MkAutocomplete.vue +++ b/packages/frontend/src/components/MkAutocomplete.vue @@ -17,7 +17,7 @@
  1. - + @@ -112,7 +112,7 @@ const emojiDb = computed(() => { customEmojiDB.sort((a, b) => a.name.length - b.name.length); //#endregion - return markRaw([ ...customEmojiDB, ...unicodeEmojiDB ]); + return markRaw([...customEmojiDB, ...unicodeEmojiDB]); }); export default { diff --git a/packages/frontend/src/components/MkEmojiPicker.section.vue b/packages/frontend/src/components/MkEmojiPicker.section.vue index acced44793..c418ac2c52 100644 --- a/packages/frontend/src/components/MkEmojiPicker.section.vue +++ b/packages/frontend/src/components/MkEmojiPicker.section.vue @@ -11,7 +11,8 @@ class="_button item" @click="emit('chosen', emoji, $event)" > - + + diff --git a/packages/frontend/src/components/MkEmojiPicker.vue b/packages/frontend/src/components/MkEmojiPicker.vue index f64cc6e9aa..39e274ba11 100644 --- a/packages/frontend/src/components/MkEmojiPicker.vue +++ b/packages/frontend/src/components/MkEmojiPicker.vue @@ -12,7 +12,7 @@ tabindex="0" @click="chosen(emoji, $event)" > - +
    @@ -39,7 +39,8 @@ tabindex="0" @click="chosen(emoji, $event)" > - + +
    @@ -53,7 +54,8 @@ class="_button item" @click="chosen(emoji, $event)" > - + + diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 1f6a2883d7..351861ac17 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -48,12 +48,12 @@
    ({{ i18n.ts.private }}) - +
    {{ $t('translatedFrom', { x: translation.sourceLang }) }}: - +
    diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index 48ace56d9c..0da06c4f14 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -65,13 +65,13 @@
    ({{ i18n.ts.private }}) - + RN:
    {{ $t('translatedFrom', { x: translation.sourceLang }) }}: - +
    diff --git a/packages/frontend/src/components/MkNoteSimple.vue b/packages/frontend/src/components/MkNoteSimple.vue index 828bbee5af..757b325a06 100644 --- a/packages/frontend/src/components/MkNoteSimple.vue +++ b/packages/frontend/src/components/MkNoteSimple.vue @@ -5,7 +5,7 @@

    - +

    diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue index a8b8fec346..b51d456eab 100644 --- a/packages/frontend/src/components/MkNotification.vue +++ b/packages/frontend/src/components/MkNotification.vue @@ -38,26 +38,26 @@
    - + - + - + - + - + - + @@ -68,7 +68,7 @@ {{ i18n.ts.receiveFollowRequest }}
    |
    {{ i18n.ts.groupInvited }}: {{ notification.invitation.group.name }}
    |
    - +
    diff --git a/packages/frontend/src/components/MkNotifications.vue b/packages/frontend/src/components/MkNotifications.vue index f5ae7bcee4..ab5dff8db5 100644 --- a/packages/frontend/src/components/MkNotifications.vue +++ b/packages/frontend/src/components/MkNotifications.vue @@ -10,7 +10,7 @@ diff --git a/packages/frontend/src/components/MkReactionIcon.vue b/packages/frontend/src/components/MkReactionIcon.vue index 6e9d2b1a6c..29b3f9b85b 100644 --- a/packages/frontend/src/components/MkReactionIcon.vue +++ b/packages/frontend/src/components/MkReactionIcon.vue @@ -1,5 +1,6 @@ diff --git a/packages/frontend/src/components/MkReactionsViewer.reaction.vue b/packages/frontend/src/components/MkReactionsViewer.reaction.vue index eed6b46594..83fdf0f988 100644 --- a/packages/frontend/src/components/MkReactionsViewer.reaction.vue +++ b/packages/frontend/src/components/MkReactionsViewer.reaction.vue @@ -6,7 +6,7 @@ :class="[$style.root, { [$style.reacted]: note.myReaction == reaction, [$style.canToggle]: canToggle }]" @click="toggleReaction()" > - + {{ count }} diff --git a/packages/frontend/src/components/MkSubNoteContent.vue b/packages/frontend/src/components/MkSubNoteContent.vue index 482a43f474..7e6833dae9 100644 --- a/packages/frontend/src/components/MkSubNoteContent.vue +++ b/packages/frontend/src/components/MkSubNoteContent.vue @@ -4,7 +4,7 @@ ({{ i18n.ts.private }}) ({{ i18n.ts.deleted }}) - + RN: ...
    diff --git a/packages/frontend/src/components/global/MkCustomEmoji.vue b/packages/frontend/src/components/global/MkCustomEmoji.vue new file mode 100644 index 0000000000..93c47f0c27 --- /dev/null +++ b/packages/frontend/src/components/global/MkCustomEmoji.vue @@ -0,0 +1,61 @@ + + + + + diff --git a/packages/frontend/src/components/global/MkEmoji.vue b/packages/frontend/src/components/global/MkEmoji.vue index b554d5e47c..1b0d34a445 100644 --- a/packages/frontend/src/components/global/MkEmoji.vue +++ b/packages/frontend/src/components/global/MkEmoji.vue @@ -1,54 +1,29 @@ @@ -58,27 +33,4 @@ function computeTitle(event: PointerEvent): void { height: 1.25em; vertical-align: -0.25em; } - -.custom { - height: 2.5em; - vertical-align: middle; - transition: transform 0.2s ease; - - &:hover { - transform: scale(1.2); - } -} - -.normal { - height: 1.25em; - vertical-align: -0.25em; - - &:hover { - transform: none; - } -} - -.noStyle { - height: auto !important; -} diff --git a/packages/frontend/src/components/global/MkUserName.vue b/packages/frontend/src/components/global/MkUserName.vue index fc08310acc..4186a4a4fb 100644 --- a/packages/frontend/src/components/global/MkUserName.vue +++ b/packages/frontend/src/components/global/MkUserName.vue @@ -1,5 +1,5 @@