Merge branch 'develop' into future-2024-05-31
This commit is contained in:
		
						commit
						5dc8c2827c
					
				
					 32 changed files with 415 additions and 222 deletions
				
			
		| 
						 | 
				
			
			@ -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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										4
									
								
								locales/index.d.ts
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								locales/index.d.ts
									
										
									
									
										vendored
									
									
								
							| 
						 | 
				
			
			@ -2792,6 +2792,10 @@ export interface Locale extends ILocale {
 | 
			
		|||
     * 表示する通知の種別を選択してください。
 | 
			
		||||
     */
 | 
			
		||||
    "notificationSettingDesc": string;
 | 
			
		||||
    /**
 | 
			
		||||
     * ファビコン通知ドットを有効にする
 | 
			
		||||
     */
 | 
			
		||||
    "enableFaviconNotificationDot": string;
 | 
			
		||||
    /**
 | 
			
		||||
     * グローバル設定を使う
 | 
			
		||||
     */
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -694,6 +694,7 @@ channel: "チャンネル"
 | 
			
		|||
create: "作成"
 | 
			
		||||
notificationSetting: "通知設定"
 | 
			
		||||
notificationSettingDesc: "表示する通知の種別を選択してください。"
 | 
			
		||||
enableFaviconNotificationDot: "ファビコン通知ドットを有効にする"
 | 
			
		||||
useGlobalSetting: "グローバル設定を使う"
 | 
			
		||||
useGlobalSettingDesc: "オンにすると、アカウントの通知設定が使用されます。オフにすると、個別に設定できるようになります。"
 | 
			
		||||
other: "その他"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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']]);
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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';
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
		}
 | 
			
		||||
| 
						 | 
				
			
			@ -642,7 +642,7 @@ export class MfmService {
 | 
			
		|||
 | 
			
		||||
    	search: (node) => {
 | 
			
		||||
    		const a = doc.createElement('a');
 | 
			
		||||
				a.setAttribute('href', `https"google.com/${node.props.query}`);
 | 
			
		||||
    		a.setAttribute('href', `https://www.google.com/search?q=${node.props.query}`);
 | 
			
		||||
    		a.textContent = node.props.content;
 | 
			
		||||
    		return a;
 | 
			
		||||
    	},
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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可能
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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) {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -4,5 +4,5 @@
 | 
			
		|||
 */
 | 
			
		||||
 | 
			
		||||
export function sqlLikeEscape(s: string) {
 | 
			
		||||
	return s.replace(/([%_])/g, '\\$1');
 | 
			
		||||
	return s.replace(/([%_\\])/g, '\\$1');
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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));
 | 
			
		||||
					}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -30,8 +30,8 @@ export const meta = {
 | 
			
		|||
	prohibitMoved: true,
 | 
			
		||||
 | 
			
		||||
	limit: {
 | 
			
		||||
		duration: ms('1hour'),
 | 
			
		||||
		max: 300,
 | 
			
		||||
		duration: ms('1minute'),
 | 
			
		||||
		max: 5,
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	kind: 'write:notes',
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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!))
 | 
			
		||||
			: '';
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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);
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 });
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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),
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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">
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 });
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 });
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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)));
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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" />
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -215,7 +215,7 @@ function gravity() {
 | 
			
		|||
 | 
			
		||||
function iLoveMisskey() {
 | 
			
		||||
	os.post({
 | 
			
		||||
		initialText: 'I $[jelly ❤] #Misskey',
 | 
			
		||||
		initialText: 'I $[jelly ❤] #Sharkey',
 | 
			
		||||
		instant: true,
 | 
			
		||||
	});
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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,
 | 
			
		||||
	})),
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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'));
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -73,6 +73,7 @@ const defaultStoreSaveKeys: (keyof typeof defaultStore['state'])[] = [
 | 
			
		|||
	'showReactionsCount',
 | 
			
		||||
	'loadRawImages',
 | 
			
		||||
	'warnMissingAltText',
 | 
			
		||||
	'enableFaviconNotificationDot',
 | 
			
		||||
	'imageNewTab',
 | 
			
		||||
	'dataSaver',
 | 
			
		||||
	'disableShowingAnimatedImages',
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -236,8 +236,9 @@ 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) {
 | 
			
		||||
	(async function() {
 | 
			
		||||
		try {
 | 
			
		||||
			const response = await fetch(`https://api.listenbrainz.org/1/user/${props.user.listenbrainz}/playing-now`, {
 | 
			
		||||
				method: 'GET',
 | 
			
		||||
| 
						 | 
				
			
			@ -247,11 +248,12 @@ if (props.user.listenbrainz) {
 | 
			
		|||
			});
 | 
			
		||||
			const data = await response.json();
 | 
			
		||||
			if (data.payload.listens && data.payload.listens.length !== 0) {
 | 
			
		||||
			listenbrainzdata = true;
 | 
			
		||||
				listenbrainzdata.value = true;
 | 
			
		||||
			}
 | 
			
		||||
		} catch (err) {
 | 
			
		||||
		listenbrainzdata = false;
 | 
			
		||||
			listenbrainzdata.value = false;
 | 
			
		||||
		}
 | 
			
		||||
	})()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const background = computed(() => {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										114
									
								
								packages/frontend/src/scripts/favicon-dot.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										114
									
								
								packages/frontend/src/scripts/favicon-dot.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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);
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue