Merge remote-tracking branch 'misskey-dev/develop' into prismisskey

# Conflicts:
#	packages/frontend/src/components/MkPostForm.vue
This commit is contained in:
mattyatea 2023-09-28 18:13:48 +09:00
commit b2df78ffcd
16 changed files with 993 additions and 961 deletions

View File

@ -15,11 +15,14 @@
## next
### General
- Feat: ノートの編集をできるように
- ロールで編集可否を設定可能
- Enhance: タイムラインからRenoteを除外するオプションを追加
- Enhance: ユーザーページのート一覧でRenoteを除外できるように
### Client
- Enhance: モデレーションログ機能の強化
- Enhance: Plugin:register_post_form_actionを用いてCWを取得・変更できるように
### Server
- Enhance: MasterプロセスのPIDを書き出せるように

1
locales/index.d.ts vendored
View File

@ -1549,6 +1549,7 @@ export interface Locale {
"gtlAvailable": string;
"ltlAvailable": string;
"canPublicNote": string;
"canEditNote": string;
"canInvite": string;
"inviteLimit": string;
"inviteLimitCycle": string;

View File

@ -1470,6 +1470,7 @@ _role:
gtlAvailable: "グローバルタイムラインの閲覧"
ltlAvailable: "ローカルタイムラインの閲覧"
canPublicNote: "パブリック投稿の許可"
canEditNote: "ノートの編集"
canInvite: "サーバー招待コードの発行"
inviteLimit: "招待コードの作成可能数"
inviteLimitCycle: "招待コードの発行間隔"

View File

@ -26,6 +26,7 @@ export type RolePolicies = {
gtlAvailable: boolean;
ltlAvailable: boolean;
canPublicNote: boolean;
canEditNote: boolean;
canInvite: boolean;
inviteLimit: number;
inviteLimitCycle: number;
@ -50,6 +51,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
gtlAvailable: true,
ltlAvailable: true,
canPublicNote: true,
canEditNote: true,
canInvite: false,
inviteLimit: 0,
inviteLimitCycle: 60 * 24 * 7,
@ -294,6 +296,7 @@ export class RoleService implements OnApplicationShutdown {
gtlAvailable: calc('gtlAvailable', vs => vs.some(v => v === true)),
ltlAvailable: calc('ltlAvailable', vs => vs.some(v => v === true)),
canPublicNote: calc('canPublicNote', vs => vs.some(v => v === true)),
canEditNote: calc('canEditNote', vs => vs.some(v => v === true)),
canInvite: calc('canInvite', vs => vs.some(v => v === true)),
inviteLimit: calc('inviteLimit', vs => Math.max(...vs)),
inviteLimitCycle: calc('inviteLimitCycle', vs => Math.max(...vs)),

View File

@ -260,6 +260,7 @@ import * as ep___notes_clips from './endpoints/notes/clips.js';
import * as ep___notes_conversation from './endpoints/notes/conversation.js';
import * as ep___notes_create from './endpoints/notes/create.js';
import * as ep___notes_delete from './endpoints/notes/delete.js';
import * as ep___notes_update from './endpoints/notes/update.js';
import * as ep___notes_favorites_create from './endpoints/notes/favorites/create.js';
import * as ep___notes_favorites_delete from './endpoints/notes/favorites/delete.js';
import * as ep___notes_featured from './endpoints/notes/featured.js';
@ -611,6 +612,7 @@ const $notes_clips: Provider = { provide: 'ep:notes/clips', useClass: ep___notes
const $notes_conversation: Provider = { provide: 'ep:notes/conversation', useClass: ep___notes_conversation.default };
const $notes_create: Provider = { provide: 'ep:notes/create', useClass: ep___notes_create.default };
const $notes_delete: Provider = { provide: 'ep:notes/delete', useClass: ep___notes_delete.default };
const $notes_update: Provider = { provide: 'ep:notes/update', useClass: ep___notes_update.default };
const $notes_favorites_create: Provider = { provide: 'ep:notes/favorites/create', useClass: ep___notes_favorites_create.default };
const $notes_favorites_delete: Provider = { provide: 'ep:notes/favorites/delete', useClass: ep___notes_favorites_delete.default };
const $notes_featured: Provider = { provide: 'ep:notes/featured', useClass: ep___notes_featured.default };
@ -966,6 +968,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$notes_conversation,
$notes_create,
$notes_delete,
$notes_update,
$notes_favorites_create,
$notes_favorites_delete,
$notes_featured,
@ -1315,6 +1318,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$notes_conversation,
$notes_create,
$notes_delete,
$notes_update,
$notes_favorites_create,
$notes_favorites_delete,
$notes_featured,

View File

@ -259,6 +259,7 @@ import * as ep___notes_clips from './endpoints/notes/clips.js';
import * as ep___notes_conversation from './endpoints/notes/conversation.js';
import * as ep___notes_create from './endpoints/notes/create.js';
import * as ep___notes_delete from './endpoints/notes/delete.js';
import * as ep___notes_update from './endpoints/notes/update.js';
import * as ep___notes_favorites_create from './endpoints/notes/favorites/create.js';
import * as ep___notes_favorites_delete from './endpoints/notes/favorites/delete.js';
import * as ep___notes_featured from './endpoints/notes/featured.js';
@ -608,6 +609,7 @@ const eps = [
['notes/conversation', ep___notes_conversation],
['notes/create', ep___notes_create],
['notes/delete', ep___notes_delete],
['notes/update', ep___notes_update],
['notes/favorites/create', ep___notes_favorites_create],
['notes/favorites/delete', ep___notes_favorites_delete],
['notes/featured', ep___notes_featured],

View File

@ -0,0 +1,88 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import ms from 'ms';
import { Inject, Injectable } from '@nestjs/common';
import type { UsersRepository, NotesRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { NoteDeleteService } from '@/core/NoteDeleteService.js';
import { DI } from '@/di-symbols.js';
import { GetterService } from '@/server/api/GetterService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { MAX_NOTE_TEXT_LENGTH } from '@/const.js';
import { ApiError } from '../../error.js';
export const meta = {
tags: ['notes'],
requireCredential: true,
requireRolePolicy: 'canEditNote',
kind: 'write:notes',
limit: {
duration: ms('1hour'),
max: 10,
minInterval: ms('1sec'),
},
errors: {
noSuchNote: {
message: 'No such note.',
code: 'NO_SUCH_NOTE',
id: 'a6584e14-6e01-4ad3-b566-851e7bf0d474',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
noteId: { type: 'string', format: 'misskey:id' },
text: {
type: 'string',
minLength: 1,
maxLength: MAX_NOTE_TEXT_LENGTH,
nullable: false,
},
cw: { type: 'string', nullable: true, maxLength: 100 },
},
required: ['noteId', 'text', 'cw'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
private getterService: GetterService,
private globalEventService: GlobalEventService,
) {
super(meta, paramDef, async (ps, me) => {
const note = await this.getterService.getNote(ps.noteId).catch(err => {
if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote);
throw err;
});
if (note.userId !== me.id) {
throw new ApiError(meta.errors.noSuchNote);
}
await this.notesRepository.update({ id: note.id }, {
cw: ps.cw,
text: ps.text,
});
this.globalEventService.publishNoteStream(note.id, 'updated', {
cw: ps.cw,
text: ps.text,
});
});
}
}

View File

@ -130,6 +130,10 @@ export interface NoteStreamTypes {
deleted: {
deletedAt: Date;
};
updated: {
cw: string | null;
text: string;
};
reacted: {
reaction: string;
emoji?: {

View File

@ -4,25 +4,23 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div
<div
:class="[$style.root, { [$style.modal]: modal, _popup: modal }]"
@dragover.stop="onDragover"
@dragenter="onDragenter"
@dragleave="onDragleave"
@drop.stop="onDrop"
>
>
<header :class="$style.header">
<div :class="$style.headerLeft">
<button v-if="!fixed" :class="$style.cancel" class="_button" @click="cancel"><i class="ti ti-x"></i></button>
<button v-click-anime v-tooltip="i18n.ts.switchAccount" :class="$style.account" class="_button"
@click="openAccountMenu">
<button v-click-anime v-tooltip="i18n.ts.switchAccount" :class="$style.account" class="_button" @click="openAccountMenu">
<MkAvatar :user="postAccount ?? $i" :class="$style.avatar"/>
</button>
</div>
<div :class="$style.headerRight">
<template v-if="!(channel != null && fixed)">
<button v-if="channel == null" ref="visibilityButton" v-click-anime v-tooltip="i18n.ts.visibility"
:class="['_button', $style.headerRightItem, $style.visibility]" @click="setVisibility">
<button v-if="channel == null" ref="visibilityButton" v-click-anime v-tooltip="i18n.ts.visibility" :class="['_button', $style.headerRightItem, $style.visibility]" @click="setVisibility">
<span v-if="visibility === 'public'"><i class="ti ti-world"></i></span>
<span v-if="visibility === 'home'"><i class="ti ti-home"></i></span>
<span v-if="visibility === 'followers'"><i class="ti ti-lock"></i></span>
@ -34,39 +32,28 @@ SPDX-License-Identifier: AGPL-3.0-only
<span :class="$style.headerRightButtonText">{{ channel.name }}</span>
</button>
</template>
<button v-click-anime v-tooltip="i18n.ts._visibility.disableFederation" class="_button"
:class="[$style.headerRightItem, { [$style.danger]: localOnly }]"
:disabled="channel != null || visibility === 'specified'" @click="toggleLocalOnly">
<button v-click-anime v-tooltip="i18n.ts._visibility.disableFederation" class="_button" :class="[$style.headerRightItem, { [$style.danger]: localOnly }]" :disabled="channel != null || visibility === 'specified'" @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>
<button v-click-anime v-tooltip="i18n.ts.reactionAcceptance" class="_button"
:class="[$style.headerRightItem, { [$style.danger]: reactionAcceptance === 'likeOnly' }]"
@click="toggleReactionAcceptance">
<button v-click-anime v-tooltip="i18n.ts.reactionAcceptance" class="_button" :class="[$style.headerRightItem, { [$style.danger]: reactionAcceptance === 'likeOnly' }]" @click="toggleReactionAcceptance">
<span v-if="reactionAcceptance === 'likeOnly'"><i class="ti ti-heart"></i></span>
<span v-else-if="reactionAcceptance === 'likeOnlyForRemote'"><i class="ti ti-heart-plus"></i></span>
<span v-else><i class="ti ti-icons"></i></span>
</button>
<button v-click-anime class="_button" :class="$style.submit" :disabled="!canPost" data-cy-open-post-form-submit
@click="post">
<div
:class="[$style.submitInner , { [$style.gamingDark]: gaming === 'dark',[$style.gamingLight]: gaming === 'light' }]">
<button v-click-anime class="_button" :class="$style.submit" :disabled="!canPost" data-cy-open-post-form-submit @click="post">
<div :class="$style.submitInner">
<template v-if="posted"></template>
<template v-else-if="posting">
<MkEllipsis/>
</template>
<template v-else-if="posting"><MkEllipsis/></template>
<template v-else>{{ submitText }}</template>
<i style="margin-left: 6px;"
:class="posted ? 'ti ti-check' : reply ? 'ti ti-arrow-back-up' : renote ? 'ti ti-quote' : 'ti ti-send'"></i>
<i style="margin-left: 6px;" :class="posted ? 'ti ti-check' : reply ? 'ti ti-arrow-back-up' : renote ? 'ti ti-quote' : 'ti ti-send'"></i>
</div>
</button>
</div>
</header>
<MkNoteSimple v-if="reply" :class="$style.targetNote" :note="reply"/>
<MkNoteSimple v-if="renote" :class="$style.targetNote" :note="renote"/>
<div v-if="quoteId" :class="$style.withQuote"><i class="ti ti-quote"></i> {{ i18n.ts.quoteAttached }}
<button @click="quoteId = null"><i class="ti ti-x"></i></button>
</div>
<div v-if="quoteId" :class="$style.withQuote"><i class="ti ti-quote"></i> {{ i18n.ts.quoteAttached }}<button @click="quoteId = null"><i class="ti ti-x"></i></button></div>
<div v-if="visibility === 'specified'" :class="$style.toSpecified">
<span style="margin-right: 8px;">{{ i18n.ts.recipient }}</span>
<div :class="$style.visibleUsers">
@ -74,95 +61,69 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkAcct :user="u"/>
<button class="_button" style="padding: 4px 8px;" @click="removeVisibleUser(u)"><i class="ti ti-x"></i></button>
</span>
<button class="_buttonPrimary" style="padding: 4px; border-radius: 8px;" @click="addVisibleUser"><i
class="ti ti-plus ti-fw"></i></button>
<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>
<input v-show="useCw" ref="cwInputEl" v-model="cw" :class="$style.cw" :placeholder="i18n.ts.annotation"
@keydown="onKeydown">
<MkInfo v-if="hasNotSpecifiedMentions" warn :class="$style.hasNotSpecifiedMentions">{{ i18n.ts.notSpecifiedMentionWarning }} - <button class="_textButton" @click="addMissingMention()">{{ i18n.ts.add }}</button></MkInfo>
<input v-show="useCw" ref="cwInputEl" v-model="cw" :class="$style.cw" :placeholder="i18n.ts.annotation" @keydown="onKeydown">
<div :class="[$style.textOuter, { [$style.withCw]: useCw }]">
<textarea ref="textareaEl" v-model="text" :class="[$style.text]" :disabled="posting || posted"
:placeholder="placeholder" data-cy-post-form-text @keydown="onKeydown" @paste="onPaste"
@compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd"/>
<div v-if="maxTextLength - textLength < 100"
:class="['_acrylic', $style.textCount, { [$style.textOver]: textLength > maxTextLength }]">
{{ maxTextLength - textLength }}
<textarea ref="textareaEl" v-model="text" :class="[$style.text]" :disabled="posting || posted" :placeholder="placeholder" data-cy-post-form-text @keydown="onKeydown" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd"/>
<div v-if="maxTextLength - textLength < 100" :class="['_acrylic', $style.textCount, { [$style.textOver]: textLength > maxTextLength }]">{{ maxTextLength - textLength }}</div>
</div>
</div>
<input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" :class="$style.hashtags"
:placeholder="i18n.ts.hashtags" list="hashtags">
<XPostFormAttaches v-model="files" @detach="detachFile" @changeSensitive="updateFileSensitive"
@changeName="updateFileName" @replaceFile="replaceFile"/>
<input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags">
<XPostFormAttaches v-model="files" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName" @replaceFile="replaceFile"/>
<MkPollEditor v-if="poll" v-model="poll" @destroyed="poll = null"/>
<MkNotePreview v-if="showPreview" :class="$style.preview" :text="text"/>
<div v-if="showingOptions" style="padding: 8px 16px;">
</div>
<footer :class="$style.footer">
<div :class="$style.footerLeft">
<button v-tooltip="i18n.ts.attachFile" class="_button" :class="$style.footerButton" @click="chooseFileFrom"><i
class="ti ti-photo-plus"></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.mention" class="_button" :class="$style.footerButton" @click="insertMention"><i
class="ti ti-at"></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>
<button v-if="postFormActions.length" v-tooltip="i18n.ts.plugin" class="_button"
:class="$style.footerButton" @click="showActions"><i class="ti ti-plug"></i></button>
<button v-tooltip="i18n.ts.emoji" :class="['_button', $style.footerButton]" @click="insertEmoji"><i
class="ti ti-mood-happy"></i></button>
<button v-tooltip="i18n.ts.mfm" :class="['_button', $style.footerButton]" @click="insertMfm"><i
class="ti ti-wand"></i></button>
<button v-tooltip="i18n.ts.attachFile" class="_button" :class="$style.footerButton" @click="chooseFileFrom"><i class="ti ti-photo-plus"></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.mention" class="_button" :class="$style.footerButton" @click="insertMention"><i class="ti ti-at"></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>
<button v-if="postFormActions.length > 0" v-tooltip="i18n.ts.plugin" class="_button" :class="$style.footerButton" @click="showActions"><i class="ti ti-plug"></i></button>
<button v-tooltip="i18n.ts.emoji" :class="['_button', $style.footerButton]" @click="insertEmoji"><i class="ti ti-mood-happy"></i></button>
</div>
<div :class="$style.footerRight">
<button v-tooltip="i18n.ts.previewNoteText" class="_button"
:class="[$style.footerButton, { [$style.previewButtonActive]: showPreview }]"
@click="showPreview = !showPreview"><i class="ti ti-eye"></i></button>
<button v-tooltip="i18n.ts.previewNoteText" class="_button" :class="[$style.footerButton, { [$style.previewButtonActive]: showPreview }]" @click="showPreview = !showPreview"><i class="ti ti-eye"></i></button>
<!--<button v-tooltip="i18n.ts.more" class="_button" :class="$style.footerButton" @click="showingOptions = !showingOptions"><i class="ti ti-dots"></i></button>-->
</div>
</footer>
<datalist id="hashtags">
<option v-for="hashtag in recentHashtags" :key="hashtag" :value="hashtag"/>
</datalist>
</div>
</div>
</template>
<script lang="ts" setup>
import {inject, watch, nextTick, onMounted, defineAsyncComponent, computed, ref} from 'vue';
import { inject, watch, nextTick, onMounted, defineAsyncComponent } from 'vue';
import * as mfm from 'mfm-js';
import * as Misskey from 'misskey-js';
import insertTextAtCursor from 'insert-text-at-cursor';
import {toASCII} from 'punycode/';
import { toASCII } from 'punycode/';
import MkNoteSimple from '@/components/MkNoteSimple.vue';
import MkNotePreview from '@/components/MkNotePreview.vue';
import XPostFormAttaches from '@/components/MkPostFormAttaches.vue';
import MkPollEditor from '@/components/MkPollEditor.vue';
import {host, url} from '@/config.js';
import {erase, unique} from '@/scripts/array.js';
import {extractMentions} from '@/scripts/extract-mentions.js';
import {formatTimeString} from '@/scripts/format-time-string.js';
import {Autocomplete} from '@/scripts/autocomplete.js';
import { host, url } from '@/config.js';
import { erase, unique } from '@/scripts/array.js';
import { extractMentions } from '@/scripts/extract-mentions.js';
import { formatTimeString } from '@/scripts/format-time-string.js';
import { Autocomplete } from '@/scripts/autocomplete.js';
import * as os from '@/os.js';
import {selectFiles} from '@/scripts/select-file.js';
import {defaultStore, notePostInterruptors, postFormActions} from '@/store.js';
import { selectFiles } from '@/scripts/select-file.js';
import { defaultStore, notePostInterruptors, postFormActions } from '@/store.js';
import MkInfo from '@/components/MkInfo.vue';
import {i18n} from '@/i18n.js';
import {instance} from '@/instance.js';
import {$i, notesCount, incNotesCount, getAccounts, openAccountMenu as openAccountMenu_} from '@/account.js';
import {uploadFile} from '@/scripts/upload.js';
import {deepClone} from '@/scripts/clone.js';
import { i18n } from '@/i18n.js';
import { instance } from '@/instance.js';
import { $i, notesCount, incNotesCount, getAccounts, openAccountMenu as openAccountMenu_ } from '@/account.js';
import { uploadFile } from '@/scripts/upload.js';
import { deepClone } from '@/scripts/clone.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
import {miLocalStorage} from '@/local-storage.js';
import {claimAchievement} from '@/scripts/achievements.js';
import { miLocalStorage } from '@/local-storage.js';
import { claimAchievement } from '@/scripts/achievements.js';
const modal = inject('modal');
@ -182,14 +143,12 @@ const props = withDefaults(defineProps<{
fixed?: boolean;
autofocus?: boolean;
freezeAfterPosted?: boolean;
updateMode?: boolean;
}>(), {
initialVisibleUsers: () => [],
autofocus: true,
});
const gamingMode = computed(defaultStore.makeGetterSetter('gamingMode'));
const darkMode = computed(defaultStore.makeGetterSetter('darkMode'));
let gaming: any;
gaming = ref();
const emit = defineEmits<{
(ev: 'posted'): void;
(ev: 'cancel'): void;
@ -319,11 +278,11 @@ const hashtags = $computed(defaultStore.makeGetterSetter('postFormHashtags'));
watch($$(text), () => {
checkMissingMention();
}, {immediate: true});
}, { immediate: true });
watch($$(visibility), () => {
checkMissingMention();
}, {immediate: true});
}, { immediate: true });
watch($$(visibleUsers), () => {
checkMissingMention();
@ -386,7 +345,7 @@ if (props.reply && ['home', 'followers', 'specified'].includes(props.reply.visib
}
if (props.reply.userId !== $i.id) {
os.api('users/show', {userId: props.reply.userId}).then(user => {
os.api('users/show', { userId: props.reply.userId }).then(user => {
pushVisibleUser(user);
});
}
@ -409,7 +368,7 @@ function watchForDraft() {
watch($$(useCw), () => saveDraft());
watch($$(cw), () => saveDraft());
watch($$(poll), () => saveDraft());
watch($$(files), () => saveDraft(), {deep: true});
watch($$(files), () => saveDraft(), { deep: true });
watch($$(visibility), () => saveDraft());
watch($$(localOnly), () => saveDraft());
}
@ -433,7 +392,7 @@ function addMissingMention() {
for (const x of extractMentions(ast)) {
if (!visibleUsers.some(u => (u.username === x.username) && (u.host === x.host))) {
os.api('users/show', {username: x.username, host: x.host}).then(user => {
os.api('users/show', { username: x.username, host: x.host }).then(user => {
visibleUsers.push(user);
});
}
@ -561,11 +520,11 @@ async function toggleReactionAcceptance() {
const select = await os.select({
title: i18n.ts.reactionAcceptance,
items: [
{value: null, text: i18n.ts.all},
{value: 'likeOnlyForRemote' as const, text: i18n.ts.likeOnlyForRemote},
{value: 'nonSensitiveOnly' as const, text: i18n.ts.nonSensitiveOnly},
{value: 'nonSensitiveOnlyForLocalLikeOnlyForRemote' as const, text: i18n.ts.nonSensitiveOnlyForLocalLikeOnlyForRemote},
{value: 'likeOnly' as const, text: i18n.ts.likeOnly},
{ value: null, text: i18n.ts.all },
{ value: 'likeOnlyForRemote' as const, text: i18n.ts.likeOnlyForRemote },
{ value: 'nonSensitiveOnly' as const, text: i18n.ts.nonSensitiveOnly },
{ value: 'nonSensitiveOnlyForLocalLikeOnlyForRemote' as const, text: i18n.ts.nonSensitiveOnlyForLocalLikeOnlyForRemote },
{ value: 'likeOnly' as const, text: i18n.ts.likeOnly },
],
default: reactionAcceptance,
});
@ -614,7 +573,7 @@ function onCompositionEnd(ev: CompositionEvent) {
}
async function onPaste(ev: ClipboardEvent) {
for (const {item, i} of Array.from(ev.clipboardData.items, (item, i) => ({item, i}))) {
for (const { item, i } of Array.from(ev.clipboardData.items, (item, i) => ({ item, i }))) {
if (item.kind === 'file') {
const file = item.getAsFile();
const lio = file.name.lastIndexOf('.');
@ -632,7 +591,7 @@ async function onPaste(ev: ClipboardEvent) {
os.confirm({
type: 'info',
text: i18n.ts.quoteQuestion,
}).then(({canceled}) => {
}).then(({ canceled }) => {
if (canceled) {
insertTextAtCursor(textareaEl, paste);
return;
@ -732,7 +691,7 @@ async function post(ev?: MouseEvent) {
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.popup(MkRippleEffect, { x, y }, {}, 'end');
}
const annoying =
@ -743,7 +702,7 @@ async function post(ev?: MouseEvent) {
text.includes('$[position');
if (annoying && visibility === 'public') {
const {canceled, result} = await os.actions({
const { canceled, result } = await os.actions({
type: 'warning',
text: i18n.ts.thisPostMayBeAnnoying,
actions: [{
@ -767,17 +726,18 @@ async function post(ev?: MouseEvent) {
}
let postData = {
text: text === '' ? undefined : text,
text: text === '' ? null : text,
fileIds: files.length > 0 ? files.map(f => f.id) : undefined,
replyId: props.reply ? props.reply.id : undefined,
renoteId: props.renote ? props.renote.id : quoteId ? quoteId : undefined,
channelId: props.channel ? props.channel.id : undefined,
poll: poll,
cw: useCw ? cw ?? '' : undefined,
cw: useCw ? cw ?? '' : null,
localOnly: localOnly,
visibility: visibility,
visibleUserIds: visibility === 'specified' ? visibleUsers.map(u => u.id) : undefined,
reactionAcceptance,
noteId: props.updateMode ? props.initialNote?.id : undefined,
};
if (withHashtags && hashtags && hashtags.trim() !== '') {
@ -800,7 +760,7 @@ async function post(ev?: MouseEvent) {
}
posting = true;
os.api('notes/create', postData, token).then(() => {
os.api(props.updateMode ? 'notes/update' : 'notes/create', postData, token).then(() => {
if (props.freezeAfterPosted) {
posted = true;
} else {
@ -881,19 +841,17 @@ function insertMention() {
async function insertEmoji(ev: MouseEvent) {
os.openEmojiPicker(ev.currentTarget ?? ev.target, {}, textareaEl);
}
async function insertMfm(){
insertTextAtCursor(textareaEl, '$');
}
function showActions(ev) {
os.popupMenu(postFormActions.map(action => ({
text: action.title,
action: () => {
action.handler({
text: text,
cw: cw,
}, (key, value) => {
if (key === 'text') {
text = value;
}
if (key === 'text') { text = value; }
if (key === 'cw') { useCw = value !== null; cw = value; }
});
},
})), ev.currentTarget ?? ev.target);
@ -1067,22 +1025,6 @@ defineExpose({
box-sizing: border-box;
color: var(--fgOnAccent);
background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB));
&.gamingLight{
background: linear-gradient(270deg, #c06161, #c0a567, #b6ba69, #81bc72, #63c3be, #8bacd6, #9f8bd6, #d18bd6, #d883b4);
background-size: 1800% 1800%;
-webkit-animation: AnimationDark var(--gamingspeed) cubic-bezier(0, 0.25, 0.25, 1) infinite;
-moz-animation: AnimationDark var(--gamingspeed) cubic-bezier(0, 0.25, 0.25, 1) infinite;
animation: AnimationDark var(--gamingspeed) cubic-bezier(0, 0.25, 0.25, 1) infinite;
}
&.gamingDark{
color: white;
background: linear-gradient(270deg, #e7a2a2, #e3cfa2, #ebefa1, #b3e7a6, #a6ebe7, #aec5e3, #cabded, #e0b9e3, #f4bddd);
background-size: 1800% 1800% !important;
-webkit-animation: AnimationLight var(--gamingspeed) cubic-bezier(0, 0.25, 0.25, 1) infinite !important;
-moz-animation: AnimationLight var(--gamingspeed) cubic-bezier(0, 0.25, 0.25, 1) infinite !important;
animation: AnimationLight var(--gamingspeed) cubic-bezier(0, 0.25, 0.25, 1) infinite !important;
}
}
.headerRightItem {
@ -1118,7 +1060,6 @@ defineExpose({
}
}
}
//#endregion
.preview {
@ -1300,7 +1241,6 @@ defineExpose({
.preview {
padding: 16px 14px 0 14px;
}
.cw,
.hashtags,
.text {
@ -1332,71 +1272,5 @@ defineExpose({
.headerRight {
gap: 0;
}
@-webkit-keyframes AnimationLight {
0% {
background-position: 0% 50%
}
50% {
background-position: 100% 50%
}
100% {
background-position: 0% 50%
}
}
@-moz-keyframes AnimationLight {
0% {
background-position: 0% 50%
}
50% {
background-position: 100% 50%
}
100% {
background-position: 0% 50%
}
}
@keyframes AnimationLight {
0% {
background-position: 0% 50%
}
50% {
background-position: 100% 50%
}
100% {
background-position: 0% 50%
}
}
@-webkit-keyframes AnimationDark {
0% {
background-position: 0% 50%
}
50% {
background-position: 100% 50%
}
100% {
background-position: 0% 50%
}
}
@-moz-keyframes AnimationDark {
0% {
background-position: 0% 50%
}
50% {
background-position: 100% 50%
}
100% {
background-position: 0% 50%
}
}
@keyframes AnimationDark {
0% {
background-position: 0% 50%
}
50% {
background-position: 100% 50%
}
100% {
background-position: 0% 50%
}
}
}
</style>

View File

@ -30,6 +30,7 @@ const props = defineProps<{
instant?: boolean;
fixed?: boolean;
autofocus?: boolean;
updateMode?: boolean;
}>();
const emit = defineEmits<{

View File

@ -61,6 +61,7 @@ export const ROLE_POLICIES = [
'gtlAvailable',
'ltlAvailable',
'canPublicNote',
'canEditNote',
'canInvite',
'inviteLimit',
'inviteLimitCycle',

View File

@ -160,6 +160,26 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canEditNote, 'canEditNote'])">
<template #label>{{ i18n.ts._role._options.canEditNote }}</template>
<template #suffix>
<span v-if="role.policies.canEditNote.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ role.policies.canEditNote.value ? i18n.ts.yes : i18n.ts.no }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.canEditNote)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="role.policies.canEditNote.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
<MkSwitch v-model="role.policies.canEditNote.value" :disabled="role.policies.canEditNote.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
<MkRange v-model="role.policies.canEditNote.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.canInvite, 'canInvite'])">
<template #label>{{ i18n.ts._role._options.canInvite }}</template>
<template #suffix>

View File

@ -48,6 +48,14 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSwitch>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canEditNote, 'canEditNote'])">
<template #label>{{ i18n.ts._role._options.canEditNote }}</template>
<template #suffix>{{ policies.canEditNote ? i18n.ts.yes : i18n.ts.no }}</template>
<MkSwitch v-model="policies.canEditNote">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canInvite, 'canInvite'])">
<template #label>{{ i18n.ts._role._options.canInvite }}</template>
<template #suffix>{{ policies.canInvite ? i18n.ts.yes : i18n.ts.no }}</template>

View File

@ -172,6 +172,10 @@ export function getNoteMenu(props: {
});
}
function edit(): void {
os.post({ initialNote: appearNote, renote: appearNote.renote, reply: appearNote.reply, channel: appearNote.channel, updateMode: true });
}
function toggleFavorite(favorite: boolean): void {
claimAchievement('noteFavorited1');
os.apiWithDialog(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', {
@ -352,6 +356,11 @@ export function getNoteMenu(props: {
),
...(appearNote.userId === $i.id || $i.isModerator || $i.isAdmin ? [
null,
appearNote.userId === $i.id && $i.policies.canEditNote ? {
icon: 'ti ti-edit',
text: i18n.ts.edit,
action: edit,
} : undefined,
appearNote.userId === $i.id ? {
icon: 'ti ti-edit',
text: i18n.ts.deleteAndEdit,

View File

@ -71,6 +71,12 @@ export function useNoteCapture(props: {
break;
}
case 'updated': {
note.value.cw = body.cw;
note.value.text = body.text;
break;
}
case 'deleted': {
props.isDeletedRef.value = true;
break;

View File

@ -133,6 +133,13 @@ export type NoteUpdatedEvent = {
body: {
deletedAt: string;
};
} | {
id: Note['id'];
type: 'updated';
body: {
cw: string | null;
text: string;
};
} | {
id: Note['id'];
type: 'pollVoted';