summaryrefslogtreecommitdiff
path: root/packages
diff options
context:
space:
mode:
authordakkar <dakkar@thenautilus.net>2024-05-31 11:24:00 +0100
committerdakkar <dakkar@thenautilus.net>2024-05-31 11:24:00 +0100
commit4ddee7b01e9a45e9f17f210ebe361b00e919073c (patch)
tree54658e54859295fb4dc71fa8435643731b6e53f6 /packages
parentfix: event propagation for reactions button in MkNote (diff)
parentmerge: feat: send edit events to servers that interacted (!515) (diff)
downloadsharkey-4ddee7b01e9a45e9f17f210ebe361b00e919073c.tar.gz
sharkey-4ddee7b01e9a45e9f17f210ebe361b00e919073c.tar.bz2
sharkey-4ddee7b01e9a45e9f17f210ebe361b00e919073c.zip
Merge branch 'develop' into future
Diffstat (limited to 'packages')
-rw-r--r--packages/backend/src/boot/common.ts1
-rw-r--r--packages/backend/src/boot/entry.ts2
-rw-r--r--packages/backend/src/config.ts122
-rw-r--r--packages/backend/src/core/AntennaService.ts8
-rw-r--r--packages/backend/src/core/DriveService.ts3
-rw-r--r--packages/backend/src/core/NoteEditService.ts18
-rw-r--r--packages/backend/src/server/api/endpoints/channels/timeline.ts31
-rw-r--r--packages/backend/src/server/api/mastodon/converters.ts4
-rw-r--r--packages/backend/src/server/api/stream/channels/channel.ts8
-rw-r--r--packages/frontend/src/components/CkFollowMouse.vue81
-rw-r--r--packages/frontend/src/components/MkMfmWindow.vue158
-rw-r--r--packages/frontend/src/components/MkTimeline.vue4
-rw-r--r--packages/frontend/src/components/SkOneko.vue2
-rw-r--r--packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts55
-rw-r--r--packages/frontend/src/const.ts7
-rw-r--r--packages/frontend/src/pages/admin-user.vue4
-rw-r--r--packages/frontend/src/pages/channel.vue35
-rw-r--r--packages/frontend/src/pages/user-list-timeline.vue36
-rw-r--r--packages/frontend/src/style.scss9
-rw-r--r--packages/frontend/src/ui/deck/channel-column.vue26
-rw-r--r--packages/frontend/src/ui/deck/list-column.vue14
21 files changed, 547 insertions, 81 deletions
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;
}
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();
}
diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts
index c99bc7ae03..f6ce9b3cdf 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,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']]);
+}
diff --git a/packages/backend/src/core/AntennaService.ts b/packages/backend/src/core/AntennaService.ts
index 793d8974b3..89e475b5f1 100644
--- a/packages/backend/src/core/AntennaService.ts
+++ b/packages/backend/src/core/AntennaService.ts
@@ -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
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/backend/src/core/NoteEditService.ts b/packages/backend/src/core/NoteEditService.ts
index 34017f015a..244f7e78d4 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);
}
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/mastodon/converters.ts b/packages/backend/src/server/api/mastodon/converters.ts
index 326d3a1d5c..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: 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,
diff --git a/packages/backend/src/server/api/stream/channels/channel.ts b/packages/backend/src/server/api/stream/channels/channel.ts
index 140dd3dd9b..865e4fed19 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;
+
if (this.isNoteMutedOrBlocked(note)) return;
if (this.user && isRenotePacked(note) && !isQuotePacked(note)) {
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..a742ad184c 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,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_crop = ref(
+ "$[crop.top=50 ๐Ÿฎ] $[crop.right=50 ๐Ÿฎ] $[crop.bottom=50 ๐Ÿฎ] $[crop.left=50 ๐Ÿฎ]",
);
-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>',
);
+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/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/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>
diff --git a/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts
index 09fede3cea..a21cd9477e 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, 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 = '';
diff --git a/packages/frontend/src/const.ts b/packages/frontend/src/const.ts
index ad798067b3..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'];
+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='],
};
diff --git a/packages/frontend/src/pages/admin-user.vue b/packages/frontend/src/pages/admin-user.vue
index 5c7fca909e..07df36bd11 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>
@@ -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;
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/style.scss b/packages/frontend/src/style.scss
index 4d9b2a77dc..057a4fb61e 100644
--- a/packages/frontend/src/style.scss
+++ b/packages/frontend/src/style.scss
@@ -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;
+ }
+} \ No newline at end of file
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>