From 14a9e1050ba57bcb4cf82b48829accc98c9bd504 Mon Sep 17 00:00:00 2001
From: Kaity A <supakaity@blahaj.zone>
Date: Tue, 24 Oct 2023 21:16:09 +1000
Subject: [PATCH 01/17] feat: allow using wildcards in antenna

---
 locales/en-US.yml                           | 2 +-
 locales/es-ES.yml                           | 2 +-
 packages/backend/src/core/AntennaService.ts | 8 ++++++--
 3 files changed, 8 insertions(+), 4 deletions(-)

diff --git a/locales/en-US.yml b/locales/en-US.yml
index ddcf610903..e573a15b09 100644
--- a/locales/en-US.yml
+++ b/locales/en-US.yml
@@ -402,7 +402,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"
diff --git a/locales/es-ES.yml b/locales/es-ES.yml
index 82a996efbb..f1193d567a 100644
--- a/locales/es-ES.yml
+++ b/locales/es-ES.yml
@@ -391,7 +391,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"
diff --git a/packages/backend/src/core/AntennaService.ts b/packages/backend/src/core/AntennaService.ts
index 65be275548..f5db80dedd 100644
--- a/packages/backend/src/core/AntennaService.ts
+++ b/packages/backend/src/core/AntennaService.ts
@@ -115,13 +115,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

From 25e6409cc91fc7ad733f8c8154aca3d55124c5c1 Mon Sep 17 00:00:00 2001
From: dakkar <dakkar@thenautilus.net>
Date: Wed, 20 Mar 2024 15:38:20 +0000
Subject: [PATCH 02/17] allow overriding all string config values via env -
 fixes #465

will need end-user documentation!
---
 packages/backend/src/config.ts | 101 +++++++++++++++++++++++++++++++++
 1 file changed, 101 insertions(+)

diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts
index c99bc7ae03..1e08a0f6a8 100644
--- a/packages/backend/src/config.ts
+++ b/packages/backend/src/config.ts
@@ -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,102 @@ 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` overrides `config.meilisearch.apikey`
+
+	the option's containing object must be present in the config *file*,
+	so in the example above, `config.meilisearch` must be set to
+	something in the file, it can't be completely commented out.
+
+	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`); again, `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)[]) {
+		_apply_inner(config, '', steps);
+	}
+
+	function _apply_inner(thisConfig: any, name: string, 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(thisConfig, name, thisOneStep, steps);
+				}
+			} else {
+				_descend(thisConfig, name, 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(thisConfig, name, lastOneStep);
+			}
+		} else {
+			_lastBit(thisConfig, name, lastStep);
+		}
+	}
+
+	// this recurses down, bailing out if there's no config to override
+	function _descend(thisConfig: any, name: string, thisStep: string | number, steps: (string | number)[]) {
+		name = `${name}${thisStep.toString().toUpperCase()}_`;
+		thisConfig = thisConfig[thisStep];
+		if (!thisConfig) return;
+		_apply_inner(thisConfig, name, steps);
+	}
+
+	// this is the bottom of the recursion: look at the environment and
+	// set the value
+	function _lastBit(thisConfig: any, name: string, lastStep: string | number) {
+		name = `${name}${lastStep.toString().toUpperCase()}`;
+
+		const val = process.env[`MK_CONFIG_${name}`];
+		if (val != null && val != undefined) {
+			thisConfig[lastStep] = val;
+		}
+
+		const file = process.env[`MK_CONFIG_${name}_FILE`];
+		if (file) {
+			thisConfig[lastStep] = fs.readFileSync(file, 'utf-8').trim();
+		}
+	}
+
+	// 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']]);
+}

From 435cab01c8cf7f29e873eb7d9711dd0ad2ead816 Mon Sep 17 00:00:00 2001
From: dakkar <dakkar@thenautilus.net>
Date: Thu, 21 Mar 2024 10:00:16 +0000
Subject: [PATCH 03/17] deal with (possible, future) non-alnum config keys

---
 packages/backend/src/config.ts | 8 ++++++--
 1 file changed, 6 insertions(+), 2 deletions(-)

diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts
index 1e08a0f6a8..8f814e4520 100644
--- a/packages/backend/src/config.ts
+++ b/packages/backend/src/config.ts
@@ -367,9 +367,13 @@ function applyEnvOverrides(config: Source) {
 		}
 	}
 
+	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(thisConfig: any, name: string, thisStep: string | number, steps: (string | number)[]) {
-		name = `${name}${thisStep.toString().toUpperCase()}_`;
+		name = `${name}${_step2name(thisStep)}_`;
 		thisConfig = thisConfig[thisStep];
 		if (!thisConfig) return;
 		_apply_inner(thisConfig, name, steps);
@@ -378,7 +382,7 @@ function applyEnvOverrides(config: Source) {
 	// this is the bottom of the recursion: look at the environment and
 	// set the value
 	function _lastBit(thisConfig: any, name: string, lastStep: string | number) {
-		name = `${name}${lastStep.toString().toUpperCase()}`;
+		name = `${name}${_step2name(lastStep)}`;
 
 		const val = process.env[`MK_CONFIG_${name}`];
 		if (val != null && val != undefined) {

From 74362af828b78d6e8b16cde00996db2afb63a4fe Mon Sep 17 00:00:00 2001
From: dakkar <dakkar@thenautilus.net>
Date: Sat, 23 Mar 2024 12:19:13 +0000
Subject: [PATCH 04/17] allow custom oneko image via themes - fixes #472

after this change, one can set a custom image by:

* upload an appropriate image to the drive (some images can be
  found at
	https://github.com/vencordcss/onekocord/tree/main/onekoskins),
	possibly with the "keep original" option set
* copy the URL to the image in the drive
* create/edit a theme so that it contains (inside `props`):

     "oneko-image": '"url(https://yourinstance.example.com/files/ee17b385-a084-4e2a-b531-225dfb96cc3c)',

  with the proper URL

That's it!
---
 packages/frontend/src/components/SkOneko.vue | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/packages/frontend/src/components/SkOneko.vue b/packages/frontend/src/components/SkOneko.vue
index fbf50067a9..a82258e97e 100644
--- a/packages/frontend/src/components/SkOneko.vue
+++ b/packages/frontend/src/components/SkOneko.vue
@@ -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>

From 0e8cdb30b719a1cc0d9fc3e9c14eacb54444caeb Mon Sep 17 00:00:00 2001
From: dakkar <dakkar@thenautilus.net>
Date: Sun, 24 Mar 2024 11:12:17 +0000
Subject: [PATCH 05/17] allow setting values not present in the config file

replicas and arrays in general, are more complicated :/
---
 packages/backend/src/config.ts | 53 +++++++++++++++++++---------------
 1 file changed, 30 insertions(+), 23 deletions(-)

diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts
index 8f814e4520..8d4c5464a6 100644
--- a/packages/backend/src/config.ts
+++ b/packages/backend/src/config.ts
@@ -311,17 +311,13 @@ function convertRedisOptions(options: RedisOptionsSource, host: string): RedisOp
 	this function allows overriding any string-valued config option with
 	a sensible-named environment variable
 
-	e.g. `MK_CONFIG_MEILISEARCH_APIKEY` overrides `config.meilisearch.apikey`
-
-	the option's containing object must be present in the config *file*,
-	so in the example above, `config.meilisearch` must be set to
-	something in the file, it can't be completely commented out.
+	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`); again, `config.dbSlaves` must be set
-	to an array of the right size already in the file
+	`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
@@ -332,10 +328,10 @@ function applyEnvOverrides(config: Source) {
 	// the given steps, building the env variable name
 
 	function _apply_top(steps: (string | number)[]) {
-		_apply_inner(config, '', steps);
+		_walk('', [], steps);
 	}
 
-	function _apply_inner(thisConfig: any, name: string, steps: (string | number)[]) {
+	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();
@@ -344,10 +340,10 @@ function applyEnvOverrides(config: Source) {
 			// if a step is not a simple value, iterate through it
 			if (typeof thisStep === 'object') {
 				for (const thisOneStep of thisStep) {
-					_descend(thisConfig, name, thisOneStep, steps);
+					_descend(name, path, thisOneStep, steps);
 				}
 			} else {
-				_descend(thisConfig, name, thisStep, steps);
+				_descend(name, path, thisStep, steps);
 			}
 
 			// the actual override has happened at the bottom of the
@@ -360,10 +356,10 @@ function applyEnvOverrides(config: Source) {
 
 		if (typeof lastStep === 'object') {
 			for (const lastOneStep of lastStep) {
-				_lastBit(thisConfig, name, lastOneStep);
+				_lastBit(name, path, lastOneStep);
 			}
 		} else {
-			_lastBit(thisConfig, name, lastStep);
+			_lastBit(name, path, lastStep);
 		}
 	}
 
@@ -372,29 +368,40 @@ function applyEnvOverrides(config: Source) {
 	}
 
 	// this recurses down, bailing out if there's no config to override
-	function _descend(thisConfig: any, name: string, thisStep: string | number, steps: (string | number)[]) {
+	function _descend(name: string, path: (string | number)[], thisStep: string | number, steps: (string | number)[]) {
 		name = `${name}${_step2name(thisStep)}_`;
-		thisConfig = thisConfig[thisStep];
-		if (!thisConfig) return;
-		_apply_inner(thisConfig, name, steps);
+		path = [ ...path, thisStep ];
+		_walk(name, path, steps);
 	}
 
 	// this is the bottom of the recursion: look at the environment and
 	// set the value
-	function _lastBit(thisConfig: any, name: string, lastStep: string | number) {
-		name = `${name}${_step2name(lastStep)}`;
+	function _lastBit(name: string, path: (string | number)[], lastStep: string | number) {
+		name = `MK_CONFIG_${name}${_step2name(lastStep)}`;
 
-		const val = process.env[`MK_CONFIG_${name}`];
+		const val = process.env[name];
 		if (val != null && val != undefined) {
-			thisConfig[lastStep] = val;
+			_assign(path, lastStep, val);
 		}
 
-		const file = process.env[`MK_CONFIG_${name}_FILE`];
+		const file = process.env[`${name}_FILE`];
 		if (file) {
-			thisConfig[lastStep] = fs.readFileSync(file, 'utf-8').trim();
+			_assign(path, lastStep, fs.readFileSync(file, 'utf-8').trim());
 		}
 	}
 
+	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];
+		}
+
+		thisConfig[lastStep] = value;
+	}
+
 	// these are all the settings that can be overridden
 
 	_apply_top([['url', 'port', 'socket', 'chmodSocket', 'disableHsts']]);

From 4271402e0d0e1840791158de288a3e7617227ec4 Mon Sep 17 00:00:00 2001
From: dakkar <dakkar@thenautilus.net>
Date: Sun, 24 Mar 2024 11:17:55 +0000
Subject: [PATCH 06/17] recognise numbers and boolean values

---
 packages/backend/src/config.ts | 10 ++++++++++
 1 file changed, 10 insertions(+)

diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts
index 8d4c5464a6..f6ce9b3cdf 100644
--- a/packages/backend/src/config.ts
+++ b/packages/backend/src/config.ts
@@ -390,6 +390,8 @@ function applyEnvOverrides(config: Source) {
 		}
 	}
 
+	const alwaysStrings = { 'chmodSocket': 1 };
+
 	function _assign(path: (string | number)[], lastStep: string | number, value: string) {
 		let thisConfig = config;
 		for (const step of path) {
@@ -399,6 +401,14 @@ function applyEnvOverrides(config: Source) {
 			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;
 	}
 

From 194d8a5527ed98a2f9679e1381a6d7290f10a1b7 Mon Sep 17 00:00:00 2001
From: Sugar <sugar@sylveon.social>
Date: Sat, 11 May 2024 09:44:03 +0200
Subject: [PATCH 07/17] feat: send edit events to servers that interacted

a server replied to, renoted or reacted to a note knows about a note,
and as such it should get notified about it being edited.

this matches similar logic in mastodon.
---
 packages/backend/src/core/NoteEditService.ts | 18 ++++++++++++++++++
 1 file changed, 18 insertions(+)

diff --git a/packages/backend/src/core/NoteEditService.ts b/packages/backend/src/core/NoteEditService.ts
index a01dfec664..399461dd70 100644
--- a/packages/backend/src/core/NoteEditService.ts
+++ b/packages/backend/src/core/NoteEditService.ts
@@ -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);
 					}

From eac9f389420b5ae3c18dfe0bf0c5aead8f164655 Mon Sep 17 00:00:00 2001
From: Sebastian  Di Luzio <ap-soft-gitlab@diluz.io>
Date: Mon, 20 May 2024 11:10:45 +0000
Subject: [PATCH 08/17] fix(i18n): adjust grammar in about_misskey

---
 locales/en-US.yml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/locales/en-US.yml b/locales/en-US.yml
index fe27a131ba..0203d9562d 100644
--- a/locales/en-US.yml
+++ b/locales/en-US.yml
@@ -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"

From 4c4b43124862a4ee564cb8d6dafa44be8c95cb78 Mon Sep 17 00:00:00 2001
From: Leah <kevinlukej@gmail.com>
Date: Thu, 23 May 2024 18:08:31 +0000
Subject: [PATCH 09/17] Ported cutiekeys followmouse mfm

---
 locales/en-US.yml                             |   3 +
 .../frontend/src/components/CkFollowMouse.vue |  81 +++++++++++
 .../frontend/src/components/MkMfmWindow.vue   | 134 ++++++++++--------
 .../global/MkMisskeyFlavoredMarkdown.ts       |  23 +++
 packages/frontend/src/const.ts                |   5 +-
 5 files changed, 184 insertions(+), 62 deletions(-)
 create mode 100644 packages/frontend/src/components/CkFollowMouse.vue

diff --git a/locales/en-US.yml b/locales/en-US.yml
index fe27a131ba..07173c131e 100644
--- a/locales/en-US.yml
+++ b/locales/en-US.yml
@@ -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,6 +2543,8 @@ _mfm:
   rotateDescription: "Turns content by a specified angle."
   position: "Position"
   positionDescription: "Move content by a specified amount."
+  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"
diff --git a/packages/frontend/src/components/CkFollowMouse.vue b/packages/frontend/src/components/CkFollowMouse.vue
new file mode 100644
index 0000000000..b55a577b3f
--- /dev/null
+++ b/packages/frontend/src/components/CkFollowMouse.vue
@@ -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>
diff --git a/packages/frontend/src/components/MkMfmWindow.vue b/packages/frontend/src/components/MkMfmWindow.vue
index ce2a0e7391..fd23eb0097 100644
--- a/packages/frontend/src/components/MkMfmWindow.vue
+++ b/packages/frontend/src/components/MkMfmWindow.vue
@@ -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,7 +299,7 @@
 					<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>
@@ -309,17 +309,29 @@
 					<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>
@@ -329,7 +341,7 @@
 					<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 +351,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 +361,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 +374,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 +399,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,25 +433,26 @@ 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_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>',
 );
 </script>
 
diff --git a/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts
index f8b5fcfedc..2f699ccd84 100644
--- a/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts
+++ b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts
@@ -6,6 +6,7 @@
 import { VNode, h, defineAsyncComponent, SetupContext } 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';
@@ -232,6 +233,28 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven
 						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;
diff --git a/packages/frontend/src/const.ts b/packages/frontend/src/const.ts
index ad798067b3..4c7df613a5 100644
--- a/packages/frontend/src/const.ts
+++ b/packages/frontend/src/const.ts
@@ -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', 'followmouse'];
 export const MFM_PARAMS: Record<typeof MFM_TAGS[number], string[]> = {
 	tada: ['speed=', 'delay='],
 	jelly: ['speed=', 'delay='],
@@ -179,11 +179,12 @@ 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: [],
+	followmouse: ['x', 'y', 'rotateByVelocity', 'speed='],
 };

From f9a7cd0daa869b23b100f6d32e71f0faaa7873fc Mon Sep 17 00:00:00 2001
From: Leah <kevinlukej@gmail.com>
Date: Thu, 23 May 2024 21:40:25 +0000
Subject: [PATCH 10/17] Ported firefish crop and fade mfm

---
 locales/en-US.yml                             |  4 +++
 .../frontend/src/components/MkMfmWindow.vue   | 26 ++++++++++++++-
 .../global/MkMisskeyFlavoredMarkdown.ts       | 32 +++++++++++++++++++
 packages/frontend/src/const.ts                |  4 ++-
 packages/frontend/src/style.scss              |  9 ++++++
 5 files changed, 73 insertions(+), 2 deletions(-)

diff --git a/locales/en-US.yml b/locales/en-US.yml
index 07173c131e..c442d41c1e 100644
--- a/locales/en-US.yml
+++ b/locales/en-US.yml
@@ -2543,12 +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"
diff --git a/packages/frontend/src/components/MkMfmWindow.vue b/packages/frontend/src/components/MkMfmWindow.vue
index fd23eb0097..a742ad184c 100644
--- a/packages/frontend/src/components/MkMfmWindow.vue
+++ b/packages/frontend/src/components/MkMfmWindow.vue
@@ -304,6 +304,16 @@
 						</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">
@@ -316,7 +326,7 @@
 				</div>
 				<div class="section _block" style="overflow: hidden">
 					<div class="title">{{ i18n.ts._mfm.followMouse }}</div>
-					<MkInfo warn>{{  i18n.ts._mfm.uncommonFeature }}</MkInfo>
+					<MkInfo warn>{{ i18n.ts._mfm.uncommonFeature }}</MkInfo>
 					<br/>
 					<div class="content">
 						<p>{{ i18n.ts._mfm.followMouseDescription }}</p>
@@ -336,6 +346,16 @@
 						</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">
@@ -445,6 +465,9 @@ const preview_rotate = ref(
 	'$[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_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 🍮]',
@@ -454,6 +477,7 @@ const preview_bg = ref('$[bg.color=31748f Background color]');
 const preview_plain = ref(
 	'<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>
diff --git a/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts
index 2f699ccd84..69fe41e2d5 100644
--- a/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts
+++ b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts
@@ -228,6 +228,22 @@ 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;`;
@@ -262,6 +278,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 = '';
diff --git a/packages/frontend/src/const.ts b/packages/frontend/src/const.ts
index 4c7df613a5..5109c34c02 100644
--- a/packages/frontend/src/const.ts
+++ b/packages/frontend/src/const.ts
@@ -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', 'followmouse'];
+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='],
@@ -186,5 +186,7 @@ export const MFM_PARAMS: Record<typeof MFM_TAGS[number], string[]> = {
 	rotate: ['deg='],
 	ruby: [],
 	unixtime: [],
+	fade: ['speed=', 'delay=', 'loop=', 'out'],
+	crop: ['top=', 'bottom=', 'left=', 'right='],
 	followmouse: ['x', 'y', 'rotateByVelocity', 'speed='],
 };
