diff options
| author | dakkar <dakkar@thenautilus.net> | 2024-05-31 11:24:00 +0100 |
|---|---|---|
| committer | dakkar <dakkar@thenautilus.net> | 2024-05-31 11:24:00 +0100 |
| commit | 4ddee7b01e9a45e9f17f210ebe361b00e919073c (patch) | |
| tree | 54658e54859295fb4dc71fa8435643731b6e53f6 /packages | |
| parent | fix: event propagation for reactions button in MkNote (diff) | |
| parent | merge: feat: send edit events to servers that interacted (!515) (diff) | |
| download | sharkey-4ddee7b01e9a45e9f17f210ebe361b00e919073c.tar.gz sharkey-4ddee7b01e9a45e9f17f210ebe361b00e919073c.tar.bz2 sharkey-4ddee7b01e9a45e9f17f210ebe361b00e919073c.zip | |
Merge branch 'develop' into future
Diffstat (limited to 'packages')
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> |