diff --git a/CHANGELOG.md b/CHANGELOG.md index c99410696d..d096d02e62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ - 画像のテンプレートはこちらです: https://misskey-hub.net/avatar-decoration-template.png - 最大でも黄色いエリア内にデコレーションを収めることを推奨します。 - 画像は512x512pxを推奨します。 +- Feat: チャンネル設定にリノート/引用リノートの可否を設定できる項目を追加 - Enhance: すでにフォローしたすべての人の返信をTLに追加できるように - Enhance: 未読の通知数を表示できるように - Enhance: ローカリゼーションの更新 diff --git a/locales/index.d.ts b/locales/index.d.ts index 2437775648..50b11acc06 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -1832,6 +1832,7 @@ export interface Locale { "notesCount": string; "nameAndDescription": string; "nameOnly": string; + "allowRenoteToExternal": string; }; "_menuDisplay": { "sideFull": string; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 58ba8e04de..de4e8ce2b3 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1737,6 +1737,7 @@ _channel: notesCount: "{n}投稿があります" nameAndDescription: "名前と説明" nameOnly: "名前のみ" + allowRenoteToExternal: "チャンネル外へのリノートと引用リノートを許可する" _menuDisplay: sideFull: "横" diff --git a/packages/backend/migration/1698840138000-add-allow-renote-to-external.js b/packages/backend/migration/1698840138000-add-allow-renote-to-external.js new file mode 100644 index 0000000000..0edf298841 --- /dev/null +++ b/packages/backend/migration/1698840138000-add-allow-renote-to-external.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class AddAllowRenoteToExternal1698840138000 { + name = 'AddAllowRenoteToExternal1698840138000' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "channel" ADD "allowRenoteToExternal" boolean NOT NULL DEFAULT true`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "channel" DROP COLUMN "allowRenoteToExternal"`); + } +} diff --git a/packages/backend/src/core/entities/ChannelEntityService.ts b/packages/backend/src/core/entities/ChannelEntityService.ts index 9e66834dfa..305946b8a6 100644 --- a/packages/backend/src/core/entities/ChannelEntityService.ts +++ b/packages/backend/src/core/entities/ChannelEntityService.ts @@ -85,6 +85,7 @@ export class ChannelEntityService { usersCount: channel.usersCount, notesCount: channel.notesCount, isSensitive: channel.isSensitive, + allowRenoteToExternal: channel.allowRenoteToExternal, ...(me ? { isFollowing, diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index 6fde1c3830..c49dad8e79 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -350,6 +350,7 @@ export class NoteEntityService implements OnModuleInit { name: channel.name, color: channel.color, isSensitive: channel.isSensitive, + allowRenoteToExternal: channel.allowRenoteToExternal, } : undefined, mentions: note.mentions.length > 0 ? note.mentions : undefined, uri: note.uri ?? undefined, diff --git a/packages/backend/src/models/Channel.ts b/packages/backend/src/models/Channel.ts index f90f8c03d8..a7f9e262b1 100644 --- a/packages/backend/src/models/Channel.ts +++ b/packages/backend/src/models/Channel.ts @@ -93,4 +93,9 @@ export class MiChannel { default: false, }) public isSensitive: boolean; + + @Column('boolean', { + default: true, + }) + public allowRenoteToExternal: boolean; } diff --git a/packages/backend/src/models/json-schema/channel.ts b/packages/backend/src/models/json-schema/channel.ts index f1019d1461..8f9770cdc5 100644 --- a/packages/backend/src/models/json-schema/channel.ts +++ b/packages/backend/src/models/json-schema/channel.ts @@ -76,5 +76,9 @@ export const packedChannelSchema = { type: 'boolean', optional: false, nullable: false, }, + allowRenoteToExternal: { + type: 'boolean', + optional: false, nullable: false, + }, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/channels/create.ts b/packages/backend/src/server/api/endpoints/channels/create.ts index 3ba411d28c..3dd1eddd01 100644 --- a/packages/backend/src/server/api/endpoints/channels/create.ts +++ b/packages/backend/src/server/api/endpoints/channels/create.ts @@ -50,6 +50,7 @@ export const paramDef = { bannerId: { type: 'string', format: 'misskey:id', nullable: true }, color: { type: 'string', minLength: 1, maxLength: 16 }, isSensitive: { type: 'boolean', nullable: true }, + allowRenoteToExternal: { type: 'boolean', nullable: true }, }, required: ['name'], } as const; @@ -87,6 +88,7 @@ export default class extends Endpoint { // eslint- bannerId: banner ? banner.id : null, isSensitive: ps.isSensitive ?? false, ...(ps.color !== undefined ? { color: ps.color } : {}), + allowRenoteToExternal: ps.allowRenoteToExternal ?? true, } as MiChannel).then(x => this.channelsRepository.findOneByOrFail(x.identifiers[0])); return await this.channelEntityService.pack(channel, me); diff --git a/packages/backend/src/server/api/endpoints/channels/update.ts b/packages/backend/src/server/api/endpoints/channels/update.ts index ab69f62a7b..93d02e4a12 100644 --- a/packages/backend/src/server/api/endpoints/channels/update.ts +++ b/packages/backend/src/server/api/endpoints/channels/update.ts @@ -61,6 +61,7 @@ export const paramDef = { }, color: { type: 'string', minLength: 1, maxLength: 16 }, isSensitive: { type: 'boolean', nullable: true }, + allowRenoteToExternal: { type: 'boolean', nullable: true }, }, required: ['channelId'], } as const; @@ -115,6 +116,7 @@ export default class extends Endpoint { // eslint- ...(typeof ps.isArchived === 'boolean' ? { isArchived: ps.isArchived } : {}), ...(banner ? { bannerId: banner.id } : {}), ...(typeof ps.isSensitive === 'boolean' ? { isSensitive: ps.isSensitive } : {}), + ...(typeof ps.allowRenoteToExternal === 'boolean' ? { allowRenoteToExternal: ps.allowRenoteToExternal } : {}), }); return await this.channelEntityService.pack(channel.id, me); diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts index fb650f69ff..df02d3acb7 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -99,6 +99,12 @@ export const meta = { code: 'NO_SUCH_FILE', 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; @@ -246,6 +252,19 @@ export default class extends Endpoint { // eslint- // specified / direct noteはreject throw new ApiError(meta.errors.cannotRenoteDueToVisibility); } + + if (renote.channelId && 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.allowRenoteToExternal) { + // リノート作成のリクエストだが、対象チャンネルがリノート禁止だった場合 + throw new ApiError(meta.errors.cannotRenoteOutsideOfChannel); + } + } } let reply: MiNote | null = null; diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index d71b07c51b..30a68f38f2 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -159,7 +159,7 @@ import { reactionPicker } from '@/scripts/reaction-picker.js'; import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js'; import { $i } from '@/account.js'; import { i18n } from '@/i18n.js'; -import { getAbuseNoteMenu, getCopyNoteLinkMenu, getNoteClipMenu, getNoteMenu } from '@/scripts/get-note-menu.js'; +import { getAbuseNoteMenu, getCopyNoteLinkMenu, getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/scripts/get-note-menu.js'; import { useNoteCapture } from '@/scripts/use-note-capture.js'; import { deepClone } from '@/scripts/clone.js'; import { useTooltip } from '@/scripts/use-tooltip.js'; @@ -275,103 +275,14 @@ if (!props.mock) { }); } -type Visibility = 'public' | 'home' | 'followers' | 'specified'; - -// defaultStore.state.visibilityがstringなためstringも受け付けている -function smallerVisibility(a: Visibility | string, b: Visibility | string): Visibility { - if (a === 'specified' || b === 'specified') return 'specified'; - if (a === 'followers' || b === 'followers') return 'followers'; - if (a === 'home' || b === 'home') return 'home'; - // if (a === 'public' || b === 'public') - return 'public'; -} - function renote(viaKeyboard = false) { pleaseLogin(); showMovedDialog(); - let items = [] as MenuItem[]; - - if (appearNote.channel) { - items = items.concat([{ - text: i18n.ts.inChannelRenote, - icon: 'ti ti-repeat', - action: () => { - const el = renoteButton.value as HTMLElement | null | undefined; - if (el) { - const rect = el.getBoundingClientRect(); - const x = rect.left + (el.offsetWidth / 2); - const y = rect.top + (el.offsetHeight / 2); - os.popup(MkRippleEffect, { x, y }, {}, 'end'); - } - - if (!props.mock) { - os.api('notes/create', { - renoteId: appearNote.id, - channelId: appearNote.channelId, - }).then(() => { - os.toast(i18n.ts.renoted); - }); - } - }, - }, { - text: i18n.ts.inChannelQuote, - icon: 'ti ti-quote', - action: () => { - if (!props.mock) { - os.post({ - renote: appearNote, - channel: appearNote.channel, - }); - } - }, - }, null]); - } - - items = items.concat([{ - text: i18n.ts.renote, - icon: 'ti ti-repeat', - action: () => { - const el = renoteButton.value as HTMLElement | null | undefined; - if (el) { - const rect = el.getBoundingClientRect(); - const x = rect.left + (el.offsetWidth / 2); - 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 localOnly = defaultStore.state.rememberNoteVisibility ? defaultStore.state.localOnly : defaultStore.state.defaultNoteLocalOnly; - - let visibility = appearNote.visibility; - visibility = smallerVisibility(visibility, configuredVisibility); - if (appearNote.channel?.isSensitive) { - visibility = smallerVisibility(visibility, 'home'); - } - - if (!props.mock) { - os.api('notes/create', { - localOnly, - visibility, - renoteId: appearNote.id, - }).then(() => { - os.toast(i18n.ts.renoted); - }); - } - }, - }, (props.mock) ? undefined : { - text: i18n.ts.quote, - icon: 'ti ti-quote', - action: () => { - os.post({ - renote: appearNote, - }); - }, - }]); - - os.popupMenu(items, renoteButton.value, { + const { menu } = getRenoteMenu({ note: note, renoteButton, mock: props.mock }); + os.popupMenu(menu, renoteButton.value, { viaKeyboard, - }); + }).then(focus); } function reply(viaKeyboard = false): void { diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index d34d35a0c3..9e9b1035d7 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -206,7 +206,7 @@ import { reactionPicker } from '@/scripts/reaction-picker.js'; import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js'; import { $i } from '@/account.js'; import { i18n } from '@/i18n.js'; -import { getNoteClipMenu, getNoteMenu } from '@/scripts/get-note-menu.js'; +import { getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/scripts/get-note-menu.js'; import { useNoteCapture } from '@/scripts/use-note-capture.js'; import { deepClone } from '@/scripts/clone.js'; import { useTooltip } from '@/scripts/use-tooltip.js'; @@ -325,71 +325,10 @@ function renote(viaKeyboard = false) { pleaseLogin(); showMovedDialog(); - let items = [] as MenuItem[]; - - if (appearNote.channel) { - items = items.concat([{ - text: i18n.ts.inChannelRenote, - icon: 'ti ti-repeat', - action: () => { - const el = renoteButton.value as HTMLElement | null | undefined; - if (el) { - const rect = el.getBoundingClientRect(); - const x = rect.left + (el.offsetWidth / 2); - const y = rect.top + (el.offsetHeight / 2); - os.popup(MkRippleEffect, { x, y }, {}, 'end'); - } - - os.api('notes/create', { - renoteId: appearNote.id, - channelId: appearNote.channelId, - }).then(() => { - os.toast(i18n.ts.renoted); - }); - }, - }, { - text: i18n.ts.inChannelQuote, - icon: 'ti ti-quote', - action: () => { - os.post({ - renote: appearNote, - channel: appearNote.channel, - }); - }, - }, null]); - } - - items = items.concat([{ - text: i18n.ts.renote, - icon: 'ti ti-repeat', - action: () => { - const el = renoteButton.value as HTMLElement | null | undefined; - if (el) { - const rect = el.getBoundingClientRect(); - const x = rect.left + (el.offsetWidth / 2); - const y = rect.top + (el.offsetHeight / 2); - os.popup(MkRippleEffect, { x, y }, {}, 'end'); - } - - os.api('notes/create', { - renoteId: appearNote.id, - }).then(() => { - os.toast(i18n.ts.renoted); - }); - }, - }, { - text: i18n.ts.quote, - icon: 'ti ti-quote', - action: () => { - os.post({ - renote: appearNote, - }); - }, - }]); - - os.popupMenu(items, renoteButton.value, { + const { menu } = getRenoteMenu({ note: note, renoteButton }); + os.popupMenu(menu, renoteButton.value, { viaKeyboard, - }); + }).then(focus); } function reply(viaKeyboard = false): void { diff --git a/packages/frontend/src/pages/channel-editor.vue b/packages/frontend/src/pages/channel-editor.vue index faef8fdb1f..5256ea4f11 100644 --- a/packages/frontend/src/pages/channel-editor.vue +++ b/packages/frontend/src/pages/channel-editor.vue @@ -24,6 +24,10 @@ SPDX-License-Identifier: AGPL-3.0-only + + + +
{{ i18n.ts._channel.setBanner }}
@@ -76,7 +80,7 @@ import { useRouter } from '@/router.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { i18n } from '@/i18n.js'; 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)); @@ -93,6 +97,7 @@ let bannerUrl = $ref(null); let bannerId = $ref(null); let color = $ref('#000'); let isSensitive = $ref(false); +let allowRenoteToExternal = $ref(true); const pinnedNotes = ref([]); watch(() => bannerId, async () => { @@ -121,6 +126,7 @@ async function fetchChannel() { id, })); color = channel.color; + allowRenoteToExternal = channel.allowRenoteToExternal; } fetchChannel(); @@ -150,6 +156,7 @@ function save() { pinnedNoteIds: pinnedNotes.value.map(x => x.id), color: color, isSensitive: isSensitive, + allowRenoteToExternal: allowRenoteToExternal, }; if (props.channelId) { diff --git a/packages/frontend/src/scripts/get-note-menu.ts b/packages/frontend/src/scripts/get-note-menu.ts index e399145fc9..d0753872ff 100644 --- a/packages/frontend/src/scripts/get-note-menu.ts +++ b/packages/frontend/src/scripts/get-note-menu.ts @@ -17,6 +17,7 @@ import { miLocalStorage } from '@/local-storage.js'; import { getUserMenu } from '@/scripts/get-user-menu.js'; import { clipsCache } from '@/cache.js'; import { MenuItem } from '@/types/menu.js'; +import MkRippleEffect from '@/components/MkRippleEffect.vue'; export async function getNoteClipMenu(props: { note: Misskey.entities.Note; @@ -418,3 +419,122 @@ export function getNoteMenu(props: { cleanup, }; } + +type Visibility = 'public' | 'home' | 'followers' | 'specified'; + +// defaultStore.state.visibilityがstringなためstringも受け付けている +function smallerVisibility(a: Visibility | string, b: Visibility | string): Visibility { + if (a === 'specified' || b === 'specified') return 'specified'; + if (a === 'followers' || b === 'followers') return 'followers'; + if (a === 'home' || b === 'home') return 'home'; + // if (a === 'public' || b === 'public') + return 'public'; +} + +export function getRenoteMenu(props: { + note: Misskey.entities.Note; + renoteButton: Ref; + mock?: boolean; +}) { + const isRenote = ( + props.note.renote != null && + props.note.text == null && + props.note.fileIds.length === 0 && + props.note.poll == null + ); + + const appearNote = isRenote ? props.note.renote as Misskey.entities.Note : props.note; + + const channelRenoteItems: MenuItem[] = []; + const normalRenoteItems: MenuItem[] = []; + + if (appearNote.channel) { + channelRenoteItems.push(...[{ + text: i18n.ts.inChannelRenote, + icon: 'ti ti-repeat', + action: () => { + const el = props.renoteButton.value as HTMLElement | null | undefined; + if (el) { + const rect = el.getBoundingClientRect(); + const x = rect.left + (el.offsetWidth / 2); + const y = rect.top + (el.offsetHeight / 2); + os.popup(MkRippleEffect, { x, y }, {}, 'end'); + } + + if (!props.mock) { + os.api('notes/create', { + renoteId: appearNote.id, + channelId: appearNote.channelId, + }).then(() => { + os.toast(i18n.ts.renoted); + }); + } + }, + }, { + text: i18n.ts.inChannelQuote, + icon: 'ti ti-quote', + action: () => { + if (!props.mock) { + os.post({ + renote: appearNote, + channel: appearNote.channel, + }); + } + }, + }]); + } + + if (!appearNote.channel || appearNote.channel?.allowRenoteToExternal) { + normalRenoteItems.push(...[{ + text: i18n.ts.renote, + icon: 'ti ti-repeat', + action: () => { + const el = props.renoteButton.value as HTMLElement | null | undefined; + if (el) { + const rect = el.getBoundingClientRect(); + const x = rect.left + (el.offsetWidth / 2); + 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 localOnly = defaultStore.state.rememberNoteVisibility ? defaultStore.state.localOnly : defaultStore.state.defaultNoteLocalOnly; + + let visibility = appearNote.visibility; + visibility = smallerVisibility(visibility, configuredVisibility); + if (appearNote.channel?.isSensitive) { + visibility = smallerVisibility(visibility, 'home'); + } + + if (!props.mock) { + os.api('notes/create', { + localOnly, + visibility, + renoteId: appearNote.id, + }).then(() => { + os.toast(i18n.ts.renoted); + }); + } + }, + }, (props.mock) ? undefined : { + text: i18n.ts.quote, + icon: 'ti ti-quote', + action: () => { + os.post({ + renote: appearNote, + }); + }, + }]); + } + + // nullを挟むことで区切り線を出せる + const renoteItems = [ + ...normalRenoteItems, + ...(channelRenoteItems.length > 0 && normalRenoteItems.length > 0) ? [null] : [], + ...channelRenoteItems, + ]; + + return { + menu: renoteItems, + }; +} diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index a15e5888e8..87922ba791 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -134,6 +134,20 @@ type Blocking = { // @public (undocumented) type Channel = { id: ID; + 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; + allowRenoteToExternal: boolean; }; // Warning: (ae-forgotten-export) The symbol "AnyOf" needs to be exported by the entry point index.d.ts @@ -2683,6 +2697,8 @@ type Note = { fileIds: DriveFile['id'][]; visibility: 'public' | 'home' | 'followers' | 'specified'; visibleUserIds?: User['id'][]; + channel?: Channel; + channelId?: Channel['id']; localOnly?: boolean; myReaction?: string; reactions: Record; @@ -3021,7 +3037,7 @@ type UserSorting = '+follower' | '-follower' | '+createdAt' | '-createdAt' | '+u // src/api.types.ts:18:25 - (ae-forgotten-export) The symbol "NoParams" needs to be exported by the entry point index.d.ts // src/api.types.ts:632:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts // src/entities.ts:116:2 - (ae-forgotten-export) The symbol "notificationTypes_2" needs to be exported by the entry point index.d.ts -// src/entities.ts:612:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts +// src/entities.ts:627:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts // src/streaming.types.ts:33:4 - (ae-forgotten-export) The symbol "FIXME" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/packages/misskey-js/src/entities.ts b/packages/misskey-js/src/entities.ts index 029bf48c84..a0d0b7528d 100644 --- a/packages/misskey-js/src/entities.ts +++ b/packages/misskey-js/src/entities.ts @@ -198,6 +198,8 @@ export type Note = { fileIds: DriveFile['id'][]; visibility: 'public' | 'home' | 'followers' | 'specified'; visibleUserIds?: User['id'][]; + channel?: Channel; + channelId?: Channel['id']; localOnly?: boolean; myReaction?: string; reactions: Record; @@ -514,7 +516,20 @@ export type FollowRequest = { export type Channel = { 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; + allowRenoteToExternal: boolean; }; export type Following = {