diff --git a/packages/frontend/src/style.scss b/packages/frontend/src/style.scss
index d876009961..dbf7d3ccef 100644
--- a/packages/frontend/src/style.scss
+++ b/packages/frontend/src/style.scss
@@ -698,3 +698,12 @@ rt {
 	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;
+	}
+}
\ No newline at end of file

From d27ce442eab9ae8e2f4ddff1e8ca0d0412e73046 Mon Sep 17 00:00:00 2001
From: dakkar <dakkar@thenautilus.net>
Date: Thu, 23 May 2024 21:56:28 +0000
Subject: [PATCH 11/17] more timeline filters - #228

---
 .../server/api/endpoints/channels/timeline.ts | 31 +++++++++++++---
 .../src/server/api/stream/channels/channel.ts |  8 +++++
 .../frontend/src/components/MkTimeline.vue    |  4 +++
 packages/frontend/src/pages/channel.vue       | 35 ++++++++++++++++--
 .../frontend/src/pages/user-list-timeline.vue | 36 ++++++++++++++++++-
 .../frontend/src/ui/deck/channel-column.vue   | 26 ++++++++++++--
 packages/frontend/src/ui/deck/list-column.vue | 14 +++++++-
 7 files changed, 144 insertions(+), 10 deletions(-)

diff --git a/packages/backend/src/server/api/endpoints/channels/timeline.ts b/packages/backend/src/server/api/endpoints/channels/timeline.ts
index 8c55673590..295fc5686c 100644
--- a/packages/backend/src/server/api/endpoints/channels/timeline.ts
+++ b/packages/backend/src/server/api/endpoints/channels/timeline.ts
@@ -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();
diff --git a/packages/backend/src/server/api/stream/channels/channel.ts b/packages/backend/src/server/api/stream/channels/channel.ts
index 90ee1ecda5..61bb4bfae2 100644
--- a/packages/backend/src/server/api/stream/channels/channel.ts
+++ b/packages/backend/src/server/api/stream/channels/channel.ts
@@ -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;
+
 		// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
 		if (isUserRelated(note, this.userIdsWhoMeMuting)) return;
 		// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue
