summaryrefslogtreecommitdiff
path: root/packages
diff options
context:
space:
mode:
authorHazelnoot <acomputerdog@gmail.com>2025-02-07 11:54:29 -0500
committerHazelnoot <acomputerdog@gmail.com>2025-02-07 11:57:44 -0500
commitf36029f795ed1615b804d66149eaaf450fa36f64 (patch)
tree4c8ca21665ef1662bc21231ecd50b451e917fd44 /packages
parentrestore support for local dev using a non-local `url` (diff)
parentmerge: Add "follow back" button on follow-related notifications (resolves #89... (diff)
downloadsharkey-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')
-rw-r--r--packages/backend/migration/1738446745738-add_user_profile_default_cw.js11
-rw-r--r--packages/backend/migration/1738468079662-add_user_profile_default_cw_priority.js13
-rw-r--r--packages/backend/src/core/MfmService.ts133
-rw-r--r--packages/backend/src/core/entities/UserEntityService.ts4
-rw-r--r--packages/backend/src/models/UserProfile.ts19
-rw-r--r--packages/backend/src/models/json-schema/user.ts9
-rw-r--r--packages/backend/src/server/api/endpoints/federation/update-remote-user.ts7
-rw-r--r--packages/backend/src/server/api/endpoints/i.ts8
-rw-r--r--packages/backend/src/server/api/endpoints/i/update.ts25
-rw-r--r--packages/backend/src/server/api/endpoints/users/show.ts6
-rw-r--r--packages/backend/src/server/web/boot.embed.js2
-rw-r--r--packages/backend/src/server/web/boot.js2
-rw-r--r--packages/backend/src/types.ts2
-rw-r--r--packages/backend/test/unit/MfmService.ts51
-rw-r--r--packages/frontend/src/components/MkFollowButton.vue12
-rw-r--r--packages/frontend/src/components/MkNotification.vue43
-rw-r--r--packages/frontend/src/components/MkPostForm.vue23
-rw-r--r--packages/frontend/src/components/global/MkMfm.ts4
-rw-r--r--packages/frontend/src/pages/settings/privacy.vue22
-rw-r--r--packages/frontend/src/pages/user/home.vue97
-rw-r--r--packages/misskey-js/src/autogen/types.ts6
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>&lt;p&gt;Hello, world!&lt;/p&gt;</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>&lt;p&gt;Hello, world!&lt;/p&gt;</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';
};
};
};