diff --git a/.config/example.yml b/.config/example.yml index 9126bdfd91..ae55b983bb 100644 --- a/.config/example.yml +++ b/.config/example.yml @@ -302,5 +302,10 @@ checkActivityPubGetSignature: false # Upload or download file size limits (bytes) #maxFileSize: 262144000 +# timeout and maximum size for imports (e.g. note imports) +#import: +# downloadTimeout: 30 +# maxFileSize: 262144000 + # PID File of master process #pidFile: /tmp/misskey.pid diff --git a/locales/en-US.yml b/locales/en-US.yml index ffebe5fbec..44f90c8d47 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -694,6 +694,7 @@ channel: "Channels" create: "Create" notificationSetting: "Notification settings" notificationSettingDesc: "Select the types of notification to display." +enableFaviconNotificationDot: "Enable favicon notification dot" useGlobalSetting: "Use global settings" useGlobalSettingDesc: "If turned on, your account's notification settings will be used. If turned off, individual configurations can be made." other: "Other" diff --git a/locales/index.d.ts b/locales/index.d.ts index dfcf6b6689..d300ed42a3 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -2792,6 +2792,10 @@ export interface Locale extends ILocale { * 表示する通知の種別を選択してください。 */ "notificationSettingDesc": string; + /** + * ファビコン通知ドットを有効にする + */ + "enableFaviconNotificationDot": string; /** * グローバル設定を使う */ diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index f12c1ab4fc..4d214b652a 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -694,6 +694,7 @@ channel: "チャンネル" create: "作成" notificationSetting: "通知設定" notificationSettingDesc: "表示する通知の種別を選択してください。" +enableFaviconNotificationDot: "ファビコン通知ドットを有効にする" useGlobalSetting: "グローバル設定を使う" useGlobalSettingDesc: "オンにすると、アカウントの通知設定が使用されます。オフにすると、個別に設定できるようになります。" other: "その他" diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index 346a557195..58c4d028aa 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -100,6 +100,12 @@ type Source = { perChannelMaxNoteCacheCount?: number; perUserNotificationsMaxCount?: number; deactivateAntennaThreshold?: number; + + import?: { + downloadTimeout: number; + maxFileSize: number; + }; + pidFile: string; }; @@ -182,6 +188,12 @@ export type Config = { perChannelMaxNoteCacheCount: number; perUserNotificationsMaxCount: number; deactivateAntennaThreshold: number; + + import: { + downloadTimeout: number; + maxFileSize: number; + } | undefined; + pidFile: string; }; @@ -291,6 +303,7 @@ export function loadConfig(): Config { perChannelMaxNoteCacheCount: config.perChannelMaxNoteCacheCount ?? 1000, perUserNotificationsMaxCount: config.perUserNotificationsMaxCount ?? 500, deactivateAntennaThreshold: config.deactivateAntennaThreshold ?? (1000 * 60 * 60 * 24 * 7), + import: config.import, pidFile: config.pidFile, }; } @@ -436,4 +449,5 @@ function applyEnvOverrides(config: Source) { _apply_top([['clusterLimit', 'deliverJobConcurrency', 'inboxJobConcurrency', 'relashionshipJobConcurrency', 'deliverJobPerSec', 'inboxJobPerSec', 'relashionshipJobPerSec', 'deliverJobMaxAttempts', 'inboxJobMaxAttempts']]); _apply_top([['outgoingAddress', 'outgoingAddressFamily', 'proxy', 'proxySmtp', 'mediaProxy', 'videoThumbnailGenerator']]); _apply_top([['maxFileSize', 'maxNoteLength', 'pidFile']]); + _apply_top(['import', ['downloadTimeout', 'maxFileSize']]); } diff --git a/packages/backend/src/core/DownloadService.ts b/packages/backend/src/core/DownloadService.ts index 21ae798f9f..83452845d4 100644 --- a/packages/backend/src/core/DownloadService.ts +++ b/packages/backend/src/core/DownloadService.ts @@ -35,14 +35,14 @@ export class DownloadService { } @bindThis - public async downloadUrl(url: string, path: string): Promise<{ + public async downloadUrl(url: string, path: string, options: { timeout?: number, operationTimeout?: number, maxSize?: number} = {} ): 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 timeout = options.timeout ?? 30 * 1000; + const operationTimeout = options.operationTimeout ?? 60 * 1000; + const maxSize = options.maxSize ?? this.config.maxFileSize ?? 262144000; const urlObj = new URL(url); let filename = urlObj.pathname.split('/').pop() ?? 'untitled'; diff --git a/packages/backend/src/core/MfmService.ts b/packages/backend/src/core/MfmService.ts index 461cb2b6a9..30009c1229 100644 --- a/packages/backend/src/core/MfmService.ts +++ b/packages/backend/src/core/MfmService.ts @@ -464,10 +464,10 @@ export class MfmService { return new XMLSerializer().serializeToString(body); } - // the toMastoHtml function was taken from Iceshrimp and written by zotan and modified by marie to work with the current MK version + // the toMastoApiHtml function was taken from Iceshrimp and written by zotan and modified by marie to work with the current MK version @bindThis - public async toMastoHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMentionedRemoteUsers = [], inline = false, quoteUri: string | null = null) { + public async toMastoApiHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMentionedRemoteUsers = [], inline = false, quoteUri: string | null = null) { if (nodes == null) { return null; } @@ -485,174 +485,174 @@ export class MfmService { const handlers: { [K in mfm.MfmNode['type']]: (node: mfm.NodeType<K>) => any; } = { - async bold(node) { - const el = doc.createElement('span'); - el.textContent = '**'; - await appendChildren(node.children, el); - el.textContent += '**'; - return el; - }, + async bold(node) { + const el = doc.createElement('span'); + el.textContent = '**'; + await appendChildren(node.children, el); + el.textContent += '**'; + return el; + }, - async small(node) { - const el = doc.createElement('small'); - await appendChildren(node.children, el); - return el; - }, + async small(node) { + const el = doc.createElement('small'); + await appendChildren(node.children, el); + return el; + }, - async strike(node) { - const el = doc.createElement('span'); - el.textContent = '~~'; - await appendChildren(node.children, el); - el.textContent += '~~'; - return el; - }, + async strike(node) { + const el = doc.createElement('span'); + el.textContent = '~~'; + await appendChildren(node.children, el); + el.textContent += '~~'; + return el; + }, - async italic(node) { - const el = doc.createElement('span'); - el.textContent = '*'; - await appendChildren(node.children, el); - el.textContent += '*'; - return el; - }, + async italic(node) { + const el = doc.createElement('span'); + el.textContent = '*'; + await appendChildren(node.children, el); + el.textContent += '*'; + return el; + }, - async fn(node) { - const el = doc.createElement('span'); - el.textContent = '*'; - await appendChildren(node.children, el); - el.textContent += '*'; - return el; - }, + async fn(node) { + const el = doc.createElement('span'); + el.textContent = '*'; + await appendChildren(node.children, el); + el.textContent += '*'; + return el; + }, - blockCode(node) { - const pre = doc.createElement('pre'); - const inner = doc.createElement('code'); + blockCode(node) { + const pre = doc.createElement('pre'); + const inner = doc.createElement('code'); - const nodes = node.props.code - .split(/\r\n|\r|\n/) - .map((x) => doc.createTextNode(x)); + const nodes = node.props.code + .split(/\r\n|\r|\n/) + .map((x) => doc.createTextNode(x)); - for (const x of intersperse<FIXME | 'br'>('br', nodes)) { - inner.appendChild(x === 'br' ? doc.createElement('br') : x); - } + for (const x of intersperse<FIXME | 'br'>('br', nodes)) { + inner.appendChild(x === 'br' ? doc.createElement('br') : x); + } - pre.appendChild(inner); - return pre; - }, + pre.appendChild(inner); + return pre; + }, - async center(node) { - const el = doc.createElement('div'); - await appendChildren(node.children, el); - return el; - }, + async center(node) { + const el = doc.createElement('div'); + await appendChildren(node.children, el); + return el; + }, - emojiCode(node) { - return doc.createTextNode(`\u200B:${node.props.name}:\u200B`); - }, + emojiCode(node) { + return doc.createTextNode(`\u200B:${node.props.name}:\u200B`); + }, - unicodeEmoji(node) { - return doc.createTextNode(node.props.emoji); - }, + unicodeEmoji(node) { + return doc.createTextNode(node.props.emoji); + }, - hashtag: (node) => { - const a = doc.createElement('a'); - a.setAttribute('href', `${this.config.url}/tags/${node.props.hashtag}`); - a.textContent = `#${node.props.hashtag}`; - a.setAttribute('rel', 'tag'); - a.setAttribute('class', 'hashtag'); - return a; - }, + hashtag: (node) => { + const a = doc.createElement('a'); + a.setAttribute('href', `${this.config.url}/tags/${node.props.hashtag}`); + a.textContent = `#${node.props.hashtag}`; + a.setAttribute('rel', 'tag'); + a.setAttribute('class', 'hashtag'); + return a; + }, - inlineCode(node) { - const el = doc.createElement('code'); - el.textContent = node.props.code; - return el; - }, + inlineCode(node) { + const el = doc.createElement('code'); + el.textContent = node.props.code; + return el; + }, - mathInline(node) { - const el = doc.createElement('code'); - el.textContent = node.props.formula; - return el; - }, + mathInline(node) { + const el = doc.createElement('code'); + el.textContent = node.props.formula; + return el; + }, - mathBlock(node) { - const el = doc.createElement('code'); - el.textContent = node.props.formula; - return el; - }, + mathBlock(node) { + const el = doc.createElement('code'); + el.textContent = node.props.formula; + return el; + }, - async link(node) { - const a = doc.createElement('a'); - a.setAttribute('rel', 'nofollow noopener noreferrer'); - a.setAttribute('target', '_blank'); - a.setAttribute('href', node.props.url); - await appendChildren(node.children, a); - return a; - }, + async link(node) { + const a = doc.createElement('a'); + a.setAttribute('rel', 'nofollow noopener noreferrer'); + a.setAttribute('target', '_blank'); + a.setAttribute('href', node.props.url); + await appendChildren(node.children, a); + return a; + }, - async mention(node) { - const { username, host, acct } = node.props; - const resolved = mentionedRemoteUsers.find(remoteUser => remoteUser.username === username && remoteUser.host === host); + async mention(node) { + const { username, host, acct } = node.props; + const resolved = mentionedRemoteUsers.find(remoteUser => remoteUser.username === username && remoteUser.host === host); - const el = doc.createElement('span'); - if (!resolved) { - el.textContent = acct; - } else { - el.setAttribute('class', 'h-card'); - el.setAttribute('translate', 'no'); - const a = doc.createElement('a'); - a.setAttribute('href', resolved.url ? resolved.url : resolved.uri); - a.className = 'u-url mention'; - const span = doc.createElement('span'); - span.textContent = resolved.username || username; - a.textContent = '@'; - a.appendChild(span); - el.appendChild(a); - } + const el = doc.createElement('span'); + if (!resolved) { + el.textContent = acct; + } else { + el.setAttribute('class', 'h-card'); + el.setAttribute('translate', 'no'); + const a = doc.createElement('a'); + a.setAttribute('href', resolved.url ? resolved.url : resolved.uri); + a.className = 'u-url mention'; + const span = doc.createElement('span'); + span.textContent = resolved.username || username; + a.textContent = '@'; + a.appendChild(span); + el.appendChild(a); + } - return el; - }, + return el; + }, - async quote(node) { - const el = doc.createElement('blockquote'); - await appendChildren(node.children, el); - return el; - }, + async quote(node) { + const el = doc.createElement('blockquote'); + await appendChildren(node.children, el); + return el; + }, - text(node) { - const el = doc.createElement('span'); - const nodes = node.props.text - .split(/\r\n|\r|\n/) - .map((x) => doc.createTextNode(x)); + text(node) { + const el = doc.createElement('span'); + const nodes = node.props.text + .split(/\r\n|\r|\n/) + .map((x) => doc.createTextNode(x)); - for (const x of intersperse<FIXME | 'br'>('br', nodes)) { - el.appendChild(x === 'br' ? doc.createElement('br') : x); - } + for (const x of intersperse<FIXME | 'br'>('br', nodes)) { + el.appendChild(x === 'br' ? doc.createElement('br') : x); + } - return el; - }, + return el; + }, - url(node) { - const a = doc.createElement('a'); - a.setAttribute('rel', 'nofollow noopener noreferrer'); - a.setAttribute('target', '_blank'); - a.setAttribute('href', node.props.url); - a.textContent = node.props.url.replace(/^https?:\/\//, ''); - return a; - }, + url(node) { + const a = doc.createElement('a'); + a.setAttribute('rel', 'nofollow noopener noreferrer'); + a.setAttribute('target', '_blank'); + a.setAttribute('href', node.props.url); + a.textContent = node.props.url.replace(/^https?:\/\//, ''); + return a; + }, - search: (node) => { - const a = doc.createElement('a'); - a.setAttribute('href', `https"google.com/${node.props.query}`); - a.textContent = node.props.content; - return a; - }, + search: (node) => { + const a = doc.createElement('a'); + a.setAttribute('href', `https://www.google.com/search?q=${node.props.query}`); + a.textContent = node.props.content; + return a; + }, - async plain(node) { - const el = doc.createElement('span'); - await appendChildren(node.children, el); - return el; - }, - }; + async plain(node) { + const el = doc.createElement('span'); + await appendChildren(node.children, el); + return el; + }, + }; await appendChildren(nodes, doc.body); diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 4ff0d0fbef..41efa76f3f 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -627,6 +627,14 @@ export class NoteCreateService implements OnApplicationShutdown { userHost: user.host, }); + // should really not happen, but better safe than sorry + if (data.reply?.id === insert.id) { + throw new Error("A note can't reply to itself"); + } + if (data.renote?.id === insert.id) { + throw new Error("A note can't renote itself"); + } + if (data.uri != null) insert.uri = data.uri; if (data.url != null) insert.url = data.url; diff --git a/packages/backend/src/core/NoteEditService.ts b/packages/backend/src/core/NoteEditService.ts index e28299316d..0cb58d04a2 100644 --- a/packages/backend/src/core/NoteEditService.ts +++ b/packages/backend/src/core/NoteEditService.ts @@ -299,6 +299,10 @@ export class NoteEditService implements OnApplicationShutdown { } if (this.isRenote(data)) { + if (data.renote.id === oldnote.id) { + throw new Error("A note can't renote itself"); + } + switch (data.renote.visibility) { case 'public': // public noteは無条件にrenote可能 diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts index cad1af02e5..4827baad84 100644 --- a/packages/backend/src/core/activitypub/models/ApNoteService.ts +++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts @@ -248,7 +248,7 @@ export class ApNoteService { > => { if (typeof uri !== 'string' || !/^https?:/.test(uri)) return { status: 'permerror' }; try { - const res = await this.resolveNote(uri); + const res = await this.resolveNote(uri, { resolver }); if (res == null) return { status: 'permerror' }; return { status: 'ok', res }; } catch (e) { @@ -473,7 +473,7 @@ export class ApNoteService { > => { if (!/^https?:/.test(uri)) return { status: 'permerror' }; try { - const res = await this.resolveNote(uri); + const res = await this.resolveNote(uri, { resolver }); if (res == null) return { status: 'permerror' }; return { status: 'ok', res }; } catch (e) { diff --git a/packages/backend/src/misc/sql-like-escape.ts b/packages/backend/src/misc/sql-like-escape.ts index 0c05255674..ffe61670ee 100644 --- a/packages/backend/src/misc/sql-like-escape.ts +++ b/packages/backend/src/misc/sql-like-escape.ts @@ -4,5 +4,5 @@ */ export function sqlLikeEscape(s: string) { - return s.replace(/([%_])/g, '\\$1'); + return s.replace(/([%_\\])/g, '\\$1'); } diff --git a/packages/backend/src/queue/processors/ImportNotesProcessorService.ts b/packages/backend/src/queue/processors/ImportNotesProcessorService.ts index 7cef858c51..58a0ea10ad 100644 --- a/packages/backend/src/queue/processors/ImportNotesProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportNotesProcessorService.ts @@ -19,12 +19,16 @@ import { IdService } from '@/core/IdService.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type * as Bull from 'bullmq'; import type { DbNoteImportToDbJobData, DbNoteImportJobData, DbNoteWithParentImportToDbJobData } from '../types.js'; +import type { Config } from '@/config.js'; @Injectable() export class ImportNotesProcessorService { private logger: Logger; constructor( + @Inject(DI.config) + private config: Config, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -73,6 +77,11 @@ export class ImportNotesProcessorService { } } + @bindThis + private downloadUrl(url: string, path:string): Promise<{filename: string}> { + return this.downloadService.downloadUrl(url, path, { operationTimeout: this.config.import?.downloadTimeout, maxSize: this.config.import?.maxFileSize }); + } + @bindThis private async recreateChain(idFieldPath: string[], replyFieldPath: string[], arr: any[], includeOrphans: boolean): Promise<any[]> { type NotesMap = { @@ -176,7 +185,7 @@ export class ImportNotesProcessorService { try { await fsp.writeFile(destPath, '', 'binary'); - await this.downloadService.downloadUrl(file.url, destPath); + await this.downloadUrl(file.url, destPath); } catch (e) { // TODO: 何度か再試行 if (e instanceof Error || typeof e === 'string') { this.logger.error(e); @@ -206,7 +215,7 @@ export class ImportNotesProcessorService { try { await fsp.writeFile(destPath, '', 'binary'); - await this.downloadService.downloadUrl(file.url, destPath); + await this.downloadUrl(file.url, destPath); } catch (e) { // TODO: 何度か再試行 if (e instanceof Error || typeof e === 'string') { this.logger.error(e); @@ -239,7 +248,7 @@ export class ImportNotesProcessorService { try { await fsp.writeFile(destPath, '', 'binary'); - await this.downloadService.downloadUrl(file.url, destPath); + await this.downloadUrl(file.url, destPath); } catch (e) { // TODO: 何度か再試行 if (e instanceof Error || typeof e === 'string') { this.logger.error(e); @@ -297,7 +306,7 @@ export class ImportNotesProcessorService { try { await fsp.writeFile(path, '', 'utf-8'); - await this.downloadService.downloadUrl(file.url, path); + await this.downloadUrl(file.url, path); } catch (e) { // TODO: 何度か再試行 if (e instanceof Error || typeof e === 'string') { this.logger.error(e); @@ -349,7 +358,7 @@ export class ImportNotesProcessorService { if (!exists) { try { - await this.downloadService.downloadUrl(file.url, filePath); + await this.downloadUrl(file.url, filePath); } catch (e) { // TODO: 何度か再試行 this.logger.error(e instanceof Error ? e : new Error(e as string)); } @@ -488,7 +497,7 @@ export class ImportNotesProcessorService { if (!exists) { try { - await this.downloadService.downloadUrl(file.url, filePath); + await this.downloadUrl(file.url, filePath); } catch (e) { // TODO: 何度か再試行 this.logger.error(e instanceof Error ? e : new Error(e as string)); } diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/list.ts b/packages/backend/src/server/api/endpoints/admin/emoji/list.ts index 5e21111f9f..f35a6667f4 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/list.ts @@ -66,6 +66,7 @@ export const paramDef = { properties: { query: { type: 'string', nullable: true, default: null }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + offset: { type: 'integer', minimum: 1, nullable: true, default: null }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, }, @@ -91,7 +92,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- //q.andWhere('emoji.name ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` }); //const emojis = await q.limit(ps.limit).getMany(); - emojis = await q.orderBy('length(emoji.name)', 'ASC').getMany(); + emojis = await q.orderBy('length(emoji.name)', 'ASC').addOrderBy('id', 'DESC').getMany(); const queryarry = ps.query.match(/:([\p{Letter}\p{Number}\p{Mark}_+-]*):/ug); if (queryarry) { @@ -105,9 +106,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- emoji.aliases.some(a => a.includes(queryNfc)) || emoji.category?.includes(queryNfc)); } - emojis.splice(ps.limit + 1); + emojis = emojis.slice((ps.offset ?? 0), ((ps.offset ?? 0) + ps.limit)); } else { - emojis = await q.limit(ps.limit).getMany(); + emojis = await q.take(ps.limit).skip(ps.offset ?? 0).getMany(); } return this.emojiEntityService.packDetailedMany(emojis); diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts index 296698522d..5d2895cd46 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -30,8 +30,8 @@ export const meta = { prohibitMoved: true, limit: { - duration: ms('1hour'), - max: 300, + duration: ms('1minute'), + max: 5, }, kind: 'write:notes', diff --git a/packages/backend/src/server/api/mastodon/converters.ts b/packages/backend/src/server/api/mastodon/converters.ts index ea219b933d..cca9505846 100644 --- a/packages/backend/src/server/api/mastodon/converters.ts +++ b/packages/backend/src/server/api/mastodon/converters.ts @@ -110,7 +110,7 @@ export class MastoConverters { private async encodeField(f: Entity.Field): Promise<Entity.Field> { return { name: f.name, - value: await this.mfmService.toMastoHtml(mfm.parse(f.value), [], true) ?? escapeMFM(f.value), + value: await this.mfmService.toMastoApiHtml(mfm.parse(f.value), [], true) ?? escapeMFM(f.value), verified_at: null, }; } @@ -179,7 +179,7 @@ export class MastoConverters { const files = this.driveFileEntityService.packManyByIds(edit.fileIds); const item = { account: noteUser, - content: this.mfmService.toMastoHtml(mfm.parse(edit.newText ?? ''), JSON.parse(note.mentionedRemoteUsers)).then(p => p ?? ''), + content: this.mfmService.toMastoApiHtml(mfm.parse(edit.newText ?? ''), JSON.parse(note.mentionedRemoteUsers)).then(p => p ?? ''), created_at: lastDate.toISOString(), emojis: [], sensitive: files.then(files => files.length > 0 ? files.some((f) => f.isSensitive) : false), @@ -240,7 +240,7 @@ export class MastoConverters { }); const content = note.text !== null - ? quoteUri.then(quoteUri => this.mfmService.toMastoHtml(mfm.parse(note.text!), JSON.parse(note.mentionedRemoteUsers), false, quoteUri)) + ? quoteUri.then(quoteUri => this.mfmService.toMastoApiHtml(mfm.parse(note.text!), JSON.parse(note.mentionedRemoteUsers), false, quoteUri)) .then(p => p ?? escapeMFM(note.text!)) : ''; diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 89c950e642..2d1f3d8d44 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -334,10 +334,12 @@ function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string return false; } +let renoting = false; + const keymap = { 'r': () => reply(true), 'e|a|plus': () => react(true), - 'q': () => renote(appearNote.value.visibility), + '(q)': () => { if (canRenote && !renoted.value && !renoting) { renoting = true; renote(appearNote.value.visibility) } }, 'up|k|shift+tab': focusBefore, 'down|j|tab': focusAfter, 'esc': blur, @@ -464,7 +466,7 @@ function renote(visibility: Visibility, localOnly: boolean = false) { }).then(() => { os.toast(i18n.ts.renoted); renoted.value = true; - }); + }).finally(() => { renoting = false }); } } else if (!appearNote.value.channel || appearNote.value.channel.allowRenoteToExternal) { const el = renoteButton.value as HTMLElement | null | undefined; @@ -483,7 +485,7 @@ function renote(visibility: Visibility, localOnly: boolean = false) { }).then(() => { os.toast(i18n.ts.renoted); renoted.value = true; - }); + }).finally(() => renoting = false); } } } diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index f17c2b542d..8de121b8de 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -346,10 +346,12 @@ if ($i) { }); } +let renoting = false; + const keymap = { 'r': () => reply(true), 'e|a|plus': () => react(true), - 'q': () => renote(appearNote.value.visibility), + '(q)': () => { if (canRenote && !renoted.value && !renoting) { renoting = true; renote(appearNote.value.visibility) } }, 'esc': blur, 'm|o': () => showMenu(true), 's': () => showContent.value !== showContent.value, @@ -489,7 +491,7 @@ function renote(visibility: Visibility, localOnly: boolean = false) { }).then(() => { os.toast(i18n.ts.renoted); renoted.value = true; - }); + }).finally(() => { renoting = false }); } else if (!appearNote.value.channel || appearNote.value.channel.allowRenoteToExternal) { const el = renoteButton.value as HTMLElement | null | undefined; if (el) { @@ -506,7 +508,7 @@ function renote(visibility: Visibility, localOnly: boolean = false) { }).then(() => { os.toast(i18n.ts.renoted); renoted.value = true; - }); + }).finally(() => { renoting = false }); } } diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue index 6f6007d432..9a324849e2 100644 --- a/packages/frontend/src/components/MkPagination.vue +++ b/packages/frontend/src/components/MkPagination.vue @@ -73,7 +73,7 @@ export type Paging<E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints> */ reversed?: boolean; - offsetMode?: boolean; + offsetMode?: boolean | ComputedRef<boolean>; pageEl?: HTMLElement; }; @@ -240,10 +240,11 @@ const fetchMore = async (): Promise<void> => { if (!more.value || fetching.value || moreFetching.value || items.value.size === 0) return; moreFetching.value = true; const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {}; + const offsetMode = props.offsetMode ? isRef(props.offsetMode) ? props.offsetMode.value : props.offsetMode : false; await misskeyApi<MisskeyEntity[]>(props.pagination.endpoint, { ...params, limit: SECOND_FETCH_LIMIT, - ...(props.pagination.offsetMode ? { + ...(offsetMode ? { offset: offset.value, } : { untilId: Array.from(items.value.keys()).at(-1), @@ -304,10 +305,11 @@ const fetchMoreAhead = async (): Promise<void> => { if (!more.value || fetching.value || moreFetching.value || items.value.size === 0) return; moreFetching.value = true; const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {}; + const offsetMode = props.offsetMode ? isRef(props.offsetMode) ? props.offsetMode.value : props.offsetMode : false; await misskeyApi<MisskeyEntity[]>(props.pagination.endpoint, { ...params, limit: SECOND_FETCH_LIMIT, - ...(props.pagination.offsetMode ? { + ...(offsetMode ? { offset: offset.value, } : { sinceId: Array.from(items.value.keys()).at(-1), diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index c6631fe1f4..cfaaeecc34 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -68,7 +68,7 @@ SPDX-License-Identifier: AGPL-3.0-only <input v-show="useCw" ref="cwInputEl" v-model="cw" :class="$style.cw" :placeholder="i18n.ts.annotation" @keydown="onKeydown"> <div :class="[$style.textOuter, { [$style.withCw]: useCw }]"> <div v-if="channel" :class="$style.colorBar" :style="{ background: channel.color }"></div> - <textarea ref="textareaEl" v-model="text" :class="[$style.text]" :disabled="posting || posted" :readonly="textAreaReadOnly" :placeholder="placeholder" data-cy-post-form-text @keydown="onKeydown" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd"/> + <textarea ref="textareaEl" v-model="text" :class="[$style.text]" :disabled="posting || posted" :readonly="textAreaReadOnly" :placeholder="placeholder" data-cy-post-form-text dir="auto" @keydown="onKeydown" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd"/> <div v-if="maxTextLength - textLength < 100" :class="['_acrylic', $style.textCount, { [$style.textOver]: textLength > maxTextLength }]">{{ maxTextLength - textLength }}</div> </div> <input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags"> diff --git a/packages/frontend/src/components/SkNote.vue b/packages/frontend/src/components/SkNote.vue index 1a8207e6c6..9e744fcac8 100644 --- a/packages/frontend/src/components/SkNote.vue +++ b/packages/frontend/src/components/SkNote.vue @@ -335,10 +335,12 @@ function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string return false; } +let renoting = false; + const keymap = { 'r': () => reply(true), 'e|a|plus': () => react(true), - 'q': () => renote(appearNote.value.visibility), + '(q)': () => { if (canRenote && !renoted.value && !renoting) { renoting = true; renote(appearNote.value.visibility) } }, 'up|k|shift+tab': focusBefore, 'down|j|tab': focusAfter, 'esc': blur, @@ -465,7 +467,7 @@ function renote(visibility: Visibility, localOnly: boolean = false) { }).then(() => { os.toast(i18n.ts.renoted); renoted.value = true; - }); + }).finally(() => { renoting = false }); } } else if (!appearNote.value.channel || appearNote.value.channel.allowRenoteToExternal) { const el = renoteButton.value as HTMLElement | null | undefined; @@ -484,7 +486,7 @@ function renote(visibility: Visibility, localOnly: boolean = false) { }).then(() => { os.toast(i18n.ts.renoted); renoted.value = true; - }); + }).finally(() => { renoting = false }); } } } diff --git a/packages/frontend/src/components/SkNoteDetailed.vue b/packages/frontend/src/components/SkNoteDetailed.vue index 0bf096fe34..70d4704b88 100644 --- a/packages/frontend/src/components/SkNoteDetailed.vue +++ b/packages/frontend/src/components/SkNoteDetailed.vue @@ -355,10 +355,12 @@ if ($i) { }); } +let renoting = false; + const keymap = { 'r': () => reply(true), 'e|a|plus': () => react(true), - 'q': () => renote(appearNote.value.visibility), + '(q)': () => { if (canRenote && !renoted.value && !renoting) { renoting = true; renote(appearNote.value.visibility) } }, 'esc': blur, 'm|o': () => showMenu(true), 's': () => showContent.value !== showContent.value, @@ -498,7 +500,7 @@ function renote(visibility: Visibility, localOnly: boolean = false) { }).then(() => { os.toast(i18n.ts.renoted); renoted.value = true; - }); + }).finally(() => { renoting = false }); } else if (!appearNote.value.channel || appearNote.value.channel.allowRenoteToExternal) { const el = renoteButton.value as HTMLElement | null | undefined; if (el) { @@ -515,7 +517,7 @@ function renote(visibility: Visibility, localOnly: boolean = false) { }).then(() => { os.toast(i18n.ts.renoted); renoted.value = true; - }); + }).finally(() => { renoting = false }); } } diff --git a/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts index a21cd9477e..a5effb65ba 100644 --- a/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts +++ b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts @@ -393,67 +393,67 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven } case 'center': { - return [h('div', { + return [h('bdi',h('div', { style: 'text-align:center;', - }, genEl(token.children, scale))]; + }, genEl(token.children, scale)))]; } case 'url': { - return [h(MkUrl, { + return [h('bdi',h(MkUrl, { key: Math.random(), url: token.props.url, rel: 'nofollow noopener', - })]; + }))]; } case 'link': { - return [h(MkLink, { + return [h('bdi',h(MkLink, { key: Math.random(), url: token.props.url, rel: 'nofollow noopener', - }, genEl(token.children, scale, true))]; + }, genEl(token.children, scale, true)))]; } case 'mention': { - return [h(MkMention, { + return [h('bdi',h(MkMention, { key: Math.random(), host: (token.props.host == null && props.author && props.author.host != null ? props.author.host : token.props.host) ?? host, username: token.props.username, - })]; + }))]; } case 'hashtag': { - return [h(MkA, { + return [h('bdi',h(MkA, { key: Math.random(), to: isNote ? `/tags/${encodeURIComponent(token.props.hashtag)}` : `/user-tags/${encodeURIComponent(token.props.hashtag)}`, style: 'color:var(--hashtag);', - }, `#${token.props.hashtag}`)]; + }, `#${token.props.hashtag}`))]; } case 'blockCode': { - return [h(MkCode, { + return [h('bdi',h(MkCode, { key: Math.random(), code: token.props.code, lang: token.props.lang ?? undefined, - })]; + }))]; } case 'inlineCode': { - return [h(MkCodeInline, { + return [h('bdi',h(MkCodeInline, { key: Math.random(), code: token.props.code, - })]; + }))]; } case 'quote': { if (!props.nowrap) { - return [h('div', { + return [h('bdi',h('div', { style: QUOTE_STYLE, - }, genEl(token.children, scale, true))]; + }, genEl(token.children, scale, true)))]; } else { - return [h('span', { + return [h('bdi',h('span', { style: QUOTE_STYLE, - }, genEl(token.children, scale, true))]; + }, genEl(token.children, scale, true)))]; } } @@ -497,17 +497,17 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven } case 'mathInline': { - return [h(MkFormula, { + return [h('bdi',h(MkFormula, { formula: token.props.formula, block: false, - })]; + }))]; } case 'mathBlock': { - return [h(MkFormula, { + return [h('bdi',h(MkFormula, { formula: token.props.formula, block: true, - })]; + }))]; } case 'search': { @@ -530,8 +530,8 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven } }).flat(Infinity) as (VNode | string)[]; - return h('span', { + return h('bdi', h('span', { // https://codeday.me/jp/qa/20190424/690106.html style: props.nowrap ? 'white-space: pre; word-wrap: normal; overflow: hidden; text-overflow: ellipsis;' : 'white-space: pre-wrap;', - }, genEl(rootAst, props.rootScale ?? 1)); + }, genEl(rootAst, props.rootScale ?? 1))); } diff --git a/packages/frontend/src/index.html b/packages/frontend/src/index.html index 51a9cdce79..733116b75f 100644 --- a/packages/frontend/src/index.html +++ b/packages/frontend/src/index.html @@ -22,7 +22,7 @@ style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: www.google.com xn--931a.moe launcher.moe localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000 activitypub.software secure.gravatar.com avatars.githubusercontent.com; media-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000; - connect-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000 https://newassets.hcaptcha.com; + connect-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000 https://newassets.hcaptcha.com https://api.listenbrainz.org; frame-src *;" /> <meta property="og:site_name" content="[DEV BUILD] Misskey" /> diff --git a/packages/frontend/src/pages/about-sharkey.vue b/packages/frontend/src/pages/about-sharkey.vue index 1bfd90c8f7..f020e043c6 100644 --- a/packages/frontend/src/pages/about-sharkey.vue +++ b/packages/frontend/src/pages/about-sharkey.vue @@ -215,7 +215,7 @@ function gravity() { function iLoveMisskey() { os.post({ - initialText: 'I $[jelly ❤] #Misskey', + initialText: 'I $[jelly ❤] #Sharkey', instant: true, }); } diff --git a/packages/frontend/src/pages/custom-emojis-manager.vue b/packages/frontend/src/pages/custom-emojis-manager.vue index 1f9a99d4f5..9357735c82 100644 --- a/packages/frontend/src/pages/custom-emojis-manager.vue +++ b/packages/frontend/src/pages/custom-emojis-manager.vue @@ -98,6 +98,9 @@ const selectedEmojis = ref<string[]>([]); const pagination = { endpoint: 'admin/emoji/list' as const, limit: 30, + offsetMode: computed(() => ( + (query.value && query.value !== '') ? true : false + )), params: computed(() => ({ query: (query.value && query.value !== '') ? query.value : null, })), diff --git a/packages/frontend/src/pages/settings/general.vue b/packages/frontend/src/pages/settings/general.vue index 3b4f000e61..28637228d0 100644 --- a/packages/frontend/src/pages/settings/general.vue +++ b/packages/frontend/src/pages/settings/general.vue @@ -117,6 +117,8 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="_gaps_m"> <MkSwitch v-model="useGroupedNotifications">{{ i18n.ts.useGroupedNotifications }}</MkSwitch> + <MkSwitch v-model="enableFaviconNotificationDot">{{ i18n.ts.enableFaviconNotificationDot }}</MkSwitch> + <MkRadios v-model="notificationPosition"> <template #label>{{ i18n.ts.position }}</template> <option value="leftTop"><i class="ph-arrow-up-left ph-bold ph-lg"></i> {{ i18n.ts.leftTop }}</option> @@ -353,6 +355,7 @@ const oneko = computed(defaultStore.makeGetterSetter('oneko')); const loadRawImages = computed(defaultStore.makeGetterSetter('loadRawImages')); const highlightSensitiveMedia = computed(defaultStore.makeGetterSetter('highlightSensitiveMedia')); const imageNewTab = computed(defaultStore.makeGetterSetter('imageNewTab')); +const enableFaviconNotificationDot = computed(defaultStore.makeGetterSetter('enableFaviconNotificationDot')); const warnMissingAltText = computed(defaultStore.makeGetterSetter('warnMissingAltText')); const nsfw = computed(defaultStore.makeGetterSetter('nsfw')); const showFixedPostForm = computed(defaultStore.makeGetterSetter('showFixedPostForm')); diff --git a/packages/frontend/src/pages/settings/preferences-backups.vue b/packages/frontend/src/pages/settings/preferences-backups.vue index c774ab5367..5390f12239 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'])[] = [ 'showReactionsCount', 'loadRawImages', 'warnMissingAltText', + 'enableFaviconNotificationDot', 'imageNewTab', 'dataSaver', 'disableShowingAnimatedImages', diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue index 96ae4824f0..4a00b204c0 100644 --- a/packages/frontend/src/pages/user/home.vue +++ b/packages/frontend/src/pages/user/home.vue @@ -236,22 +236,24 @@ const moderationNote = ref(props.user.moderationNote); const editModerationNote = ref(false); const noteview = ref<string | null>(null); -let listenbrainzdata = false; +const listenbrainzdata = ref(false); if (props.user.listenbrainz) { - try { - const response = await fetch(`https://api.listenbrainz.org/1/user/${props.user.listenbrainz}/playing-now`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json' - }, - }); - const data = await response.json(); - if (data.payload.listens && data.payload.listens.length !== 0) { - listenbrainzdata = true; + (async function() { + try { + const response = await fetch(`https://api.listenbrainz.org/1/user/${props.user.listenbrainz}/playing-now`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + }, + }); + const data = await response.json(); + if (data.payload.listens && data.payload.listens.length !== 0) { + listenbrainzdata.value = true; + } + } catch (err) { + listenbrainzdata.value = false; } - } catch (err) { - listenbrainzdata = false; - } + })() } const background = computed(() => { diff --git a/packages/frontend/src/scripts/favicon-dot.ts b/packages/frontend/src/scripts/favicon-dot.ts new file mode 100644 index 0000000000..e338f55f72 --- /dev/null +++ b/packages/frontend/src/scripts/favicon-dot.ts @@ -0,0 +1,114 @@ +import tinycolor from 'tinycolor2'; + +class FavIconDot { + canvas: HTMLCanvasElement; + src: string | null = null; + ctx: CanvasRenderingContext2D | null = null; + faviconImage: HTMLImageElement | null = null; + faviconEL: HTMLLinkElement | undefined; + hasLoaded: Promise<void> | undefined; + + constructor() { + this.canvas = document.createElement('canvas'); + } + + /** + * Must be called before calling any other functions + */ + public async setup() { + const element: HTMLLinkElement = await this.getOrMakeFaviconElement(); + + this.faviconEL = element; + this.src = this.faviconEL.getAttribute('href'); + this.ctx = this.canvas.getContext('2d'); + + this.faviconImage = document.createElement('img'); + + this.hasLoaded = new Promise((resolve, reject) => { + (this.faviconImage as HTMLImageElement).addEventListener('load', () => { + this.canvas.width = (this.faviconImage as HTMLImageElement).width; + this.canvas.height = (this.faviconImage as HTMLImageElement).height; + resolve(); + }); + (this.faviconImage as HTMLImageElement).addEventListener('error', () => { + reject('Failed to create favicon img element'); + }); + }); + + this.faviconImage.src = this.faviconEL.href; + } + + private async getOrMakeFaviconElement(): Promise<HTMLLinkElement> { + return new Promise((resolve, reject) => { + const favicon = (document.querySelector('link[rel=icon]') ?? this.createFaviconElem()) as HTMLLinkElement; + favicon.addEventListener('load', () => { + resolve(favicon); + }); + + favicon.onerror = () => { + reject('Failed to load favicon'); + }; + resolve(favicon); + }); + } + + private createFaviconElem() { + const newLink = document.createElement('link'); + newLink.setAttribute('rel', 'icon'); + newLink.setAttribute('href', '/favicon.ico'); + newLink.setAttribute('type', 'image/x-icon'); + + document.head.appendChild(newLink); + return newLink; + } + + private drawIcon() { + if (!this.ctx || !this.faviconImage) return; + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + this.ctx.drawImage(this.faviconImage, 0, 0, this.faviconImage.width, this.faviconImage.height); + } + + private drawDot() { + if (!this.ctx || !this.faviconImage) return; + this.ctx.beginPath(); + this.ctx.arc(this.faviconImage.width - 10, 10, 10, 0, 2 * Math.PI); + const computedStyle = getComputedStyle(document.documentElement); + this.ctx.fillStyle = tinycolor(computedStyle.getPropertyValue('--navIndicator')).toHexString(); + this.ctx.strokeStyle = 'white'; + this.ctx.fill(); + this.ctx.stroke(); + } + + private setFavicon() { + if (this.faviconEL) this.faviconEL.href = this.canvas.toDataURL('image/png'); + } + + async setVisible(isVisible: boolean) { + // Wait for it to have loaded the icon + await this.hasLoaded; + this.drawIcon(); + if (isVisible) this.drawDot(); + this.setFavicon(); + } +} + +let icon: FavIconDot | undefined = undefined; + +export function setFavIconDot(visible: boolean) { + const setIconVisibility = async () => { + if (!icon) { + icon = new FavIconDot(); + await icon.setup(); + } + + (icon as FavIconDot).setVisible(visible); + }; + + // If document is already loaded, set visibility immediately + if (document.readyState === 'complete') { + setIconVisibility(); + } else { + // Otherwise, set visibility when window loads + window.addEventListener('load', setIconVisibility); + } +} diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index 574e98b022..f3f6746b62 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -271,6 +271,10 @@ export const defaultStore = markRaw(new Storage('base', { where: 'device', default: true, }, + enableFaviconNotificationDot: { + where: 'device', + default: true, + }, imageNewTab: { where: 'device', default: false, diff --git a/packages/frontend/src/style.scss b/packages/frontend/src/style.scss index 057a4fb61e..f77739fdbc 100644 --- a/packages/frontend/src/style.scss +++ b/packages/frontend/src/style.scss @@ -565,6 +565,8 @@ html[data-color-mode=dark] ._woodenFrame { // MFM ----------------------------- +div > bdi, p > bdi { display: block } + ._mfm_blur_ { filter: blur(6px); transition: filter 0.3s; diff --git a/packages/frontend/src/ui/_common_/common.vue b/packages/frontend/src/ui/_common_/common.vue index 4fe53ae6a3..b1fe8e54fc 100644 --- a/packages/frontend/src/ui/_common_/common.vue +++ b/packages/frontend/src/ui/_common_/common.vue @@ -47,8 +47,9 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { defineAsyncComponent, ref } from 'vue'; +import { defineAsyncComponent, ref, watch } from 'vue'; import * as Misskey from 'misskey-js'; +import { setFavIconDot } from '../../scripts/favicon-dot'; import { swInject } from './sw-inject.js'; import XNotification from './notification.vue'; import { popups } from '@/os.js'; @@ -93,6 +94,12 @@ function onNotification(notification: Misskey.entities.Notification, isClient = if ($i) { const connection = useStream().useChannel('main', null, 'UI'); connection.on('notification', onNotification); + + // For the favicon notification dot + watch(() => $i?.hasUnreadNotification && defaultStore.state.enableFaviconNotificationDot, (hasAny) => setFavIconDot(hasAny as boolean)); + + if ($i.hasUnreadNotification && defaultStore.state.enableFaviconNotificationDot) setFavIconDot(true); + globalEvents.on('clientNotification', notification => onNotification(notification, true)); //#region Listen message from SW