index 1c14174a37..0f7eb3b86c 100644
--- a/packages/frontend/src/components/MkTimeline.vue
+++ b/packages/frontend/src/components/MkTimeline.vue
@@ -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') {
diff --git a/packages/frontend/src/pages/channel.vue b/packages/frontend/src/pages/channel.vue
index 881acd0197..ee081d07ee 100644
--- a/packages/frontend/src/pages/channel.vue
+++ b/packages/frontend/src/pages/channel.vue
@@ -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',
diff --git a/packages/frontend/src/pages/user-list-timeline.vue b/packages/frontend/src/pages/user-list-timeline.vue
index dd0b7fb675..b2d52b013c 100644
--- a/packages/frontend/src/pages/user-list-timeline.vue
+++ b/packages/frontend/src/pages/user-list-timeline.vue
@@ -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,
diff --git a/packages/frontend/src/ui/deck/channel-column.vue b/packages/frontend/src/ui/deck/channel-column.vue
index 984de82c3f..993be46910 100644
--- a/packages/frontend/src/ui/deck/channel-column.vue
+++ b/packages/frontend/src/ui/deck/channel-column.vue
@@ -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>
diff --git a/packages/frontend/src/ui/deck/list-column.vue b/packages/frontend/src/ui/deck/list-column.vue
index 128562823b..f7988ed1b7 100644
--- a/packages/frontend/src/ui/deck/list-column.vue
+++ b/packages/frontend/src/ui/deck/list-column.vue
@@ -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>

