チャンネル内→チャンネル外へのリノート制限機能追加

This commit is contained in:
osamu 2023-11-03 09:33:00 +09:00
parent 5fb6847419
commit 8d5273c18b
13 changed files with 119 additions and 35 deletions

1
locales/index.d.ts vendored
View File

@ -1742,6 +1742,7 @@ export interface Locale {
"notesCount": string; "notesCount": string;
"nameAndDescription": string; "nameAndDescription": string;
"nameOnly": string; "nameOnly": string;
"canRenoteSwitch": string;
}; };
"_menuDisplay": { "_menuDisplay": {
"sideFull": string; "sideFull": string;

View File

@ -1659,6 +1659,7 @@ _channel:
notesCount: "{n}投稿があります" notesCount: "{n}投稿があります"
nameAndDescription: "名前と説明" nameAndDescription: "名前と説明"
nameOnly: "名前のみ" nameOnly: "名前のみ"
canRenoteSwitch: "チャンネル外へのリノートと引用リノートを許可する"
_menuDisplay: _menuDisplay:
sideFull: "横" sideFull: "横"

View File

@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class AddCanRenoteToChannel1698840138000 {
name = 'AddCanRenoteToChannel1698840138000'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "channel" ADD "canRenote" boolean NOT NULL DEFAULT true`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "channel" DROP COLUMN "canRenote"`);
}
}

View File

@ -95,6 +95,7 @@ export class ChannelEntityService {
usersCount: channel.usersCount, usersCount: channel.usersCount,
notesCount: channel.notesCount, notesCount: channel.notesCount,
isSensitive: channel.isSensitive, isSensitive: channel.isSensitive,
canRenote: channel.canRenote,
...(me ? { ...(me ? {
isFollowing, isFollowing,

View File

@ -350,6 +350,7 @@ export class NoteEntityService implements OnModuleInit {
name: channel.name, name: channel.name,
color: channel.color, color: channel.color,
isSensitive: channel.isSensitive, isSensitive: channel.isSensitive,
canRenote: channel.canRenote,
} : undefined, } : undefined,
mentions: note.mentions.length > 0 ? note.mentions : undefined, mentions: note.mentions.length > 0 ? note.mentions : undefined,
uri: note.uri ?? undefined, uri: note.uri ?? undefined,

View File

@ -93,4 +93,9 @@ export class MiChannel {
default: false, default: false,
}) })
public isSensitive: boolean; public isSensitive: boolean;
@Column('boolean', {
default: true,
})
public canRenote: boolean;
} }

View File

@ -76,5 +76,9 @@ export const packedChannelSchema = {
type: 'boolean', type: 'boolean',
optional: false, nullable: false, optional: false, nullable: false,
}, },
canRenote: {
type: 'boolean',
optional: false, nullable: false,
},
}, },
} as const; } as const;

View File

@ -50,6 +50,7 @@ export const paramDef = {
bannerId: { type: 'string', format: 'misskey:id', nullable: true }, bannerId: { type: 'string', format: 'misskey:id', nullable: true },
color: { type: 'string', minLength: 1, maxLength: 16 }, color: { type: 'string', minLength: 1, maxLength: 16 },
isSensitive: { type: 'boolean', nullable: true }, isSensitive: { type: 'boolean', nullable: true },
canRenote: { type: 'boolean', nullable: true },
}, },
required: ['name'], required: ['name'],
} as const; } as const;
@ -87,6 +88,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
bannerId: banner ? banner.id : null, bannerId: banner ? banner.id : null,
isSensitive: ps.isSensitive ?? false, isSensitive: ps.isSensitive ?? false,
...(ps.color !== undefined ? { color: ps.color } : {}), ...(ps.color !== undefined ? { color: ps.color } : {}),
canRenote: ps.canRenote ?? true,
} as MiChannel).then(x => this.channelsRepository.findOneByOrFail(x.identifiers[0])); } as MiChannel).then(x => this.channelsRepository.findOneByOrFail(x.identifiers[0]));
return await this.channelEntityService.pack(channel, me); return await this.channelEntityService.pack(channel, me);

View File

@ -61,6 +61,7 @@ export const paramDef = {
}, },
color: { type: 'string', minLength: 1, maxLength: 16 }, color: { type: 'string', minLength: 1, maxLength: 16 },
isSensitive: { type: 'boolean', nullable: true }, isSensitive: { type: 'boolean', nullable: true },
canRenote: { type: 'boolean', nullable: true },
}, },
required: ['channelId'], required: ['channelId'],
} as const; } as const;
@ -115,6 +116,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
...(typeof ps.isArchived === 'boolean' ? { isArchived: ps.isArchived } : {}), ...(typeof ps.isArchived === 'boolean' ? { isArchived: ps.isArchived } : {}),
...(banner ? { bannerId: banner.id } : {}), ...(banner ? { bannerId: banner.id } : {}),
...(typeof ps.isSensitive === 'boolean' ? { isSensitive: ps.isSensitive } : {}), ...(typeof ps.isSensitive === 'boolean' ? { isSensitive: ps.isSensitive } : {}),
...(typeof ps.canRenote === 'boolean' ? { canRenote: ps.canRenote } : {}),
}); });
return await this.channelEntityService.pack(channel.id, me); return await this.channelEntityService.pack(channel.id, me);

