Merge branch 'develop' into future
This commit is contained in:
		
						commit
						4ddee7b01e
					
				
					 23 changed files with 557 additions and 84 deletions
				
			
		| 
						 | 
				
			
			@ -415,7 +415,7 @@ antennaKeywordsDescription: "Separate with spaces for an AND condition or with l
 | 
			
		|||
notifyAntenna: "Notify about new notes"
 | 
			
		||||
withFileAntenna: "Only notes with files"
 | 
			
		||||
enableServiceworker: "Enable Push-Notifications for your Browser"
 | 
			
		||||
antennaUsersDescription: "List one username per line"
 | 
			
		||||
antennaUsersDescription: "List one username per line. Use \"*@instance.com\" to specify all users of an instance"
 | 
			
		||||
caseSensitive: "Case sensitive"
 | 
			
		||||
withReplies: "Include replies"
 | 
			
		||||
connectedTo: "Following account(s) are connected"
 | 
			
		||||
| 
						 | 
				
			
			@ -1830,7 +1830,7 @@ _registry:
 | 
			
		|||
  domain: "Domain"
 | 
			
		||||
  createKey: "Create key"
 | 
			
		||||
_aboutMisskey:
 | 
			
		||||
  about: "Sharkey is open-source software based on Misskey which has been in developed since 2014 by syuilo."
 | 
			
		||||
  about: "Sharkey is open-source software based on Misskey which has been in development by syuilo since 2014."
 | 
			
		||||
  contributors: "Main contributors"
 | 
			
		||||
  allContributors: "All contributors"
 | 
			
		||||
  source: "Source code"
 | 
			
		||||
| 
						 | 
				
			
			@ -2478,6 +2478,7 @@ _moderationLogTypes:
 | 
			
		|||
  unsetUserAvatar: "Unset this user's avatar"
 | 
			
		||||
  unsetUserBanner: "Unset this user's banner"
 | 
			
		||||
_mfm:
 | 
			
		||||
  uncommonFeature: "This is not a widespread feature, it may not display properly on most other fedi software, including other Misskey forks"
 | 
			
		||||
  intro: "MFM is a markup language used on Misskey, Sharkey, Firefish, Akkoma, and more that can be used in many places. Here you can view a list of all available MFM syntax."
 | 
			
		||||
  dummy: "Sharkey expands the world of the Fediverse"
 | 
			
		||||
  mention: "Mention"
 | 
			
		||||
| 
						 | 
				
			
			@ -2542,10 +2543,16 @@ _mfm:
 | 
			
		|||
  rotateDescription: "Turns content by a specified angle."
 | 
			
		||||
  position: "Position"
 | 
			
		||||
  positionDescription: "Move content by a specified amount."
 | 
			
		||||
  crop: "Crop"
 | 
			
		||||
  cropDescription: "Crop content."
 | 
			
		||||
  followMouse: "Follow Mouse"
 | 
			
		||||
  followMouseDescription: "Content will follow the mouse. On mobile it will follow wherever the user taps."
 | 
			
		||||
  scale: "Scale"
 | 
			
		||||
  scaleDescription: "Scale content by a specified amount."
 | 
			
		||||
  foreground: "Foreground color"
 | 
			
		||||
  foregroundDescription: "Change the foreground color of text."
 | 
			
		||||
  fade: 'Fade'
 | 
			
		||||
  fadeDescription: 'Fade text in and out.'
 | 
			
		||||
  background: "Background color"
 | 
			
		||||
  backgroundDescription: "Change the background color of text."
 | 
			
		||||
  plain: "Plain"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -404,7 +404,7 @@ antennaKeywordsDescription: "Separar con espacios es una declaración AND, separ
 | 
			
		|||
notifyAntenna: "Notificar nueva nota"
 | 
			
		||||
withFileAntenna: "Sólo notas con archivos adjuntados"
 | 
			
		||||
enableServiceworker: "Activar ServiceWorker"
 | 
			
		||||
antennaUsersDescription: "Elegir nombres de usuarios separados por una linea nueva"
 | 
			
		||||
antennaUsersDescription: "Elegir nombres de usuarios separados por una linea nueva. Utilice \"*@instance.com\" para especificar todos los usuarios de una instancia."
 | 
			
		||||
caseSensitive: "Distinguir mayúsculas de minúsculas"
 | 
			
		||||
withReplies: "Incluir respuestas"
 | 
			
		||||