From c42d61f69ba28b82aa41d7d5799c8218881f9138 Mon Sep 17 00:00:00 2001
From: dakkar <dakkar@thenautilus.net>
Date: Fri, 24 May 2024 20:47:10 +0000
Subject: [PATCH 12/17] put back button to delete all files for a user - fixes
 #535

---
 packages/frontend/src/pages/admin-user.vue | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/packages/frontend/src/pages/admin-user.vue b/packages/frontend/src/pages/admin-user.vue
index 3beaf5d08b..eb9ca602ab 100644
--- a/packages/frontend/src/pages/admin-user.vue
+++ b/packages/frontend/src/pages/admin-user.vue
@@ -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>

From 981975404de5672e7dac5f04b37f66ffc048bb93 Mon Sep 17 00:00:00 2001
From: Marie <marie@kaifa.ch>
Date: Thu, 30 May 2024 12:08:30 +0000
Subject: [PATCH 13/17] Fix Visiblity issue

---
 packages/backend/src/server/api/mastodon/converters.ts | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/packages/backend/src/server/api/mastodon/converters.ts b/packages/backend/src/server/api/mastodon/converters.ts
index 326d3a1d5c..0918fc74f9 100644
--- a/packages/backend/src/server/api/mastodon/converters.ts
+++ b/packages/backend/src/server/api/mastodon/converters.ts
@@ -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?.ffVisibility === 'public' ? user.followersCount : 0,
+			following_count: profile?.ffVisibility === 'public' ? user.followingCount : 0,
 			statuses_count: user.notesCount,
 			note: profile?.description ?? '',
 			url: user.uri ?? acctUrl,