View File

@ -16,8 +16,8 @@ import { Endpoint } from '@/server/api/endpoint-base.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { NoteCreateService } from '@/core/NoteCreateService.js'; import { NoteCreateService } from '@/core/NoteCreateService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { ApiError } from '../../error.js';
import { isPureRenote } from '@/misc/is-pure-renote.js'; import { isPureRenote } from '@/misc/is-pure-renote.js';
import { ApiError } from '../../error.js';
export const meta = { export const meta = {
tags: ['notes'], tags: ['notes'],
@ -99,6 +99,12 @@ export const meta = {
code: 'NO_SUCH_FILE', code: 'NO_SUCH_FILE',
id: 'b6992544-63e7-67f0-fa7f-32444b1b5306', id: 'b6992544-63e7-67f0-fa7f-32444b1b5306',
}, },
cannotRenoteOutsideOfChannel: {
message: 'Cannot renote outside of channel.',
code: 'CANNOT_RENOTE_OUTSIDE_OF_CHANNEL',
id: '33510210-8452-094c-6227-4a6c05d99f00',
},
}, },
} as const; } as const;
@ -246,6 +252,19 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
// specified / direct noteはreject // specified / direct noteはreject
throw new ApiError(meta.errors.cannotRenoteDueToVisibility); throw new ApiError(meta.errors.cannotRenoteDueToVisibility);
} }
if (renote.channelId && !ps.channelId) {
// チャンネル外へのリノート可否をチェック
// リートのユースケースのうち、チャンネル内→チャンネル外は少数だと考えられるため、JOINはせず必要な時に都度取得する
const renoteChannel = await this.channelsRepository.findOneById(renote.channelId);
if (renoteChannel == null) {
// リノートしたいノートが書き込まれているチャンネルが無い
throw new ApiError(meta.errors.noSuchChannel);
} else if (!renoteChannel.canRenote) {
// リノート作成のリクエストだが、対象チャンネルがリノート禁止だった場合
throw new ApiError(meta.errors.cannotRenoteOutsideOfChannel);
}
}
} }
let reply: MiNote | null = null; let reply: MiNote | null = null;

View File

@ -272,10 +272,11 @@ function renote(viaKeyboard = false) {
pleaseLogin(); pleaseLogin();
showMovedDialog(); showMovedDialog();
let items = [] as MenuItem[]; const channelRenoteItems: MenuItem[] = [];
const normalRenoteItems: MenuItem[] = [];
if (appearNote.channel) { if (appearNote.channel) {
items = items.concat([{ channelRenoteItems.push(...[{
text: i18n.ts.inChannelRenote, text: i18n.ts.inChannelRenote,
icon: 'ti ti-repeat', icon: 'ti ti-repeat',
action: () => { action: () => {
@ -303,20 +304,21 @@ function renote(viaKeyboard = false) {
channel: appearNote.channel, channel: appearNote.channel,
}); });
}, },
}, null]); }]);
} }
items = items.concat([{ if (!appearNote.channel || appearNote.channel?.canRenote) {
text: i18n.ts.renote, normalRenoteItems.push(...[{
icon: 'ti ti-repeat', text: i18n.ts.renote,
action: () => { icon: 'ti ti-repeat',
const el = renoteButton.value as HTMLElement | null | undefined; action: () => {
if (el) { const el = renoteButton.value as HTMLElement | null | undefined;
const rect = el.getBoundingClientRect(); if (el) {
const x = rect.left + (el.offsetWidth / 2); const rect = el.getBoundingClientRect();
const y = rect.top + (el.offsetHeight / 2); const x = rect.left + (el.offsetWidth / 2);
os.popup(MkRippleEffect, { x, y }, {}, 'end'); const y = rect.top + (el.offsetHeight / 2);
} os.popup(MkRippleEffect, { x, y }, {}, 'end');
}
const configuredVisibility = defaultStore.state.rememberNoteVisibility ? defaultStore.state.visibility : defaultStore.state.defaultNoteVisibility; const configuredVisibility = defaultStore.state.rememberNoteVisibility ? defaultStore.state.visibility : defaultStore.state.defaultNoteVisibility;
const localOnly = defaultStore.state.rememberNoteVisibility ? defaultStore.state.localOnly : defaultStore.state.defaultNoteLocalOnly; const localOnly = defaultStore.state.rememberNoteVisibility ? defaultStore.state.localOnly : defaultStore.state.defaultNoteLocalOnly;
@ -327,25 +329,33 @@ function renote(viaKeyboard = false) {
visibility = smallerVisibility(visibility, 'home'); visibility = smallerVisibility(visibility, 'home');
} }
os.api('notes/create', { os.api('notes/create', {
localOnly, localOnly,
visibility, visibility,
renoteId: appearNote.id, renoteId: appearNote.id,
}).then(() => { }).then(() => {
os.toast(i18n.ts.renoted); os.toast(i18n.ts.renoted);
}); });
}, },
}, { }, {
text: i18n.ts.quote, text: i18n.ts.quote,
icon: 'ti ti-quote', icon: 'ti ti-quote',
action: () => { action: () => {
os.post({ os.post({
renote: appearNote, renote: appearNote,
}); });
}, },
}]); }]);
}
os.popupMenu(items, renoteButton.value, { // null
const renoteItems = [
...channelRenoteItems,
...(channelRenoteItems.length > 0 && normalRenoteItems.length > 0) ? [null] : [],
...normalRenoteItems,
];
os.popupMenu(renoteItems, renoteButton.value, {
viaKeyboard, viaKeyboard,
}); });
} }

