Merge pull request #4016 from h3poteto/iss-3771

refs #3771 Add compose window to footer
This commit is contained in:
AkiraFukushima 2023-01-22 14:56:14 +09:00 committed by GitHub
commit 29100da80a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 584 additions and 51 deletions

View File

@ -294,7 +294,7 @@
"add_image": "Add images",
"poll": "Add a poll",
"change_visibility": "Change visibility",
"change_sensitive": "Change sensitive",
"change_sensitive": "Mark media as sensitive",
"add_cw": "Add content warning",
"pined_hashtag": "Pin the hashtag"
},

View File

@ -11,7 +11,13 @@
<header class="header" style="-webkit-app-region: drag">
<header-menu></header-menu>
</header>
<contents></contents>
<div class="contents-wrapper" ref="contentsRef">
<contents />
</div>
<div class="compose-wrapper">
<compose />
<resize-observer @notify="composeResized" />
</div>
</div>
<modals></modals>
<receive-drop v-show="droppableVisible"></receive-drop>
@ -26,6 +32,7 @@ import { useI18next } from 'vue3-i18next'
import SideMenu from './TimelineSpace/SideMenu.vue'
import HeaderMenu from './TimelineSpace/HeaderMenu.vue'
import Contents from './TimelineSpace/Contents.vue'
import Compose from './TimelineSpace/Compose.vue'
import Modals from './TimelineSpace/Modals.vue'
import Mousetrap from 'mousetrap'
import ReceiveDrop from './TimelineSpace/ReceiveDrop.vue'
@ -42,7 +49,7 @@ import { ACTION_TYPES as NEW_TOOT_ACTION } from '@/store/TimelineSpace/Modals/Ne
export default defineComponent({
name: 'timeline-space',
components: { SideMenu, HeaderMenu, Modals, Contents, ReceiveDrop },
components: { SideMenu, HeaderMenu, Modals, Contents, ReceiveDrop, Compose },
setup() {
const space = 'TimelineSpace'
const store = useStore()
@ -51,6 +58,7 @@ export default defineComponent({
const dropTarget = ref<any>(null)
const droppableVisible = ref<boolean>(false)
const contentsRef = ref<HTMLElement | null>(null)
const loading = computed(() => store.state.TimelineSpace.loading)
const collapse = computed(() => store.state.TimelineSpace.SideMenu.collapse)
@ -155,10 +163,18 @@ export default defineComponent({
e.preventDefault()
}
const composeResized = (event: { width: number; height: number }) => {
if (contentsRef.value) {
contentsRef.value.style.setProperty('height', `calc(100% - ${event.height}px)`)
}
}
return {
loading,
collapse,
droppableVisible
droppableVisible,
composeResized,
contentsRef
}
}
})
@ -169,6 +185,12 @@ export default defineComponent({
height: 100%;
}
.compose-wrapper {
position: sticky;
bottom: 0;
padding: 0 12px 18px 12px;
}
.page {
margin-left: 180px;
height: 100%;

View File

@ -0,0 +1,512 @@
<template>
<div class="compose">
<el-form :model="form" class="compose-form">
<el-input v-model="form.spoiler" class="spoiler" :placeholder="$t('modals.new_toot.cw')" v-if="cw" />
<el-input
v-model="form.status"
type="textarea"
:autosize="{ minRows: 2 }"
:placeholder="$t('modals.new_toot.status')"
ref="statusRef"
/>
<div class="preview" ref="previewRef">
<div class="image-wrapper" v-for="media in attachments" :key="media.id">
<img :src="media.preview_url" class="preview-image" />
<el-button class="remove-image" link @click="removeAttachment(media)"><font-awesome-icon icon="circle-xmark" /></el-button>
</div>
</div>
<div class="nsfw" v-if="attachments.length > 0">
<el-checkbox v-model="nsfw">{{ $t('modals.new_toot.footer.change_sensitive') }}</el-checkbox>
</div>
<div class="poll" v-if="poll.options.length > 0">
<ul class="options-list">
<li class="option" v-for="(option, index) in poll.options" :key="index">
<el-radio :disabled="true" :label="index">
<el-input :placeholder="`Choice ${index}`" v-model="poll.options[index]" size="small"></el-input>
<el-button class="remove-poll" link size="small" @click="removePollOption(index)"
><font-awesome-icon icon="xmark"
/></el-button>
</el-radio>
</li>
</ul>
<el-button class="add-poll" type="info" size="small" @click="addPollOption">{{ $t('modals.new_toot.poll.add_choice') }}</el-button>
<el-select v-model="poll.expires_in" size="small" value-key="value">
<el-option v-for="exp in expiresList" :key="exp.value" :label="exp.label" :value="exp.value"> </el-option>
</el-select>
</div>
<div class="form-footer">
<el-button-group class="tool-buttons">
<el-button link size="default" @click="selectImage">
<font-awesome-icon icon="camera" />
</el-button>
<input name="image" type="file" class="image-input" ref="imageRef" @change="onChangeImage" />
<el-button link size="default" @click="togglePoll">
<font-awesome-icon icon="square-poll-horizontal" />
</el-button>
<div>
<el-dropdown trigger="click" @command="changeVisibility">
<el-button size="default" link :title="$t('modals.new_toot.footer.change_visibility')">
<font-awesome-icon :icon="visibilityIcon" />
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item :command="visibilityList.Public.key">
<font-awesome-icon icon="globe" class="privacy-icon" />
{{ $t(visibilityList.Public.name) }}
</el-dropdown-item>
<el-dropdown-item :command="visibilityList.Unlisted.key">
<font-awesome-icon icon="unlock" class="privacy-icon" />
{{ $t(visibilityList.Unlisted.name) }}
</el-dropdown-item>
<el-dropdown-item :command="visibilityList.Private.key">
<font-awesome-icon icon="lock" class="privacy-icon" />
{{ $t(visibilityList.Private.name) }}
</el-dropdown-item>
<el-dropdown-item :command="visibilityList.Direct.key">
<font-awesome-icon icon="envelope" class="privacy-icon" size="sm" />
{{ $t(visibilityList.Direct.name) }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<el-popover placement="top" width="0" :visible="emojiVisible" popper-class="new-toot-emoji-picker">
<picker
v-if="emojiData !== null"
:data="emojiData"
set="twitter"
:autoFocus="true"
@select="selectEmoji"
:perLine="7"
:emojiSize="24"
:showPreview="false"
:emojiTooltip="true"
/>
<template #reference>
<el-button link size="default" @click="emojiVisible = !emojiVisible">
<font-awesome-icon :icon="['far', 'face-smile']" />
</el-button>
</template>
</el-popover>
<el-button link size="default" @click="cw = !cw"> CW </el-button>
</el-button-group>
<div class="actions-group">
<span>500</span>
<el-button type="primary" @click="post" :loading="loading"> {{ $t('modals.new_toot.toot') }} </el-button>
</div>
</div>
</el-form>
</div>
</template>
<script lang="ts">
import { defineComponent, reactive, computed, ref, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import generator, { Entity, MegalodonInterface } from 'megalodon'
import emojiDefault from 'emoji-mart-vue-fast/data/all.json'
import { Picker, EmojiIndex } from 'emoji-mart-vue-fast/src'
import { useI18next } from 'vue3-i18next'
import { useStore } from '@/store'
import { MyWindow } from '~/src/types/global'
import { LocalAccount } from '~/src/types/localAccount'
import { LocalServer } from '~/src/types/localServer'
import visibilityList from '~/src/constants/visibility'
import { MUTATION_TYPES } from '@/store/TimelineSpace/Compose'
type Expire = {
label: string
value: number
}
export default defineComponent({
name: 'Compose',
components: { Picker },
setup() {
const route = useRoute()
const store = useStore()
const i18n = useI18next()
const space = 'TimelineSpace/Compose'
const win = (window as any) as MyWindow
const id = computed(() => parseInt(route.params.id as string))
const userAgent = computed(() => store.state.App.userAgent)
const visibilityIcon = computed(() => {
switch (visibility.value) {
case visibilityList.Public.key:
return 'globe'
case visibilityList.Unlisted.key:
return 'unlock'
case visibilityList.Private.key:
return 'lock'
case visibilityList.Direct.key:
return 'envelope'
default:
return 'globe'
}
})
const expiresList = reactive<Array<Expire>>([
{
label: i18n.t('modals.new_toot.poll.expires.5_minutes'),
value: 60 * 5
},
{
label: i18n.t('modals.new_toot.poll.expires.30_minutes'),
value: 60 * 30
},
{
label: i18n.t('modals.new_toot.poll.expires.1_hour'),
value: 3600
},
{
label: i18n.t('modals.new_toot.poll.expires.6_hours'),
value: 3600 * 6
},
{
label: i18n.t('modals.new_toot.poll.expires.1_day'),
value: 3600 * 24
},
{
label: i18n.t('modals.new_toot.poll.expires.3_days'),
value: 3600 * 24 * 3
},
{
label: i18n.t('modals.new_toot.poll.expires.7_days'),
value: 3600 * 24 * 7
}
])
const emojiData = ref<EmojiIndex | null>(null)
const client = ref<MegalodonInterface | null>(null)
const form = reactive({
status: '',
spoiler: ''
})
const attachments = ref<Array<Entity.Attachment | Entity.AsyncAttachment>>([])
const cw = ref<boolean>(false)
const visibility = ref(visibilityList.Public.key)
const nsfw = ref<boolean>(false)
const inReplyTo = computed(() => store.state.TimelineSpace.Compose.inReplyTo)
const poll = reactive<{ options: Array<string>; expires_in: number }>({
options: [],
expires_in: 86400
})
const loading = ref<boolean>(false)
const emojiVisible = ref<boolean>(false)
const imageRef = ref<any>(null)
const statusRef = ref<any>(null)
onMounted(async () => {
const [a, s]: [LocalAccount, LocalServer] = await win.ipcRenderer.invoke('get-local-account', id.value)
const c = generator(s.sns, s.baseURL, a.accessToken, userAgent.value)
client.value = c
const res = await c.getInstanceCustomEmojis()
const customEmojis = res.data
.map(emoji => {
return {
name: `:${emoji.shortcode}:`,
image: emoji.url
}
})
.filter((e, i, array) => {
return array.findIndex(ar => e.name === ar.name) === i
})
.map(e => ({
name: e.name,
short_names: [e.name],
text: e.name,
emoticons: [],
keywords: [e.name],
imageUrl: e.image
}))
emojiData.value = new EmojiIndex(emojiDefault, { custom: customEmojis })
})
watch(inReplyTo, current => {
if (current) {
form.status = `@${current.acct} `
}
})
const post = async () => {
if (!client.value) {
return
}
let options = {
visibility: visibility.value
}
try {
loading.value = true
if (attachments.value.length > 0) {
options = Object.assign(options, {
media_ids: attachments.value.map(m => m.id)
})
if (nsfw.value) {
options = Object.assign(options, {
sensitive: nsfw.value
})
}
}
if (form.spoiler.length > 0) {
options = Object.assign(options, {
spoiler_text: form.spoiler
})
}
if (inReplyTo.value) {
options = Object.assign(options, {
in_reply_to_id: inReplyTo.value?.id
})
}
if (poll.options.length > 0) {
options = Object.assign(options, {
poll: poll
})
}
await client.value.postStatus(form.status, options)
clear()
} catch (err) {
console.error(err)
} finally {
loading.value = false
}
}
const clear = () => {
form.status = ''
form.spoiler = ''
attachments.value = []
cw.value = false
emojiVisible.value = false
store.commit(`${space}/${MUTATION_TYPES.CLEAR_REPLY_TO_ID}`)
poll.options = []
}
const selectImage = () => {
imageRef?.value?.click()
}
const onChangeImage = async (e: Event) => {
const target = e.target as HTMLInputElement
const file = target.files?.item(0)
if (file === null || file === undefined) {
return
}
await uploadImage(file)
}
const uploadImage = async (file: File) => {
if (!client.value) {
return
}
try {
loading.value = true
const res = await client.value.uploadMedia(file)
attachments.value = [...attachments.value, res.data]
} catch (err) {
console.error(err)
} finally {
loading.value = false
}
}
const removeAttachment = async (attachment: Entity.Attachment | Entity.AsyncAttachment) => {
attachments.value = attachments.value.filter(e => e.id !== attachment.id)
}
const selectEmoji = emoji => {
if (!statusRef.value) {
return
}
const current = statusRef.value?.textarea.selectionStart
if (emoji.native) {
form.status = form.status.slice(0, current) + emoji.native + form.status.slice(current)
} else {
form.status = form.status.slice(0, current) + emoji.name + form.status.slice(current)
}
emojiVisible.value = false
}
const changeVisibility = (value: 'public' | 'unlisted' | 'private' | 'direct') => {
visibility.value = value
}
const togglePoll = () => {
if (poll.options.length > 0) {
poll.options = []
} else {
poll.options = ['', '']
}
}
const addPollOption = () => {
poll.options = [...poll.options, '']
}
const removePollOption = (index: number) => {
poll.options = poll.options.filter((_, i) => i !== index)
}
return {
form,
post,
imageRef,
selectImage,
onChangeImage,
loading,
attachments,
removeAttachment,
emojiData,
selectEmoji,
statusRef,
emojiVisible,
cw,
visibilityList,
visibilityIcon,
changeVisibility,
nsfw,
poll,
togglePoll,
expiresList,
addPollOption,
removePollOption
}
}
})
</script>
<style lang="scss" scoped>
.compose {
height: auto;
width: 100%;
box-sizing: border-box;
.compose-form {
height: calc(100% - 24px);
.spoiler {
padding-bottom: 8px;
}
}
.compose-form :deep(.el-textarea__inner) {
color: var(--theme-primary-color);
background-color: var(--theme-background-color);
box-shadow: 0 0 0 1px var(--theme-border-color, var(--theme-border-color)) inset;
}
.compose-form :deep(.el-input__wrapper) {
background-color: var(--theme-background-color);
box-shadow: 0 0 0 1px var(--theme-border-color, var(--theme-border-color)) inset;
}
.compose-form :deep(.el-input__inner) {
color: var(--theme-primary-color);
}
.preview {
box-sizing: border-box;
display: flex;
flex-flow: row wrap;
.image-wrapper {
position: relative;
display: flex;
min-width: 10%;
height: 80px;
margin: 4px;
.preview-image {
width: 80px;
height: 80px;
object-fit: cover;
border: 0;
border-radius: 8px;
}
.remove-image {
position: absolute;
top: 2px;
left: 2px;
padding: 0;
cursor: pointer;
font-size: 1.5rem;
.fa-icon {
font-size: 0.9rem;
width: auto;
height: 1em;
max-width: 100%;
max-height: 100%;
}
}
}
}
.poll {
.options-list {
list-style: none;
padding-left: 16px;
.poll-option {
line-height: 38px;
.remove-poll {
margin-left: 4px;
}
}
}
.add-poll {
margin: 0 4px 0 40px;
}
}
.poll :deep(.el-input__inner) {
box-shadow: none;
}
.form-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 4px;
.tool-buttons {
display: flex;
button {
margin-right: 8px;
}
.image-input {
display: none;
}
}
.actions-group {
span {
margin-right: 8px;
color: var(--theme-secondary-color);
}
}
}
}
.privacy-icon {
margin-right: 4px;
}
</style>
<style lang="scss">
.new-toot-emoji-picker {
background-color: transparent !important;
border: none !important;
.el-popper__arrow {
display: none;
}
}
</style>

View File

@ -27,12 +27,6 @@
</template>
</template>
</DynamicScroller>
<div :class="openSideBar ? 'upper-with-side-bar' : 'upper'" v-show="!heading">
<el-button type="primary" @click="upper" circle>
<font-awesome-icon icon="angle-up" class="upper-icon" />
</el-button>
</div>
</div>
</template>
@ -204,10 +198,6 @@ export default defineComponent({
}, 500)
})
}
const upper = () => {
scroller.value.scrollToItem(0)
focusedId.value = null
}
const focusNext = () => {
if (currentFocusedIndex.value === -1) {
focusedId.value = timeline.value[0].uri + timeline.value[0].id
@ -245,7 +235,6 @@ export default defineComponent({
focusToot,
openSideBar,
heading,
upper,
account
}
}
@ -269,39 +258,6 @@ export default defineComponent({
.loading-card:empty {
height: 0;
}
.upper {
position: fixed;
bottom: 20px;
right: 20px;
transition: all 0.5s;
}
.upper-with-side-bar {
position: fixed;
bottom: 20px;
right: calc(20px + var(--current-sidebar-width));
transition: all 0.5s;
}
.upper-icon {
padding: 3px;
}
.unread {
position: fixed;
right: 24px;
top: 52px;
background-color: rgba(0, 0, 0, 0.6);
color: #ffffff;
padding: 4px 8px;
border-radius: 0 0 2px 2px;
z-index: 1;
&:empty {
display: none;
}
}
}
</style>

View File

@ -255,9 +255,9 @@ import { usernameWithStyle, accountNameWithStyle } from '@/utils/username'
import { parseDatetime } from '@/utils/datetime'
import { MUTATION_TYPES as SIDEBAR_MUTATION, ACTION_TYPES as SIDEBAR_ACTION } from '@/store/TimelineSpace/Contents/SideBar'
import { ACTION_TYPES as PROFILE_ACTION } from '@/store/TimelineSpace/Contents/SideBar/AccountProfile'
import { ACTION_TYPES as NEW_ACTION } from '@/store/TimelineSpace/Modals/NewToot'
import { ACTION_TYPES as DETAIL_ACTION } from '@/store/TimelineSpace/Contents/SideBar/TootDetail'
import { ACTION_TYPES as REPORT_ACTION } from '@/store/TimelineSpace/Modals/Report'
import { MUTATION_TYPES as COMPOSE_MUTATION } from '@/store/TimelineSpace/Compose'
import { ACTION_TYPES as MUTE_ACTION } from '@/store/TimelineSpace/Modals/MuteConfirm'
import { ACTION_TYPES as VIEWER_ACTION } from '@/store/TimelineSpace/Modals/ImageViewer'
import { ACTION_TYPES } from '@/store/organisms/Toot'
@ -477,7 +477,10 @@ export default defineComponent({
}
}
const openReply = () => {
store.dispatch(`TimelineSpace/Modals/NewToot/${NEW_ACTION.OPEN_REPLY}`, originalMessage.value)
store.commit(`TimelineSpace/Compose/${COMPOSE_MUTATION.SET_REPLY_TO_ID}`, {
acct: originalMessage.value.account.acct,
id: originalMessage.value.id
})
}
const openDetail = (message: Entity.Status) => {
store.dispatch(`TimelineSpace/Contents/SideBar/${SIDEBAR_ACTION.OPEN_TOOT_COMPONENT}`)

View File

@ -11,6 +11,7 @@ import { MyWindow } from '~/src/types/global'
import { LocalServer } from '~/src/types/localServer'
import { Setting } from '~/src/types/setting'
import { DefaultSetting } from '~/src/constants/initializer/setting'
import Compose, { ComposeState } from './TimelineSpace/Compose'
const win = (window as any) as MyWindow
@ -201,6 +202,7 @@ type TimelineSpaceModule = {
HeaderMenu: HeaderMenuState
Modals: ModalsModuleState
Contents: ContentsModuleState
Compose: ComposeState
}
export type TimelineSpaceModuleState = TimelineSpaceModule & TimelineSpaceState
@ -211,7 +213,8 @@ const TimelineSpace: Module<TimelineSpaceState, RootState> = {
SideMenu,
HeaderMenu,
Modals,
Contents
Contents,
Compose
},
state: state,
mutations: mutations,

View File

@ -0,0 +1,37 @@
import { Module, MutationTree } from 'vuex'
import { RootState } from '@/store'
export type ReplyTo = {
acct: string
id: string
}
export type ComposeState = {
inReplyTo: ReplyTo | null
}
const state = (): ComposeState => ({
inReplyTo: null
})
export const MUTATION_TYPES = {
SET_REPLY_TO_ID: 'setReplyToId',
CLEAR_REPLY_TO_ID: 'clearReplyToId'
}
const mutations: MutationTree<ComposeState> = {
[MUTATION_TYPES.SET_REPLY_TO_ID]: (state, inReplyTo: ReplyTo) => {
state.inReplyTo = inReplyTo
},
[MUTATION_TYPES.CLEAR_REPLY_TO_ID]: state => {
state.inReplyTo = null
}
}
const Compose: Module<ComposeState, RootState> = {
namespaced: true,
state: state,
mutations: mutations
}
export default Compose