diff options
| author | Hazelnoot <acomputerdog@gmail.com> | 2025-02-07 11:54:29 -0500 |
|---|---|---|
| committer | Hazelnoot <acomputerdog@gmail.com> | 2025-02-07 11:57:44 -0500 |
| commit | f36029f795ed1615b804d66149eaaf450fa36f64 (patch) | |
| tree | 4c8ca21665ef1662bc21231ecd50b451e917fd44 /packages | |
| parent | restore support for local dev using a non-local `url` (diff) | |
| parent | merge: Add "follow back" button on follow-related notifications (resolves #89... (diff) | |
| download | sharkey-f36029f795ed1615b804d66149eaaf450fa36f64.tar.gz sharkey-f36029f795ed1615b804d66149eaaf450fa36f64.tar.bz2 sharkey-f36029f795ed1615b804d66149eaaf450fa36f64.zip | |
Merge branch 'develop' into merge/2024-02-03
# Conflicts:
# locales/index.d.ts
# packages/backend/src/core/entities/UserEntityService.ts
# packages/frontend/src/_dev_boot_.ts
# packages/misskey-js/src/autogen/types.ts
# sharkey-locales/en-US.yml
Diffstat (limited to 'packages')
21 files changed, 465 insertions, 34 deletions
diff --git a/packages/backend/migration/1738446745738-add_user_profile_default_cw.js b/packages/backend/migration/1738446745738-add_user_profile_default_cw.js new file mode 100644 index 0000000000..205ca2087a --- /dev/null +++ b/packages/backend/migration/1738446745738-add_user_profile_default_cw.js @@ -0,0 +1,11 @@ +export class AddUserProfileDefaultCw1738446745738 { + name = 'AddUserProfileDefaultCw1738446745738' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_profile" ADD "default_cw" text`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "default_cw"`); + } +} diff --git a/packages/backend/migration/1738468079662-add_user_profile_default_cw_priority.js b/packages/backend/migration/1738468079662-add_user_profile_default_cw_priority.js new file mode 100644 index 0000000000..90de25e06f --- /dev/null +++ b/packages/backend/migration/1738468079662-add_user_profile_default_cw_priority.js @@ -0,0 +1,13 @@ +export class AddUserProfileDefaultCwPriority1738468079662 { + name = 'AddUserProfileDefaultCwPriority1738468079662' + + async up(queryRunner) { + await queryRunner.query(`CREATE TYPE "public"."user_profile_default_cw_priority_enum" AS ENUM ('default', 'parent', 'defaultParent', 'parentDefault')`); + await queryRunner.query(`ALTER TABLE "user_profile" ADD "default_cw_priority" "public"."user_profile_default_cw_priority_enum" NOT NULL DEFAULT 'parent'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "default_cw_priority"`); + await queryRunner.query(`DROP TYPE "public"."user_profile_default_cw_priority_enum"`); + } +} diff --git a/packages/backend/src/core/MfmService.ts b/packages/backend/src/core/MfmService.ts index 1aca3737b3..2095ebca98 100644 --- a/packages/backend/src/core/MfmService.ts +++ b/packages/backend/src/core/MfmService.ts @@ -263,6 +263,67 @@ export class MfmService { break; } + case 'rp': break; + case 'rt': { + appendChildren(node.childNodes); + break; + } + case 'ruby': { + if (node.childNodes) { + /* + we get: + ``` + <ruby> + some text <rp>(</rp> <rt>annotation</rt> <rp>)</rp> + more text <rt>more annotation<rt> + </ruby> + ``` + + and we want to produce: + ``` + $[ruby $[group some text] annotation] + $[ruby $[group more text] more annotation] + ``` + + that `group` is a hack, because when the `ruby` render + sees just text inside the `$[ruby]`, it splits on + whitespace, considers the first "word" to be the main + content, and the rest the annotation + + with that `group`, we force it to consider the whole + group as the main content + + (note that the `rp` are to be ignored, they only exist + for browsers who don't understand ruby) + */ + let nonRtNodes = []; + // scan children, ignore `rp`, split on `rt` + for (const child of node.childNodes) { + if (treeAdapter.isTextNode(child)) { + nonRtNodes.push(child); + continue; + } + if (!treeAdapter.isElementNode(child)) { + continue; + } + if (child.nodeName === 'rp') { + continue; + } + if (child.nodeName === 'rt') { + text += '$[ruby $[group '; + appendChildren(nonRtNodes); + text += '] '; + analyze(child); + text += '] '; + nonRtNodes = []; + continue; + } + nonRtNodes.push(child); + } + } + break; + } + default: // includes inline elements { appendChildren(node.childNodes); @@ -381,6 +442,14 @@ export class MfmService { } } + // hack for ruby, should never be needed because we should + // never send this out to other instances + case 'group': { + const el = doc.createElement('span'); + appendChildren(node.children, el); + return el; + } + default: { return fnDefault(node); } @@ -559,11 +628,65 @@ export class MfmService { }, async fn(node) { - const el = doc.createElement('span'); - el.textContent = '*'; - await appendChildren(node.children, el); - el.textContent += '*'; - return el; + switch (node.props.name) { + case 'group': { // hack for ruby + const el = doc.createElement('span'); + await appendChildren(node.children, el); + return el; + } + case 'ruby': { + if (node.children.length === 1) { + const child = node.children[0]; + const text = child.type === 'text' ? child.props.text : ''; + const rubyEl = doc.createElement('ruby'); + const rtEl = doc.createElement('rt'); + + const rpStartEl = doc.createElement('rp'); + rpStartEl.appendChild(doc.createTextNode('(')); + const rpEndEl = doc.createElement('rp'); + rpEndEl.appendChild(doc.createTextNode(')')); + + rubyEl.appendChild(doc.createTextNode(text.split(' ')[0])); + rtEl.appendChild(doc.createTextNode(text.split(' ')[1])); + rubyEl.appendChild(rpStartEl); + rubyEl.appendChild(rtEl); + rubyEl.appendChild(rpEndEl); + return rubyEl; + } else { + const rt = node.children.at(-1); + + if (!rt) { + const el = doc.createElement('span'); + await appendChildren(node.children, el); + return el; + } + + const text = rt.type === 'text' ? rt.props.text : ''; + const rubyEl = doc.createElement('ruby'); + const rtEl = doc.createElement('rt'); + + const rpStartEl = doc.createElement('rp'); + rpStartEl.appendChild(doc.createTextNode('(')); + const rpEndEl = doc.createElement('rp'); + rpEndEl.appendChild(doc.createTextNode(')')); + + await appendChildren(node.children.slice(0, node.children.length - 1), rubyEl); + rtEl.appendChild(doc.createTextNode(text.trim())); + rubyEl.appendChild(rpStartEl); + rubyEl.appendChild(rtEl); + rubyEl.appendChild(rpEndEl); + return rubyEl; + } + } + + default: { + const el = doc.createElement('span'); + el.textContent = '*'; + await appendChildren(node.children, el); + el.textContent += '*'; + return el; + } + } }, blockCode(node) { diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index c818fa5603..6ea2d6629a 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -55,6 +55,8 @@ import type { NoteEntityService } from './NoteEntityService.js'; import type { DriveFileEntityService } from './DriveFileEntityService.js'; import type { PageEntityService } from './PageEntityService.js'; +/* eslint-disable @typescript-eslint/no-non-null-assertion */ + const Ajv = _Ajv.default; const ajv = new Ajv(); @@ -669,6 +671,8 @@ export class UserEntityService implements OnModuleInit { achievements: profile!.achievements, loggedInDays: profile!.loggedInDates.length, policies: this.roleService.getUserPolicies(user.id), + defaultCW: profile!.defaultCW, + defaultCWPriority: profile!.defaultCWPriority, } : {}), ...(opts.includeSecrets ? { diff --git a/packages/backend/src/models/UserProfile.ts b/packages/backend/src/models/UserProfile.ts index 751b1aff08..449c2f370b 100644 --- a/packages/backend/src/models/UserProfile.ts +++ b/packages/backend/src/models/UserProfile.ts @@ -4,7 +4,7 @@ */ import { Entity, Column, Index, OneToOne, JoinColumn, PrimaryColumn } from 'typeorm'; -import { obsoleteNotificationTypes, followingVisibilities, followersVisibilities, notificationTypes } from '@/types.js'; +import { obsoleteNotificationTypes, followingVisibilities, followersVisibilities, notificationTypes, noteVisibilities, defaultCWPriorities } from '@/types.js'; import { id } from './util/id.js'; import { MiUser } from './User.js'; import { MiPage } from './Page.js'; @@ -36,10 +36,10 @@ export class MiUserProfile { }) public birthday: string | null; - @Column("varchar", { + @Column('varchar', { length: 128, nullable: true, - comment: "The ListenBrainz username of the User.", + comment: 'The ListenBrainz username of the User.', }) public listenbrainz: string | null; @@ -290,6 +290,19 @@ export class MiUserProfile { unlockedAt: number; }[]; + @Column('text', { + name: 'default_cw', + nullable: true, + }) + public defaultCW: string | null; + + @Column('enum', { + name: 'default_cw_priority', + enum: defaultCWPriorities, + default: 'parent', + }) + public defaultCWPriority: typeof defaultCWPriorities[number]; + //#region Denormalized fields @Index() @Column('varchar', { diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index f953008b3f..93b031e9c5 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -752,6 +752,15 @@ export const packedMeDetailedOnlySchema = { }, }, //#endregion + defaultCW: { + type: 'string', + nullable: true, optional: false, + }, + defaultCWPriority: { + type: 'string', + enum: ['default', 'parent', 'defaultParent', 'parentDefault'], + nullable: false, optional: false, + }, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/federation/update-remote-user.ts b/packages/backend/src/server/api/endpoints/federation/update-remote-user.ts index 3ec9522c44..5217f79065 100644 --- a/packages/backend/src/server/api/endpoints/federation/update-remote-user.ts +++ b/packages/backend/src/server/api/endpoints/federation/update-remote-user.ts @@ -13,10 +13,11 @@ export const meta = { requireCredential: false, - // 2 calls per second + // Up to 10 calls, then 4 / second. + // This allows for reliable automation. limit: { - duration: 1000, - max: 2, + max: 10, + dripRate: 250, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/i.ts b/packages/backend/src/server/api/endpoints/i.ts index 9347c9ca27..48a2e3b40a 100644 --- a/packages/backend/src/server/api/endpoints/i.ts +++ b/packages/backend/src/server/api/endpoints/i.ts @@ -31,10 +31,12 @@ export const meta = { }, }, - // 3 calls per second + // up to 20 calls, then 1 per second. + // This handles bursty traffic when all tabs reload as a group limit: { - duration: 1000, - max: 3, + max: 20, + dripSize: 1, + dripRate: 1000, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index a80e5ed033..f74452e2af 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -133,6 +133,12 @@ export const meta = { id: '0b3f9f6a-2f4d-4b1f-9fb4-49d3a2fd7191', httpStatusCode: 422, }, + + maxCwLength: { + message: 'You tried setting a default content warning which is too long.', + code: 'MAX_CW_LENGTH', + id: '7004c478-bda3-4b4f-acb2-4316398c9d52', + }, }, res: { @@ -243,6 +249,12 @@ export const paramDef = { uniqueItems: true, items: { type: 'string' }, }, + defaultCW: { type: 'string', nullable: true }, + defaultCWPriority: { + type: 'string', + enum: ['default', 'parent', 'defaultParent', 'parentDefault'], + nullable: false, + }, }, } as const; @@ -494,6 +506,19 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- updates.alsoKnownAs = newAlsoKnownAs.size > 0 ? Array.from(newAlsoKnownAs) : null; } + let defaultCW = ps.defaultCW; + if (defaultCW !== undefined) { + if (defaultCW === '') defaultCW = null; + if (defaultCW && defaultCW.length > this.config.maxCwLength) { + throw new ApiError(meta.errors.maxCwLength); + } + + profileUpdates.defaultCW = defaultCW; + } + if (ps.defaultCWPriority !== undefined) { + profileUpdates.defaultCWPriority = ps.defaultCWPriority; + } + //#region emojis/tags let emojis = [] as string[]; diff --git a/packages/backend/src/server/api/endpoints/users/show.ts b/packages/backend/src/server/api/endpoints/users/show.ts index 7ebca78a7d..118362149d 100644 --- a/packages/backend/src/server/api/endpoints/users/show.ts +++ b/packages/backend/src/server/api/endpoints/users/show.ts @@ -57,10 +57,10 @@ export const meta = { }, }, - // 5 calls per 2 seconds + // up to 50 calls @ 4 per second limit: { - duration: 1000 * 2, - max: 5, + max: 50, + dripRate: 250, }, } as const; diff --git a/packages/backend/src/server/web/boot.embed.js b/packages/backend/src/server/web/boot.embed.js index b07dce3ac4..1af1dc545b 100644 --- a/packages/backend/src/server/web/boot.embed.js +++ b/packages/backend/src/server/web/boot.embed.js @@ -48,7 +48,7 @@ if (supportedLangs.includes(navigator.language)) { lang = navigator.language; } else { - lang = supportedLangs.find(x => x.split('-')[0] === navigator.language); + lang = supportedLangs.find(x => x.split('-')[0] === navigator.language.split('-')[0]); // Fallback if (lang == null) lang = 'en-US'; diff --git a/packages/backend/src/server/web/boot.js b/packages/backend/src/server/web/boot.js index bf83340bde..54750e26e5 100644 --- a/packages/backend/src/server/web/boot.js +++ b/packages/backend/src/server/web/boot.js @@ -39,7 +39,7 @@ if (supportedLangs.includes(navigator.language)) { lang = navigator.language; } else { - lang = supportedLangs.find(x => x.split('-')[0] === navigator.language); + lang = supportedLangs.find(x => x.split('-')[0] === navigator.language.split('-')[0]); // Fallback if (lang == null) lang = 'en-US'; diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index 37bed27fb1..067481d9da 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -58,6 +58,8 @@ export const mutedNoteReasons = ['word', 'manual', 'spam', 'other'] as const; export const followingVisibilities = ['public', 'followers', 'private'] as const; export const followersVisibilities = ['public', 'followers', 'private'] as const; +export const defaultCWPriorities = ['default', 'parent', 'defaultParent', 'parentDefault'] as const; + /** * ユーザーがエクスポートできるものの種類 * diff --git a/packages/backend/test/unit/MfmService.ts b/packages/backend/test/unit/MfmService.ts index 73d9c7b60c..d263aafa0f 100644 --- a/packages/backend/test/unit/MfmService.ts +++ b/packages/backend/test/unit/MfmService.ts @@ -45,6 +45,50 @@ describe('MfmService', () => { const output = '<p><pre><code><p>Hello, world!</p></code></pre></p>'; assert.equal(mfmService.toHtml(mfm.parse(input)), output); }); + + test('ruby', () => { + const input = '$[ruby some text ignore me]'; + const output = '<p><ruby>some<rp>(</rp><rt>text</rt><rp>)</rp></ruby></p>'; + assert.equal(mfmService.toHtml(mfm.parse(input)), output); + }); + + test('ruby2', () => { + const input = '$[ruby *some text* ignore me]'; + const output = '<p><ruby><i>some text</i><rp>(</rp><rt>ignore me</rt><rp>)</rp></ruby></p>'; + assert.equal(mfmService.toHtml(mfm.parse(input)), output); + }); + + test('ruby 3', () => { + const input = '$[ruby $[group *some* text] ignore me]'; + const output = '<p><ruby><span><i>some</i> text</span><rp>(</rp><rt>ignore me</rt><rp>)</rp></ruby></p>'; + assert.equal(mfmService.toHtml(mfm.parse(input)), output); + }); + }); + + describe('toMastoApiHtml', () => { + test('br', async () => { + const input = 'foo\nbar\nbaz'; + const output = '<p><span>foo<br>bar<br>baz</span></p>'; + assert.equal(await mfmService.toMastoApiHtml(mfm.parse(input)), output); + }); + + test('br alt', async () => { + const input = 'foo\r\nbar\rbaz'; + const output = '<p><span>foo<br>bar<br>baz</span></p>'; + assert.equal(await mfmService.toMastoApiHtml(mfm.parse(input)), output); + }); + + test('escape', async () => { + const input = '```\n<p>Hello, world!</p>\n```'; + const output = '<p><pre><code><p>Hello, world!</p></code></pre></p>'; + assert.equal(await mfmService.toMastoApiHtml(mfm.parse(input)), output); + }); + + test('ruby', async () => { + const input = '$[ruby $[group *some* text] ignore me]'; + const output = '<p><ruby><span><span>*some*</span><span> text</span></span><rp>(</rp><rt>ignore me</rt><rp>)</rp></ruby></p>'; + assert.equal(await mfmService.toMastoApiHtml(mfm.parse(input)), output); + }); }); describe('fromHtml', () => { @@ -133,5 +177,12 @@ describe('MfmService', () => { test('hashtag', () => { assert.deepStrictEqual(mfmService.fromHtml('<p>a <a href="https://example.com/tags/a">#a</a> d</p>', ['#a']), 'a #a d'); }); + + test('ruby', () => { + assert.deepStrictEqual( + mfmService.fromHtml('<ruby> <i>some</i> text <rp>(</rp><rt>ignore me</rt><rp>)</rp> and <rt>more</rt></ruby>'), + '$[ruby $[group <i>some</i> text ] ignore me] $[ruby $[group and ] more]' + ); + }); }); }); diff --git a/packages/frontend/src/components/MkFollowButton.vue b/packages/frontend/src/components/MkFollowButton.vue index c7965aaac4..42e8485f46 100644 --- a/packages/frontend/src/components/MkFollowButton.vue +++ b/packages/frontend/src/components/MkFollowButton.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <button class="_button" :class="[$style.root, { [$style.wait]: wait, [$style.active]: isFollowing || hasPendingFollowRequestFromYou, [$style.full]: full, [$style.large]: large }]" - :disabled="wait" + :disabled="wait || disabled" @click="onClick" > <template v-if="!wait"> @@ -35,7 +35,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onBeforeUnmount, onMounted, ref } from 'vue'; +import { onBeforeUnmount, onMounted, ref, watch } from 'vue'; import * as Misskey from 'misskey-js'; import { host } from '@@/js/config.js'; import * as os from '@/os.js'; @@ -51,13 +51,16 @@ const props = withDefaults(defineProps<{ user: Misskey.entities.UserDetailed, full?: boolean, large?: boolean, + disabled?: boolean, }>(), { full: false, large: false, + disabled: false, }); const emit = defineEmits<{ - (_: 'update:user', value: Misskey.entities.UserDetailed): void + (_: 'update:user', value: Misskey.entities.UserDetailed): void, + (_: 'update:wait', value: boolean): void, }>(); const isFollowing = ref(props.user.isFollowing); @@ -65,6 +68,9 @@ const hasPendingFollowRequestFromYou = ref(props.user.hasPendingFollowRequestFro const wait = ref(false); const connection = useStream().useChannel('main'); +// Emit the "wait" status so external components can synchronize state +watch(wait, value => emit('update:wait', value)); + if (props.user.isFollowing == null && $i) { misskeyApi('users/show', { userId: props.user.id, diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue index 4620b966af..f979287bdd 100644 --- a/packages/frontend/src/components/MkNotification.vue +++ b/packages/frontend/src/components/MkNotification.vue @@ -122,6 +122,9 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <template v-else-if="notification.type === 'follow'"> <span :class="$style.text" style="opacity: 0.6;">{{ i18n.ts.youGotNewFollower }}</span> + <div v-if="full" :class="$style.followRequestCommands"> + <MkFollowButton v-if="userDetailed" :class="$style.followCommandButton" :user="userDetailed" :transparent="false" :full="false"/> + </div> </template> <template v-else-if="notification.type === 'followRequestAccepted'"> <div :class="$style.text" style="opacity: 0.6;">{{ i18n.ts.followRequestAccepted }}</div> @@ -136,6 +139,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-if="full && !followRequestDone" :class="$style.followRequestCommands"> <MkButton :class="$style.followRequestCommandButton" rounded primary @click="acceptFollowRequest()"><i class="ti ti-check"/> {{ i18n.ts.accept }}</MkButton> <MkButton :class="$style.followRequestCommandButton" rounded danger @click="rejectFollowRequest()"><i class="ti ti-x"/> {{ i18n.ts.reject }}</MkButton> + <MkFollowButton v-if="userDetailed" :class="$style.followCommandButton" :user="userDetailed" :transparent="false" :full="false"/> </div> </template> <span v-else-if="notification.type === 'test'" :class="$style.text">{{ i18n.ts._notification.notificationWillBeDisplayedLikeThis }}</span> @@ -179,8 +183,9 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref } from 'vue'; +import { Ref, ref, watch } from 'vue'; import * as Misskey from 'misskey-js'; +import { UserDetailed } from 'misskey-js/autogen/models.js'; import MkReactionIcon from '@/components/MkReactionIcon.vue'; import MkButton from '@/components/MkButton.vue'; import { getNoteSummary } from '@/scripts/get-note-summary.js'; @@ -190,6 +195,7 @@ import { i18n } from '@/i18n.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { signinRequired } from '@/account.js'; import { infoImageUrl } from '@/instance.js'; +import MkFollowButton from '@/components/MkFollowButton.vue'; const $i = signinRequired(); @@ -202,6 +208,26 @@ const props = withDefaults(defineProps<{ full: false, }); +const userDetailed: Ref<UserDetailed | null> = ref(null); + +// watch() is required because computed() doesn't support async. +watch(props, async () => { + const type = props.notification.type; + + // To avoid extra lookups, only do the query when it actually matters. + if (type === 'follow' || type === 'receiveFollowRequest') { + const user = await misskeyApi('users/show', { + userId: props.notification.userId, + }); + + userDetailed.value = user; + followRequestDone.value = !user.hasPendingFollowRequestToYou; + } else { + userDetailed.value = null; + followRequestDone.value = false; + } +}, { immediate: true }); + type ExportCompletedNotification = Misskey.entities.Notification & { type: 'exportCompleted' }; const exportEntityName = { @@ -216,7 +242,7 @@ const exportEntityName = { userList: i18n.ts.lists, } as const satisfies Record<ExportCompletedNotification['exportedEntity'], string>; -const followRequestDone = ref(false); +const followRequestDone = ref(true); const acceptFollowRequest = () => { if (!('user' in props.notification)) return; @@ -434,13 +460,24 @@ function getActualReactedUsersCount(notification: Misskey.entities.Notification) .followRequestCommands { display: flex; gap: 8px; - max-width: 300px; margin-top: 8px; + width: 100%; } .followRequestCommandButton { + max-width: 175px; + width: 100%; +} + +.flexSpacer { flex: 1; } +.followCommandButton { + margin-left: auto; + flex-grow: 0; + flex-shrink: 0; +} + .reactionsItem { display: inline-block; position: relative; diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index 41d443a388..059de8011c 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -366,6 +366,29 @@ if (defaultStore.state.keepCw && props.reply && props.reply.cw) { cw.value = props.reply.cw; } +// apply default CW +if ($i.defaultCW) { + useCw.value = true; + + if (!cw.value || $i.defaultCWPriority === 'default') { + cw.value = $i.defaultCW; + } else if ($i.defaultCWPriority !== 'parent') { + // This is a fancy way of simulating /\bsearch\b/ without a regular expression. + // We're checking to see whether the default CW appears inside the existing CW, but *only* if there's word boundaries. + const parts = cw.value.split($i.defaultCW); + const hasExistingDefaultCW = parts.length === 2 && !/\w$/.test(parts[0]) && !/^\w/.test(parts[1]); + if (!hasExistingDefaultCW) { + // We need to merge the CWs + if ($i.defaultCWPriority === 'defaultParent') { + cw.value = `${$i.defaultCW}, ${cw.value}`; + } else if ($i.defaultCWPriority === 'parentDefault') { + cw.value = `${cw.value}, ${$i.defaultCW}`; + } + } + } + // else { do nothing, because existing CW takes priority. } +} + function watchForDraft() { watch(text, () => saveDraft()); watch(useCw, () => saveDraft()); diff --git a/packages/frontend/src/components/global/MkMfm.ts b/packages/frontend/src/components/global/MkMfm.ts index e7b7d4ef14..9785bc0f07 100644 --- a/packages/frontend/src/components/global/MkMfm.ts +++ b/packages/frontend/src/components/global/MkMfm.ts @@ -358,6 +358,10 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven return h('ruby', {}, [...genEl(token.children.slice(0, token.children.length - 1), scale), h('rt', text.trim())]); } } + case 'group': { // this is mostly a hack for the insides of `ruby` + style = ''; + break; + } case 'unixtime': { const child = token.children[0]; const unixtime = parseInt(child.type === 'text' ? child.props.text : ''); diff --git a/packages/frontend/src/pages/settings/privacy.vue b/packages/frontend/src/pages/settings/privacy.vue index ce4c229a3a..0b8e89a6a5 100644 --- a/packages/frontend/src/pages/settings/privacy.vue +++ b/packages/frontend/src/pages/settings/privacy.vue @@ -155,10 +155,24 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSwitch v-model="defaultNoteLocalOnly">{{ i18n.ts._visibility.disableFederation }}</MkSwitch> </div> </MkFolder> + + <MkSwitch v-model="keepCw" @update:modelValue="save()">{{ i18n.ts.keepCw }}</MkSwitch> + + <MkInput v-model="defaultCW" type="text" manualSave @update:modelValue="save()"> + <template #label>{{ i18n.ts.defaultCW }}</template> + <template #caption>{{ i18n.ts.defaultCWDescription }}</template> + </MkInput> + + <MkSelect v-model="defaultCWPriority" :disabled="!defaultCW || !keepCw" @update:modelValue="save()"> + <template #label>{{ i18n.ts.defaultCWPriority }}</template> + <template #caption>{{ i18n.ts.defaultCWPriorityDescription }}</template> + <option value="default">{{ i18n.ts._defaultCWPriority.default }}</option> + <option value="parent">{{ i18n.ts._defaultCWPriority.parent }}</option> + <option value="parentDefault">{{ i18n.ts._defaultCWPriority.parentDefault }}</option> + <option value="defaultParent">{{ i18n.ts._defaultCWPriority.defaultParent }}</option> + </MkSelect> </div> </FormSection> - - <MkSwitch v-model="keepCw" @update:modelValue="save()">{{ i18n.ts.keepCw }}</MkSwitch> </div> </template> @@ -194,6 +208,8 @@ const hideOnlineStatus = ref($i.hideOnlineStatus); const publicReactions = ref($i.publicReactions); const followingVisibility = ref($i.followingVisibility); const followersVisibility = ref($i.followersVisibility); +const defaultCW = ref($i.defaultCW); +const defaultCWPriority = ref($i.defaultCWPriority); const defaultNoteVisibility = computed(defaultStore.makeGetterSetter('defaultNoteVisibility')); const defaultNoteLocalOnly = computed(defaultStore.makeGetterSetter('defaultNoteLocalOnly')); @@ -252,6 +268,8 @@ function save() { publicReactions: !!publicReactions.value, followingVisibility: followingVisibility.value, followersVisibility: followersVisibility.value, + defaultCWPriority: defaultCWPriority.value, + defaultCW: defaultCW.value, }); } diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue index da2066391b..4b3773e0ae 100644 --- a/packages/frontend/src/pages/user/home.vue +++ b/packages/frontend/src/pages/user/home.vue @@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkRemoteCaution v-if="user.host != null" :href="user.url ?? user.uri!" class="warn"/> <div :key="user.id" class="main _panel"> - <div class="banner-container" :style="style"> + <div class="banner-container" :class="{ [$style.bannerContainerTall]: useTallBanner }" :style="style"> <div ref="bannerEl" class="banner" :style="style"></div> <div class="fade"></div> <div class="title"> @@ -39,12 +39,15 @@ SPDX-License-Identifier: AGPL-3.0-only <li v-if="user.isBlocking">{{ i18n.ts.blocked }}</li> <li v-if="user.isBlocked && $i.isModerator">{{ i18n.ts.blockingYou }}</li> </ul> - <div class="actions"> - <button class="menu _button" @click="menu"><i class="ti ti-dots"></i></button> - <MkFollowButton v-if="$i?.id != user.id" v-model:user="user" :inline="true" :transparent="false" :full="true" class="koudoku"/> + <div :class="$style.actions" class="actions"> + <button :class="$style.actionsMenu" class="menu _button" @click="menu"><i class="ti ti-dots"></i></button> + <MkFollowButton v-if="$i?.id != user.id" v-model:user="user" :class="$style.actionsFollow" :disabled="disableFollowControls" :inline="true" :transparent="false" :full="true" class="koudoku" @update:wait="onFollowButtonDisabledChanged"/> + <div v-if="hasFollowRequest" :class="$style.actionsBanner">{{ i18n.ts.receiveFollowRequest }}</div> + <MkButton v-if="hasFollowRequest" :class="$style.actionsAccept" :disabled="disableFollowControls" :inline="true" :transparent="false" :full="true" rounded primary @click="acceptFollowRequest"><i class="ti ti-check"/> {{ i18n.ts.accept }}</MkButton> + <MkButton v-if="hasFollowRequest" :class="$style.actionsReject" :disabled="disableFollowControls" :inline="true" :transparent="false" :full="true" rounded danger @click="rejectFollowRequest"><i class="ti ti-x"/> {{ i18n.ts.reject }}</MkButton> </div> </div> - <MkAvatar class="avatar" :user="user" indicator/> + <MkAvatar class="avatar" :class="{ [$style.avatarTall]: useTallBanner }" :user="user" indicator/> <div class="title"> <MkUserName :user="user" :nowrap="false" class="name"/> <div class="bottom"> @@ -220,8 +223,8 @@ import MkSparkle from '@/components/MkSparkle.vue'; const MkNote = defineAsyncComponent(() => defaultStore.state.noteDesign === 'sharkey' - ? import('@/components/SkNote.vue') - : import('@/components/MkNote.vue'), + ? import('@/components/SkNote.vue') + : import('@/components/MkNote.vue'), ); function calcAge(birthdate: string): number { @@ -387,6 +390,42 @@ async function updateMemo() { isEditingMemo.value = false; } +// Set true to disable the follow / follow request controls +const disableFollowControls = ref(false); +const hasFollowRequest = computed(() => user.value.hasPendingFollowRequestToYou); +const useTallBanner = computed(() => hasFollowRequest.value && narrow.value); + +async function onFollowButtonDisabledChanged(disabled: boolean) { + try { + // Refresh the UI after MkFollowButton changes the follow relation + if (!disabled) { + user.value = await os.apiWithDialog('users/show', { userId: user.value.id }); + } + } finally { + disableFollowControls.value = disabled; + } +} + +async function acceptFollowRequest() { + try { + disableFollowControls.value = true; + await os.apiWithDialog('following/requests/accept', { userId: user.value.id }); + user.value = await os.apiWithDialog('users/show', { userId: user.value.id }); + } finally { + disableFollowControls.value = false; + } +} + +async function rejectFollowRequest() { + try { + disableFollowControls.value = true; + await os.apiWithDialog('following/requests/reject', { userId: user.value.id }); + user.value = await os.apiWithDialog('users/show', { userId: user.value.id }); + } finally { + disableFollowControls.value = false; + } +} + watch([props.user], () => { memoDraft.value = props.user.memo; }); @@ -863,4 +902,48 @@ onUnmounted(() => { margin-left: 8px; } } + +.actions { + display: grid; + grid-template-rows: min-content min-content min-content; + grid-template-columns: min-content auto 1fr; + grid-template-areas: + "menu follow follow" + "banner banner banner" + "accept accept reject"; +} + +.actionsMenu { + grid-area: menu; + width: unset; +} + +.actionsFollow { + grid-area: follow; + margin-left: 8px; +} + +.actionsBanner { + grid-area: banner; + justify-self: center; + margin-top: 8px; + margin-bottom: 4px; +} + +.actionsAccept { + grid-area: accept; +} + +.actionsReject { + grid-area: reject; + margin-left: 8px; +} + +.bannerContainerTall { + height: 200px !important; +} + +.avatarTall { + top: 150px !important; +} </style> diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 561be3075e..3d6bcab8c7 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -4244,6 +4244,9 @@ export type components = { /** Format: date-time */ lastUsed: string; }[]; + defaultCW: string | null; + /** @enum {string} */ + defaultCWPriority: 'default' | 'parent' | 'defaultParent' | 'parentDefault'; }; UserDetailedNotMe: components['schemas']['UserLite'] & components['schemas']['UserDetailedNotMeOnly']; MeDetailed: components['schemas']['UserLite'] & components['schemas']['UserDetailedNotMeOnly'] & components['schemas']['MeDetailedOnly']; @@ -22777,6 +22780,9 @@ export type operations = { }; emailNotificationTypes?: string[]; alsoKnownAs?: string[]; + defaultCW?: string | null; + /** @enum {string} */ + defaultCWPriority?: 'default' | 'parent' | 'defaultParent' | 'parentDefault'; }; }; }; |