From d7bd112b37f573790bcde2d7208960b190e96c48 Mon Sep 17 00:00:00 2001
From: Marie <marie@kaifa.ch>
Date: Thu, 30 May 2024 13:03:54 +0000
Subject: [PATCH 14/17] fix incorrect variable name

---
 packages/backend/src/server/api/mastodon/converters.ts | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/packages/backend/src/server/api/mastodon/converters.ts b/packages/backend/src/server/api/mastodon/converters.ts
index 0918fc74f9..ea219b933d 100644
--- a/packages/backend/src/server/api/mastodon/converters.ts
+++ b/packages/backend/src/server/api/mastodon/converters.ts
@@ -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: profile?.ffVisibility === 'public' ? user.followersCount : 0,
-			following_count: profile?.ffVisibility === 'public' ? user.followingCount : 0,
+			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,

From cebad801e21500f87b4978bb97bf0df025dceb9d Mon Sep 17 00:00:00 2001
From: fEmber <acomputerdog@gmail.com>
Date: Thu, 30 May 2024 13:17:51 +0000
Subject: [PATCH 15/17] fix: don't create duplicate workers when clustering is
 disabled

---
 packages/backend/src/boot/entry.ts | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/packages/backend/src/boot/entry.ts b/packages/backend/src/boot/entry.ts
