FoundKey/packages/client/src/components/post-form.vue
Johann150 334368f6e2
fix: allow to pick higher visibility than chosen before
If you selected a lower visibility that one would then be used as
the parent visibility. Instead it is necessary to use two separate
variables, one for parent and one for the preselected visibility.
2023-01-04 21:39:33 +01:00

1003 lines
26 KiB
Vue

<template>
<div
v-size="{ max: [310, 500] }" class="gafaadew"
:class="{ modal, _popup: modal }"
@dragover.stop="onDragover"
@drop.stop="onDrop"
>
<header>
<button v-if="!fixed" class="cancel _button" @click="cancel"><i class="fas fa-times"></i></button>
<button v-tooltip="i18n.ts.switchAccount" class="account _button" @click="openAccountMenu">
<MkAvatar :user="postAccount ?? $i" class="avatar"/>
</button>
<div>
<span class="text-count" :class="{ over: textLength > maxTextLength }">{{ maxTextLength - textLength }}</span>
<span v-if="localOnly" class="local-only"><i class="fas fa-biohazard"></i></span>
<button ref="visibilityButton" v-tooltip="i18n.ts.visibility" class="_button visibility" :disabled="channel != null" @click="setVisibility">
<span v-if="visibility === 'public'"><i class="fas fa-globe"></i></span>
<span v-else-if="visibility === 'home'"><i class="fas fa-home"></i></span>
<span v-else-if="visibility === 'followers'"><i class="fas fa-unlock"></i></span>
<span v-else-if="visibility === 'specified'"><i class="fas fa-envelope"></i></span>
</button>
<button v-tooltip="i18n.ts.previewNoteText" class="_button preview" :class="{ active: showPreview }" @click="showPreview = !showPreview"><i class="fas fa-file-code"></i></button>
<button class="submit _buttonGradate" :disabled="!canPost" data-cy-open-post-form-submit @click="post">{{ submitText }}<i :class="reply ? 'fas fa-reply' : renote ? 'fas fa-quote-right' : 'fas fa-paper-plane'"></i></button>
</div>
</header>
<div class="form" :class="{ fixed }">
<XNoteSimple v-if="reply" class="preview" :note="reply"/>
<XNoteSimple v-if="renote" class="preview" :note="renote"/>
<div v-if="quoteId" class="with-quote"><i class="fas fa-quote-left"></i> {{ i18n.ts.quoteAttached }}<button @click="quoteId = null"><i class="fas fa-times"></i></button></div>
<div v-if="visibility === 'specified'" class="to-specified">
<span style="margin-right: 8px;">{{ i18n.ts.recipient }}</span>
<div class="visibleUsers">
<span v-for="u in visibleUsers" :key="u.id">
<MkAcct :user="u"/>
<button class="_button" @click="removeVisibleUser(u)"><i class="fas fa-times"></i></button>
</span>
<button class="_buttonPrimary" @click="addVisibleUser"><i class="fas fa-plus fa-fw"></i></button>
</div>
</div>
<MkInfo v-if="hasNotSpecifiedMentions" warn class="hasNotSpecifiedMentions">{{ i18n.ts.notSpecifiedMentionWarning }} - <button class="_textButton" @click="addMissingMention()">{{ i18n.ts.add }}</button></MkInfo>
<input v-show="useCw" ref="cwInputEl" v-model="cw" class="cw" :placeholder="i18n.ts.annotation" @keydown="onKeydown">
<textarea ref="textareaEl" v-model="text" class="text" :class="{ withCw: useCw }" :disabled="posting" :placeholder="placeholder" data-cy-post-form-text @keydown="onKeydown" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd"/>
<input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" class="hashtags" :placeholder="i18n.ts.hashtags" list="hashtags">
<XPostFormAttaches class="attaches" :files="files" @updated="updateFiles" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName"/>
<XPollEditor v-if="poll" v-model="poll" @destroyed="poll = null"/>
<XNotePreview v-if="showPreview" class="preview" :text="text"/>
<footer>
<button v-tooltip="i18n.ts.attachFile" class="_button" @click="chooseFileFrom"><i class="fas fa-photo-video"></i></button>
<button v-tooltip="i18n.ts.poll" class="_button" :class="{ active: poll }" @click="togglePoll"><i class="fas fa-poll-h"></i></button>
<button v-tooltip="i18n.ts.useCw" class="_button" :class="{ active: useCw }" @click="useCw = !useCw"><i class="fas fa-eye-slash"></i></button>
<button v-tooltip="i18n.ts.mention" class="_button" @click="insertMention"><i class="fas fa-at"></i></button>
<button v-tooltip="i18n.ts.hashtags" class="_button" :class="{ active: withHashtags }" @click="withHashtags = !withHashtags"><i class="fas fa-hashtag"></i></button>
<button v-tooltip="i18n.ts.emoji" class="_button" @click="insertEmoji"><i class="fas fa-laugh-squint"></i></button>
<button v-if="postFormActions.length > 0" v-tooltip="i18n.ts.plugin" class="_button" @click="showActions"><i class="fas fa-plug"></i></button>
</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 } from 'vue';
import * as mfm from 'mfm-js';
import * as foundkey from 'foundkey-js';
import insertTextAtCursor from 'insert-text-at-cursor';
import { length } from 'stringz';
import { toASCII } from 'punycode/';
import * as Acct from 'foundkey-js/built/acct';
import { throttle } from 'throttle-debounce';
import XNoteSimple from './note-simple.vue';
import XNotePreview from './note-preview.vue';
import XPostFormAttaches from './post-form-attaches.vue';
import XPollEditor from './poll-editor.vue';
import { host, url } from '@/config';
import { erase, unique } from '@/scripts/array';
import { extractMentions } from '@/scripts/extract-mentions';
import { formatTimeString } from '@/scripts/format-time-string';
import { Autocomplete } from '@/scripts/autocomplete';
import * as os from '@/os';
import { stream } from '@/stream';
import { selectFiles } from '@/scripts/select-file';
import { defaultStore, notePostInterruptors, postFormActions } from '@/store';
import MkInfo from '@/components/ui/info.vue';
import { i18n } from '@/i18n';
import { instance } from '@/instance';
import { $i, getAccounts, openAccountMenu as openAccountMenu_ } from '@/account';
import { uploadFile } from '@/scripts/upload';
const modal = inject('modal');
const props = withDefaults(defineProps<{
reply?: foundkey.entities.Note;
renote?: foundkey.entities.Note;
channel?: any; // TODO
mention?: foundkey.entities.User;
specified?: foundkey.entities.User;
initialText?: string;
initialVisibility?: foundkey.NoteVisibility;
initialFiles?: foundkey.entities.DriveFile[];
initialLocalOnly?: boolean;
initialVisibleUsers?: foundkey.entities.User[];
initialNote?: foundkey.entities.Note;
instant?: boolean;
fixed?: boolean;
autofocus?: boolean;
}>(), {
initialVisibleUsers: () => [],
autofocus: true,
});
const emit = defineEmits<{
(ev: 'posted'): void;
(ev: 'cancel'): void;
(ev: 'esc'): void;
}>();
const textareaEl = $ref<HTMLTextAreaElement | null>(null);
const cwInputEl = $ref<HTMLInputElement | null>(null);
const hashtagsInputEl = $ref<HTMLInputElement | null>(null);
const visibilityButton = $ref<HTMLElement | null>(null);
let posting: boolean = $ref(false);
let text: string = $ref(props.initialText ?? '');
let files: foundkey.entities.DriveFile[] = $ref(props.initialFiles ?? []);
let poll = $ref<{
choices: string[];
multiple: boolean;
expiresAt: string | null;
expiredAfter: string | null;
} | null>(null);
let useCw = $ref(false);
let showPreview = $ref(false);
let cw = $ref<string | null>(null);
// these define the "maximum" these parameters can be set to and will be tightened further down
let parentLocalOnly = false;
let parentVisibility = 'public';
let localOnly = $ref<boolean>(props.initialLocalOnly ?? defaultStore.state.defaultNoteLocalOnly);
let visibility = $ref(props.initialVisibility ?? defaultStore.state.defaultNoteVisibility as foundkey.NoteVisibility);
let visibleUsers = $ref([]);
if (props.initialVisibleUsers) {
props.initialVisibleUsers.forEach(pushVisibleUser);
}
let quoteId = $ref(null);
let hasNotSpecifiedMentions = $ref(false);
let recentHashtags = $ref(JSON.parse(localStorage.getItem('hashtags') || '[]'));
let imeText = $ref('');
const typing = throttle(3000, () => {
if (props.channel) {
stream.send('typingOnChannel', { channel: props.channel.id });
}
});
const draftKey = $computed((): string => {
let key = props.channel ? `channel:${props.channel.id}` : '';
if (props.renote) {
key += `renote:${props.renote.id}`;
} else if (props.reply) {
key += `reply:${props.reply.id}`;
} else {
key += 'note';
}
return key;
});
const placeholder = $computed((): string => {
if (props.renote) {
return i18n.ts._postForm.quotePlaceholder;
} else if (props.reply) {
return i18n.ts._postForm.replyPlaceholder;
} else if (props.channel) {
return i18n.ts._postForm.channelPlaceholder;
} else {
const xs = [
i18n.ts._postForm._placeholders.a,
i18n.ts._postForm._placeholders.b,
i18n.ts._postForm._placeholders.c,
i18n.ts._postForm._placeholders.d,
i18n.ts._postForm._placeholders.e,
i18n.ts._postForm._placeholders.f,
];
return xs[Math.floor(Math.random() * xs.length)];
}
});
const submitText = $computed((): string => {
return props.renote
? i18n.ts.quote
: props.reply
? i18n.ts.reply
: i18n.ts.note;
});
const textLength = $computed((): number => {
return length((text + imeText).trim());
});
const maxTextLength = $computed((): number => {
return instance ? instance.maxNoteTextLength : 1000;
});
const canPost = $computed((): boolean => {
return !posting &&
(1 <= textLength || 1 <= files.length || !!poll || !!props.renote) &&
(textLength <= maxTextLength) &&
(!poll || poll.choices.length >= 2);
});
const withHashtags = $computed(defaultStore.makeGetterSetter('postFormWithHashtags'));
const hashtags = $computed(defaultStore.makeGetterSetter('postFormHashtags'));
watch($$(text), () => {
checkMissingMention();
});
watch($$(visibleUsers), () => {
checkMissingMention();
}, {
deep: true,
});
if (props.mention) {
text = props.mention.host ? `@${props.mention.username}@${toASCII(props.mention.host)}` : `@${props.mention.username}`;
text += ' ';
}
if (props.reply && (props.reply.user.username !== $i.username || (props.reply.user.host != null && props.reply.user.host !== host))) {
text = `@${props.reply.user.username}${props.reply.user.host != null ? '@' + toASCII(props.reply.user.host) : ''} `;
}
if (props.reply && props.reply.text != null) {
const ast = mfm.parse(props.reply.text);
const otherHost = props.reply.user.host;
for (const x of extractMentions(ast)) {
const mention = x.host ?
`@${x.username}@${toASCII(x.host)}` :
(otherHost == null || otherHost === host) ?
`@${x.username}` :
`@${x.username}@${toASCII(otherHost)}`;
// 自分は除外
if ($i.username === x.username && (x.host == null || x.host === host)) continue;
// 重複は除外
if (text.includes(`${mention} `)) continue;
text += `${mention} `;
}
}
if (props.channel) {
parentLocalOnly = true; // TODO: remove when channels are federated
}
if (props.reply) {
parentVisibility = foundkey.minVisibility(props.reply.visibility, parentVisibility);
if (props.reply.visibility === 'specified') {
os.api('users/show', {
userIds: props.reply.visibleUserIds.filter(uid => uid !== $i.id && uid !== props.reply.userId),
}).then(users => {
users.forEach(pushVisibleUser);
});
if (props.reply.userId !== $i.id) {
os.api('users/show', { userId: props.reply.userId }).then(user => {
pushVisibleUser(user);
});
}
}
parentLocalOnly ||= props.reply.localOnly;
}
if (props.renote) {
parentVisibility = foundkey.minVisibility(props.renote.visibility, parentVisibility);
if (props.renote.visibility === 'specified') {
os.api('users/show', {
userIds: props.renote.visibleUserIds.filter(uid => uid !== $i.id && uid !== props.renote.userId),
}).then(users => {
users.forEach(pushVisibleUser);
});
if (props.renote.userId !== $i.id) {
os.api('users/show', { userId: props.renote.userId }).then(user => {
pushVisibleUser(user);
});
}
}
parentLocalOnly ||= props.renote.localOnly;
}
if (props.specified) {
parentVisibility = 'specified';
pushVisibleUser(props.specified);
}
// set visibility and local only defaults to minimum of preselected or allowed.
visibility = foundkey.minVisibility(visibility, parentVisibility);
localOnly ||= parentLocalOnly;
// keep cw when reply
if (defaultStore.state.keepCw && props.reply && props.reply.cw) {
useCw = true;
cw = props.reply.cw;
}
function watchForDraft() {
watch($$(text), () => saveDraft());
watch($$(useCw), () => saveDraft());
watch($$(cw), () => saveDraft());
watch($$(poll), () => saveDraft());
watch($$(files), () => saveDraft(), { deep: true });
watch($$(visibility), () => saveDraft());
watch($$(localOnly), () => saveDraft());
}
function checkMissingMention() {
if (visibility === 'specified') {
const ast = mfm.parse(text);
for (const x of extractMentions(ast)) {
if (!visibleUsers.some(u => (u.username === x.username) && (u.host === x.host))) {
hasNotSpecifiedMentions = true;
return;
}
}
hasNotSpecifiedMentions = false;
}
}
function addMissingMention() {
const ast = mfm.parse(text);
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 => {
visibleUsers.push(user);
});
}
}
}
function togglePoll() {
if (poll) {
poll = null;
} else {
poll = {
choices: ['', ''],
multiple: false,
expiresAt: null,
expiredAfter: null,
};
}
}
function focus() {
if (textareaEl) {
textareaEl.focus();
textareaEl.setSelectionRange(textareaEl.value.length, textareaEl.value.length);
}
}
function chooseFileFrom(ev) {
selectFiles(ev.currentTarget ?? ev.target, i18n.ts.attachFile).then(files_ => {
for (const file of files_) {
files.push(file);
}
});
}
function detachFile(id) {
files = files.filter(x => x.id !== id);
}
function updateFiles(_files) {
files = _files;
}
function updateFileSensitive(file, sensitive) {
files[files.findIndex(x => x.id === file.id)].isSensitive = sensitive;
}
function updateFileName(file, name) {
files[files.findIndex(x => x.id === file.id)].name = name;
}
function upload(file: File, name?: string) {
uploadFile(file, defaultStore.state.uploadFolder, name).then(res => {
files.push(res);
});
}
function setVisibility() {
if (props.channel) {
// TODO: information dialog
return;
}
os.popup(defineAsyncComponent(() => import('./visibility-picker.vue')), {
parentVisibility,
parentLocalOnly,
currentVisibility: visibility,
currentLocalOnly: localOnly,
src: visibilityButton,
}, {
changeVisibility: v => {
visibility = v;
},
changeLocalOnly: v => {
localOnly = v;
},
}, 'closed');
}
function pushVisibleUser(user) {
if (!visibleUsers.some(u => u.username === user.username && u.host === user.host)) {
visibleUsers.push(user);
}
}
function addVisibleUser() {
os.selectUser().then(user => {
pushVisibleUser(user);
});
}
function removeVisibleUser(user) {
visibleUsers = erase(user, visibleUsers);
}
function clear() {
text = '';
files = [];
poll = null;
quoteId = null;
}
function onKeydown(ev: KeyboardEvent) {
if ((ev.which === 10 || ev.which === 13) && (ev.ctrlKey || ev.metaKey) && canPost) post();
if (ev.which === 27) emit('esc');
typing();
}
function onCompositionUpdate(ev: CompositionEvent) {
imeText = ev.data;
typing();
}
function onCompositionEnd() {
imeText = '';
}
async function onPaste(ev: ClipboardEvent) {
for (const { item, i } of Array.from(ev.clipboardData.items).map((item, i) => ({ item, i }))) {
if (item.kind === 'file') {
const file = item.getAsFile();
const lio = file.name.lastIndexOf('.');
const ext = lio >= 0 ? file.name.slice(lio) : '';
const formatted = `${formatTimeString(new Date(file.lastModified), defaultStore.state.pastedFileName).replace(/{{number}}/g, `${i + 1}`)}${ext}`;
upload(file, formatted);
}
}
const paste = ev.clipboardData.getData('text');
if (!props.renote && !quoteId && paste.startsWith(url + '/notes/')) {
ev.preventDefault();
os.confirm({
type: 'info',
text: i18n.ts.quoteQuestion,
}).then(({ canceled }) => {
if (canceled) {
insertTextAtCursor(textareaEl, paste);
return;
}
quoteId = paste.substr(url.length).match(/^\/notes\/(.+?)\/?$/)[1];
});
}
}
function onDragover(ev) {
if (!ev.dataTransfer.items[0]) return;
const isFile = ev.dataTransfer.items[0].kind === 'file';
const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_;
if (isFile || isDriveFile) {
ev.preventDefault();
ev.dataTransfer.dropEffect = ev.dataTransfer.effectAllowed === 'all' ? 'copy' : 'move';
}
}
function onDrop(ev): void {
// ファイルだったら
if (ev.dataTransfer.files.length > 0) {
ev.preventDefault();
for (const x of Array.from(ev.dataTransfer.files)) upload(x);
return;
}
//#region ドライブのファイル
const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
if (driveFile != null && driveFile !== '') {
const file = JSON.parse(driveFile);
files.push(file);
ev.preventDefault();
}
//#endregion
}
// Save a copy of the initial data to detect whether it has been changed.
// Cloning the data to make sure there are no remaining references.
const initialDraftData = JSON.parse(JSON.stringify({
text,
useCw,
cw,
visibility,
localOnly,
files,
}));
function saveDraft() {
const draftData = JSON.parse(localStorage.getItem('drafts') || '{}');
if (
initialDraftData.text === text
&& initialDraftData.useCw === useCw
// don't compare cw for equality if it is disabled, since it won't be sent
&& (!useCw || initialDraftData.cw === cw)
&& initialDraftData.visibility === visibility
&& initialDraftData.localOnly === localOnly
&& initialDraftData.files.every((file, i) => file.id === files[i].id)
// initial state is always poll == null
&& poll == null
) {
// This is the same as the initial draft data, no need to save it.
// If it was saved before, delete it.
delete draftData[draftKey];
} else {
draftData[draftKey] = {
updatedAt: new Date(),
data: {
text,
useCw,
cw,
visibility,
localOnly,
files,
poll,
},
};
}
localStorage.setItem('drafts', JSON.stringify(draftData));
}
function deleteDraft() {
const draftData = JSON.parse(localStorage.getItem('drafts') || '{}');
delete draftData[draftKey];
localStorage.setItem('drafts', JSON.stringify(draftData));
}
async function post() {
let postData = {
text: text === '' ? undefined : 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,
cw: useCw ? cw || '' : undefined,
localOnly,
visibility,
visibleUserIds: visibility === 'specified' ? visibleUsers.map(u => u.id) : undefined,
};
if (withHashtags && hashtags && hashtags.trim() !== '') {
const hashtags_ = hashtags.trim().split(' ').map(x => x.startsWith('#') ? x : '#' + x).join(' ');
postData.text = postData.text ? `${postData.text} ${hashtags_}` : hashtags_;
}
// plugin
if (notePostInterruptors.length > 0) {
for (const interruptor of notePostInterruptors) {
postData = await interruptor.handler(JSON.parse(JSON.stringify(postData)));
}
}
let token = undefined;
if (postAccount) {
const storedAccounts = await getAccounts();
token = storedAccounts.find(x => x.id === postAccount.id)?.token;
}
posting = true;
os.api('notes/create', postData, token).then(() => {
clear();
nextTick(() => {
deleteDraft();
emit('posted');
if (postData.text && postData.text !== '') {
const hashtags_ = mfm.parse(postData.text).filter(x => x.type === 'hashtag').map(x => x.props.hashtag);
const history = JSON.parse(localStorage.getItem('hashtags') || '[]') as string[];
localStorage.setItem('hashtags', JSON.stringify(unique(hashtags_.concat(history))));
}
posting = false;
postAccount = null;
});
}).catch(err => {
posting = false;
os.alert({
type: 'error',
text: err.message + '\n' + (err as any).id,
});
});
}
function cancel() {
emit('cancel');
}
function insertMention() {
os.selectUser().then(user => {
insertTextAtCursor(textareaEl, '@' + Acct.toString(user) + ' ');
});
}
async function insertEmoji(ev: MouseEvent) {
os.openEmojiPicker(ev.currentTarget ?? ev.target, {}, textareaEl);
}
function showActions(ev) {
os.popupMenu(postFormActions.map(action => ({
text: action.title,
action: () => {
action.handler({
text,
}, (key, value) => {
if (key === 'text') { text = value; }
});
},
})), ev.currentTarget ?? ev.target);
}
let postAccount = $ref<foundkey.entities.UserDetailed | null>(null);
function openAccountMenu(ev: MouseEvent) {
openAccountMenu_({
withExtraOperation: false,
includeCurrentAccount: true,
active: postAccount != null ? postAccount.id : $i.id,
onChoose: (account) => {
if (account.id === $i.id) {
postAccount = null;
} else {
postAccount = account;
}
},
}, ev);
}
onMounted(() => {
if (props.autofocus) {
focus();
nextTick(() => {
focus();
});
}
// TODO: detach when unmount
new Autocomplete(textareaEl, $$(text));
new Autocomplete(cwInputEl, $$(cw));
new Autocomplete(hashtagsInputEl, $$(hashtags));
nextTick(() => {
// 書きかけの投稿を復元
if (!props.instant && !props.mention && !props.specified) {
const draft = JSON.parse(localStorage.getItem('drafts') || '{}')[draftKey];
if (draft) {
text = draft.data.text;
useCw = draft.data.useCw;
cw = draft.data.cw;
visibility = draft.data.visibility;
localOnly = draft.data.localOnly;
files = (draft.data.files || []).filter(draftFile => draftFile);
if (draft.data.poll) {
poll = draft.data.poll;
}
}
}
// 削除して編集
if (props.initialNote) {
const init = props.initialNote;
text = init.text ? init.text : '';
files = init.files;
cw = init.cw;
useCw = init.cw != null;
if (init.poll) {
poll = {
choices: init.poll.choices.map(x => x.text),
multiple: init.poll.multiple,
expiresAt: init.poll.expiresAt,
expiredAfter: init.poll.expiredAfter,
};
}
visibility = init.visibility;
localOnly = init.localOnly;
quoteId = init.renote ? init.renote.id : null;
}
nextTick(() => watchForDraft());
});
});
</script>
<style lang="scss" scoped>
.gafaadew {
position: relative;
&.modal {
width: 100%;
max-width: 520px;
}
> header {
z-index: 1000;
height: 66px;
> .cancel {
padding: 0;
font-size: 20px;
width: 64px;
line-height: 66px;
}
> .account {
height: 100%;
aspect-ratio: 1/1;
display: inline-flex;
vertical-align: bottom;
> .avatar {
width: 28px;
height: 28px;
margin: auto;
}
}
> div {
position: absolute;
top: 0;
right: 0;
> .text-count {
opacity: 0.7;
line-height: 66px;
}
> .visibility {
height: 34px;
width: 34px;
margin: 0 0 0 8px;
& + .localOnly {
margin-left: 0 !important;
}
}
> .local-only {
margin: 0 0 0 12px;
opacity: 0.7;
}
> .preview {
display: inline-block;
padding: 0;
margin: 0 8px 0 0;
font-size: 16px;
width: 34px;
height: 34px;
border-radius: 6px;
&:hover {
background: var(--X5);
}
&.active {
color: var(--accent);
}
}
> .submit {
margin: 16px 16px 16px 0;
padding: 0 12px;
line-height: 34px;
font-weight: bold;
vertical-align: bottom;
border-radius: 4px;
font-size: 0.9em;
&:disabled {
opacity: 0.7;
}
> i {
margin-left: 6px;
}
}
}
}
> .form {
> .preview {
padding: 16px;
}
> .with-quote {
margin: 0 0 8px 0;
color: var(--accent);
> button {
padding: 4px 8px;
color: var(--accentAlpha04);
&:hover {
color: var(--accentAlpha06);
}
&:active {
color: var(--accentDarken30);
}
}
}
> .to-specified {
padding: 6px 24px;
margin-bottom: 8px;
overflow: auto;
white-space: nowrap;
> .visibleUsers {
display: inline;
top: -1px;
font-size: 14px;
> button {
padding: 4px;
border-radius: 8px;
}
> span {
margin-right: 14px;
padding: 8px 0 8px 8px;
border-radius: 8px;
background: var(--X4);
> button {
padding: 4px 8px;
}
}
}
}
> .hasNotSpecifiedMentions {
margin: 0 20px 16px 20px;
}
> .cw,
> .hashtags,
> .text {
display: block;
box-sizing: border-box;
padding: 0 24px;
margin: 0;
width: 100%;
font-size: 16px;
border: none;
border-radius: 0;
background: transparent;
color: var(--fg);
font-family: inherit;
&:focus {
outline: none;
}
&:disabled {
opacity: 0.5;
}
}
> .cw {
z-index: 1;
padding-bottom: 8px;
border-bottom: solid 0.5px var(--divider);
}
> .hashtags {
z-index: 1;
padding-top: 8px;
padding-bottom: 8px;
border-top: solid 0.5px var(--divider);
}
> .text {
max-width: 100%;
min-width: 100%;
min-height: 90px;
&.withCw {
padding-top: 8px;
}
}
> footer {
padding: 0 16px 16px 16px;
> button {
display: inline-block;
padding: 0;
margin: 0;
font-size: 16px;
width: 48px;
height: 48px;
border-radius: 6px;
&:hover {
background: var(--X5);
}
&.active {
color: var(--accent);
}
}
}
}
&.max-width_500px {
> header {
height: 50px;
> .cancel {
width: 50px;
line-height: 50px;
}
> div {
> .text-count {
line-height: 50px;
}
> .submit {
margin: 8px;
}
}
}
> .form {
> .to-specified {
padding: 6px 16px;
}
> .cw,
> .hashtags,
> .text {
padding: 0 16px;
}
> .text {
min-height: 80px;
}
> footer {
padding: 0 8px 8px 8px;
}
}
}
&.max-width_310px {
> .form {
> footer {
> button {
font-size: 14px;
width: 44px;
height: 44px;
}
}
}
}
}
</style>