View File

@ -24,6 +24,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ i18n.ts.sensitive }}</template> <template #label>{{ i18n.ts.sensitive }}</template>
</MkSwitch> </MkSwitch>
<MkSwitch v-model="canRenote">
<template #label>{{ i18n.ts._channel.canRenoteSwitch }}</template>
</MkSwitch>
<div> <div>
<MkButton v-if="bannerId == null" @click="setBannerImage"><i class="ti ti-plus"></i> {{ i18n.ts._channel.setBanner }}</MkButton> <MkButton v-if="bannerId == null" @click="setBannerImage"><i class="ti ti-plus"></i> {{ i18n.ts._channel.setBanner }}</MkButton>
<div v-else-if="bannerUrl"> <div v-else-if="bannerUrl">
@ -76,7 +80,7 @@ import { useRouter } from '@/router.js';
import { definePageMetadata } from '@/scripts/page-metadata.js'; import { definePageMetadata } from '@/scripts/page-metadata.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import MkFolder from '@/components/MkFolder.vue'; import MkFolder from '@/components/MkFolder.vue';
import MkSwitch from "@/components/MkSwitch.vue"; import MkSwitch from '@/components/MkSwitch.vue';
const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
@ -93,6 +97,7 @@ let bannerUrl = $ref<string | null>(null);
let bannerId = $ref<string | null>(null); let bannerId = $ref<string | null>(null);
let color = $ref('#000'); let color = $ref('#000');
let isSensitive = $ref(false); let isSensitive = $ref(false);
let canRenote = $ref(true);
const pinnedNotes = ref([]); const pinnedNotes = ref([]);
watch(() => bannerId, async () => { watch(() => bannerId, async () => {
@ -121,6 +126,7 @@ async function fetchChannel() {
id, id,
})); }));
color = channel.color; color = channel.color;
canRenote = channel.canRenote;
} }
fetchChannel(); fetchChannel();
@ -150,6 +156,7 @@ function save() {
pinnedNoteIds: pinnedNotes.value.map(x => x.id), pinnedNoteIds: pinnedNotes.value.map(x => x.id),
color: color, color: color,
isSensitive: isSensitive, isSensitive: isSensitive,
canRenote: canRenote,
}; };
if (props.channelId) { if (props.channelId) {

View File

@ -198,6 +198,8 @@ export type Note = {
fileIds: DriveFile['id'][]; fileIds: DriveFile['id'][];
visibility: 'public' | 'home' | 'followers' | 'specified'; visibility: 'public' | 'home' | 'followers' | 'specified';
visibleUserIds?: User['id'][]; visibleUserIds?: User['id'][];
channel?: Channel;
channelId?: Channel['id'];
localOnly?: boolean; localOnly?: boolean;
myReaction?: string; myReaction?: string;
reactions: Record<string, number>; reactions: Record<string, number>;
@ -514,7 +516,20 @@ export type FollowRequest = {
export type Channel = { export type Channel = {
id: ID; id: ID;
// TODO lastNotedAt: Date | null;
userId: User['id'] | null;
user: User | null;
name: string;
description: string | null;
bannerId: DriveFile['id'] | null;
banner: DriveFile | null;
pinnedNoteIds: string[];
color: string;
isArchived: boolean;
notesCount: number;
usersCount: number;
isSensitive: boolean;
canRenote: boolean;
}; };
export type Following = { export type Following = {