index ae74a43c84..3882686fdc 100644
--- a/packages/backend/src/boot/entry.ts
+++ b/packages/backend/src/boot/entry.ts
@@ -75,7 +75,7 @@ async function main() {
 			ev.mount();
 		}
 	}
-	if (cluster.isWorker || envOption.disableClustering) {
+	if (cluster.isWorker) {
 		await workerMain();
 	}
 

From 2532fea702e185aa8a7b98f167535cd679da34bc Mon Sep 17 00:00:00 2001
From: fEmber <acomputerdog@gmail.com>
Date: Thu, 30 May 2024 13:18:44 +0000
Subject: [PATCH 16/17] fix: start only one instance of ChartManagementService
 scheduled job

---
 packages/backend/src/boot/common.ts | 1 -
 1 file changed, 1 deletion(-)

diff --git a/packages/backend/src/boot/common.ts b/packages/backend/src/boot/common.ts
index 268c07582d..18a2ab149a 100644
--- a/packages/backend/src/boot/common.ts
+++ b/packages/backend/src/boot/common.ts
@@ -36,7 +36,6 @@ export async function jobQueue() {
 	});
 
 	jobQueue.get(QueueProcessorService).start();
-	jobQueue.get(ChartManagementService).start();
 
 	return jobQueue;
 }

From 3050dcbef7f43e7ad2a07a60a6da175023527d89 Mon Sep 17 00:00:00 2001
From: dakkar <dakkar@thenautilus.net>
Date: Thu, 30 May 2024 14:22:00 +0000
Subject: [PATCH 17/17] set the correct "marked an NSFW" when loading
 admin-user

---
 packages/backend/src/core/DriveService.ts  | 3 ++-
 packages/frontend/src/pages/admin-user.vue | 1 +
 2 files changed, 3 insertions(+), 1 deletion(-)

diff --git a/packages/backend/src/core/DriveService.ts b/packages/backend/src/core/DriveService.ts
index f64568ee9a..4203b03c74 100644
--- a/packages/backend/src/core/DriveService.ts
+++ b/packages/backend/src/core/DriveService.ts
@@ -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();
diff --git a/packages/frontend/src/pages/admin-user.vue b/packages/frontend/src/pages/admin-user.vue
index 3beaf5d08b..52272e7365 100644
--- a/packages/frontend/src/pages/admin-user.vue
+++ b/packages/frontend/src/pages/admin-user.vue
@@ -265,6 +265,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;