connectedTo: "Estas cuentas están conectadas"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -36,7 +36,6 @@ export async function jobQueue() {
 | 
			
		|||
	});
 | 
			
		||||
 | 
			
		||||
	jobQueue.get(QueueProcessorService).start();
 | 
			
		||||
	jobQueue.get(ChartManagementService).start();
 | 
			
		||||
 | 
			
		||||
	return jobQueue;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -75,7 +75,7 @@ async function main() {
 | 
			
		|||
			ev.mount();
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	if (cluster.isWorker || envOption.disableClustering) {
 | 
			
		||||
	if (cluster.isWorker) {
 | 
			
		||||
		await workerMain();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -212,6 +212,8 @@ export function loadConfig(): Config {
 | 
			
		|||
			{} as Source,
 | 
			
		||||
		) as Source;
 | 
			
		||||
 | 
			
		||||
	applyEnvOverrides(config);
 | 
			
		||||
 | 
			
		||||
	const url = tryCreateUrl(config.url);
 | 
			
		||||
	const version = meta.version;
 | 
			
		||||
	const host = url.host;
 | 
			
		||||
| 
						 | 
				
			
			@ -304,3 +306,123 @@ function convertRedisOptions(options: RedisOptionsSource, host: string): RedisOp
 | 
			
		|||
		db: options.db ?? 0,
 | 
			
		||||
	};
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
	this function allows overriding any string-valued config option with
 | 
			
		||||
	a sensible-named environment variable
 | 
			
		||||
 | 
			
		||||
	e.g. `MK_CONFIG_MEILISEARCH_APIKEY` sets `config.meilisearch.apikey`
 | 
			
		||||
 | 
			
		||||
	you can also override a single `dbSlave` value,
 | 
			
		||||
	e.g. `MK_CONFIG_DBSLAVES_1_PASS` sets the password for the 2nd
 | 
			
		||||
	database replica (the first one would be
 | 
			
		||||
	`MK_CONFIG_DBSLAVES_0_PASS`); in this case, `config.dbSlaves` must
 | 
			
		||||
	be set to an array of the right size already in the file
 | 
			
		||||
 | 
			
		||||
	values can be read from files, too: setting `MK_DB_PASS_FILE` to
 | 
			
		||||
	`/some/file` would set the main database password to the contents of
 | 
			
		||||
	`/some/file` (trimmed of whitespaces)
 | 
			
		||||
 */
 | 
			
		||||
function applyEnvOverrides(config: Source) {
 | 
			
		||||
	// these inner functions recurse through the config structure, using
 | 
			
		||||
	// the given steps, building the env variable name
 | 
			
		||||
 | 
			
		||||
	function _apply_top(steps: (string | number)[]) {
 | 
			
		||||
		_walk('', [], steps);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	function _walk(name: string, path: (string | number)[], steps: (string | number)[]) {
 | 
			
		||||
		// are there more steps after this one? recurse
 | 
			
		||||
		if (steps.length > 1) {
 | 
			
		||||
			const thisStep = steps.shift();
 | 
			
		||||
			if (thisStep === null || thisStep === undefined) return;
 | 
			
		||||
 | 
			
		||||
			// if a step is not a simple value, iterate through it
 | 
			
		||||
			if (typeof thisStep === 'object') {
 | 
			
		||||
				for (const thisOneStep of thisStep) {
 | 
			
		||||
					_descend(name, path, thisOneStep, steps);
 | 
			
		||||
				}
 | 
			
		||||
			} else {
 | 
			
		||||
				_descend(name, path, thisStep, steps);
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			// the actual override has happened at the bottom of the
 | 
			
		||||
			// recursion, we're done
 | 
			
		||||
			return;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// this is the last step, same thing as above
 | 
			
		||||
		const lastStep = steps[0];
 | 
			
		||||
 | 
			
		||||
		if (typeof lastStep === 'object') {
 | 
			
		||||
			for (const lastOneStep of lastStep) {
 | 
			
		||||
				_lastBit(name, path, lastOneStep);
 | 
			
		||||
			}
 | 
			
		||||
		} else {
 | 
			
		||||
			_lastBit(name, path, lastStep);
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	function _step2name(step: string|number): string {
 | 
			
		||||
		return step.toString().replaceAll(/[^a-z0-9]+/gi,'').toUpperCase();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// this recurses down, bailing out if there's no config to override
 | 
			
		||||
	function _descend(name: string, path: (string | number)[], thisStep: string | number, steps: (string | number)[]) {
 | 
			
		||||
		name = `${name}${_step2name(thisStep)}_`;
 | 
			
		||||
		path = [ ...path, thisStep ];
 | 
			
		||||
		_walk(name, path, steps);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// this is the bottom of the recursion: look at the environment and
 | 
			
		||||
	// set the value
 | 
			
		||||
	function _lastBit(name: string, path: (string | number)[], lastStep: string | number) {
 | 
			
		||||
		name = `MK_CONFIG_${name}${_step2name(lastStep)}`;
 | 
			
		||||
 | 
			
		||||
		const val = process.env[name];
 | 
			
		||||
		if (val != null && val != undefined) {
 | 
			
		||||
			_assign(path, lastStep, val);
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		const file = process.env[`${name}_FILE`];
 | 
			
		||||
		if (file) {
 | 
			
		||||
			_assign(path, lastStep, fs.readFileSync(file, 'utf-8').trim());
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	const alwaysStrings = { 'chmodSocket': 1 };
 | 
			
		||||
 | 
			
		||||
	function _assign(path: (string | number)[], lastStep: string | number, value: string) {
 | 
			
		||||
		let thisConfig = config;
 | 
			
		||||
		for (const step of path) {
 | 
			
		||||
			if (!thisConfig[step]) {
 | 
			
		||||
				thisConfig[step] = {};
 | 
			
		||||
			}
 | 
			
		||||
			thisConfig = thisConfig[step];
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if (!alwaysStrings[lastStep]) {
 | 
			
		||||
			if (value.match(/^[0-9]+$/)) {
 | 
			
		||||
				value = parseInt(value);
 | 
			
		||||
			} else if (value.match(/^(true|false)$/i)) {
 | 
			
		||||
				value = !!value.match(/^true$/i);
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		thisConfig[lastStep] = value;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// these are all the settings that can be overridden
 | 
			
		||||
 | 
			
		||||
	_apply_top([['url', 'port', 'socket', 'chmodSocket', 'disableHsts']]);
 | 
			
		||||
	_apply_top(['db', ['host', 'port', 'db', 'user', 'pass']]);
 | 
			
		||||
	_apply_top(['dbSlaves', config.dbSlaves?.keys(), ['host', 'port', 'db', 'user', 'pass']]);
 | 
			
		||||
	_apply_top([
 | 
			
		||||
		['redis', 'redisForPubsub', 'redisForJobQueue', 'redisForTimelines'],
 | 
			
		||||
		['host','port','username','pass','db','prefix'],
 | 
			
		||||
	]);
 | 
			
		||||
	_apply_top(['meilisearch', ['host', 'port', 'apikey', 'ssl', 'index', 'scope']]);
 | 
			
		||||
	_apply_top([['clusterLimit', 'deliverJobConcurrency', 'inboxJobConcurrency', 'relashionshipJobConcurrency', 'deliverJobPerSec', 'inboxJobPerSec', 'relashionshipJobPerSec', 'deliverJobMaxAttempts', 'inboxJobMaxAttempts']]);
 | 
			
		||||
	_apply_top([['outgoingAddress', 'outgoingAddressFamily', 'proxy', 'proxySmtp', 'mediaProxy', 'videoThumbnailGenerator']]);
 | 
			
		||||
	_apply_top([['maxFileSize', 'maxNoteLength', 'pidFile']]);
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -133,13 +133,17 @@ export class AntennaService implements OnApplicationShutdown {
 | 
			
		|||
				const { username, host } = Acct.parse(x);
 | 
			
		||||
				return this.utilityService.getFullApAccount(username, host).toLowerCase();
 | 
			
		||||
			});
 | 
			
		||||
			if (!accts.includes(this.utilityService.getFullApAccount(noteUser.username, noteUser.host).toLowerCase())) return false;
 | 
			
		||||
			const matchUser = this.utilityService.getFullApAccount(noteUser.username, noteUser.host).toLowerCase();
 | 
			
		||||
			const matchWildcard = this.utilityService.getFullApAccount('*', noteUser.host).toLowerCase();
 | 
			
		||||
			if (!accts.includes(matchUser) && !accts.includes(matchWildcard)) return false;
 | 
			
		||||
		} else if (antenna.src === 'users_blacklist') {
 | 
			
		||||
			const accts = antenna.users.map(x => {
 | 
			
		||||
				const { username, host } = Acct.parse(x);
 | 
			
		||||
				return this.utilityService.getFullApAccount(username, host).toLowerCase();
 | 
			
		||||
			});
 | 
			
		||||
			if (accts.includes(this.utilityService.getFullApAccount(noteUser.username, noteUser.host).toLowerCase())) return false;
 | 
			
		||||
			const matchUser = this.utilityService.getFullApAccount(noteUser.username, noteUser.host).toLowerCase();
 | 
			
		||||
			const matchWildcard = this.utilityService.getFullApAccount('*', noteUser.host).toLowerCase();
 | 
			
		||||
			if (accts.includes(matchUser) || accts.includes(matchWildcard)) return false;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		const keywords = antenna.keywords
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -632,7 +632,8 @@ export class DriveService {
 | 
			
		|||
 | 
			
		||||
	@bindThis
 | 
			
		||||
	public async updateFile(file: MiDriveFile, values: Partial<MiDriveFile>, updater: MiUser) {
 | 
			
		||||
		const alwaysMarkNsfw = (await this.roleService.getUserPolicies(file.userId)).alwaysMarkNsfw;
 | 
			
		||||
		const profile = await this.userProfilesRepository.findOneBy({ userId: file.userId });
 | 
			
		||||
		const alwaysMarkNsfw = (await this.roleService.getUserPolicies(file.userId)).alwaysMarkNsfw || (profile !== null && profile!.alwaysMarkNsfw);
 | 
			
		||||
 | 
			
		||||
		if (values.name != null && !this.driveFileEntityService.validateFileName(values.name)) {
 | 
			
		||||
			throw new DriveService.InvalidFileNameError();
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -699,6 +699,24 @@ export class NoteEditService implements OnApplicationShutdown {
 | 
			
		|||
						dm.addFollowersRecipe();
 | 
			
		||||
					}
 | 
			
		||||
 | 
			
		||||
					if (['public', 'home'].includes(note.visibility)) {
 | 
			
		||||
						// Send edit event to all users who replied to,
 | 
			
		||||
						// renoted a post or reacted to a note.
 | 
			
		||||
						const noteId = note.id;
 | 
			
		||||
						const users = await this.usersRepository.createQueryBuilder()
 | 
			
		||||
							.where(
 | 
			
		||||
								'id IN (SELECT "userId" FROM note WHERE "replyId" = :noteId OR "renoteId" = :noteId UNION SELECT "userId" FROM note_reaction WHERE "noteId" = :noteId)',
 | 
			
		||||
								{ noteId },
 | 
			
		||||
							)
 | 
			
		||||
							.andWhere('host IS NOT NULL')
 | 
			
		||||
							.getMany();
 | 
			
		||||
						for (const u of users) {
 | 
			
		||||
							// User was verified to be remote by checking
 | 
			
		||||
							// whether host IS NOT NULL in SQL query.
 | 
			
		||||
							dm.addDirectRecipe(u as MiRemoteUser);
 | 
			
		||||
						}
 | 
			
		||||
					}
 | 
			
		||||
 | 
			
		||||
					if (['public'].includes(note.visibility)) {
 | 
			
		||||
						this.relayService.deliverToRelays(user, noteActivity);
 | 
			
		||||
					}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -51,6 +51,12 @@ export const paramDef = {
 | 
			
		|||
		sinceDate: { type: 'integer' },
 | 
			
		||||
		untilDate: { type: 'integer' },
 | 
			
		||||
		allowPartial: { type: 'boolean', default: false }, // true is recommended but for compatibility false by default
 | 
			
		||||
		withRenotes: { type: 'boolean', default: true },
 | 
			
		||||
		withFiles: {
 | 
			
		||||
			type: 'boolean',
 | 
			
		||||
			default: false,
 | 
			
		||||
			description: 'Only show notes that have attached files.',
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
	required: ['channelId'],
 | 
			
		||||
} as const;
 | 
			
		||||
| 
						 | 
				
			
			@ -89,7 +95,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 | 
			
		|||
			if (me) this.activeUsersChart.read(me);
 | 
			
		||||
 | 
			
		||||
			if (!serverSettings.enableFanoutTimeline) {
 | 
			
		||||
				return await this.noteEntityService.packMany(await this.getFromDb({ untilId, sinceId, limit: ps.limit, channelId: channel.id }, me), me);
 | 
			
		||||
				return await this.noteEntityService.packMany(await this.getFromDb({ untilId, sinceId, limit: ps.limit, channelId: channel.id, withFiles: ps.withFiles, withRenotes: ps.withRenotes }, me), me);
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			return await this.fanoutTimelineEndpointService.timeline({
 | 
			
		||||
| 
						 | 
				
			
			@ -100,9 +106,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 | 
			
		|||
				me,
 | 
			
		||||
				useDbFallback: true,
 | 
			
		||||
				redisTimelines: [`channelTimeline:${channel.id}`],
 | 
			
		||||
				excludePureRenotes: false,
 | 
			
		||||
				excludePureRenotes: !ps.withRenotes,
 | 
			
		||||
				excludeNoFiles: ps.withFiles,
 | 
			
		||||
				dbFallback: async (untilId, sinceId, limit) => {
 | 
			
		||||
					return await this.getFromDb({ untilId, sinceId, limit, channelId: channel.id }, me);
 | 
			
		||||
					return await this.getFromDb({ untilId, sinceId, limit, channelId: channel.id, withFiles: ps.withFiles, withRenotes: ps.withRenotes }, me);
 | 
			
		||||
				},
 | 
			
		||||
			});
 | 
			
		||||
		});
 | 
			
		||||
| 
						 | 
				
			
			@ -112,7 +119,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 | 
			
		|||
		untilId: string | null,
 | 
			
		||||
		sinceId: string | null,
 | 
			
		||||
		limit: number,
 | 
			
		||||
		channelId: string
 | 
			
		||||
		channelId: string,
 | 
			
		||||
		withFiles: boolean,
 | 
			
		||||
		withRenotes: boolean,
 | 
			
		||||
	}, me: MiLocalUser | null) {
 | 
			
		||||
		//#region fallback to database
 | 
			
		||||
		const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
 | 
			
		||||
| 
						 | 
				
			
			@ -128,6 +137,20 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 | 
			
		|||
			this.queryService.generateMutedUserQuery(query, me);
 | 
			
		||||
			this.queryService.generateBlockedUserQuery(query, me);
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if (ps.withRenotes === false) {
 | 
			
		||||
			query.andWhere(new Brackets(qb => {
 | 
			
		||||
				qb.orWhere('note.renoteId IS NULL');
 | 
			
		||||
				qb.orWhere(new Brackets(qb => {
 | 
			
		||||
					qb.orWhere('note.text IS NOT NULL');
 | 
			
		||||
					qb.orWhere('note.fileIds != \'{}\'');
 | 
			
		||||
				}));
 | 
			
		||||
			}));
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if (ps.withFiles) {
 | 
			
		||||
			query.andWhere('note.fileIds != \'{}\'');
 | 
			
		||||
		}
 | 
			
		||||
		//#endregion
 | 
			
		||||
 | 
			
		||||
		return await query.limit(ps.limit).getMany();
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -146,8 +146,8 @@ export class MastoConverters {
 | 
			
		|||
			display_name: user.name ?? user.username,
 | 
			
		||||
			locked: user.isLocked,
 | 
			
		||||
			created_at: this.idService.parse(user.id).date.toISOString(),
 | 
			
		||||
			followers_count: user.followersCount,
 | 
			
		||||
			following_count: user.followingCount,
 | 
			
		||||
			followers_count: profile?.followersVisibility === 'public' ? user.followersCount : 0,
 | 
			
		||||
			following_count: profile?.followingVisibility === 'public' ? user.followingCount : 0,
 | 
			
		||||
			statuses_count: user.notesCount,
 | 
			
		||||
			note: profile?.description ?? '',
 | 
			
		||||
			url: user.uri ?? acctUrl,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -15,6 +15,8 @@ class ChannelChannel extends Channel {
 | 
			
		|||
	public static shouldShare = false;
 | 
			
		||||
	public static requireCredential = false as const;
 | 
			
		||||
	private channelId: string;
 | 
			
		||||
	private withFiles: boolean;
 | 
			
		||||
	private withRenotes: boolean;
 | 
			
		||||
 | 
			
		||||
	constructor(
 | 
			
		||||
		private noteEntityService: NoteEntityService,
 | 
			
		||||
| 
						 | 
				
			
			@ -29,6 +31,8 @@ class ChannelChannel extends Channel {
 | 
			
		|||
	@bindThis
 | 
			
		||||
	public async init(params: any) {
 | 
			
		||||
		this.channelId = params.channelId as string;
 | 
			
		||||
		this.withFiles = params.withFiles ?? false;
 | 
			
		||||
		this.withRenotes = params.withRenotes ?? true;
 | 
			
		||||
 | 
			
		||||
		// Subscribe stream
 | 
			
		||||
		this.subscriber.on('notesStream', this.onNote);
 | 
			
		||||
| 
						 | 
				
			
			@ -38,6 +42,10 @@ class ChannelChannel extends Channel {
 | 
			
		|||
	private async onNote(note: Packed<'Note'>) {
 | 
			
		||||
		if (note.channelId !== this.channelId) return;
 | 
			
		||||
 | 
			
		||||
		if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return;
 | 
			
		||||
 | 
			
		||||
		if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return;
 | 
			
		||||
 | 
			
		||||
		if (this.isNoteMutedOrBlocked(note)) return;
 | 
			
		||||
 | 
			
		||||
		if (this.user && isRenotePacked(note) && !isQuotePacked(note)) {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										81
									
								
								packages/frontend/src/components/CkFollowMouse.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										81
									
								
								packages/frontend/src/components/CkFollowMouse.vue
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,81 @@
 | 
			
		|||
<template>
 | 
			
		||||
<span ref="container" :class="$style.root">
 | 
			
		||||
	<span ref="el" :class="$style.inner" style="position: absolute">
 | 
			
		||||
		<slot></slot>
 | 
			
		||||
	</span>
 | 
			
		||||
</span>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { onMounted, onUnmounted, shallowRef } from 'vue';
 | 
			
		||||
const el = shallowRef<HTMLElement>();
 | 
			
		||||
const container = shallowRef<HTMLElement>();
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
	x: {
 | 
			
		||||
		type: Boolean,
 | 
			
		||||
		default: true,
 | 
			
		||||
	},
 | 
			
		||||
	y: {
 | 
			
		||||
		type: Boolean,
 | 
			
		||||
		default: true,
 | 
			
		||||
	},
 | 
			
		||||
	speed: {
 | 
			
		||||
		type: String,
 | 
			
		||||
		default: '0.1s',
 | 
			
		||||
	},
 | 
			
		||||
	rotateByVelocity: {
 | 
			
		||||
		type: Boolean,
 | 
			
		||||
		default: true,
 | 
			
		||||
	},
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
let lastX = 0;
 | 
			
		||||
let lastY = 0;
 | 
			
		||||
let oldAngle = 0;
 | 
			
		||||
 | 
			
		||||
function lerp(a, b, alpha) {
 | 
			
		||||
	return a + alpha * (b - a);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const updatePosition = (mouseEvent: MouseEvent) => {
 | 
			
		||||
	if (el.value && container.value) {
 | 
			
		||||
		const containerRect = container.value.getBoundingClientRect();
 | 
			
		||||
		const newX = mouseEvent.clientX - containerRect.left;
 | 
			
		||||
		const newY = mouseEvent.clientY - containerRect.top;
 | 
			
		||||
		let transform = `translate(calc(${props.x ? newX : 0}px - 50%), calc(${props.y ? newY : 0}px - 50%))`;
 | 
			
		||||
		if (props.rotateByVelocity) {
 | 
			
		||||
			const deltaX = newX - lastX;
 | 
			
		||||
			const deltaY = newY - lastY;
 | 
			
		||||
			const angle = lerp(
 | 
			
		||||
				oldAngle,
 | 
			
		||||
				Math.atan2(deltaY, deltaX) * (180 / Math.PI),
 | 
			
		||||
				0.1,
 | 
			
		||||
			);
 | 
			
		||||
			transform += ` rotate(${angle}deg)`;
 | 
			
		||||
			oldAngle = angle;
 | 
			
		||||
		}
 | 
			
		||||
		el.value.style.transform = transform;
 | 
			
		||||
		el.value.style.transition = `transform ${props.speed}`;
 | 
			
		||||
		lastX = newX;
 | 
			
		||||
		lastY = newY;
 | 
			
		||||
	}
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
onMounted(() => {
 | 
			
		||||
	window.addEventListener('mousemove', updatePosition);
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
onUnmounted(() => {
 | 
			
		||||
	window.removeEventListener('mousemove', updatePosition);
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" module>
 | 
			
		||||
.root {
 | 
			
		||||
	position: relative;
 | 
			
		||||
	display: inline-block;
 | 
			
		||||
}
 | 
			
		||||
.inner {
 | 
			
		||||
	transform-origin: center center;
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
| 
						 | 
				
			
			@ -9,17 +9,17 @@
 | 
			
		|||
	<template #header>
 | 
			
		||||
		MFM Cheatsheet
 | 
			
		||||
	</template>
 | 
			
		||||
    <MkStickyContainer>
 | 
			
		||||
	<MkStickyContainer>
 | 
			
		||||
		<MkSpacer :contentMax="800">
 | 
			
		||||
			<div class="mfm-cheat-sheet">
 | 
			
		||||
				<div>{{ i18n.ts._mfm.intro }}</div>
 | 
			
		||||
				<br />
 | 
			
		||||
				<br/>
 | 
			
		||||
				<div class="section _block">
 | 
			
		||||
					<div class="title">{{ i18n.ts._mfm.mention }}</div>
 | 
			
		||||
					<div class="content">
 | 
			
		||||
						<p>{{ i18n.ts._mfm.mentionDescription }}</p>
 | 
			
		||||
						<div class="preview">
 | 
			
		||||
							<Mfm :text="preview_mention" />
 | 
			
		||||
							<Mfm :text="preview_mention"/>
 | 
			
		||||
							<MkTextarea v-model="preview_mention"><template #label>MFM</template></MkTextarea>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
| 
						 | 
				
			
			@ -29,7 +29,7 @@
 | 
			
		|||
					<div class="content">
 | 
			
		||||
						<p>{{ i18n.ts._mfm.hashtagDescription }}</p>
 | 
			
		||||
						<div class="preview">
 | 
			
		||||
							<Mfm :text="preview_hashtag" />
 | 
			
		||||
							<Mfm :text="preview_hashtag"/>
 | 
			
		||||
							<MkTextarea v-model="preview_hashtag"><template #label>MFM</template></MkTextarea>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
| 
						 | 
				
			
			@ -39,7 +39,7 @@
 | 
			
		|||
					<div class="content">
 | 
			
		||||
						<p>{{ i18n.ts._mfm.linkDescription }}</p>
 | 
			
		||||
						<div class="preview">
 | 
			
		||||
							<Mfm :text="preview_link" />
 | 
			
		||||
							<Mfm :text="preview_link"/>
 | 
			
		||||
							<MkTextarea v-model="preview_link"><template #label>MFM</template></MkTextarea>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
| 
						 | 
				
			
			@ -49,7 +49,7 @@
 | 
			
		|||
					<div class="content">
 | 
			
		||||
						<p>{{ i18n.ts._mfm.emojiDescription }}</p>
 | 
			
		||||
						<div class="preview">
 | 
			
		||||
							<Mfm :text="preview_emoji" />
 | 
			
		||||
							<Mfm :text="preview_emoji"/>
 | 
			
		||||
							<MkTextarea v-model="preview_emoji"><template #label>MFM</template></MkTextarea>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
| 
						 | 
				
			
			@ -59,7 +59,7 @@
 | 
			
		|||
					<div class="content">
 | 
			
		||||
						<p>{{ i18n.ts._mfm.boldDescription }}</p>
 | 
			
		||||
						<div class="preview">
 | 
			
		||||
							<Mfm :text="preview_bold" />
 | 
			
		||||
							<Mfm :text="preview_bold"/>
 | 
			
		||||
							<MkTextarea v-model="preview_bold"><template #label>MFM</template></MkTextarea>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
| 
						 | 
				
			
			@ -69,7 +69,7 @@
 | 
			
		|||
					<div class="content">
 | 
			
		||||
						<p>{{ i18n.ts._mfm.smallDescription }}</p>
 | 
			
		||||
						<div class="preview">
 | 
			
		||||
							<Mfm :text="preview_small" />
 | 
			
		||||
							<Mfm :text="preview_small"/>
 | 
			
		||||
							<MkTextarea v-model="preview_small"><template #label>MFM</template></MkTextarea>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
| 
						 | 
				
			
			@ -79,7 +79,7 @@
 | 
			
		|||
					<div class="content">
 | 
			
		||||
						<p>{{ i18n.ts._mfm.quoteDescription }}</p>
 | 
			
		||||
						<div class="preview">
 | 
			
		||||
							<Mfm :text="preview_quote" />
 | 
			
		||||
							<Mfm :text="preview_quote"/>
 | 
			
		||||
							<MkTextarea v-model="preview_quote"><template #label>MFM</template></MkTextarea>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
| 
						 | 
				
			
			@ -89,7 +89,7 @@
 | 
			
		|||
					<div class="content">
 | 
			
		||||
						<p>{{ i18n.ts._mfm.centerDescription }}</p>
 | 
			
		||||
						<div class="preview">
 | 
			
		||||
							<Mfm :text="preview_center" />
 | 
			
		||||
							<Mfm :text="preview_center"/>
 | 
			
		||||
							<MkTextarea v-model="preview_center"><template #label>MFM</template></MkTextarea>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
| 
						 | 
				
			
			@ -99,7 +99,7 @@
 | 
			
		|||
					<div class="content">
 | 
			
		||||
						<p>{{ i18n.ts._mfm.inlineCodeDescription }}</p>
 | 
			
		||||
						<div class="preview">
 | 
			
		||||
							<Mfm :text="preview_inlineCode" />
 | 
			
		||||
							<Mfm :text="preview_inlineCode"/>
 | 
			
		||||
							<MkTextarea v-model="preview_inlineCode"><template #label>MFM</template></MkTextarea>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
| 
						 | 
				
			
			@ -109,7 +109,7 @@
 | 
			
		|||
					<div class="content">
 | 
			
		||||
						<p>{{ i18n.ts._mfm.blockCodeDescription }}</p>
 | 
			
		||||
						<div class="preview">
 | 
			
		||||
							<Mfm :text="preview_blockCode" />
 | 
			
		||||
							<Mfm :text="preview_blockCode"/>
 | 
			
		||||
							<MkTextarea v-model="preview_blockCode"><template #label>MFM</template></MkTextarea>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
| 
						 | 
				
			
			@ -119,7 +119,7 @@
 | 
			
		|||
					<div class="content">
 | 
			
		||||
						<p>{{ i18n.ts._mfm.inlineMathDescription }}</p>
 | 
			
		||||
						<div class="preview">
 | 
			
		||||
							<Mfm :text="preview_inlineMath" />
 | 
			
		||||
							<Mfm :text="preview_inlineMath"/>
 | 
			
		||||
							<MkTextarea v-model="preview_inlineMath"><template #label>MFM</template></MkTextarea>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
| 
						 | 
				
			
			@ -129,7 +129,7 @@
 | 
			
		|||
					<div class="content">
 | 
			
		||||
						<p>{{ i18n.ts._mfm.blockMathDescription }}</p>
 | 
			
		||||
						<div class="preview">
 | 
			
		||||
							<Mfm :text="preview_blockMath" />
 | 
			
		||||
							<Mfm :text="preview_blockMath"/>
 | 
			
		||||
							<MkTextarea v-model="preview_blockMath"><template #label>MFM</template></MkTextarea>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
| 
						 | 
				
			
			@ -139,7 +139,7 @@
 | 
			
		|||
					<div class="content">
 | 
			
		||||
						<p>{{ i18n.ts._mfm.searchDescription }}</p>
 | 
			
		||||
						<div class="preview">
 | 
			
		||||
							<Mfm :text="preview_search" />
 | 
			
		||||
							<Mfm :text="preview_search"/>
 | 
			
		||||
							<MkTextarea v-model="preview_search"><template #label>MFM</template></MkTextarea>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
| 
						 | 
				
			
			@ -149,7 +149,7 @@
 | 
			
		|||
					<div class="content">
 | 
			
		||||
						<p>{{ i18n.ts._mfm.flipDescription }}</p>
 | 
			
		||||
						<div class="preview">
 | 
			
		||||
							<Mfm :text="preview_flip" />
 | 
			
		||||
							<Mfm :text="preview_flip"/>
 | 
			
		||||
							<MkTextarea v-model="preview_flip"><template #label>MFM</template></MkTextarea>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
| 
						 | 
				
			
			@ -159,7 +159,7 @@
 | 
			
		|||
					<div class="content">
 | 
			
		||||
						<p>{{ i18n.ts._mfm.fontDescription }}</p>
 | 
			
		||||
						<div class="preview">
 | 
			
		||||
							<Mfm :text="preview_font" />
 | 
			
		||||
							<Mfm :text="preview_font"/>
 | 
			
		||||
							<MkTextarea v-model="preview_font"><template #label>MFM</template></MkTextarea>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
| 
						 | 
				
			
			@ -169,7 +169,7 @@
 | 
			
		|||
					<div class="content">
 | 
			
		||||
						<p>{{ i18n.ts._mfm.x2Description }}</p>
 | 
			
		||||
						<div class="preview">
 | 
			
		||||
							<Mfm :text="preview_x2" />
 | 
			
		||||
							<Mfm :text="preview_x2"/>
 | 
			
		||||
							<MkTextarea v-model="preview_x2"><template #label>MFM</template></MkTextarea>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
| 
						 | 
				
			
			@ -179,7 +179,7 @@
 | 
			
		|||
					<div class="content">
 | 
			
		||||
						<p>{{ i18n.ts._mfm.x3Description }}</p>
 | 
			
		||||
						<div class="preview">
 | 
			
		||||
							<Mfm :text="preview_x3" />
 | 
			
		||||
							<Mfm :text="preview_x3"/>
 | 
			
		||||
							<MkTextarea v-model="preview_x3"><template #label>MFM</template></MkTextarea>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
| 
						 | 
				
			
			@ -189,7 +189,7 @@
 | 
			
		|||
					<div class="content">
 | 
			
		||||
						<p>{{ i18n.ts._mfm.x4Description }}</p>
 | 
			
		||||
						<div class="preview">
 | 
			
		||||
							<Mfm :text="preview_x4" />
 | 
			
		||||
							<Mfm :text="preview_x4"/>
 | 
			
		||||
							<MkTextarea v-model="preview_x4"><template #label>MFM</template></MkTextarea>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
| 
						 | 
				
			
			@ -199,7 +199,7 @@
 | 
			
		|||
					<div class="content">
 | 
			
		||||
						<p>{{ i18n.ts._mfm.blurDescription }}</p>
 | 
			
		||||
						<div class="preview">
 | 
			
		||||
							<Mfm :text="preview_blur" />
 | 
			
		||||
							<Mfm :text="preview_blur"/>
 | 
			
		||||
							<MkTextarea v-model="preview_blur"><template #label>MFM</template></MkTextarea>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
| 
						 | 
				
			
			@ -209,7 +209,7 @@
 | 
			
		|||
					<div class="content">
 | 
			
		||||
						<p>{{ i18n.ts._mfm.jellyDescription }}</p>
 | 
			
		||||
						<div class="preview">
 | 
			
		||||
							<Mfm :text="preview_jelly" />
 | 
			
		||||
							<Mfm :text="preview_jelly"/>
 | 
			
		||||
							<MkTextarea v-model="preview_jelly"><template #label>MFM</template></MkTextarea>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
| 
						 | 
				
			
			@ -219,7 +219,7 @@
 | 
			
		|||
					<div class="content">
 | 
			
		||||
						<p>{{ i18n.ts._mfm.tadaDescription }}</p>
 | 
			
		||||
						<div class="preview">
 | 
			
		||||
							<Mfm :text="preview_tada" />
 | 
			
		||||
							<Mfm :text="preview_tada"/>
 | 
			
		||||
							<MkTextarea v-model="preview_tada"><template #label>MFM</template></MkTextarea>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
| 
						 | 
				
			
			@ -229,7 +229,7 @@
 | 
			
		|||
					<div class="content">
 | 
			
		||||
						<p>{{ i18n.ts._mfm.jumpDescription }}</p>
 | 
			
		||||
						<div class="preview">
 | 
			
		||||
							<Mfm :text="preview_jump" />
 | 
			
		||||
							<Mfm :text="preview_jump"/>
 | 
			
		||||
							<MkTextarea v-model="preview_jump"><template #label>MFM</template></MkTextarea>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
| 
						 | 
				
			
			@ -239,7 +239,7 @@
 | 
			
		|||
					<div class="content">
 | 
			
		||||
						<p>{{ i18n.ts._mfm.bounceDescription }}</p>
 | 
			
		||||
						<div class="preview">
 | 
			
		||||
							<Mfm :text="preview_bounce" />
 | 
			
		||||
							<Mfm :text="preview_bounce"/>
 | 
			
		||||
							<MkTextarea v-model="preview_bounce"><template #label>MFM</template></MkTextarea>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
| 
						 | 
				
			
			@ -249,7 +249,7 @@
 | 
			
		|||
					<div class="content">
 | 
			
		||||
						<p>{{ i18n.ts._mfm.spinDescription }}</p>
 | 
			
		||||
						<div class="preview">
 | 
			
		||||
							<Mfm :text="preview_spin" />
 | 
			
		||||
							<Mfm :text="preview_spin"/>
 | 
			
		||||
							<MkTextarea v-model="preview_spin"><template #label>MFM</template></MkTextarea>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
| 
						 | 
				
			
			@ -259,7 +259,7 @@
 | 
			
		|||
					<div class="content">
 | 
			
		||||
						<p>{{ i18n.ts._mfm.shakeDescription }}</p>
 | 
			
		||||
						<div class="preview">
 | 
			
		||||
							<Mfm :text="preview_shake" />
 | 
			
		||||
							<Mfm :text="preview_shake"/>
 | 
			
		||||
							<MkTextarea v-model="preview_shake"><template #label>MFM</template></MkTextarea>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
| 
						 | 
				
			
			@ -269,7 +269,7 @@
 | 
			
		|||
					<div class="content">
 | 
			
		||||
						<p>{{ i18n.ts._mfm.twitchDescription }}</p>
 | 
			
		||||
						<div class="preview">
 | 
			
		||||
							<Mfm :text="preview_twitch" />
 | 
			
		||||
							<Mfm :text="preview_twitch"/>
 | 
			
		||||
							<MkTextarea v-model="preview_twitch"><template #label>MFM</template></MkTextarea>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
| 
						 | 
				
			
			@ -279,7 +279,7 @@
 | 
			
		|||
					<div class="content">
 | 
			
		||||
						<p>{{ i18n.ts._mfm.rainbowDescription }}</p>
 | 
			
		||||
						<div class="preview">
 | 
			
		||||
							<Mfm :text="preview_rainbow" />
 | 
			
		||||
							<Mfm :text="preview_rainbow"/>
 | 
			
		||||
							<MkTextarea v-model="preview_rainbow"><template #label>MFM</template></MkTextarea>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
| 
						 | 
				
			
			@ -289,7 +289,7 @@
 | 
			
		|||
					<div class="content">
 | 
			
		||||
						<p>{{ i18n.ts._mfm.sparkleDescription }}</p>
 | 
			
		||||
						<div class="preview">
 | 
			
		||||
							<Mfm :text="preview_sparkle" />
 | 
			
		||||
							<Mfm :text="preview_sparkle"/>
 | 
			
		||||
							<MkTextarea v-model="preview_sparkle"><span>MFM</span></MkTextarea>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
| 
						 | 
				
			
			@ -299,37 +299,69 @@
 | 
			
		|||
					<div class="content">
 | 
			
		||||
						<p>{{ i18n.ts._mfm.rotateDescription }}</p>
 | 
			
		||||
						<div class="preview">
 | 
			
		||||
							<Mfm :text="preview_rotate" />
 | 
			
		||||
							<Mfm :text="preview_rotate"/>
 | 
			
		||||
							<MkTextarea v-model="preview_rotate"><span>MFM</span></MkTextarea>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
				<div class="section _block">
 | 
			
		||||
					<div class="title">{{ i18n.ts._mfm.crop }}</div>
 | 
			
		||||
					<div class="content">
 | 
			
		||||
						<p>{{ i18n.ts._mfm.cropDescription }}</p>
 | 
			
		||||
						<div class="preview">
 | 
			
		||||
							<Mfm :text="preview_crop" />
 | 
			
		||||
							<MkTextarea v-model="preview_crop"><span>MFM</span></MkTextarea>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
				<div class="section _block">
 | 
			
		||||
					<div class="title">{{ i18n.ts._mfm.position }}</div>
 | 
			
		||||
					<div class="content">
 | 
			
		||||
						<p>{{ i18n.ts._mfm.positionDescription }}</p>
 | 
			
		||||
						<div class="preview">
 | 
			
		||||
							<Mfm :text="preview_position" />
 | 
			
		||||
							<Mfm :text="preview_position"/>
 | 
			
		||||
							<MkTextarea v-model="preview_position"><span>MFM</span></MkTextarea>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
				<div class="section _block" style="overflow: hidden">
 | 
			
		||||
					<div class="title">{{ i18n.ts._mfm.followMouse }}</div>
 | 
			
		||||
					<MkInfo warn>{{ i18n.ts._mfm.uncommonFeature }}</MkInfo>
 | 
			
		||||
					<br/>
 | 
			
		||||
					<div class="content">
 | 
			
		||||
						<p>{{ i18n.ts._mfm.followMouseDescription }}</p>
 | 
			
		||||
						<div class="preview">
 | 
			
		||||
							<Mfm :text="preview_followmouse"/>
 | 
			
		||||
							<MkTextarea v-model="preview_followmouse"><span>MFM</span></MkTextarea>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
				<div class="section _block">
 | 
			
		||||
					<div class="title">{{ i18n.ts._mfm.scale }}</div>
 | 
			
		||||
					<div class="content">
 | 
			
		||||
						<p>{{ i18n.ts._mfm.scaleDescription }}</p>
 | 
			
		||||
						<div class="preview">
 | 
			
		||||
							<Mfm :text="preview_scale" />
 | 
			
		||||
							<Mfm :text="preview_scale"/>
 | 
			
		||||
							<MkTextarea v-model="preview_scale"><span>MFM</span></MkTextarea>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
				<div class="section _block">
 | 
			
		||||
					<div class="title">{{ i18n.ts._mfm.fade }}</div>
 | 
			
		||||
					<div class="content">
 | 
			
		||||
						<p>{{ i18n.ts._mfm.fadeDescription }}</p>
 | 
			
		||||
						<div class="preview">
 | 
			
		||||
							<Mfm :text="preview_fade" />
 | 
			
		||||
							<MkTextarea v-model="preview_fade"><span>MFM</span></MkTextarea>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
				<div class="section _block">
 | 
			
		||||
					<div class="title">{{ i18n.ts._mfm.foreground }}</div>
 | 
			
		||||
					<div class="content">
 | 
			
		||||
						<p>{{ i18n.ts._mfm.foregroundDescription }}</p>
 | 
			
		||||
						<div class="preview">
 | 
			
		||||
							<Mfm :text="preview_fg" />
 | 
			
		||||
							<Mfm :text="preview_fg"/>
 | 
			
		||||
							<MkTextarea v-model="preview_fg"><span>MFM</span></MkTextarea>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
| 
						 | 
				
			
			@ -339,7 +371,7 @@
 | 
			
		|||
					<div class="content">
 | 
			
		||||
						<p>{{ i18n.ts._mfm.backgroundDescription }}</p>
 | 
			
		||||
						<div class="preview">
 | 
			
		||||
							<Mfm :text="preview_bg" />
 | 
			
		||||
							<Mfm :text="preview_bg"/>
 | 
			
		||||
							<MkTextarea v-model="preview_bg"><span>MFM</span></MkTextarea>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
| 
						 | 
				
			
			@ -349,7 +381,7 @@
 | 
			
		|||
					<div class="content">
 | 
			
		||||
						<p>{{ i18n.ts._mfm.plainDescription }}</p>
 | 
			
		||||
						<div class="preview">
 | 
			
		||||
							<Mfm :text="preview_plain" />
 | 
			
		||||
							<Mfm :text="preview_plain"/>
 | 
			
		||||
							<MkTextarea v-model="preview_plain"><span>MFM</span></MkTextarea>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
| 
						 | 
				
			
			@ -362,18 +394,19 @@
 | 
			
		|||
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { ref } from 'vue';
 | 
			
		||||
import MkInfo from './MkInfo.vue';
 | 
			
		||||
import MkWindow from '@/components/MkWindow.vue';
 | 
			
		||||
import MkTextarea from '@/components/MkTextarea.vue';
 | 
			
		||||
import { i18n } from "@/i18n.js";
 | 
			
		||||
import { i18n } from '@/i18n.js';
 | 
			
		||||
 | 
			
		||||
const emit = defineEmits<{
 | 
			
		||||
	(ev: 'closed'): void;
 | 
			
		||||
}>();
 | 
			
		||||
 | 
			
		||||
const preview_mention = ref("@example");
 | 
			
		||||
const preview_hashtag = ref("#test");
 | 
			
		||||
const preview_mention = ref('@example');
 | 
			
		||||
const preview_hashtag = ref('#test');
 | 
			
		||||
const preview_link = ref(`[${i18n.ts._mfm.dummy}](https://joinsharkey.org)`);
 | 
			
		||||
const preview_emoji = ref(`:heart:`);
 | 
			
		||||
const preview_emoji = ref(':heart:');
 | 
			
		||||
const preview_bold = ref(`**${i18n.ts._mfm.dummy}**`);
 | 
			
		||||
const preview_small = ref(
 | 
			
		||||
	`<small>${i18n.ts._mfm.dummy}</small>`,
 | 
			
		||||
| 
						 | 
				
			
			@ -386,33 +419,33 @@ const preview_blockCode = ref(
 | 
			
		|||
	'```\n~ (#i, 100) {\n\t<: ? ((i % 15) = 0) "FizzBuzz"\n\t\t.? ((i % 3) = 0) "Fizz"\n\t\t.? ((i % 5) = 0) "Buzz"\n\t\t. i\n}\n```',
 | 
			
		||||
);
 | 
			
		||||
const preview_inlineMath = ref(
 | 
			
		||||
	"\\(x= \\frac{-b' \\pm \\sqrt{(b')^2-ac}}{a}\\)",
 | 
			
		||||
	'\\(x= \\frac{-b\' \\pm \\sqrt{(b\')^2-ac}}{a}\\)',
 | 
			
		||||
);
 | 
			
		||||
const preview_blockMath = ref("\\[x= \\frac{-b' \\pm \\sqrt{(b')^2-ac}}{a}\\]");
 | 
			
		||||
const preview_blockMath = ref('\\[x= \\frac{-b\' \\pm \\sqrt{(b\')^2-ac}}{a}\\]');
 | 
			
		||||
const preview_quote = ref(`> ${i18n.ts._mfm.dummy}`);
 | 
			
		||||
const preview_search = ref(
 | 
			
		||||
	`${i18n.ts._mfm.dummy} [search]\n${i18n.ts._mfm.dummy} [検索]`,
 | 
			
		||||
);
 | 
			
		||||
const preview_jelly = ref(
 | 
			
		||||
	"$[jelly 🍮] $[jelly.speed=3s 🍮] $[jelly.delay=3s 🍮] $[jelly.loop=3 🍮]",
 | 
			
		||||
	'$[jelly 🍮] $[jelly.speed=3s 🍮] $[jelly.delay=3s 🍮] $[jelly.loop=3 🍮]',
 | 
			
		||||
);
 | 
			
		||||
const preview_tada = ref(
 | 
			
		||||
	"$[tada 🍮] $[tada.speed=3s 🍮] $[tada.delay=3s 🍮] $[tada.loop=3 🍮]",
 | 
			
		||||
	'$[tada 🍮] $[tada.speed=3s 🍮] $[tada.delay=3s 🍮] $[tada.loop=3 🍮]',
 | 
			
		||||
);
 | 
			
		||||
const preview_jump = ref(
 | 
			
		||||
	"$[jump 🍮] $[jump.speed=3s 🍮] $[jump.delay=3s 🍮] $[jump.loop=3 🍮]",
 | 
			
		||||
	'$[jump 🍮] $[jump.speed=3s 🍮] $[jump.delay=3s 🍮] $[jump.loop=3 🍮]',
 | 
			
		||||
);
 | 
			
		||||
const preview_bounce = ref(
 | 
			
		||||
	"$[bounce 🍮] $[bounce.speed=3s 🍮] $[bounce.delay=3s 🍮] $[bounce.loop=3 🍮]",
 | 
			
		||||
	'$[bounce 🍮] $[bounce.speed=3s 🍮] $[bounce.delay=3s 🍮] $[bounce.loop=3 🍮]',
 | 
			
		||||
);
 | 
			
		||||
const preview_shake = ref(
 | 
			
		||||
	"$[shake 🍮] $[shake.speed=3s 🍮] $[shake.delay=3s 🍮] $[shake.loop=3 🍮]",
 | 
			
		||||
	'$[shake 🍮] $[shake.speed=3s 🍮] $[shake.delay=3s 🍮] $[shake.loop=3 🍮]',
 | 
			
		||||
);
 | 
			
		||||
const preview_twitch = ref(
 | 
			
		||||
	"$[twitch 🍮] $[twitch.speed=3s 🍮] $[twitch.delay=3s 🍮] $[twitch.loop=3 🍮]",
 | 
			
		||||
	'$[twitch 🍮] $[twitch.speed=3s 🍮] $[twitch.delay=3s 🍮] $[twitch.loop=3 🍮]',
 | 
			
		||||
);
 | 
			
		||||
const preview_spin = ref(
 | 
			
		||||
	"$[spin 🍮] $[spin.left 🍮] $[spin.alternate 🍮]\n$[spin.x 🍮] $[spin.x,left 🍮] $[spin.x,alternate 🍮]\n$[spin.y 🍮] $[spin.y,left 🍮] $[spin.y,alternate 🍮]\n\n$[spin.speed=3s 🍮] $[spin.delay=3s 🍮] $[spin.loop=3 🍮]",
 | 
			
		||||
	'$[spin 🍮] $[spin.left 🍮] $[spin.alternate 🍮]\n$[spin.x 🍮] $[spin.x,left 🍮] $[spin.x,alternate 🍮]\n$[spin.y 🍮] $[spin.y,left 🍮] $[spin.y,alternate 🍮]\n\n$[spin.speed=3s 🍮] $[spin.delay=3s 🍮] $[spin.loop=3 🍮]',
 | 
			
		||||
);
 | 
			
		||||
const preview_flip = ref(
 | 
			
		||||
	`$[flip ${i18n.ts._mfm.dummy}]\n$[flip.v ${i18n.ts._mfm.dummy}]\n$[flip.h,v ${i18n.ts._mfm.dummy}]`,
 | 
			
		||||
| 
						 | 
				
			
			@ -420,26 +453,31 @@ const preview_flip = ref(
 | 
			
		|||
const preview_font = ref(
 | 
			
		||||
	`$[font.serif ${i18n.ts._mfm.dummy}]\n$[font.monospace ${i18n.ts._mfm.dummy}]`,
 | 
			
		||||
);
 | 
			
		||||
const preview_x2 = ref("$[x2 🍮]");
 | 
			
		||||
const preview_x3 = ref("$[x3 🍮]");
 | 
			
		||||
const preview_x4 = ref("$[x4 🍮]");
 | 
			
		||||
const preview_x2 = ref('$[x2 🍮]');
 | 
			
		||||
const preview_x3 = ref('$[x3 🍮]');
 | 
			
		||||
const preview_x4 = ref('$[x4 🍮]');
 | 
			
		||||
const preview_blur = ref(`$[blur ${i18n.ts._mfm.dummy}]`);
 | 
			
		||||
const preview_rainbow = ref(
 | 
			
		||||
	"$[rainbow 🍮] $[rainbow.speed=3s 🍮] $[rainbow.delay=3s 🍮] $[rainbow.loop=3 🍮]",
 | 
			
		||||
	'$[rainbow 🍮] $[rainbow.speed=3s 🍮] $[rainbow.delay=3s 🍮] $[rainbow.loop=3 🍮]',
 | 
			
		||||
);
 | 
			
		||||
const preview_sparkle = ref("$[sparkle 🍮]");
 | 
			
		||||
const preview_sparkle = ref('$[sparkle 🍮]');
 | 
			
		||||
const preview_rotate = ref(
 | 
			
		||||
	"$[rotate 🍮]\n$[rotate.deg=45 🍮]\n$[rotate.x,deg=45 Hello, world!]",
 | 
			
		||||
	'$[rotate 🍮]\n$[rotate.deg=45 🍮]\n$[rotate.x,deg=45 Hello, world!]',
 | 
			
		||||
);
 | 
			
		||||
const preview_position = ref("$[position.y=-1 🍮]\n$[position.x=-1 🍮]");
 | 
			
		||||
const preview_position = ref('$[position.y=-1 🍮]\n$[position.x=-1 🍮]');
 | 
			
		||||
const preview_crop = ref(
 | 
			
		||||
	"$[crop.top=50 🍮] $[crop.right=50 🍮] $[crop.bottom=50 🍮] $[crop.left=50 🍮]",
 | 
			
		||||
);
 | 
			
		||||
const preview_followmouse = ref('$[followmouse.x 🍮]\n$[followmouse.x,y,rotateByVelocity,speed=0.4 🍮]');
 | 
			
		||||
const preview_scale = ref(
 | 
			
		||||
	"$[scale.x=1.3 🍮]\n$[scale.x=1.5,y=3 🍮]\n$[scale.y=0.3 🍮]",
 | 
			
		||||
	'$[scale.x=1.3 🍮]\n$[scale.x=1.5,y=3 🍮]\n$[scale.y=0.3 🍮]',
 | 
			
		||||
);
 | 
			
		||||
const preview_fg = ref("$[fg.color=eb6f92 Text color]");
 | 
			
		||||
const preview_bg = ref("$[bg.color=31748f Background color]");
 | 
			
		||||
const preview_fg = ref('$[fg.color=eb6f92 Text color]');
 | 
			
		||||
const preview_bg = ref('$[bg.color=31748f Background color]');
 | 
			
		||||
const preview_plain = ref(
 | 
			
		||||
	"<plain>**bold** @mention #hashtag `code` $[x2 🍮]</plain>",
 | 
			
		||||
	'<plain>**bold** @mention #hashtag `code` $[x2 🍮]</plain>',
 | 
			
		||||
);
 | 
			
		||||
const preview_fade = ref(`$[fade 🍮] $[fade.out 🍮] $[fade.speed=3s 🍮] $[fade.delay=3s 🍮]`);
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -154,6 +154,8 @@ function connectChannel() {
 | 
			
		|||
	} else if (props.src === 'channel') {
 | 
			
		||||
		if (props.channel == null) return;
 | 
			
		||||
		connection = stream.useChannel('channel', {
 | 
			
		||||
			withRenotes: props.withRenotes,
 | 
			
		||||
			withFiles: props.onlyFiles ? true : undefined,
 | 
			
		||||
			channelId: props.channel,
 | 
			
		||||
		});
 | 
			
		||||
	} else if (props.src === 'role') {
 | 
			
		||||
| 
						 | 
				
			
			@ -234,6 +236,8 @@ function updatePaginationQuery() {
 | 
			
		|||
	} else if (props.src === 'channel') {
 | 
			
		||||
		endpoint = 'channels/timeline';
 | 
			
		||||
		query = {
 | 
			
		||||
			withRenotes: props.withRenotes,
 | 
			
		||||
			withFiles: props.onlyFiles ? true : undefined,
 | 
			
		||||
			channelId: props.channel,
 | 
			
		||||
		};
 | 
			
		||||
	} else if (props.src === 'role') {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -235,6 +235,6 @@ onMounted(init);
 | 
			
		|||
	pointer-events: none;
 | 
			
		||||
	image-rendering: pixelated;
 | 
			
		||||
	z-index: 2147483647;
 | 
			
		||||
	background-image: url(/client-assets/oneko.gif);
 | 
			
		||||
	background-image: var(--oneko-image, url(/client-assets/oneko.gif));
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -6,6 +6,7 @@
 | 
			
		|||
import { VNode, h, defineAsyncComponent, SetupContext, provide } from 'vue';
 | 
			
		||||
import * as mfm from '@transfem-org/sfm-js';
 | 
			
		||||
import * as Misskey from 'misskey-js';
 | 
			
		||||
import CkFollowMouse from '../CkFollowMouse.vue';
 | 
			
		||||
import MkUrl from '@/components/global/MkUrl.vue';
 | 
			
		||||
import MkTime from '@/components/global/MkTime.vue';
 | 
			
		||||
import MkLink from '@/components/MkLink.vue';
 | 
			
		||||
| 
						 | 
				
			
			@ -230,11 +231,49 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven
 | 
			
		|||
						}
 | 
			
		||||
						return h(MkSparkle, {}, genEl(token.children, scale));
 | 
			
		||||
					}
 | 
			
		||||
					case 'fade': {
 | 
			
		||||
						// Dont run with reduced motion on
 | 
			
		||||
						if (!defaultStore.state.animation) {
 | 
			
		||||
							style = '';
 | 
			
		||||
							break;
 | 
			
		||||
						}
 | 
			
		||||
			
 | 
			
		||||
						const direction = token.props.args.out
 | 
			
		||||
							? 'alternate-reverse'
 | 
			
		||||
							: 'alternate';
 | 
			
		||||
						const speed = validTime(token.props.args.speed) ?? '1.5s';
 | 
			
		||||
						const delay = validTime(token.props.args.delay) ?? '0s';
 | 
			
		||||
						const loop = safeParseFloat(token.props.args.loop) ?? 'infinite';
 | 
			
		||||
						style = `animation: mfm-fade ${speed} ${delay} linear ${loop}; animation-direction: ${direction};`;
 | 
			
		||||
						break;
 | 
			
		||||
					}
 | 
			
		||||
					case 'rotate': {
 | 
			
		||||
						const degrees = safeParseFloat(token.props.args.deg) ?? 90;
 | 
			
		||||
						style = `transform: rotate(${degrees}deg); transform-origin: center center;`;
 | 
			
		||||
						break;
 | 
			
		||||
					}
 | 
			
		||||
					case 'followmouse': {
 | 
			
		||||
						// Make sure advanced MFM is on and that reduced motion is off
 | 
			
		||||
						if (!useAnim) {
 | 
			
		||||
							style = '';
 | 
			
		||||
							break;
 | 
			
		||||
						}
 | 
			
		||||
 | 
			
		||||
						let x = (!!token.props.args.x);
 | 
			
		||||
						let y = (!!token.props.args.y);
 | 
			
		||||
 | 
			
		||||
						if (!x && !y) {
 | 
			
		||||
							x = true;
 | 
			
		||||
							y = true;
 | 
			
		||||
						}
 | 
			
		||||
 | 
			
		||||
						return h(CkFollowMouse, {
 | 
			
		||||
							x: x,
 | 
			
		||||
							y: y,
 | 
			
		||||
							speed: validTime(token.props.args.speed) ?? '0.1s',
 | 
			
		||||
							rotateByVelocity: !!token.props.args.rotateByVelocity,
 | 
			
		||||
						}, genEl(token.children, scale));
 | 
			
		||||
					}
 | 
			
		||||
					case 'position': {
 | 
			
		||||
						if (!defaultStore.state.advancedMfm) break;
 | 
			
		||||
						const x = safeParseFloat(token.props.args.x) ?? 0;
 | 
			
		||||
| 
						 | 
				
			
			@ -242,6 +281,22 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven
 | 
			
		|||
						style = `transform: translateX(${x}em) translateY(${y}em);`;
 | 
			
		||||
						break;
 | 
			
		||||
					}
 | 
			
		||||
					case 'crop': {
 | 
			
		||||
						const top = Number.parseFloat(
 | 
			
		||||
							(token.props.args.top ?? '0').toString(),
 | 
			
		||||
						);
 | 
			
		||||
						const right = Number.parseFloat(
 | 
			
		||||
							(token.props.args.right ?? '0').toString(),
 | 
			
		||||
						);
 | 
			
		||||
						const bottom = Number.parseFloat(
 | 
			
		||||
							(token.props.args.bottom ?? '0').toString(),
 | 
			
		||||
						);
 | 
			
		||||
						const left = Number.parseFloat(
 | 
			
		||||
							(token.props.args.left ?? '0').toString(),
 | 
			
		||||
						);
 | 
			
		||||
						style = `clip-path: inset(${top}% ${right}% ${bottom}% ${left}%);`;
 | 
			
		||||
						break;
 | 
			
		||||
					}
 | 
			
		||||
					case 'scale': {
 | 
			
		||||
						if (!defaultStore.state.advancedMfm) {
 | 
			
		||||
							style = '';
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -162,7 +162,7 @@ export const DEFAULT_SERVER_ERROR_IMAGE_URL = 'https://launcher.moe/error.png';
 | 
			
		|||
export const DEFAULT_NOT_FOUND_IMAGE_URL = 'https://launcher.moe/missingpage.webp';
 | 
			
		||||
export const DEFAULT_INFO_IMAGE_URL = 'https://launcher.moe/nothinghere.png';
 | 
			
		||||
 | 
			
		||||
export const MFM_TAGS = ['tada', 'jelly', 'twitch', 'shake', 'spin', 'jump', 'bounce', 'flip', 'x2', 'x3', 'x4', 'scale', 'position', 'fg', 'bg', 'border', 'font', 'blur', 'rainbow', 'sparkle', 'rotate', 'ruby', 'unixtime'];
 | 
			
		||||
export const MFM_TAGS = ['tada', 'jelly', 'twitch', 'shake', 'spin', 'jump', 'bounce', 'flip', 'x2', 'x3', 'x4', 'scale', 'position', 'fg', 'bg', 'border', 'font', 'blur', 'rainbow', 'sparkle', 'rotate', 'ruby', 'unixtime', 'crop', 'fade', 'followmouse'];
 | 
			
		||||
export const MFM_PARAMS: Record<typeof MFM_TAGS[number], string[]> = {
 | 
			
		||||
	tada: ['speed=', 'delay='],
 | 
			
		||||
	jelly: ['speed=', 'delay='],
 | 
			
		||||
| 
						 | 
				
			
			@ -179,11 +179,14 @@ export const MFM_PARAMS: Record<typeof MFM_TAGS[number], string[]> = {
 | 
			
		|||
	position: ['x=', 'y='],
 | 
			
		||||
	fg: ['color='],
 | 
			
		||||
	bg: ['color='],
 | 
			
		||||
  border: ['width=', 'style=', 'color=', 'radius=', 'noclip'],
 | 
			
		||||
	border: ['width=', 'style=', 'color=', 'radius=', 'noclip'],
 | 
			
		||||
	font: ['serif', 'monospace', 'cursive', 'fantasy', 'emoji', 'math'],
 | 
			
		||||
	blur: [],
 | 
			
		||||
	rainbow: ['speed=', 'delay='],
 | 
			
		||||
	rotate: ['deg='],
 | 
			
		||||
	ruby: [],
 | 
			
		||||
	unixtime: [],
 | 
			
		||||
	fade: ['speed=', 'delay=', 'loop=', 'out'],
 | 
			
		||||
	crop: ['top=', 'bottom=', 'left=', 'right='],
 | 
			
		||||
	followmouse: ['x', 'y', 'rotateByVelocity', 'speed='],
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -111,7 +111,8 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		|||
 | 
			
		||||
						<div>
 | 
			
		||||
							<MkButton v-if="iAmModerator" inline danger style="margin-right: 8px;" @click="unsetUserAvatar"><i class="ph-user-circle ph-bold ph-lg"></i> {{ i18n.ts.unsetUserAvatar }}</MkButton>
 | 
			
		||||
							<MkButton v-if="iAmModerator" inline danger @click="unsetUserBanner"><i class="ph-photo ph-bold ph-lg"></i> {{ i18n.ts.unsetUserBanner }}</MkButton>
 | 
			
		||||
							<MkButton v-if="iAmModerator" inline danger style="margin-right: 8px;" @click="unsetUserBanner"><i class="ph-image ph-bold ph-lg"></i> {{ i18n.ts.unsetUserBanner }}</MkButton>
 | 
			
		||||
							<MkButton v-if="iAmModerator" inline danger @click="deleteAllFiles"><i class="ph-cloud ph-bold ph-lg"></i> {{ i18n.ts.deleteAllFiles }}</MkButton>
 | 
			
		||||
						</div>
 | 
			
		||||
						<MkButton v-if="$i.isAdmin" inline danger @click="deleteAccount">{{ i18n.ts.deleteAccount }}</MkButton>
 | 
			
		||||
					</div>
 | 
			
		||||
| 
						 | 
				
			
			@ -265,6 +266,7 @@ function createFetcher() {
 | 
			
		|||
		moderator.value = info.value.isModerator;
 | 
			
		||||
		silenced.value = info.value.isSilenced;
 | 
			
		||||
		approved.value = info.value.approved;
 | 
			
		||||
		markedAsNSFW.value = info.value.alwaysMarkNsfw;
 | 
			
		||||
		suspended.value = info.value.isSuspended;
 | 
			
		||||
		moderationNote.value = info.value.moderationNote;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -39,7 +39,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		|||
				<!-- スマホ・タブレットの場合、キーボードが表示されると投稿が見づらくなるので、デスクトップ場合のみ自動でフォーカスを当てる -->
 | 
			
		||||
				<MkPostForm v-if="$i && defaultStore.reactiveState.showFixedPostFormInChannel.value" :channel="channel" class="post-form _panel" fixed :autofocus="deviceKind === 'desktop'"/>
 | 
			
		||||
 | 
			
		||||
				<MkTimeline :key="channelId" src="channel" :channel="channelId" @before="before" @after="after" @note="miLocalStorage.setItemAsJson(`channelLastReadedAt:${channel.id}`, Date.now())"/>
 | 
			
		||||
				<MkTimeline :key="channelId + withRenotes + onlyFiles" src="channel" :channel="channelId" :withRenotes="withRenotes" :onlyFiles="onlyFiles" @before="before" @after="after" @note="miLocalStorage.setItemAsJson(`channelLastReadedAt:${channel.id}`, Date.now())"/>
 | 
			
		||||
			</div>
 | 
			
		||||
			<div v-else-if="tab === 'featured'" key="featured">
 | 
			
		||||
				<MkNotes :pagination="featuredPagination"/>
 | 
			
		||||
| 
						 | 
				
			
			@ -95,6 +95,7 @@ import { isSupportShare } from '@/scripts/navigator.js';
 | 
			
		|||
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
 | 
			
		||||
import { miLocalStorage } from '@/local-storage.js';
 | 
			
		||||
import { useRouter } from '@/router/supplier.js';
 | 
			
		||||
import { deepMerge } from '@/scripts/merge.js';
 | 
			
		||||
 | 
			
		||||
const router = useRouter();
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -116,6 +117,15 @@ const featuredPagination = computed(() => ({
 | 
			
		|||
		channelId: props.channelId,
 | 
			
		||||
	},
 | 
			
		||||
}));
 | 
			
		||||
const withRenotes = computed<boolean>({
 | 
			
		||||
	get: () => defaultStore.reactiveState.tl.value.filter.withRenotes,
 | 
			
		||||
	set: (x) => saveTlFilter('withRenotes', x),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const onlyFiles = computed<boolean>({
 | 
			
		||||
	get: () => defaultStore.reactiveState.tl.value.filter.onlyFiles,
 | 
			
		||||
	set: (x) => saveTlFilter('onlyFiles', x),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
watch(() => props.channelId, async () => {
 | 
			
		||||
	channel.value = await misskeyApi('channels/show', {
 | 
			
		||||
| 
						 | 
				
			
			@ -136,6 +146,13 @@ watch(() => props.channelId, async () => {
 | 
			
		|||
	}
 | 
			
		||||
}, { immediate: true });
 | 
			
		||||
 | 
			
		||||
function saveTlFilter(key: keyof typeof defaultStore.state.tl.filter, newValue: boolean) {
 | 
			
		||||
	if (key !== 'withReplies' || $i) {
 | 
			
		||||
		const out = deepMerge({ filter: { [key]: newValue } }, defaultStore.state.tl);
 | 
			
		||||
		defaultStore.set('tl', out);
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function edit() {
 | 
			
		||||
	router.push(`/channels/${channel.value?.id}/edit`);
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -192,7 +209,21 @@ async function search() {
 | 
			
		|||
 | 
			
		||||
const headerActions = computed(() => {
 | 
			
		||||
	if (channel.value && channel.value.userId) {
 | 
			
		||||
		const headerItems: PageHeaderItem[] = [];
 | 
			
		||||
		const headerItems: PageHeaderItem[] = [{
 | 
			
		||||
			icon: 'ph-dots-three ph-bold ph-lg',
 | 
			
		||||
			text: i18n.ts.options,
 | 
			
		||||
			handler: (ev) => {
 | 
			
		||||
				os.popupMenu([{
 | 
			
		||||
					type: 'switch',
 | 
			
		||||
					text: i18n.ts.showRenotes,
 | 
			
		||||
					ref: withRenotes,
 | 
			
		||||
				}, {
 | 
			
		||||
					type: 'switch',
 | 
			
		||||
					text: i18n.ts.fileAttachedOnly,
 | 
			
		||||
					ref: onlyFiles,
 | 
			
		||||
				}], ev.currentTarget ?? ev.target);
 | 
			
		||||
			},
 | 
			
		||||
		}];
 | 
			
		||||
 | 
			
		||||
		headerItems.push({
 | 
			
		||||
			icon: 'ph-share-network ph-bold ph-lg',
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -11,10 +11,12 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		|||
			<div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" :class="$style.newButton" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div>
 | 
			
		||||
			<div :class="$style.tl">
 | 
			
		||||
				<MkTimeline
 | 
			
		||||
					ref="tlEl" :key="listId"
 | 
			
		||||
					ref="tlEl" :key="listId + withRenotes + onlyFiles"
 | 
			
		||||
					src="list"
 | 
			
		||||
					:list="listId"
 | 
			
		||||
					:sound="true"
 | 
			
		||||
					:withRenotes="withRenotes"
 | 
			
		||||
					:onlyFiles="onlyFiles"
 | 
			
		||||
					@queue="queueUpdated"
 | 
			
		||||
				/>
 | 
			
		||||
			</div>
 | 
			
		||||
| 
						 | 
				
			
			@ -32,6 +34,9 @@ import { misskeyApi } from '@/scripts/misskey-api.js';
 | 
			
		|||
import { definePageMetadata } from '@/scripts/page-metadata.js';
 | 
			
		||||
import { i18n } from '@/i18n.js';
 | 
			
		||||
import { useRouter } from '@/router/supplier.js';
 | 
			
		||||
import { defaultStore } from '@/store.js';
 | 
			
		||||
import { deepMerge } from '@/scripts/merge.js';
 | 
			
		||||
import * as os from '@/os.js';
 | 
			
		||||
 | 
			
		||||
const router = useRouter();
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -43,6 +48,21 @@ const list = ref<Misskey.entities.UserList | null>(null);
 | 
			
		|||
const queue = ref(0);
 | 
			
		||||
const tlEl = shallowRef<InstanceType<typeof MkTimeline>>();
 | 
			
		||||
const rootEl = shallowRef<HTMLElement>();
 | 
			
		||||
const withRenotes = computed<boolean>({
 | 
			
		||||
	get: () => defaultStore.reactiveState.tl.value.filter.withRenotes,
 | 
			
		||||
	set: (x) => saveTlFilter('withRenotes', x),
 | 
			
		||||
});
 | 
			
		||||
const onlyFiles = computed<boolean>({
 | 
			
		||||
	get: () => defaultStore.reactiveState.tl.value.filter.onlyFiles,
 | 
			
		||||
	set: (x) => saveTlFilter('onlyFiles', x),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
function saveTlFilter(key: keyof typeof defaultStore.state.tl.filter, newValue: boolean) {
 | 
			
		||||
	if (key !== 'withReplies' || $i) {
 | 
			
		||||
		const out = deepMerge({ filter: { [key]: newValue } }, defaultStore.state.tl);
 | 
			
		||||
		defaultStore.set('tl', out);
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
watch(() => props.listId, async () => {
 | 
			
		||||
	list.value = await misskeyApi('users/lists/show', {
 | 
			
		||||
| 
						 | 
				
			
			@ -63,6 +83,20 @@ function settings() {
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
const headerActions = computed(() => list.value ? [{
 | 
			
		||||
	icon: 'ph-dots-three ph-bold ph-lg',
 | 
			
		||||
	text: i18n.ts.options,
 | 
			
		||||
	handler: (ev) => {
 | 
			
		||||
		os.popupMenu([{
 | 
			
		||||
			type: 'switch',
 | 
			
		||||
			text: i18n.ts.showRenotes,
 | 
			
		||||
			ref: withRenotes,
 | 
			
		||||
		}, {
 | 
			
		||||
			type: 'switch',
 | 
			
		||||
			text: i18n.ts.fileAttachedOnly,
 | 
			
		||||
			ref: onlyFiles,
 | 
			
		||||
		}], ev.currentTarget ?? ev.target);
 | 
			
		||||
	},
 | 
			
		||||
}, {
 | 
			
		||||
	icon: 'ph-gear ph-bold ph-lg',
 | 
			
		||||
	text: i18n.ts.settings,
 | 
			
		||||
	handler: settings,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -706,3 +706,12 @@ html[data-color-mode=dark] ._woodenFrame {
 | 
			
		|||
	0% { filter: hue-rotate(0deg) contrast(150%) saturate(150%); }
 | 
			
		||||
	100% { filter: hue-rotate(360deg) contrast(150%) saturate(150%); }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@keyframes mfm-fade {
 | 
			
		||||
	0% {
 | 
			
		||||
	  opacity: 0;
 | 
			
		||||
	}
 | 
			
		||||
	100% {
 | 
			
		||||
	  opacity: 1;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -13,13 +13,13 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		|||
		<div style="padding: 8px; text-align: center;">
 | 
			
		||||
			<MkButton primary gradate rounded inline small @click="post"><i class="ph-pencil-simple ph-bold ph-lg"></i></MkButton>
 | 
			
		||||
		</div>
 | 
			
		||||
		<MkTimeline ref="timeline" src="channel" :channel="column.channelId"/>
 | 
			
		||||
		<MkTimeline ref="timeline" src="channel" :channel="column.channelId" :key="column.channelId + column.withRenotes + column.onlyFiles" :withRenotes="withRenotes" :onlyFiles="onlyFiles"/>
 | 
			
		||||
	</template>
 | 
			
		||||
</XColumn>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { shallowRef } from 'vue';
 | 
			
		||||
import { watch, ref, shallowRef } from 'vue';
 | 
			
		||||
import * as Misskey from 'misskey-js';
 | 
			
		||||
import XColumn from './column.vue';
 | 
			
		||||
import { updateColumn, Column } from './deck-store.js';
 | 
			
		||||
| 
						 | 
				
			
			@ -36,6 +36,20 @@ const props = defineProps<{
 | 
			
		|||
 | 
			
		||||
const timeline = shallowRef<InstanceType<typeof MkTimeline>>();
 | 
			
		||||
const channel = shallowRef<Misskey.entities.Channel>();
 | 
			
		||||
const withRenotes = ref(props.column.withRenotes ?? true);
 | 
			
		||||
const onlyFiles = ref(props.column.onlyFiles ?? false);
 | 
			
		||||
 | 
			
		||||
watch(withRenotes, v => {
 | 
			
		||||
	updateColumn(props.column.id, {
 | 
			
		||||
		withRenotes: v,
 | 
			
		||||
	});
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
watch(onlyFiles, v => {
 | 
			
		||||
	updateColumn(props.column.id, {
 | 
			
		||||
		onlyFiles: v,
 | 
			
		||||
	});
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
if (props.column.channelId == null) {
 | 
			
		||||
	setChannel();
 | 
			
		||||
| 
						 | 
				
			
			@ -75,5 +89,13 @@ const menu = [{
 | 
			
		|||
	icon: 'ph-pencil-simple ph-bold ph-lg',
 | 
			
		||||
	text: i18n.ts.selectChannel,
 | 
			
		||||
	action: setChannel,
 | 
			
		||||
}, {
 | 
			
		||||
	type: 'switch',
 | 
			
		||||
	text: i18n.ts.showRenotes,
 | 
			
		||||
	ref: withRenotes,
 | 
			
		||||
}, {
 | 
			
		||||
	type: 'switch',
 | 
			
		||||
	text: i18n.ts.fileAttachedOnly,
 | 
			
		||||
	ref: onlyFiles,
 | 
			
		||||
}];
 | 
			
		||||
</script>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		|||
		<i class="ph-list ph-bold ph-lg"></i><span style="margin-left: 8px;">{{ column.name }}</span>
 | 
			
		||||
	</template>
 | 
			
		||||
 | 
			
		||||
	<MkTimeline v-if="column.listId" ref="timeline" src="list" :list="column.listId" :withRenotes="withRenotes"/>
 | 
			
		||||
	<MkTimeline v-if="column.listId" ref="timeline" src="list" :list="column.listId" :key="column.listId + column.withRenotes + column.onlyFiles" :withRenotes="withRenotes" :onlyFiles="onlyFiles"/>
 | 
			
		||||
</XColumn>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -29,6 +29,7 @@ const props = defineProps<{
 | 
			
		|||
 | 
			
		||||
const timeline = shallowRef<InstanceType<typeof MkTimeline>>();
 | 
			
		||||
const withRenotes = ref(props.column.withRenotes ?? true);
 | 
			
		||||
const onlyFiles = ref(props.column.onlyFiles ?? false);
 | 
			
		||||
 | 
			
		||||
if (props.column.listId == null) {
 | 
			
		||||
	setList();
 | 
			
		||||
| 
						 | 
				
			
			@ -40,6 +41,12 @@ watch(withRenotes, v => {
 | 
			
		|||
	});
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
watch(onlyFiles, v => {
 | 
			
		||||
	updateColumn(props.column.id, {
 | 
			
		||||
		onlyFiles: v,
 | 
			
		||||
	});
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
async function setList() {
 | 
			
		||||
	const lists = await misskeyApi('users/lists/list');
 | 
			
		||||
	const { canceled, result: list } = await os.select({
 | 
			
		||||
| 
						 | 
				
			
			@ -75,5 +82,10 @@ const menu = [
 | 
			
		|||
		text: i18n.ts.showRenotes,
 | 
			
		||||
		ref: withRenotes,
 | 
			
		||||
	},
 | 
			
		||||
	{
 | 
			
		||||
		type: 'switch',
 | 
			
		||||
		text: i18n.ts.fileAttachedOnly,
 | 
			
		||||
		ref: onlyFiles,
 | 
			
		||||
	},
 | 
			
		||||
];
 | 
			
		||||
</script>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue