diff --git a/locales/en-US.yml b/locales/en-US.yml index fe27a131ba..edf4abfac1 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -415,7 +415,7 @@ antennaKeywordsDescription: "Separate with spaces for an AND condition or with l notifyAntenna: "Notify about new notes" withFileAntenna: "Only notes with files" enableServiceworker: "Enable Push-Notifications for your Browser" -antennaUsersDescription: "List one username per line" +antennaUsersDescription: "List one username per line. Use \"*@instance.com\" to specify all users of an instance" caseSensitive: "Case sensitive" withReplies: "Include replies" connectedTo: "Following account(s) are connected" @@ -1830,7 +1830,7 @@ _registry: domain: "Domain" createKey: "Create key" _aboutMisskey: - about: "Sharkey is open-source software based on Misskey which has been in developed since 2014 by syuilo." + about: "Sharkey is open-source software based on Misskey which has been in development by syuilo since 2014." contributors: "Main contributors" allContributors: "All contributors" source: "Source code" @@ -2478,6 +2478,7 @@ _moderationLogTypes: unsetUserAvatar: "Unset this user's avatar" unsetUserBanner: "Unset this user's banner" _mfm: + uncommonFeature: "This is not a widespread feature, it may not display properly on most other fedi software, including other Misskey forks" intro: "MFM is a markup language used on Misskey, Sharkey, Firefish, Akkoma, and more that can be used in many places. Here you can view a list of all available MFM syntax." dummy: "Sharkey expands the world of the Fediverse" mention: "Mention" @@ -2542,10 +2543,16 @@ _mfm: rotateDescription: "Turns content by a specified angle." position: "Position" positionDescription: "Move content by a specified amount." + crop: "Crop" + cropDescription: "Crop content." + followMouse: "Follow Mouse" + followMouseDescription: "Content will follow the mouse. On mobile it will follow wherever the user taps." scale: "Scale" scaleDescription: "Scale content by a specified amount." foreground: "Foreground color" foregroundDescription: "Change the foreground color of text." + fade: 'Fade' + fadeDescription: 'Fade text in and out.' background: "Background color" backgroundDescription: "Change the background color of text." plain: "Plain" diff --git a/locales/es-ES.yml b/locales/es-ES.yml index 08fed63f5d..c77773949f 100644 --- a/locales/es-ES.yml +++ b/locales/es-ES.yml @@ -404,7 +404,7 @@ antennaKeywordsDescription: "Separar con espacios es una declaración AND, separ notifyAntenna: "Notificar nueva nota" withFileAntenna: "Sólo notas con archivos adjuntados" enableServiceworker: "Activar ServiceWorker" -antennaUsersDescription: "Elegir nombres de usuarios separados por una linea nueva" +antennaUsersDescription: "Elegir nombres de usuarios separados por una linea nueva. Utilice \"*@instance.com\" para especificar todos los usuarios de una instancia." caseSensitive: "Distinguir mayúsculas de minúsculas" withReplies: "Incluir respuestas" connectedTo: "Estas cuentas están conectadas" 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, 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 { // 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 { // 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 { // 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 { // 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 @@ + + + + + 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 @@ - +
{{ i18n.ts._mfm.intro }}
-
+
{{ i18n.ts._mfm.mention }}

{{ i18n.ts._mfm.mentionDescription }}

- +
@@ -29,7 +29,7 @@

{{ i18n.ts._mfm.hashtagDescription }}

- +
@@ -39,7 +39,7 @@

{{ i18n.ts._mfm.linkDescription }}

- +
@@ -49,7 +49,7 @@

{{ i18n.ts._mfm.emojiDescription }}

- +
@@ -59,7 +59,7 @@

{{ i18n.ts._mfm.boldDescription }}

- +
@@ -69,7 +69,7 @@

{{ i18n.ts._mfm.smallDescription }}

- +
@@ -79,7 +79,7 @@

{{ i18n.ts._mfm.quoteDescription }}

- +
@@ -89,7 +89,7 @@

{{ i18n.ts._mfm.centerDescription }}

- +
@@ -99,7 +99,7 @@

{{ i18n.ts._mfm.inlineCodeDescription }}

- +
@@ -109,7 +109,7 @@

{{ i18n.ts._mfm.blockCodeDescription }}

- +
@@ -119,7 +119,7 @@

{{ i18n.ts._mfm.inlineMathDescription }}

- +
@@ -129,7 +129,7 @@

{{ i18n.ts._mfm.blockMathDescription }}

- +
@@ -139,7 +139,7 @@

{{ i18n.ts._mfm.searchDescription }}

- +
@@ -149,7 +149,7 @@

{{ i18n.ts._mfm.flipDescription }}

- +
@@ -159,7 +159,7 @@

{{ i18n.ts._mfm.fontDescription }}

- +
@@ -169,7 +169,7 @@

{{ i18n.ts._mfm.x2Description }}

- +
@@ -179,7 +179,7 @@

{{ i18n.ts._mfm.x3Description }}

- +
@@ -189,7 +189,7 @@

{{ i18n.ts._mfm.x4Description }}

- +
@@ -199,7 +199,7 @@

{{ i18n.ts._mfm.blurDescription }}

- +
@@ -209,7 +209,7 @@

{{ i18n.ts._mfm.jellyDescription }}

- +
@@ -219,7 +219,7 @@

{{ i18n.ts._mfm.tadaDescription }}

- +
@@ -229,7 +229,7 @@

{{ i18n.ts._mfm.jumpDescription }}

- +
@@ -239,7 +239,7 @@

{{ i18n.ts._mfm.bounceDescription }}

- +
@@ -249,7 +249,7 @@

{{ i18n.ts._mfm.spinDescription }}

- +
@@ -259,7 +259,7 @@

{{ i18n.ts._mfm.shakeDescription }}

- +
@@ -269,7 +269,7 @@

{{ i18n.ts._mfm.twitchDescription }}

- +
@@ -279,7 +279,7 @@

{{ i18n.ts._mfm.rainbowDescription }}

- +
@@ -289,7 +289,7 @@

{{ i18n.ts._mfm.sparkleDescription }}

- + MFM
@@ -299,37 +299,69 @@

{{ i18n.ts._mfm.rotateDescription }}

- + MFM
+
+
{{ i18n.ts._mfm.crop }}
+
+

{{ i18n.ts._mfm.cropDescription }}

+
+ + MFM +
+
+
{{ i18n.ts._mfm.position }}

{{ i18n.ts._mfm.positionDescription }}

- + MFM
+
+
{{ i18n.ts._mfm.followMouse }}
+ {{ i18n.ts._mfm.uncommonFeature }} +
+
+

{{ i18n.ts._mfm.followMouseDescription }}

+
+ + MFM +
+
+
{{ i18n.ts._mfm.scale }}

{{ i18n.ts._mfm.scaleDescription }}

- + MFM
+
+
{{ i18n.ts._mfm.fade }}
+
+

{{ i18n.ts._mfm.fadeDescription }}

+
+ + MFM +
+
+
{{ i18n.ts._mfm.foreground }}

{{ i18n.ts._mfm.foregroundDescription }}

- + MFM
@@ -339,7 +371,7 @@

{{ i18n.ts._mfm.backgroundDescription }}

- + MFM
@@ -349,7 +381,7 @@

{{ i18n.ts._mfm.plainDescription }}

- + MFM
@@ -362,18 +394,19 @@ 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 = { tada: ['speed=', 'delay='], jelly: ['speed=', 'delay='], @@ -179,11 +179,14 @@ export const MFM_PARAMS: Record = { 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
{{ i18n.ts.unsetUserAvatar }} - {{ i18n.ts.unsetUserBanner }} + {{ i18n.ts.unsetUserBanner }} + {{ i18n.ts.deleteAllFiles }}
{{ i18n.ts.deleteAccount }}
@@ -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 - +
@@ -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({ + get: () => defaultStore.reactiveState.tl.value.filter.withRenotes, + set: (x) => saveTlFilter('withRenotes', x), +}); + +const onlyFiles = computed({ + 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
@@ -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(null); const queue = ref(0); const tlEl = shallowRef>(); const rootEl = shallowRef(); +const withRenotes = computed({ + get: () => defaultStore.reactiveState.tl.value.filter.withRenotes, + set: (x) => saveTlFilter('withRenotes', x), +}); +const onlyFiles = computed({ + 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
- + 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 {{ column.name }} - + @@ -29,6 +29,7 @@ const props = defineProps<{ const timeline = shallowRef>(); 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, + }, ];