This commit is contained in:
かっこかり 2025-08-26 01:32:08 +08:00 committed by GitHub
commit a335e1991a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 414 additions and 73 deletions

View File

@ -20,6 +20,12 @@
- Enhance: Unicode 15.1 および 16.0 に収録されている絵文字に対応
- Enhance: acctに `.` が入っているユーザーのメンションに対応
- Fix: Unicode絵文字に隣接する異体字セレクタ`U+FE0F`)が絵文字として認識される問題を修正
- Enhance: ノートの投稿に関するロールポリシーを強化しました
- ノートの投稿を一切禁止できるように
- リノート・引用の可否
- 指名ノートの投稿の可否
- 連合するノートの投稿の可否
- ノートに添付できるファイルの最大数
- Enhance: ユーザー検索をロールポリシーで制限できるように
### Client

36
locales/index.d.ts vendored
View File

@ -5529,6 +5529,10 @@ export interface Locale extends ILocale {
*
*/
"thankYouForTestingBeta": string;
/**
* 使稿
*/
"youAreNotAllowedToCreateNote": string;
"_order": {
/**
*
@ -7879,6 +7883,38 @@ export interface Locale extends ILocale {
* 使
*/
"watermarkAvailable": string;
/**
* 稿
*/
"canNote": string;
/**
*
*/
"renotePolicy": string;
/**
*
*/
"renotePolicy_allow": string;
/**
*
*/
"renotePolicy_renoteOnly": string;
/**
*
*/
"renotePolicy_disallow": string;
/**
* 稿
*/
"canCreateSpecifiedNote": string;
/**
* 稿
*/
"canFederateNote": string;
/**
*
*/
"noteFilesLimit": string;
};
"_condition": {
/**

View File

@ -1377,6 +1377,7 @@ pluginsAreDisabledBecauseSafeMode: "セーフモードが有効なため、プ
customCssIsDisabledBecauseSafeMode: "セーフモードが有効なため、カスタムCSSは適用されていません。"
themeIsDefaultBecauseSafeMode: "セーフモードが有効な間はデフォルトのテーマが使用されます。セーフモードをオフにすると元に戻ります。"
thankYouForTestingBeta: "ベータ版の検証にご協力いただきありがとうございます!"
youAreNotAllowedToCreateNote: "お使いのアカウントにはノートを投稿する権限がありません。"
_order:
newest: "新しい順"
@ -2040,6 +2041,14 @@ _role:
uploadableFileTypes_caption2: "ファイルによっては種別を判定できないことがあります。そのようなファイルを許可する場合は {x} を指定に追加してください。"
noteDraftLimit: "サーバーサイドのノートの下書きの作成可能数"
watermarkAvailable: "ウォーターマーク機能の使用可否"
canNote: "ノートの投稿を許可"
renotePolicy: "リノート・引用を許可"
renotePolicy_allow: "リノート・引用を許可"
renotePolicy_renoteOnly: "リノートのみ許可"
renotePolicy_disallow: "リノート・引用を禁止"
canCreateSpecifiedNote: "指名ノートの投稿を許可"
canFederateNote: "連合するノートの投稿を許可"
noteFilesLimit: "ノートに添付できるファイルの最大数"
_condition:
roleAssignedTo: "マニュアルロールにアサイン済み"
isLocal: "ローカルユーザー"

View File

@ -5,6 +5,8 @@
export const MAX_NOTE_TEXT_LENGTH = 3000;
export const MAX_NOTE_ATTACHMENTS = 16;
export const USER_ONLINE_THRESHOLD = 1000 * 60 * 10; // 10min
export const USER_ACTIVE_THRESHOLD = 1000 * 60 * 60 * 24 * 3; // 3days

View File

@ -229,6 +229,16 @@ export class NoteCreateService implements OnApplicationShutdown {
isBot: MiUser['isBot'];
isCat: MiUser['isCat'];
}, data: Option, silent = false): Promise<MiNote> {
const userPolicies = await this.roleService.getUserPolicies(user.id);
if (!userPolicies.canNote) {
throw new IdentifiableError('ebd9b2a9-4d95-4b01-8824-e701629b65e7', 'You are not allowed to create notes');
}
if (data.files != null && data.files.length > userPolicies.noteFilesLimit) {
throw new IdentifiableError('80dc1304-d910-4daa-b26f-4220b6c944ff', 'Too many files attached to note');
}
// チャンネル外にリプライしたら対象のスコープに合わせる
// (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで)
if (data.reply && data.channel && data.reply.channelId !== data.channel.id) {
@ -256,7 +266,7 @@ export class NoteCreateService implements OnApplicationShutdown {
const sensitiveWords = this.meta.sensitiveWords;
if (this.utilityService.isKeyWordIncluded(data.cw ?? data.text ?? '', sensitiveWords)) {
data.visibility = 'home';
} else if ((await this.roleService.getUserPolicies(user.id)).canPublicNote === false) {
} else if (userPolicies.canPublicNote === false) {
data.visibility = 'home';
}
}
@ -303,8 +313,19 @@ export class NoteCreateService implements OnApplicationShutdown {
}
}
const isRenote = this.isRenote(data);
const isQuote = isRenote ? this.isQuote(data) : false;
if (isRenote && userPolicies.renotePolicy === 'disallow') {
throw new IdentifiableError('d35d80dc-02ba-4c9b-b9b8-905d306dcb67', 'You are not allowed to renote');
}
if (isQuote && (userPolicies.renotePolicy === 'disallow' || userPolicies.renotePolicy === 'renoteOnly')) {
throw new IdentifiableError('3a97010b-c338-4cdf-a567-24c54b67726e', 'You are not allowed to quote');
}
// Check blocking
if (this.isRenote(data) && !this.isQuote(data)) {
if (isRenote && !isQuote) {
if (data.renote.userHost === null) {
if (data.renote.userId !== user.id) {
const blocked = await this.userBlockingService.checkBlocked(data.renote.userId, user.id);
@ -374,6 +395,7 @@ export class NoteCreateService implements OnApplicationShutdown {
if (data.visibility === 'specified') {
if (data.visibleUsers == null) throw new Error('invalid param');
if (!userPolicies.canCreateSpecifiedNote) throw new IdentifiableError('80d26afb-d466-4d86-9c01-11b9cad9da24', 'You are not allowed to send direct notes');
for (const u of data.visibleUsers) {
if (!mentionedUsers.some(x => x.id === u.id)) {
@ -384,6 +406,12 @@ export class NoteCreateService implements OnApplicationShutdown {
if (data.reply && !data.visibleUsers.some(x => x.id === data.reply!.userId)) {
data.visibleUsers.push(await this.usersRepository.findOneByOrFail({ id: data.reply!.userId }));
}
if (!userPolicies.canFederateNote && data.visibleUsers.some(u => this.userEntityService.isRemoteUser(u))) {
throw new IdentifiableError('5bbfae8d-097c-4c58-93f4-bc242d600529', 'You are not allowed to send direct notes to remote users');
}
} else if (!userPolicies.canFederateNote) {
data.localOnly = true;
}
if (mentionedUsers.length > 0 && mentionedUsers.length > (await this.roleService.getUserPolicies(user.id)).mentionLimit) {

View File

@ -30,6 +30,7 @@ import type { Packed } from '@/misc/json-schema.js';
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
import { NotificationService } from '@/core/NotificationService.js';
import type { OnApplicationShutdown, OnModuleInit } from '@nestjs/common';
import { MAX_NOTE_ATTACHMENTS } from '@/const.js';
export type RolePolicies = {
gtlAvailable: boolean;
@ -69,6 +70,11 @@ export type RolePolicies = {
uploadableFileTypes: string[];
noteDraftLimit: number;
watermarkAvailable: boolean;
canNote: boolean;
renotePolicy: 'allow' | 'renoteOnly' | 'disallow';
canCreateSpecifiedNote: boolean;
canFederateNote: boolean;
noteFilesLimit: number;
};
export const DEFAULT_POLICIES: RolePolicies = {
@ -115,6 +121,11 @@ export const DEFAULT_POLICIES: RolePolicies = {
],
noteDraftLimit: 10,
watermarkAvailable: true,
canNote: true,
renotePolicy: 'allow',
canCreateSpecifiedNote: true,
canFederateNote: true,
noteFilesLimit: MAX_NOTE_ATTACHMENTS,
};
@Injectable()
@ -392,6 +403,12 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
return 'unavailable';
}
function aggregateRenotePolicy(vs: RolePolicies['renotePolicy'][]) {
if (vs.some(v => v === 'allow')) return 'allow';
if (vs.some(v => v === 'renoteOnly')) return 'renoteOnly';
return 'disallow';
}
return {
gtlAvailable: calc('gtlAvailable', vs => vs.some(v => v === true)),
ltlAvailable: calc('ltlAvailable', vs => vs.some(v => v === true)),
@ -439,6 +456,11 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
}),
noteDraftLimit: calc('noteDraftLimit', vs => Math.max(...vs)),
watermarkAvailable: calc('watermarkAvailable', vs => vs.some(v => v === true)),
canNote: calc('canNote', vs => vs.some(v => v === true)),
renotePolicy: calc('renotePolicy', aggregateRenotePolicy),
canCreateSpecifiedNote: calc('canCreateSpecifiedNote', vs => vs.some(v => v === true)),
canFederateNote: calc('canFederateNote', vs => vs.some(v => v === true)),
noteFilesLimit: calc('noteFilesLimit', vs => Math.min(Math.max(...vs), MAX_NOTE_ATTACHMENTS)),
};
}

View File

@ -321,6 +321,27 @@ export const packedRolePoliciesSchema = {
type: 'boolean',
optional: false, nullable: false,
},
canNote: {
type: 'boolean',
optional: false, nullable: false,
},
renotePolicy: {
type: 'string',
optional: false, nullable: false,
enum: ['allow', 'renoteOnly', 'disallow'],
},
canCreateSpecifiedNote: {
type: 'boolean',
optional: false, nullable: false,
},
canFederateNote: {
type: 'boolean',
optional: false, nullable: false,
},
noteFilesLimit: {
type: 'integer',
optional: false, nullable: false,
},
},
} as const;

View File

@ -11,7 +11,7 @@ import type { UsersRepository, NotesRepository, BlockingsRepository, DriveFilesR
import type { MiDriveFile } from '@/models/DriveFile.js';
import type { MiNote } from '@/models/Note.js';
import type { MiChannel } from '@/models/Channel.js';
import { MAX_NOTE_TEXT_LENGTH } from '@/const.js';
import { MAX_NOTE_ATTACHMENTS, MAX_NOTE_TEXT_LENGTH } from '@/const.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { NoteCreateService } from '@/core/NoteCreateService.js';
@ -162,14 +162,14 @@ export const paramDef = {
type: 'array',
uniqueItems: true,
minItems: 1,
maxItems: 16,
maxItems: MAX_NOTE_ATTACHMENTS,
items: { type: 'string', format: 'misskey:id' },
},
mediaIds: {
type: 'array',
uniqueItems: true,
minItems: 1,
maxItems: 16,
maxItems: MAX_NOTE_ATTACHMENTS,
items: { type: 'string', format: 'misskey:id' },
},
poll: {
@ -396,10 +396,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} catch (e) {
// TODO: 他のErrorもここでキャッチしてエラーメッセージを当てるようにしたい
if (e instanceof IdentifiableError) {
if (e.id === '689ee33f-f97c-479a-ac49-1b9f8140af99') {
throw new ApiError(meta.errors.containsProhibitedWords);
} else if (e.id === '9f466dab-c856-48cd-9e65-ff90ff750580') {
throw new ApiError(meta.errors.containsTooManyMentions);
switch (e.id) {
case '689ee33f-f97c-479a-ac49-1b9f8140af99':
throw new ApiError(meta.errors.containsProhibitedWords);
case '9f466dab-c856-48cd-9e65-ff90ff750580':
throw new ApiError(meta.errors.containsTooManyMentions);
}
}
throw e;

View File

@ -114,6 +114,11 @@ export const ROLE_POLICIES = [
'uploadableFileTypes',
'noteDraftLimit',
'watermarkAvailable',
'canNote',
'renotePolicy',
'canCreateSpecifiedNote',
'canFederateNote',
'noteFilesLimit',
] as const;
export const MFM_TAGS = ['tada', 'jelly', 'twitch', 'shake', 'spin', 'jump', 'bounce', 'flip', 'x2', 'x3', 'x4', 'scale', 'position', 'fg', 'bg', 'border', 'font', 'blur', 'rainbow', 'sparkle', 'rotate', 'ruby', 'unixtime'];

View File

@ -306,7 +306,10 @@ const showSoftWordMutedWord = computed(() => prefer.s.showSoftWordMutedWord);
const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null);
const translating = ref(false);
const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.user.instance);
const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || (appearNote.visibility === 'followers' && appearNote.userId === $i?.id));
const canRenote = computed(() => (
(['public', 'home'].includes(appearNote.visibility) || (appearNote.visibility === 'followers' && appearNote.userId === $i?.id)) &&
($i == null || ($i.policies.canNote && $i.policies.renotePolicy !== 'disallow'))
));
const renoteCollapsed = ref(
prefer.s.collapseRenotes && isRenote && (
($i && ($i.id === note.userId || $i.id === appearNote.userId)) || // `||` must be `||`! See https://github.com/misskey-dev/misskey/issues/13131

View File

@ -326,7 +326,10 @@ const urls = parsed ? extractUrlFromMfm(parsed).filter((url) => appearNote.renot
const showTicker = (prefer.s.instanceTicker === 'always') || (prefer.s.instanceTicker === 'remote' && appearNote.user.instance);
const conversation = ref<Misskey.entities.Note[]>([]);
const replies = ref<Misskey.entities.Note[]>([]);
const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || appearNote.userId === $i?.id);
const canRenote = computed(() => (
(['public', 'home'].includes(appearNote.visibility) || (appearNote.visibility === 'followers' && appearNote.userId === $i?.id)) &&
($i == null || ($i.policies.canNote && $i.policies.renotePolicy !== 'disallow'))
));
useGlobalEvent('noteDeleted', (noteId) => {
if (noteId === note.id || noteId === appearNote.id) {

View File

@ -33,7 +33,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<span :class="$style.headerRightButtonText">{{ targetChannel.name }}</span>
</button>
</template>
<button v-tooltip="i18n.ts._visibility.disableFederation" class="_button" :class="[$style.headerRightItem, { [$style.danger]: localOnly }]" :disabled="targetChannel != null || visibility === 'specified'" @click="toggleLocalOnly">
<button v-tooltip="i18n.ts._visibility.disableFederation" class="_button" :class="[$style.headerRightItem, { [$style.danger]: localOnly }]" :disabled="targetChannel != null || visibility === 'specified' || !$i.policies.canFederateNote" @click="toggleLocalOnly">
<span v-if="!localOnly"><i class="ti ti-rocket"></i></span>
<span v-else><i class="ti ti-rocket-off"></i></span>
</button>
@ -61,7 +61,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<button class="_buttonPrimary" style="padding: 4px; border-radius: 8px;" @click="addVisibleUser"><i class="ti ti-plus ti-fw"></i></button>
</div>
</div>
<MkInfo v-if="hasNotSpecifiedMentions" warn :class="$style.hasNotSpecifiedMentions">{{ i18n.ts.notSpecifiedMentionWarning }} - <button class="_textButton" @click="addMissingMention()">{{ i18n.ts.add }}</button></MkInfo>
<MkInfo v-if="!$i.policies.canNote" warn :class="$style.formWarn">{{ i18n.ts.youAreNotAllowedToCreateNote }}</MkInfo>
<MkInfo v-else-if="hasNotSpecifiedMentions" warn :class="$style.formWarn">{{ i18n.ts.notSpecifiedMentionWarning }} - <button class="_textButton" @click="addMissingMention()">{{ i18n.ts.add }}</button></MkInfo>
<div v-show="useCw" :class="$style.cwOuter">
<input ref="cwInputEl" v-model="cw" :class="$style.cw" :placeholder="i18n.ts.annotation" @keydown="onKeydown" @keyup="onKeyup" @compositionend="onCompositionEnd">
<div v-if="maxCwTextLength - cwTextLength < 20" :class="['_acrylic', $style.cwTextCount, { [$style.cwTextOver]: cwTextLength > maxCwTextLength }]">{{ maxCwTextLength - cwTextLength }}</div>
@ -85,8 +86,8 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<footer :class="$style.footer">
<div :class="$style.footerLeft">
<button v-tooltip="i18n.ts.attachFile + ' (' + i18n.ts.upload + ')'" class="_button" :class="$style.footerButton" @click="chooseFileFromPc"><i class="ti ti-photo-plus"></i></button>
<button v-tooltip="i18n.ts.attachFile + ' (' + i18n.ts.fromDrive + ')'" class="_button" :class="$style.footerButton" @click="chooseFileFromDrive"><i class="ti ti-cloud-download"></i></button>
<button v-if="$i.policies.noteFilesLimit > 0" v-tooltip="i18n.ts.attachFile + ' (' + i18n.ts.upload + ')'" class="_button" :class="$style.footerButton" @click="chooseFileFromPc"><i class="ti ti-photo-plus"></i></button>
<button v-if="$i.policies.noteFilesLimit > 0" v-tooltip="i18n.ts.attachFile + ' (' + i18n.ts.fromDrive + ')'" class="_button" :class="$style.footerButton" @click="chooseFileFromDrive"><i class="ti ti-cloud-download"></i></button>
<button v-tooltip="i18n.ts.poll" class="_button" :class="[$style.footerButton, { [$style.footerButtonActive]: poll }]" @click="togglePoll"><i class="ti ti-chart-arrows"></i></button>
<button v-tooltip="i18n.ts.useCw" class="_button" :class="[$style.footerButton, { [$style.footerButtonActive]: useCw }]" @click="useCw = !useCw"><i class="ti ti-eye-off"></i></button>
<button v-tooltip="i18n.ts.hashtags" class="_button" :class="[$style.footerButton, { [$style.footerButtonActive]: withHashtags }]" @click="withHashtags = !withHashtags"><i class="ti ti-hash"></i></button>
@ -280,25 +281,44 @@ const cwTextLength = computed((): number => {
const maxCwTextLength = 100;
const canPost = computed((): boolean => {
return !props.mock && !posting.value && !posted.value && !uploader.uploading.value && (uploader.items.value.length === 0 || uploader.readyForUpload.value) &&
(
1 <= textLength.value ||
1 <= files.value.length ||
1 <= uploader.items.value.length ||
poll.value != null ||
renoteTargetNote.value != null ||
quoteId.value != null
) &&
(textLength.value <= maxTextLength.value) &&
(
useCw.value ?
(
cw.value != null && cw.value.trim() !== '' &&
cwTextLength.value <= maxCwTextLength
) : true
) &&
(files.value.length <= 16) &&
(!poll.value || poll.value.choices.length >= 2);
const isNotMock = !props.mock;
const canNote = $i.policies.canNote;
const canQuote = renoteTargetNote.value ? $i.policies.renotePolicy === 'allow' : true;
const isNotPosting = !posting.value && !posted.value;
const isNotUploading = !uploader.uploading.value;
const isUploaderReady = uploader.items.value.length === 0 || uploader.readyForUpload.value;
const hasContent = (
textLength.value >= 1 ||
files.value.length >= 1 ||
uploader.items.value.length >= 1 ||
poll.value != null ||
renoteTargetNote.value != null ||
quoteId.value != null
);
const isTextLengthValid = textLength.value <= maxTextLength.value;
const isCwValid = useCw.value
? cw.value != null && cw.value.trim() !== '' && cwTextLength.value <= maxCwTextLength
: true;
const isFilesCountValid = files.value.length <= $i.policies.noteFilesLimit;
const isPollValid = !poll.value || poll.value.choices.length >= 2;
return (
isNotMock &&
canNote &&
canQuote &&
isNotPosting &&
isNotUploading &&
isUploaderReady &&
hasContent &&
isTextLengthValid &&
isCwValid &&
isFilesCountValid &&
isPollValid
);
});
// cannot save pure renote as draft
@ -699,7 +719,7 @@ async function onPaste(ev: ClipboardEvent) {
const paste = ev.clipboardData.getData('text');
if (!renoteTargetNote.value && !quoteId.value && paste.startsWith(url + '/notes/')) {
if (!renoteTargetNote.value && !quoteId.value && paste.startsWith(url + '/notes/') && $i.policies.renotePolicy === 'allow') {
ev.preventDefault();
os.confirm({
@ -1512,7 +1532,7 @@ html[data-color-scheme=light] .preview {
background: light-dark(rgba(0, 0, 0, 0.1), rgba(255, 255, 255, 0.1));
}
.hasNotSpecifiedMentions {
.formWarn {
margin: 0 20px 16px 20px;
}

View File

@ -24,10 +24,10 @@ SPDX-License-Identifier: AGPL-3.0-only
</Sortable>
<p
:class="[$style.remain, {
[$style.exceeded]: props.modelValue.length > 16,
[$style.exceeded]: props.modelValue.length > $i.policies.noteFilesLimit,
}]"
>
{{ props.modelValue.length }}/16
{{ props.modelValue.length }}/{{ $i.policies.noteFilesLimit }}
</p>
</div>
</template>
@ -36,6 +36,7 @@ SPDX-License-Identifier: AGPL-3.0-only
import { defineAsyncComponent, inject } from 'vue';
import * as Misskey from 'misskey-js';
import type { MenuItem } from '@/types/menu';
import { ensureSignin } from '@/i.js';
import { copyToClipboard } from '@/utility/copy-to-clipboard';
import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue';
import * as os from '@/os.js';
@ -47,6 +48,8 @@ import { globalEvents } from '@/events.js';
const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
const $i = ensureSignin();
const props = defineProps<{
modelValue: Misskey.entities.DriveFile[];
detachMediaFn?: (id: string) => void;

View File

@ -30,7 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<span :class="$style.itemDescription">{{ i18n.ts._visibility.followersDescription }}</span>
</div>
</button>
<button key="specified" :disabled="localOnly" class="_button" :class="[$style.item, { [$style.active]: v === 'specified' }]" data-index="4" @click="choose('specified')">
<button key="specified" :disabled="!$i.policies.canCreateSpecifiedNote || localOnly" class="_button" :class="[$style.item, { [$style.active]: v === 'specified' }]" data-index="4" @click="choose('specified')">
<div :class="$style.icon"><i class="ti ti-mail"></i></div>
<div :class="$style.body">
<span :class="$style.itemTitle">{{ i18n.ts._visibility.specified }}</span>
@ -44,9 +44,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { nextTick, useTemplateRef, ref } from 'vue';
import * as Misskey from 'misskey-js';
import { ensureSignin } from '@/i.js';
import MkModal from '@/components/MkModal.vue';
import { i18n } from '@/i18n.js';
const $i = ensureSignin();
const modal = useTemplateRef('modal');
const props = withDefaults(defineProps<{
@ -115,11 +118,16 @@ function choose(visibility: typeof Misskey.noteVisibilities[number]): void {
width: 100%;
box-sizing: border-box;
&:hover {
&:disabled {
opacity: 0.8;
cursor: not-allowed;
}
&:not(:disabled):hover {
background: rgba(0, 0, 0, 0.05);
}
&:active {
&:not(:disabled):active {
background: rgba(0, 0, 0, 0.1);
}

View File

@ -145,6 +145,26 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canNote, 'canNote'])">
<template #label>{{ i18n.ts._role._options.canNote }}</template>
<template #suffix>
<span v-if="role.policies.canNote.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ role.policies.canNote.value ? i18n.ts.yes : i18n.ts.no }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.canNote)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="role.policies.canNote.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
<MkSwitch v-model="role.policies.canNote.value" :disabled="role.policies.canNote.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
<MkRange v-model="role.policies.canNote.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canPublicNote, 'canPublicNote'])">
<template #label>{{ i18n.ts._role._options.canPublicNote }}</template>
<template #suffix>
@ -165,6 +185,88 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.renotePolicy, 'renotePolicy'])">
<template #label>{{ i18n.ts._role._options.renotePolicy }}</template>
<template #suffix>
<span v-if="role.policies.renotePolicy.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ role.policies.renotePolicy.value === 'allow' ? i18n.ts._role._options.renotePolicy_allow : role.policies.renotePolicy.value === 'renoteOnly' ? i18n.ts._role._options.renotePolicy_renoteOnly : i18n.ts._role._options.renotePolicy_disallow }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.renotePolicy)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="role.policies.renotePolicy.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
<MkSelect v-model="role.policies.renotePolicy.value" :disabled="role.policies.renotePolicy.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts.enable }}</template>
<option value="allow">{{ i18n.ts._role._options.renotePolicy_allow }}</option>
<option value="renoteOnly">{{ i18n.ts._role._options.renotePolicy_renoteOnly }}</option>
<option value="disallow">{{ i18n.ts._role._options.renotePolicy_disallow }}</option>
</MkSelect>
<MkRange v-model="role.policies.renotePolicy.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canCreateSpecifiedNote, 'canCreateSpecifiedNote'])">
<template #label>{{ i18n.ts._role._options.canCreateSpecifiedNote }}</template>
<template #suffix>
<span v-if="role.policies.canCreateSpecifiedNote.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ role.policies.canCreateSpecifiedNote.value ? i18n.ts.yes : i18n.ts.no }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.canCreateSpecifiedNote)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="role.policies.canCreateSpecifiedNote.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
<MkSwitch v-model="role.policies.canCreateSpecifiedNote.value" :disabled="role.policies.canCreateSpecifiedNote.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
<MkRange v-model="role.policies.canCreateSpecifiedNote.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canFederateNote, 'canFederateNote'])">
<template #label>{{ i18n.ts._role._options.canFederateNote }}</template>
<template #suffix>
<span v-if="role.policies.canFederateNote.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ role.policies.canFederateNote.value ? i18n.ts.yes : i18n.ts.no }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.canFederateNote)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="role.policies.canFederateNote.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
<MkSwitch v-model="role.policies.canFederateNote.value" :disabled="role.policies.canFederateNote.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
<MkRange v-model="role.policies.canFederateNote.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.noteFilesLimit, 'noteFilesLimit'])">
<template #label>{{ i18n.ts._role._options.noteFilesLimit }}</template>
<template #suffix>
<span v-if="role.policies.noteFilesLimit.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ role.policies.noteFilesLimit.value }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.noteFilesLimit)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="role.policies.noteFilesLimit.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
<MkInput v-model="role.policies.noteFilesLimit.value" :disabled="role.policies.noteFilesLimit.useDefault" type="number" :readonly="readonly">
</MkInput>
<MkRange v-model="role.policies.noteFilesLimit.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.chatAvailability, 'chatAvailability'])">
<template #label>{{ i18n.ts._role._options.chatAvailability }}</template>
<template #suffix>

View File

@ -41,6 +41,14 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSwitch>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canNote, 'canNote'])">
<template #label>{{ i18n.ts._role._options.canNote }}</template>
<template #suffix>{{ policies.canNote ? i18n.ts.yes : i18n.ts.no }}</template>
<MkSwitch v-model="policies.canNote">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canPublicNote, 'canPublicNote'])">
<template #label>{{ i18n.ts._role._options.canPublicNote }}</template>
<template #suffix>{{ policies.canPublicNote ? i18n.ts.yes : i18n.ts.no }}</template>
@ -49,6 +57,40 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSwitch>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.renotePolicy, 'renotePolicy'])">
<template #label>{{ i18n.ts._role._options.renotePolicy }}</template>
<template #suffix>{{ policies.renotePolicy.value === 'allow' ? i18n.ts._role._options.renotePolicy_allow : policies.renotePolicy.value === 'renoteOnly' ? i18n.ts._role._options.renotePolicy_renoteOnly : i18n.ts._role._options.renotePolicy_disallow }}</template>
<MkSelect v-model="policies.renotePolicy">
<template #label>{{ i18n.ts.enable }}</template>
<option value="allow">{{ i18n.ts._role._options.renotePolicy_allow }}</option>
<option value="renoteOnly">{{ i18n.ts._role._options.renotePolicy_renoteOnly }}</option>
<option value="disallow">{{ i18n.ts._role._options.renotePolicy_disallow }}</option>
</MkSelect>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canCreateSpecifiedNote, 'canCreateSpecifiedNote'])">
<template #label>{{ i18n.ts._role._options.canCreateSpecifiedNote }}</template>
<template #suffix>{{ policies.canCreateSpecifiedNote ? i18n.ts.yes : i18n.ts.no }}</template>
<MkSwitch v-model="policies.canCreateSpecifiedNote">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canFederateNote, 'canFederateNote'])">
<template #label>{{ i18n.ts._role._options.canFederateNote }}</template>
<template #suffix>{{ policies.canFederateNote ? i18n.ts.yes : i18n.ts.no }}</template>
<MkSwitch v-model="policies.canFederateNote">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.noteFilesLimit, 'noteFilesLimit'])">
<template #label>{{ i18n.ts._role._options.noteFilesLimit }}</template>
<template #suffix>{{ policies.noteFilesLimit }}</template>
<MkInput v-model="policies.noteFilesLimit" type="number" max="16" min="0">
</MkInput>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.chatAvailability, 'chatAvailability'])">
<template #label>{{ i18n.ts._role._options.chatAvailability }}</template>
<template #suffix>{{ policies.chatAvailability === 'available' ? i18n.ts.yes : policies.chatAvailability === 'readonly' ? i18n.ts.readonly : i18n.ts.no }}</template>

View File

@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<i class="ti ti-pencil" :class="$style.fileNameEditIcon"></i>
</button>
<div :class="$style.fileQuickActionsOthers">
<button v-tooltip="i18n.ts.createNoteFromTheFile" class="_button" :class="$style.fileQuickActionsOthersButton" @click="postThis()">
<button v-if="$i.policies.noteFilesLimit > 0" v-tooltip="i18n.ts.createNoteFromTheFile" class="_button" :class="$style.fileQuickActionsOthersButton" @click="postThis()">
<i class="ti ti-pencil"></i>
</button>
<button v-if="file.isSensitive" v-tooltip="i18n.ts.unmarkAsSensitive" class="_button" :class="$style.fileQuickActionsOthersButton" @click="toggleSensitive()">
@ -76,6 +76,7 @@ import MkInfo from '@/components/MkInfo.vue';
import MkMediaList from '@/components/MkMediaList.vue';
import MkKeyValue from '@/components/MkKeyValue.vue';
import bytes from '@/filters/bytes.js';
import { ensureSignin } from '@/i.js';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
@ -85,6 +86,8 @@ import { globalEvents } from '@/events.js';
const router = useRouter();
const $i = ensureSignin();
const props = defineProps<{
fileId: string;
}>();

View File

@ -6,5 +6,5 @@
import * as Misskey from 'misskey-js';
export function getAppearNote(note: Misskey.entities.Note) {
return Misskey.note.isPureRenote(note) ? note.renote : note;
return Misskey.note.isPureRenote(note) ? note.renote! : note;
}

View File

@ -13,6 +13,7 @@ import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { prefer } from '@/preferences.js';
import { globalEvents } from '@/events.js';
import { $i } from '@/i.js';
function rename(file: Misskey.entities.DriveFile) {
os.inputText({
@ -116,13 +117,19 @@ export function getDriveFileMenu(file: Misskey.entities.DriveFile, folder?: Miss
action: () => describe(file),
});
menuItems.push({ type: 'divider' }, {
text: i18n.ts.createNoteFromTheFile,
icon: 'ti ti-pencil',
action: () => os.post({
initialFiles: [file],
}),
}, {
menuItems.push({ type: 'divider' });
if ($i != null && $i.policies.noteFilesLimit > 0) {
menuItems.push({
text: i18n.ts.createNoteFromTheFile,
icon: 'ti ti-pencil',
action: () => os.post({
initialFiles: [file],
}),
});
}
menuItems.push({
text: i18n.ts.copyUrl,
icon: 'ti ti-link',
action: () => copyUrl(file),

View File

@ -554,6 +554,12 @@ export function getRenoteMenu(props: {
renoteButton: ShallowRef<HTMLElement | null | undefined>;
mock?: boolean;
}) {
if ($i?.policies.renotePolicy === 'disallow') {
return {
menu: [],
};
}
const appearNote = getAppearNote(props.note);
const channelRenoteItems: MenuItem[] = [];
@ -561,7 +567,7 @@ export function getRenoteMenu(props: {
const normalExternalChannelRenoteItems: MenuItem[] = [];
if (appearNote.channel) {
channelRenoteItems.push(...[{
channelRenoteItems.push({
text: i18n.ts.inChannelRenote,
icon: 'ti ti-repeat',
action: () => {
@ -585,22 +591,26 @@ export function getRenoteMenu(props: {
});
}
},
}, {
text: i18n.ts.inChannelQuote,
icon: 'ti ti-quote',
action: () => {
if (!props.mock) {
os.post({
renote: appearNote,
channel: appearNote.channel,
});
}
},
}]);
});
if ($i?.policies.renotePolicy === 'allow') {
channelRenoteItems.push({
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(...[{
normalRenoteItems.push({
text: i18n.ts.renote,
icon: 'ti ti-repeat',
action: () => {
@ -634,15 +644,19 @@ export function getRenoteMenu(props: {
});
}
},
}, ...(props.mock ? [] : [{
text: i18n.ts.quote,
icon: 'ti ti-quote',
action: () => {
os.post({
renote: appearNote,
});
},
}])]);
});
if (!props.mock && $i?.policies.renotePolicy === 'allow') {
normalRenoteItems.push({
text: i18n.ts.quote,
icon: 'ti ti-quote',
action: () => {
os.post({
renote: appearNote,
});
},
});
}
normalExternalChannelRenoteItems.push({
type: 'parent',

View File

@ -5247,6 +5247,12 @@ export type components = {
chatAvailability: 'available' | 'readonly' | 'unavailable';
noteDraftLimit: number;
watermarkAvailable: boolean;
canNote: boolean;
/** @enum {string} */
renotePolicy: 'allow' | 'renoteOnly' | 'disallow';
canCreateSpecifiedNote: boolean;
canFederateNote: boolean;
noteFilesLimit: number;
};
ReversiGameLite: {
/** Format: id */