diff options
| author | syuilo <syuilotan@yahoo.co.jp> | 2021-02-19 21:42:47 +0900 |
|---|---|---|
| committer | syuilo <syuilotan@yahoo.co.jp> | 2021-02-19 21:42:47 +0900 |
| commit | d6c8b9b99470db45c201229b5c9235e7be3067de (patch) | |
| tree | 01e5fccad6d84cf1e7f41e0a5e3aae955f3695e0 /src/client | |
| parent | Merge branch 'develop' (diff) | |
| parent | 12.70.0 (diff) | |
| download | misskey-d6c8b9b99470db45c201229b5c9235e7be3067de.tar.gz misskey-d6c8b9b99470db45c201229b5c9235e7be3067de.tar.bz2 misskey-d6c8b9b99470db45c201229b5c9235e7be3067de.zip | |
Merge branch 'develop'
Diffstat (limited to 'src/client')
59 files changed, 4397 insertions, 488 deletions
diff --git a/src/client/account.ts b/src/client/account.ts index e6ee8613d2..e5b451cf14 100644 --- a/src/client/account.ts +++ b/src/client/account.ts @@ -1,6 +1,7 @@ import { reactive } from 'vue'; import { apiUrl } from '@/config'; import { waiting } from '@/os'; +import { unisonReload } from '@/scripts/unison-reload'; // TODO: 他のタブと永続化されたstateを同期 @@ -62,6 +63,7 @@ export function updateAccount(data) { for (const [key, value] of Object.entries(data)) { $i[key] = value; } + localStorage.setItem('account', JSON.stringify($i)); } export function refreshAccount() { @@ -74,7 +76,7 @@ export async function login(token: Account['token']) { const me = await fetchAccount(token); localStorage.setItem('account', JSON.stringify(me)); addAccount(me.id, token); - location.reload(); + unisonReload(); } // このファイルに書きたくないけどここに書かないと何故かVeturが認識しない diff --git a/src/client/components/emoji-picker.vue b/src/client/components/emoji-picker.vue index c2d1008e1b..9a261ef83f 100644 --- a/src/client/components/emoji-picker.vue +++ b/src/client/components/emoji-picker.vue @@ -99,7 +99,8 @@ import { faHeart, faFlag, faLaugh } from '@fortawesome/free-regular-svg-icons'; import MkModal from '@/components/ui/modal.vue'; import Particle from '@/components/particle.vue'; import * as os from '@/os'; -import { isDeviceTouch } from '../scripts/is-device-touch'; +import { isDeviceTouch } from '@/scripts/is-device-touch'; +import { isMobile } from '@/scripts/is-mobile'; import { emojiCategories } from '@/instance'; export default defineComponent({ @@ -322,7 +323,7 @@ export default defineComponent({ }, mounted() { - if (!os.isMobile) { + if (!isMobile && !isDeviceTouch) { this.$refs.search.focus({ preventScroll: true }); diff --git a/src/client/components/form/input.vue b/src/client/components/form/input.vue index c8c22e95c7..f0aa6b0534 100644 --- a/src/client/components/form/input.vue +++ b/src/client/components/form/input.vue @@ -1,63 +1,50 @@ <template> -<div class="ztzhwixg _formItem" :class="{ inline, disabled }"> - <div class="_formLabel"><slot></slot></div> - <div class="icon" ref="icon"><slot name="icon"></slot></div> - <div class="input _formPanel"> - <div class="prefix" ref="prefixEl"><slot name="prefix"></slot></div> - <input v-if="debounce" ref="inputEl" - v-debounce="500" - :type="type" - v-model.lazy="v" - :disabled="disabled" - :required="required" - :readonly="readonly" - :placeholder="placeholder" - :pattern="pattern" - :autocomplete="autocomplete" - :spellcheck="spellcheck" - :step="step" - @focus="focused = true" - @blur="focused = false" - @keydown="onKeydown($event)" - @input="onInput" - :list="id" - > - <input v-else ref="inputEl" - :type="type" - v-model="v" - :disabled="disabled" - :required="required" - :readonly="readonly" - :placeholder="placeholder" - :pattern="pattern" - :autocomplete="autocomplete" - :spellcheck="spellcheck" - :step="step" - @focus="focused = true" - @blur="focused = false" - @keydown="onKeydown($event)" - @input="onInput" - :list="id" - > - <datalist :id="id" v-if="datalist"> - <option v-for="data in datalist" :value="data"/> - </datalist> - <div class="suffix" ref="suffixEl"><slot name="suffix"></slot></div> +<FormGroup class="_formItem"> + <template #label><slot></slot></template> + <div class="ztzhwixg _formItem" :class="{ inline, disabled }"> + <div class="icon" ref="icon"><slot name="icon"></slot></div> + <div class="input _formPanel"> + <div class="prefix" ref="prefixEl"><slot name="prefix"></slot></div> + <input ref="inputEl" + :type="type" + v-model="v" + :disabled="disabled" + :required="required" + :readonly="readonly" + :placeholder="placeholder" + :pattern="pattern" + :autocomplete="autocomplete" + :spellcheck="spellcheck" + :step="step" + @focus="focused = true" + @blur="focused = false" + @keydown="onKeydown($event)" + @input="onInput" + :list="id" + > + <datalist :id="id" v-if="datalist"> + <option v-for="data in datalist" :value="data"/> + </datalist> + <div class="suffix" ref="suffixEl"><slot name="suffix"></slot></div> + </div> </div> - <button class="save _textButton" v-if="save && changed" @click="() => { changed = false; save(); }">{{ $ts.save }}</button> - <div class="_formCaption"><slot name="desc"></slot></div> -</div> + <template #caption><slot name="desc"></slot></template> + + <FormButton v-if="manualSave && changed" @click="updated" primary><Fa :icon="faSave"/> {{ $ts.save }}</FormButton> +</FormGroup> </template> <script lang="ts"> import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs } from 'vue'; -import debounce from 'v-debounce'; -import { faExclamationCircle } from '@fortawesome/free-solid-svg-icons'; +import { faExclamationCircle, faSave } from '@fortawesome/free-solid-svg-icons'; import './form.scss'; +import FormButton from './button.vue'; +import FormGroup from './group.vue'; export default defineComponent({ - directives: { - debounce + components: { + FormGroup, + FormButton, }, props: { value: { @@ -101,9 +88,6 @@ export default defineComponent({ step: { required: false }, - debounce: { - required: false - }, datalist: { type: Array, required: false, @@ -113,9 +97,10 @@ export default defineComponent({ required: false, default: false }, - save: { - type: Function, + manualSave: { + type: Boolean, required: false, + default: false }, }, emits: ['change', 'keydown', 'enter'], @@ -144,15 +129,22 @@ export default defineComponent({ } }; + const updated = () => { + changed.value = false; + if (type?.value === 'number') { + context.emit('update:value', parseFloat(v.value)); + } else { + context.emit('update:value', v.value); + } + }; + watch(value, newValue => { v.value = newValue; }); watch(v, newValue => { - if (type?.value === 'number') { - context.emit('update:value', parseFloat(newValue)); - } else { - context.emit('update:value', newValue); + if (!props.manualSave) { + updated(); } invalid.value = inputEl.value.validity.badInput; @@ -198,7 +190,8 @@ export default defineComponent({ focus, onInput, onKeydown, - faExclamationCircle, + updated, + faExclamationCircle, faSave, }; }, }); @@ -285,11 +278,6 @@ export default defineComponent({ } } - > .save { - margin: 6px 0 0 0; - font-size: 0.8em; - } - &.inline { display: inline-block; margin: 0; diff --git a/src/client/components/form/textarea.vue b/src/client/components/form/textarea.vue index 711cd50124..135e16c259 100644 --- a/src/client/components/form/textarea.vue +++ b/src/client/components/form/textarea.vue @@ -1,29 +1,39 @@ <template> -<div class="rivhosbp _formItem" :class="{ tall, pre }"> - <div class="_formLabel"><slot></slot></div> - <div class="input _formPanel"> - <textarea ref="input" :class="{ code, _monospace: code }" - :value="value" - :required="required" - :readonly="readonly" - :pattern="pattern" - :autocomplete="autocomplete" - :spellcheck="!code" - @input="onInput" - @focus="focused = true" - @blur="focused = false" - ></textarea> +<FormGroup class="_formItem"> + <template #label><slot></slot></template> + <div class="rivhosbp _formItem" :class="{ tall, pre }"> + <div class="input _formPanel"> + <textarea ref="input" :class="{ code, _monospace: code }" + v-model="v" + :required="required" + :readonly="readonly" + :pattern="pattern" + :autocomplete="autocomplete" + :spellcheck="!code" + @input="onInput" + @focus="focused = true" + @blur="focused = false" + ></textarea> + </div> </div> - <button class="save _textButton" v-if="save && changed" @click="() => { changed = false; save(); }">{{ $ts.save }}</button> - <div class="_formCaption"><slot name="desc"></slot></div> -</div> + <template #caption><slot name="desc"></slot></template> + + <FormButton v-if="manualSave && changed" @click="updated" primary><Fa :icon="faSave"/> {{ $ts.save }}</FormButton> +</FormGroup> </template> <script lang="ts"> -import { defineComponent } from 'vue'; +import { defineComponent, ref, toRefs, watch } from 'vue'; +import { faSave } from '@fortawesome/free-solid-svg-icons'; import './form.scss'; +import FormButton from './button.vue'; +import FormGroup from './group.vue'; export default defineComponent({ + components: { + FormGroup, + FormButton, + }, props: { value: { required: false @@ -58,24 +68,46 @@ export default defineComponent({ required: false, default: false }, - save: { - type: Function, + manualSave: { + type: Boolean, required: false, + default: false }, }, - data() { + setup(props, context) { + const { value } = toRefs(props); + const v = ref(value.value); + const changed = ref(false); + const inputEl = ref(null); + const focus = () => inputEl.value.focus(); + const onInput = (ev) => { + changed.value = true; + context.emit('change', ev); + }; + + const updated = () => { + changed.value = false; + context.emit('update:value', v.value); + }; + + watch(value, newValue => { + v.value = newValue; + }); + + watch(v, newValue => { + if (!props.manualSave) { + updated(); + } + }); + return { - changed: false, - } - }, - methods: { - focus() { - this.$refs.input.focus(); - }, - onInput(ev) { - this.changed = true; - this.$emit('update:value', ev.target.value); - } + v, + updated, + changed, + focus, + onInput, + faSave, + }; } }); </script> @@ -112,11 +144,6 @@ export default defineComponent({ } } - > .save { - margin: 6px 0 0 0; - font-size: 0.8em; - } - &.tall { > .input { > textarea { diff --git a/src/client/components/global/avatar.vue b/src/client/components/global/avatar.vue index 9f8b0eeca1..d2f25fa41e 100644 --- a/src/client/components/global/avatar.vue +++ b/src/client/components/global/avatar.vue @@ -1,8 +1,8 @@ <template> -<span class="eiwwqkts" :class="{ cat }" :title="acct(user)" v-if="disableLink" v-user-preview="disablePreview ? undefined : user.id" @click="onClick"> +<span class="eiwwqkts _noSelect" :class="{ cat }" :title="acct(user)" v-if="disableLink" v-user-preview="disablePreview ? undefined : user.id" @click="onClick"> <img class="inner" :src="url" decoding="async"/> </span> -<MkA class="eiwwqkts" :class="{ cat }" :to="userPage(user)" :title="acct(user)" :target="target" v-else v-user-preview="disablePreview ? undefined : user.id"> +<MkA class="eiwwqkts _noSelect" :class="{ cat }" :to="userPage(user)" :title="acct(user)" :target="target" v-else v-user-preview="disablePreview ? undefined : user.id"> <img class="inner" :src="url" decoding="async"/> </MkA> </template> diff --git a/src/client/components/note.vue b/src/client/components/note.vue index b839ab3e8f..d532289857 100644 --- a/src/client/components/note.vue +++ b/src/client/components/note.vue @@ -1,6 +1,6 @@ <template> <div - class="note _panel" + class="tkcbzcuz _panel" v-if="!muted" v-show="!isDeleted" :tabindex="!isDeleted ? '-1' : null" @@ -858,7 +858,7 @@ export default defineComponent({ </script> <style lang="scss" scoped> -.note { +.tkcbzcuz { position: relative; transition: box-shadow 0.1s ease; overflow: hidden; diff --git a/src/client/components/notes.vue b/src/client/components/notes.vue index 9973809192..bd6d5bb4f5 100644 --- a/src/client/components/notes.vue +++ b/src/client/components/notes.vue @@ -8,7 +8,7 @@ <MkError v-if="error" @retry="init()"/> <div v-show="more && reversed" style="margin-bottom: var(--margin);"> - <button class="_loadMore" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> + <button class="_buttonPrimary" @click="fetchMoreFeature" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> <template v-if="!moreFetching">{{ $ts.loadMore }}</template> <template v-if="moreFetching"><MkLoading inline/></template> </button> @@ -19,7 +19,7 @@ </XList> <div v-show="more && !reversed" style="margin-top: var(--margin);"> - <button class="_loadMore" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> + <button class="_buttonPrimary" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> <template v-if="!moreFetching">{{ $ts.loadMore }}</template> <template v-if="moreFetching"><MkLoading inline/></template> </button> diff --git a/src/client/components/notifications.vue b/src/client/components/notifications.vue index 9759cc2395..56dbfd5bdf 100644 --- a/src/client/components/notifications.vue +++ b/src/client/components/notifications.vue @@ -1,11 +1,11 @@ <template> -<div class="mfcuwfyp"> +<div class="mfcuwfyp _noGap_"> <XList class="notifications" :items="items" v-slot="{ item: notification }"> <XNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :note="notification.note" @update:note="noteUpdated(notification.note, $event)" :key="notification.id"/> <XNotification v-else :notification="notification" :with-time="true" :full="true" class="_panel notification" :key="notification.id"/> </XList> - <button class="_loadMore" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" v-show="more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> + <button class="_buttonPrimary" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" v-show="more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> <template v-if="!moreFetching">{{ $ts.loadMore }}</template> <template v-if="moreFetching"><MkLoading inline/></template> </button> diff --git a/src/client/components/post-form.vue b/src/client/components/post-form.vue index d2c0cffa12..fa9aeff8af 100644 --- a/src/client/components/post-form.vue +++ b/src/client/components/post-form.vue @@ -69,6 +69,7 @@ import { noteVisibilities } from '../../types'; import * as os from '@/os'; import { selectFile } from '@/scripts/select-file'; import { notePostInterruptors, postFormActions } from '@/store'; +import { isMobile } from '@/scripts/is-mobile'; export default defineComponent({ components: { @@ -554,7 +555,7 @@ export default defineComponent({ localOnly: this.localOnly, visibility: this.visibility, visibleUserIds: this.visibility == 'specified' ? this.visibleUsers.map(u => u.id) : undefined, - viaMobile: os.isMobile + viaMobile: isMobile }; // plugin diff --git a/src/client/components/sample.vue b/src/client/components/sample.vue index 8fd79ceec9..0f29fc69bb 100644 --- a/src/client/components/sample.vue +++ b/src/client/components/sample.vue @@ -51,7 +51,7 @@ export default defineComponent({ text: '', flag: true, radio: 'misskey', - mfm: `Hello world! This is an @example mention. BTW you are @${this.$i.username}.\nAlso, here is ${config.url} and [example link](${config.url}). for more details, see https://example.com.\nAs you know #misskey is open-source software.` + mfm: `Hello world! This is an @example mention. BTW you are @${this.$i ? this.$i.username : 'guest'}.\nAlso, here is ${config.url} and [example link](${config.url}). for more details, see https://example.com.\nAs you know #misskey is open-source software.` } }, diff --git a/src/client/components/sidebar.vue b/src/client/components/sidebar.vue index 251f68527a..f7c50a2ba5 100644 --- a/src/client/components/sidebar.vue +++ b/src/client/components/sidebar.vue @@ -55,6 +55,14 @@ import { sidebarDef } from '@/sidebar'; import { getAccounts, addAccount, login } from '@/account'; export default defineComponent({ + props: { + defaultHidden: { + type: Boolean, + required: false, + default: false, + } + }, + data() { return { host: host, @@ -63,7 +71,7 @@ export default defineComponent({ connection: null, menuDef: sidebarDef, iconOnly: false, - hidden: false, + hidden: this.defaultHidden, faGripVertical, faChevronLeft, faComments, faHashtag, faBroadcastTower, faFireAlt, faEllipsisH, faPencilAlt, faBars, faTimes, faBell, faSearch, faUserCog, faCog, faUser, faHome, faStar, faCircle, faAt, faEnvelope, faListUl, faPlus, faUserClock, faLaugh, faUsers, faTachometerAlt, faExchangeAlt, faGlobe, faChartBar, faCloud, faServer, faProjectDiagram }; }, @@ -112,7 +120,9 @@ export default defineComponent({ methods: { calcViewState() { this.iconOnly = (window.innerWidth <= 1279) || (this.$store.state.sidebarDisplay === 'icon'); - this.hidden = (window.innerWidth <= 650); + if (!this.defaultHidden) { + this.hidden = (window.innerWidth <= 650); + } }, show() { @@ -128,13 +138,19 @@ export default defineComponent({ }, async openAccountMenu(ev) { - const storedAccounts = getAccounts(); - const accounts = (await os.api('users/show', { userIds: storedAccounts.map(x => x.id) })).filter(x => x.id !== this.$i.id); + const storedAccounts = getAccounts().filter(x => x.id !== this.$i.id); + const accountsPromise = os.api('users/show', { userIds: storedAccounts.map(x => x.id) }); - const accountItems = accounts.map(account => ({ - type: 'user', - user: account, - action: () => { this.switchAccount(account); } + const accountItemPromises = storedAccounts.map(a => new Promise(res => { + accountsPromise.then(accounts => { + const account = accounts.find(x => x.id === a.id); + if (account == null) return res(null); + res({ + type: 'user', + user: account, + action: () => { this.switchAccount(account); } + }); + }); })); os.modalMenu([...[{ @@ -142,7 +158,7 @@ export default defineComponent({ text: this.$ts.profile, to: `/@${ this.$i.username }`, avatar: this.$i, - }, null, ...accountItems, { + }, null, ...accountItemPromises, { icon: faPlus, text: this.$ts.addAcount, action: () => { diff --git a/src/client/components/ui/modal.vue b/src/client/components/ui/modal.vue index 0d1038dce9..69a83e002c 100644 --- a/src/client/components/ui/modal.vue +++ b/src/client/components/ui/modal.vue @@ -98,11 +98,11 @@ export default defineComponent({ } } else { if (left + width - window.pageXOffset > window.innerWidth) { - left = window.innerWidth - width + window.pageXOffset; + left = window.innerWidth - width + window.pageXOffset - 1; } if (top + height - window.pageYOffset > window.innerHeight) { - top = window.innerHeight - height + window.pageYOffset; + top = window.innerHeight - height + window.pageYOffset - 1; } } diff --git a/src/client/components/ui/tooltip.vue b/src/client/components/ui/tooltip.vue index 6ea344c54d..b220fe5d8c 100644 --- a/src/client/components/ui/tooltip.vue +++ b/src/client/components/ui/tooltip.vue @@ -1,6 +1,6 @@ <template> -<transition name="zoom-in-top" appear @after-leave="$emit('closed')"> - <div class="buebdbiu _acrylic _shadow" v-if="showing"> +<transition name="tooltip" appear @after-leave="$emit('closed')"> + <div class="buebdbiu _acrylic _shadow" v-show="showing" ref="content"> <slot>{{ text }}</slot> </div> </transition> @@ -35,19 +35,43 @@ export default defineComponent({ const rect = this.source.getBoundingClientRect(); - let x = rect.left + window.pageXOffset + (this.source.offsetWidth / 2); - let y = rect.top + window.pageYOffset + this.source.offsetHeight; + const contentWidth = this.$refs.content.offsetWidth; + const contentHeight = this.$refs.content.offsetHeight; - x -= (this.$el.offsetWidth / 2); + let left = rect.left + window.pageXOffset + (this.source.offsetWidth / 2); + let top = rect.top + window.pageYOffset + this.source.offsetHeight; - this.$el.style.left = x + 'px'; - this.$el.style.top = y + 'px'; + left -= (this.$el.offsetWidth / 2); + + if (left + contentWidth - window.pageXOffset > window.innerWidth) { + left = window.innerWidth - contentWidth + window.pageXOffset - 1; + } + + if (top + contentHeight - window.pageYOffset > window.innerHeight) { + top = rect.top + window.pageYOffset - contentHeight; + this.$refs.content.style.transformOrigin = 'center bottom'; + } + + this.$el.style.left = left + 'px'; + this.$el.style.top = top + 'px'; }); }, }) </script> <style lang="scss" scoped> +.tooltip-enter-active, +.tooltip-leave-active { + opacity: 1; + transform: scale(1); + transition: transform 200ms cubic-bezier(0.23, 1, 0.32, 1), opacity 200ms cubic-bezier(0.23, 1, 0.32, 1); +} +.tooltip-enter-from, +.tooltip-leave-active { + opacity: 0; + transform: scale(0.75); +} + .buebdbiu { position: absolute; z-index: 11000; @@ -57,6 +81,6 @@ export default defineComponent({ text-align: center; border-radius: 4px; pointer-events: none; - transform-origin: center -16px; + transform-origin: center top; } </style> diff --git a/src/client/components/widgets.vue b/src/client/components/widgets.vue new file mode 100644 index 0000000000..23fce7d714 --- /dev/null +++ b/src/client/components/widgets.vue @@ -0,0 +1,153 @@ +<template> +<div class="vjoppmmu"> + <template v-if="edit"> + <header> + <MkSelect v-model:value="widgetAdderSelected" style="margin-bottom: var(--margin)"> + <template #label>{{ $ts.selectWidget }}</template> + <option v-for="widget in widgetDefs" :value="widget" :key="widget">{{ $t(`_widgets.${widget}`) }}</option> + </MkSelect> + <MkButton inline @click="addWidget" primary><Fa :icon="faPlus"/> {{ $ts.add }}</MkButton> + <MkButton inline @click="$emit('exit')">{{ $ts.close }}</MkButton> + </header> + <XDraggable + v-model="_widgets" + item-key="id" + animation="150" + > + <template #item="{element}"> + <div class="customize-container"> + <button class="config _button" @click.prevent.stop="configWidget(element.id)"><Fa :icon="faCog"/></button> + <button class="remove _button" @click.prevent.stop="removeWidget(element)"><Fa :icon="faTimes"/></button> + <component :is="`mkw-${element.name}`" :widget="element" :setting-callback="setting => settings[element.id] = setting" :column="column" @updateProps="updateWidget(element.id, $event)"/> + </div> + </template> + </XDraggable> + </template> + <component v-else class="widget" v-for="widget in widgets" :is="`mkw-${widget.name}`" :key="widget.id" :widget="widget" :column="column" @updateProps="updateWidget(widget.id, $event)"/> +</div> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue'; +import { v4 as uuid } from 'uuid'; +import { faTimes, faCog, faPlus } from '@fortawesome/free-solid-svg-icons'; +import MkSelect from '@/components/ui/select.vue'; +import MkButton from '@/components/ui/button.vue'; +import { widgets as widgetDefs } from '@/widgets'; + +export default defineComponent({ + components: { + XDraggable: defineAsyncComponent(() => import('vuedraggable').then(x => x.default)), + MkSelect, + MkButton, + }, + + props: { + widgets: { + required: true, + }, + edit: { + type: Boolean, + required: true, + }, + }, + + emits: ['updateWidgets', 'addWidget', 'removeWidget', 'updateWidget', 'exit'], + + data() { + return { + widgetAdderSelected: null, + widgetDefs, + settings: {}, + faTimes, faPlus, faCog + }; + }, + + computed: { + _widgets: { + get() { + return this.widgets; + }, + set(value) { + this.$emit('updateWidgets', value); + } + } + }, + + methods: { + configWidget(id) { + this.settings[id](); + }, + + addWidget() { + if (this.widgetAdderSelected == null) return; + + this.$emit('addWidget', { + name: this.widgetAdderSelected, + id: uuid(), + data: {} + }); + + this.widgetAdderSelected = null; + }, + + removeWidget(widget) { + this.$emit('removeWidget', widget); + }, + + updateWidget(id, data) { + this.$emit('updateWidget', { id, data }); + }, + } +}); +</script> + +<style lang="scss" scoped> +.vjoppmmu { + > header { + margin: 16px 0; + + > * { + width: 100%; + padding: 4px; + } + } + + > .widget, .customize-container { + margin: var(--margin) 0; + + &:first-of-type { + margin-top: 0; + } + } + + .customize-container { + position: relative; + cursor: move; + + > *:not(.remove):not(.config) { + pointer-events: none; + } + + > .config, + > .remove { + position: absolute; + z-index: 10000; + top: 8px; + width: 32px; + height: 32px; + color: #fff; + background: rgba(#000, 0.7); + border-radius: 4px; + } + + > .config { + right: 8px + 8px + 32px; + } + + > .remove { + right: 8px; + } + } +} +</style> diff --git a/src/client/directives/follow-append.ts b/src/client/directives/follow-append.ts index 26f9e9f82b..b0e99628b0 100644 --- a/src/client/directives/follow-append.ts +++ b/src/client/directives/follow-append.ts @@ -3,12 +3,22 @@ import { getScrollContainer, getScrollPosition } from '@/scripts/scroll'; export default { mounted(src, binding, vn) { - const ro = new ResizeObserver((entries, observer) => { - const pos = getScrollPosition(src); - const container = getScrollContainer(src); + if (binding.value === false) return; + + let isBottom = true; + + const container = getScrollContainer(src)!; + container.addEventListener('scroll', () => { + const pos = getScrollPosition(container); const viewHeight = container.clientHeight; const height = container.scrollHeight; - if (pos + viewHeight > height - 32) { + isBottom = (pos + viewHeight > height - 32); + }, { passive: true }); + container.scrollTop = container.scrollHeight; + + const ro = new ResizeObserver((entries, observer) => { + if (isBottom) { + const height = container.scrollHeight; container.scrollTop = height; } }); @@ -20,6 +30,6 @@ export default { }, unmounted(src, binding, vn) { - src._ro_.unobserve(src); + if (src._ro_) src._ro_.unobserve(src); } } as Directive; diff --git a/src/client/directives/tooltip.ts b/src/client/directives/tooltip.ts index faeeef79a7..2a0a13663c 100644 --- a/src/client/directives/tooltip.ts +++ b/src/client/directives/tooltip.ts @@ -4,6 +4,7 @@ import { popup } from '@/os'; const start = isDeviceTouch ? 'touchstart' : 'mouseover'; const end = isDeviceTouch ? 'touchend' : 'mouseleave'; +const delay = 100; export default { mounted(el: HTMLElement, binding, vn) { @@ -47,13 +48,13 @@ export default { el.addEventListener(start, () => { clearTimeout(self.showTimer); clearTimeout(self.hideTimer); - self.showTimer = setTimeout(show, 300); + self.showTimer = setTimeout(show, delay); }, { passive: true }); el.addEventListener(end, () => { clearTimeout(self.showTimer); clearTimeout(self.hideTimer); - self.hideTimer = setTimeout(self.close, 300); + self.hideTimer = setTimeout(self.close, delay); }, { passive: true }); el.addEventListener('click', () => { diff --git a/src/client/i18n.ts b/src/client/i18n.ts index fbc10a0bad..6c29ef153f 100644 --- a/src/client/i18n.ts +++ b/src/client/i18n.ts @@ -1,6 +1,6 @@ import { markRaw } from 'vue'; import { locale } from '@/config'; -import { I18n } from '@/scripts/i18n'; +import { I18n } from '../misc/i18n'; export const i18n = markRaw(new I18n(locale)); diff --git a/src/client/init.ts b/src/client/init.ts index 17feca4c8b..c60b25359b 100644 --- a/src/client/init.ts +++ b/src/client/init.ts @@ -49,15 +49,17 @@ import { router } from '@/router'; import { applyTheme } from '@/scripts/theme'; import { isDeviceDarkmode } from '@/scripts/is-device-darkmode'; import { i18n } from '@/i18n'; -import { stream, isMobile, dialog, post } from '@/os'; +import { stream, dialog, post } from '@/os'; import * as sound from '@/scripts/sound'; import { $i, refreshAccount, login, updateAccount, signout } from '@/account'; import { defaultStore, ColdDeviceStorage } from '@/store'; import { fetchInstance, instance } from '@/instance'; -import { makeHotkey } from './scripts/hotkey'; -import { search } from './scripts/search'; -import { getThemes } from './theme-store'; -import { initializeSw } from './scripts/initialize-sw'; +import { makeHotkey } from '@/scripts/hotkey'; +import { search } from '@/scripts/search'; +import { isMobile } from '@/scripts/is-mobile'; +import { getThemes } from '@/theme-store'; +import { initializeSw } from '@/scripts/initialize-sw'; +import { reloadChannel } from '@/scripts/unison-reload'; console.info(`Misskey v${version}`); @@ -105,6 +107,9 @@ if (defaultStore.state.reportError && !_DEV_) { // タッチデバイスでCSSの:hoverを機能させる document.addEventListener('touchend', () => {}, { passive: true }); +// 一斉リロード +reloadChannel.addEventListener('message', () => location.reload()); + //#region SEE: https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ // TODO: いつの日にか消したい const vh = window.innerHeight * 0.01; @@ -182,6 +187,7 @@ const app = createApp(await ( !$i ? import('@/ui/visitor.vue') : ui === 'deck' ? import('@/ui/deck.vue') : ui === 'desktop' ? import('@/ui/desktop.vue') : + ui === 'chat' ? import('@/ui/chat/index.vue') : import('@/ui/default.vue') ).then(x => x.default)); diff --git a/src/client/os.ts b/src/client/os.ts index 95dcd3eb65..59ee3617e1 100644 --- a/src/client/os.ts +++ b/src/client/os.ts @@ -9,9 +9,6 @@ import { resolve } from '@/router'; import { $i } from '@/account'; import { defaultStore } from '@/store'; -const ua = navigator.userAgent.toLowerCase(); -export const isMobile = /mobile|iphone|ipad|android/.test(ua); - export const stream = markRaw(new Stream()); export const pendingApiRequestsCount = ref(0); diff --git a/src/client/pages/scratchpad.vue b/src/client/pages/scratchpad.vue index e070f477be..6ee7ec02fc 100644 --- a/src/client/pages/scratchpad.vue +++ b/src/client/pages/scratchpad.vue @@ -70,7 +70,8 @@ export default defineComponent({ async run() { this.logs = []; const aiscript = new AiScript(createAiScriptEnv({ - storageKey: 'scratchpad' + storageKey: 'scratchpad', + token: this.$i?.token, }), { in: (q) => { return new Promise(ok => { diff --git a/src/client/pages/search.vue b/src/client/pages/search.vue index ed92243cd2..5e79531b20 100644 --- a/src/client/pages/search.vue +++ b/src/client/pages/search.vue @@ -28,6 +28,7 @@ export default defineComponent({ limit: 10, params: () => ({ query: this.$route.query.q, + channelId: this.$route.query.channel, }) }, }; diff --git a/src/client/pages/settings/deck.vue b/src/client/pages/settings/deck.vue index 30d36d4a06..cbc5edca94 100644 --- a/src/client/pages/settings/deck.vue +++ b/src/client/pages/settings/deck.vue @@ -40,6 +40,7 @@ import FormBase from '@/components/form/base.vue'; import FormGroup from '@/components/form/group.vue'; import { deckStore } from '@/ui/deck/deck-store'; import * as os from '@/os'; +import { unisonReload } from '@/scripts/unison-reload'; export default defineComponent({ components: { @@ -81,7 +82,7 @@ export default defineComponent({ }); if (canceled) return; - location.reload(); + unisonReload(); } }, @@ -99,7 +100,7 @@ export default defineComponent({ }); if (canceled) return; this.profile = name; - location.reload(); + unisonReload(); } } }); diff --git a/src/client/pages/settings/email-notification.vue b/src/client/pages/settings/email-notification.vue new file mode 100644 index 0000000000..de2cfd3912 --- /dev/null +++ b/src/client/pages/settings/email-notification.vue @@ -0,0 +1,90 @@ +<template> +<FormBase> + <FormGroup> + <FormSwitch v-model:value="mention"> + {{ $ts._notification._types.mention }} + </FormSwitch> + <FormSwitch v-model:value="reply"> + {{ $ts._notification._types.reply }} + </FormSwitch> + <FormSwitch v-model:value="quote"> + {{ $ts._notification._types.quote }} + </FormSwitch> + <FormSwitch v-model:value="follow"> + {{ $ts._notification._types.follow }} + </FormSwitch> + <FormSwitch v-model:value="receiveFollowRequest"> + {{ $ts._notification._types.receiveFollowRequest }} + </FormSwitch> + <FormSwitch v-model:value="groupInvited"> + {{ $ts._notification._types.groupInvited }} + </FormSwitch> + </FormGroup> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { faCog } from '@fortawesome/free-solid-svg-icons'; +import { faBell, faEnvelope } from '@fortawesome/free-regular-svg-icons'; +import FormButton from '@/components/form/button.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormBase from '@/components/form/base.vue'; +import FormGroup from '@/components/form/group.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + FormBase, + FormSwitch, + FormButton, + FormGroup, + }, + + emits: ['info'], + + data() { + return { + INFO: { + title: this.$ts.emailNotification, + icon: faEnvelope + }, + + mention: this.$i.emailNotificationTypes.includes('mention'), + reply: this.$i.emailNotificationTypes.includes('reply'), + quote: this.$i.emailNotificationTypes.includes('quote'), + follow: this.$i.emailNotificationTypes.includes('follow'), + receiveFollowRequest: this.$i.emailNotificationTypes.includes('receiveFollowRequest'), + groupInvited: this.$i.emailNotificationTypes.includes('groupInvited'), + } + }, + + created() { + this.$watch('mention', this.save); + this.$watch('reply', this.save); + this.$watch('quote', this.save); + this.$watch('follow', this.save); + this.$watch('receiveFollowRequest', this.save); + this.$watch('groupInvited', this.save); + }, + + mounted() { + this.$emit('info', this.INFO); + }, + + methods: { + save() { + os.api('i/update', { + emailNotificationTypes: [ + ...[this.mention ? 'mention' : null], + ...[this.reply ? 'reply' : null], + ...[this.quote ? 'quote' : null], + ...[this.follow ? 'follow' : null], + ...[this.receiveFollowRequest ? 'receiveFollowRequest' : null], + ...[this.groupInvited ? 'groupInvited' : null], + ].filter(x => x != null) + }); + } + } +}); +</script> diff --git a/src/client/pages/settings/email.vue b/src/client/pages/settings/email.vue index 5ccb79a41d..e334e23cbd 100644 --- a/src/client/pages/settings/email.vue +++ b/src/client/pages/settings/email.vue @@ -9,6 +9,11 @@ </FormLink> </FormGroup> + <FormLink to="/settings/email/notification"> + <template #icon><Fa :icon="faBell"/></template> + {{ $ts.emailNotification }} + </FormLink> + <FormSwitch :value="$i.receiveAnnouncementEmail" @update:value="onChangeReceiveAnnouncementEmail"> {{ $ts.receiveAnnouncementFromInstance }} </FormSwitch> @@ -43,7 +48,7 @@ export default defineComponent({ title: this.$ts.email, icon: faEnvelope }, - faCog, faExclamationTriangle, faCheck + faCog, faExclamationTriangle, faCheck, faBell } }, diff --git a/src/client/pages/settings/general.vue b/src/client/pages/settings/general.vue index 9314e14dab..0e741d474c 100644 --- a/src/client/pages/settings/general.vue +++ b/src/client/pages/settings/general.vue @@ -96,6 +96,7 @@ import { langs } from '@/config'; import { defaultStore } from '@/store'; import { ColdDeviceStorage } from '@/store'; import * as os from '@/os'; +import { unisonReload } from '@/scripts/unison-reload'; export default defineComponent({ components: { @@ -200,7 +201,7 @@ export default defineComponent({ }); if (canceled) return; - location.reload(); + unisonReload(); } } }); diff --git a/src/client/pages/settings/index.vue b/src/client/pages/settings/index.vue index df53eb5133..1b4aa70eca 100644 --- a/src/client/pages/settings/index.vue +++ b/src/client/pages/settings/index.vue @@ -52,6 +52,7 @@ import FormBase from '@/components/form/base.vue'; import FormButton from '@/components/form/button.vue'; import { scroll } from '@/scripts/scroll'; import { signout } from '@/account'; +import { unisonReload } from '@/scripts/unison-reload'; export default defineComponent({ components: { @@ -99,6 +100,7 @@ export default defineComponent({ case 'general': return defineAsyncComponent(() => import('./general.vue')); case 'email': return defineAsyncComponent(() => import('./email.vue')); case 'email/address': return defineAsyncComponent(() => import('./email-address.vue')); + case 'email/notification': return defineAsyncComponent(() => import('./email-notification.vue')); case 'theme': return defineAsyncComponent(() => import('./theme.vue')); case 'theme/install': return defineAsyncComponent(() => import('./theme.install.vue')); case 'theme/manage': return defineAsyncComponent(() => import('./theme.manage.vue')); @@ -159,7 +161,7 @@ export default defineComponent({ clear: () => { localStorage.removeItem('locale'); localStorage.removeItem('theme'); - location.reload(); + unisonReload(); }, faPalette, faPlug, faUser, faListUl, faLock, faLaugh, faCommentSlash, faMusic, faBell, faCogs, faEllipsisH, faBan, faShareAlt, faLockOpen, faKey, faBoxes, faEnvelope, faCloud, }; diff --git a/src/client/pages/settings/other.vue b/src/client/pages/settings/other.vue index c6ec9f6c0a..c0b9625098 100644 --- a/src/client/pages/settings/other.vue +++ b/src/client/pages/settings/other.vue @@ -40,6 +40,7 @@ import * as os from '@/os'; import { debug } from '@/config'; import { defaultStore } from '@/store'; import { signout } from '@/account'; +import { unisonReload } from '@/scripts/unison-reload'; export default defineComponent({ components: { @@ -76,7 +77,7 @@ export default defineComponent({ changeDebug(v) { console.log(v); localStorage.setItem('debug', v.toString()); - location.reload(); + unisonReload(); }, onChangeInjectFeaturedNote(v) { diff --git a/src/client/pages/settings/plugin.install.vue b/src/client/pages/settings/plugin.install.vue index 34c62619ad..0f6393f73c 100644 --- a/src/client/pages/settings/plugin.install.vue +++ b/src/client/pages/settings/plugin.install.vue @@ -1,6 +1,6 @@ <template> <FormBase> - <MkInfo warn>{{ $ts.pluginInstallWarn }}</MkInfo> + <MkInfo warn>{{ $ts._plugin.installWarn }}</MkInfo> <FormGroup> <FormTextarea v-model:value="code" tall> @@ -28,6 +28,7 @@ import FormButton from '@/components/form/button.vue'; import MkInfo from '@/components/ui/info.vue'; import * as os from '@/os'; import { ColdDeviceStorage } from '@/store'; +import { unisonReload } from '@/scripts/unison-reload'; export default defineComponent({ components: { @@ -138,7 +139,7 @@ export default defineComponent({ os.success(); this.$nextTick(() => { - location.reload(); + unisonReload(); }); }, } diff --git a/src/client/pages/settings/plugin.manage.vue b/src/client/pages/settings/plugin.manage.vue index 88eeef2bb2..5fd93d8b72 100644 --- a/src/client/pages/settings/plugin.manage.vue +++ b/src/client/pages/settings/plugin.manage.vue @@ -76,7 +76,7 @@ export default defineComponent({ ColdDeviceStorage.set('plugins', this.plugins.filter(x => x.id !== plugin.id)); os.success(); this.$nextTick(() => { - location.reload(); + unisonReload(); }); }, diff --git a/src/client/pages/settings/profile.vue b/src/client/pages/settings/profile.vue index 8c2c63e317..e9bffc3b00 100644 --- a/src/client/pages/settings/profile.vue +++ b/src/client/pages/settings/profile.vue @@ -8,25 +8,30 @@ <FormButton @click="changeBanner" primary>{{ $ts._profile.changeBanner }}</FormButton> </FormGroup> - <FormInput v-model:value="name" :max="30"> + <FormInput v-model:value="name" :max="30" manual-save> <span>{{ $ts._profile.name }}</span> </FormInput> - <FormTextarea v-model:value="description" :max="500"> + <FormTextarea v-model:value="description" :max="500" tall manual-save> <span>{{ $ts._profile.description }}</span> <template #desc>{{ $ts._profile.youCanIncludeHashtags }}</template> </FormTextarea> - <FormInput v-model:value="location"> + <FormInput v-model:value="location" manual-save> <span>{{ $ts.location }}</span> <template #prefix><Fa :icon="faMapMarkerAlt"/></template> </FormInput> - <FormInput v-model:value="birthday" type="date"> + <FormInput v-model:value="birthday" type="date" manual-save> <span>{{ $ts.birthday }}</span> <template #prefix><Fa :icon="faBirthdayCake"/></template> </FormInput> + <FormSelect v-model:value="lang"> + <template #label>{{ $ts.language }}</template> + <option v-for="x in langs" :value="x[0]" :key="x[0]">{{ x[1] }}</option> + </FormSelect> + <FormGroup> <FormButton @click="editMetadata" primary>{{ $ts._profile.metadataEdit }}</FormButton> <template #caption>{{ $ts._profile.metadataDescription }}</template> @@ -37,8 +42,6 @@ <FormSwitch v-model:value="isBot">{{ $ts.flagAsBot }}<template #desc>{{ $ts.flagAsBotDescription }}</template></FormSwitch> <FormSwitch v-model:value="alwaysMarkNsfw">{{ $ts.alwaysMarkSensitive }}</FormSwitch> - - <FormButton @click="save(true)" primary><Fa :icon="faSave"/> {{ $ts.save }}</FormButton> </FormBase> </template> @@ -50,10 +53,10 @@ import FormButton from '@/components/form/button.vue'; import FormInput from '@/components/form/input.vue'; import FormTextarea from '@/components/form/textarea.vue'; import FormSwitch from '@/components/form/switch.vue'; -import FormTuple from '@/components/form/tuple.vue'; +import FormSelect from '@/components/form/select.vue'; import FormBase from '@/components/form/base.vue'; import FormGroup from '@/components/form/group.vue'; -import { host } from '@/config'; +import { host, langs } from '@/config'; import { selectFile } from '@/scripts/select-file'; import * as os from '@/os'; @@ -63,7 +66,7 @@ export default defineComponent({ FormInput, FormTextarea, FormSwitch, - FormTuple, + FormSelect, FormBase, FormGroup, }, @@ -77,9 +80,11 @@ export default defineComponent({ icon: faUser }, host, + langs, name: null, description: null, birthday: null, + lang: null, location: null, fieldName0: null, fieldValue0: null, @@ -104,6 +109,7 @@ export default defineComponent({ this.description = this.$i.description; this.location = this.$i.location; this.birthday = this.$i.birthday; + this.lang = this.$i.lang; this.avatarId = this.$i.avatarId; this.bannerId = this.$i.bannerId; this.isBot = this.$i.isBot; @@ -118,6 +124,15 @@ export default defineComponent({ this.fieldValue2 = this.$i.fields[2] ? this.$i.fields[2].value : null; this.fieldName3 = this.$i.fields[3] ? this.$i.fields[3].name : null; this.fieldValue3 = this.$i.fields[3] ? this.$i.fields[3].value : null; + + this.$watch('name', this.save); + this.$watch('description', this.save); + this.$watch('location', this.save); + this.$watch('birthday', this.save); + this.$watch('lang', this.save); + this.$watch('isBot', this.save); + this.$watch('isCat', this.save); + this.$watch('alwaysMarkNsfw', this.save); }, mounted() { @@ -214,14 +229,15 @@ export default defineComponent({ }); }, - save(notify) { + save() { this.saving = true; - os.api('i/update', { + os.apiWithDialog('i/update', { name: this.name || null, description: this.description || null, location: this.location || null, birthday: this.birthday || null, + lang: this.lang || null, isBot: !!this.isBot, isCat: !!this.isCat, alwaysMarkNsfw: !!this.alwaysMarkNsfw, @@ -231,16 +247,8 @@ export default defineComponent({ this.$i.avatarUrl = i.avatarUrl; this.$i.bannerId = i.bannerId; this.$i.bannerUrl = i.bannerUrl; - - if (notify) { - os.success(); - } }).catch(err => { this.saving = false; - os.dialog({ - type: 'error', - text: err.id - }); }); }, } diff --git a/src/client/scripts/i18n.ts b/src/client/scripts/i18n.ts deleted file mode 100644 index d535e236bb..0000000000 --- a/src/client/scripts/i18n.ts +++ /dev/null @@ -1,44 +0,0 @@ -// Notice: Service Workerでも使用します -export class I18n<T extends Record<string, any>> { - public locale: T; - - constructor(locale: T) { - this.locale = locale; - - if (_DEV_) { - console.log('i18n', this.locale); - } - - //#region BIND - this.t = this.t.bind(this); - //#endregion - } - - // string にしているのは、ドット区切りでのパス指定を許可するため - // なるべくこのメソッド使うよりもlocale直接参照の方がvueのキャッシュ効いてパフォーマンスが良いかも - public t(key: string, args?: Record<string, any>): string { - try { - let str = key.split('.').reduce((o, i) => o[i], this.locale) as string; - - if (_DEV_) { - if (!str.includes('{')) { - console.warn(`i18n: '${key}' has no any arg. so ref prop directly instead of call this method.`); - } - } - - if (args) { - for (const [k, v] of Object.entries(args)) { - str = str.replace(`{${k}}`, v); - } - } - return str; - } catch (e) { - if (_DEV_) { - console.warn(`missing localization '${key}'`); - return `⚠'${key}'⚠`; - } - - return key; - } - } -} diff --git a/src/client/scripts/is-mobile.ts b/src/client/scripts/is-mobile.ts new file mode 100644 index 0000000000..60cb59f91e --- /dev/null +++ b/src/client/scripts/is-mobile.ts @@ -0,0 +1,2 @@ +const ua = navigator.userAgent.toLowerCase(); +export const isMobile = /mobile|iphone|ipad|android/.test(ua); diff --git a/src/client/scripts/paging.ts b/src/client/scripts/paging.ts index 3d9668f108..a8f122412c 100644 --- a/src/client/scripts/paging.ts +++ b/src/client/scripts/paging.ts @@ -1,9 +1,11 @@ import { markRaw } from 'vue'; import * as os from '@/os'; -import { onScrollTop, isTopVisible } from './scroll'; +import { onScrollTop, isTopVisible, getScrollPosition, getScrollContainer } from './scroll'; const SECOND_FETCH_LIMIT = 30; +// reversed: items 配列の中身を逆順にする(新しい方が最後) + export default (opts) => ({ emits: ['queue'], @@ -122,10 +124,8 @@ export default (opts) => ({ limit: SECOND_FETCH_LIMIT + 1, ...(this.pagination.offsetMode ? { offset: this.offset, - } : this.pagination.reversed ? { - sinceId: this.items[0].id, } : { - untilId: this.items[this.items.length - 1].id, + untilId: this.pagination.reversed ? this.items[0].id : this.items[this.items.length - 1].id, }), }).then(items => { for (const item of items) { @@ -146,26 +146,78 @@ export default (opts) => ({ }); }, - prepend(item) { - const isTop = this.isBackTop || (document.body.contains(this.$el) && isTopVisible(this.$el)); - - if (isTop) { - // Prepend the item - this.items.unshift(item); - - // オーバーフローしたら古いアイテムは捨てる - if (this.items.length >= opts.displayLimit) { - this.items = this.items.slice(0, opts.displayLimit); + async fetchMoreFeature() { + if (!this.more || this.fetching || this.moreFetching || this.items.length === 0) return; + this.moreFetching = true; + let params = typeof this.pagination.params === 'function' ? this.pagination.params(false) : this.pagination.params; + if (params && params.then) params = await params; + const endpoint = typeof this.pagination.endpoint === 'function' ? this.pagination.endpoint() : this.pagination.endpoint; + await os.api(endpoint, { + ...params, + limit: SECOND_FETCH_LIMIT + 1, + ...(this.pagination.offsetMode ? { + offset: this.offset, + } : { + sinceId: this.pagination.reversed ? this.items[0].id : this.items[this.items.length - 1].id, + }), + }).then(items => { + for (const item of items) { + markRaw(item); + } + if (items.length > SECOND_FETCH_LIMIT) { + items.pop(); + this.items = this.pagination.reversed ? [...items].reverse().concat(this.items) : this.items.concat(items); this.more = true; + } else { + this.items = this.pagination.reversed ? [...items].reverse().concat(this.items) : this.items.concat(items); + this.more = false; + } + this.offset += items.length; + this.moreFetching = false; + }, e => { + this.moreFetching = false; + }); + }, + + prepend(item) { + if (this.pagination.reversed) { + const container = getScrollContainer(this.$el); + const pos = getScrollPosition(this.$el); + const viewHeight = container.clientHeight; + const height = container.scrollHeight; + const isBottom = (pos + viewHeight > height - 32); + if (isBottom) { + // オーバーフローしたら古いアイテムは捨てる + if (this.items.length >= opts.displayLimit) { + this.items = this.items.slice(-opts.displayLimit); + this.more = true; + } + } else { + } + this.items.push(item); + // TODO } else { - this.queue.push(item); - onScrollTop(this.$el, () => { - for (const item of this.queue) { - this.prepend(item); + const isTop = this.isBackTop || (document.body.contains(this.$el) && isTopVisible(this.$el)); + + if (isTop) { + // Prepend the item + this.items.unshift(item); + + // オーバーフローしたら古いアイテムは捨てる + if (this.items.length >= opts.displayLimit) { + this.items = this.items.slice(0, opts.displayLimit); + this.more = true; } - this.queue = []; - }); + } else { + this.queue.push(item); + onScrollTop(this.$el, () => { + for (const item of this.queue) { + this.prepend(item); + } + this.queue = []; + }); + } } }, diff --git a/src/client/scripts/scroll.ts b/src/client/scripts/scroll.ts index 18c3366891..bc6d1530c5 100644 --- a/src/client/scripts/scroll.ts +++ b/src/client/scripts/scroll.ts @@ -54,6 +54,14 @@ export function scroll(el: Element, top: number) { } } +export function scrollToTop(el: Element) { + scroll(el, 0); +} + +export function scrollToBottom(el: Element) { + scroll(el, 99999); // TODO: ちゃんと計算する +} + export function isBottom(el: Element, asobi = 0) { const container = getScrollContainer(el); const current = container diff --git a/src/client/scripts/unison-reload.ts b/src/client/scripts/unison-reload.ts new file mode 100644 index 0000000000..92556aefaa --- /dev/null +++ b/src/client/scripts/unison-reload.ts @@ -0,0 +1,10 @@ +// SafariがBroadcastChannel未実装なのでライブラリを使う +import { BroadcastChannel } from 'broadcast-channel'; + +export const reloadChannel = new BroadcastChannel<'reload'>('reload'); + +// BroadcastChannelを用いて、クライアントが一斉にreloadするようにします。 +export function unisonReload() { + reloadChannel.postMessage('reload'); + location.reload(); +} diff --git a/src/client/sidebar.ts b/src/client/sidebar.ts index 98a70d2d1a..6bc786fdc0 100644 --- a/src/client/sidebar.ts +++ b/src/client/sidebar.ts @@ -5,6 +5,7 @@ import { search } from '@/scripts/search'; import * as os from '@/os'; import { i18n } from '@/i18n'; import { $i } from './account'; +import { unisonReload } from '@/scripts/unison-reload'; export const sidebarDef = { notifications: { @@ -133,19 +134,25 @@ export const sidebarDef = { text: i18n.locale.default, action: () => { localStorage.setItem('ui', 'default'); - location.reload(); + unisonReload(); } }, { text: i18n.locale.deck, action: () => { localStorage.setItem('ui', 'deck'); - location.reload(); + unisonReload(); + } + }, { + text: 'Chat (β)', + action: () => { + localStorage.setItem('ui', 'chat'); + unisonReload(); } }, { text: i18n.locale.desktop + ' (β)', action: () => { localStorage.setItem('ui', 'desktop'); - location.reload(); + unisonReload(); } }], ev.currentTarget || ev.target); }, diff --git a/src/client/style.scss b/src/client/style.scss index 1ac9b4e0b6..de548cc9c9 100644 --- a/src/client/style.scss +++ b/src/client/style.scss @@ -308,13 +308,6 @@ hr { box-shadow: none; } -._loadMore { - @extend ._panel; - @extend ._button; - width: 100%; - padding: 12px 0; -} - ._borderButton { @extend ._button; display: block; @@ -495,19 +488,6 @@ hr { transform: scale(0.9); } -.zoom-in-top-enter-active, -.zoom-in-top-leave-active { - opacity: 1; - transform: scaleY(1); - transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); - transform-origin: center top; -} -.zoom-in-top-enter-from, -.zoom-in-top-leave-active { - opacity: 0; - transform: scaleY(0); -} - @keyframes blink { 0% { opacity: 1; transform: scale(1); } 30% { opacity: 1; transform: scale(1); } diff --git a/src/client/sw/sw.ts b/src/client/sw/sw.ts index a18d305ea1..ec4de17551 100644 --- a/src/client/sw/sw.ts +++ b/src/client/sw/sw.ts @@ -5,12 +5,11 @@ declare var self: ServiceWorkerGlobalScope; import { get, set } from 'idb-keyval'; import composeNotification from '@/sw/compose-notification'; -import { I18n } from '@/scripts/i18n'; +import { I18n } from '../../misc/i18n'; //#region Variables const version = _VERSION_; const cacheName = `mk-cache-${version}`; -const apiUrl = `${location.origin}/api/`; let lang: string; let i18n: I18n<any>; @@ -27,15 +26,7 @@ get('lang').then(async prelang => { //#region Lifecycle: Install self.addEventListener('install', ev => { - ev.waitUntil( - caches.open(cacheName) - .then(cache => { - return cache.addAll([ - `/?v=${version}` - ]); - }) - .then(() => self.skipWaiting()) - ); + self.skipWaiting(); }); //#endregion @@ -53,19 +44,9 @@ self.addEventListener('activate', ev => { }); //#endregion -// TODO: 消せるかも ref. https://github.com/syuilo/misskey/pull/7108#issuecomment-774573666 //#region When: Fetching self.addEventListener('fetch', ev => { - if (ev.request.method !== 'GET' || ev.request.url.startsWith(apiUrl)) return; - ev.respondWith( - caches.match(ev.request) - .then(response => { - return response || fetch(ev.request); - }) - .catch(() => { - return caches.match(`/?v=${version}`); - }) - ); + // Nothing to do }); //#endregion diff --git a/src/client/themes/_dark.json5 b/src/client/themes/_dark.json5 index 847c0b4ec4..2fa4853e6f 100644 --- a/src/client/themes/_dark.json5 +++ b/src/client/themes/_dark.json5 @@ -16,6 +16,8 @@ bg: '#000', acrylicBg: ':alpha<0.5<@bg', fg: '#dadada', + fgTransparentWeak: ':alpha<0.75<@fg', + fgTransparent: ':alpha<0.5<@fg', fgHighlighted: ':lighten<3<@fg', divider: 'rgba(255, 255, 255, 0.1)', indicator: '@accent', @@ -77,5 +79,6 @@ X14: ':alpha<0.5<@navBg', X15: ':alpha<0<@panel', X16: ':alpha<0.7<@panel', + X17: ':alpha<0.8<@bg', }, } diff --git a/src/client/themes/_light.json5 b/src/client/themes/_light.json5 index d75e94afd6..94e6977502 100644 --- a/src/client/themes/_light.json5 +++ b/src/client/themes/_light.json5 @@ -16,6 +16,8 @@ bg: '#fff', acrylicBg: ':alpha<0.5<@bg', fg: '#5f5f5f', + fgTransparentWeak: ':alpha<0.75<@fg', + fgTransparent: ':alpha<0.5<@fg', fgHighlighted: ':darken<3<@fg', divider: 'rgba(0, 0, 0, 0.1)', indicator: '@accent', @@ -77,5 +79,6 @@ X14: ':alpha<0.5<@navBg', X15: ':alpha<0<@panel', X16: ':alpha<0.7<@panel', + X17: ':alpha<0.8<@bg', }, } diff --git a/src/client/ui/_common_/header.vue b/src/client/ui/_common_/header.vue index f662f6144d..f150653a84 100644 --- a/src/client/ui/_common_/header.vue +++ b/src/client/ui/_common_/header.vue @@ -1,5 +1,5 @@ <template> -<div class="fdidabkb" :style="`--height:${height};`"> +<div class="fdidabkb" :class="{ center }" :style="`--height:${height};`"> <transition :name="$store.state.animation ? 'header' : ''" mode="out-in" appear> <button class="_button back" v-if="withBack && canBack" @click.stop="back()"><Fa :icon="faChevronLeft"/></button> </transition> @@ -31,6 +31,11 @@ export default defineComponent({ required: false, default: true, }, + center: { + type: Boolean, + required: false, + default: true, + }, }, data() { @@ -67,7 +72,9 @@ export default defineComponent({ <style lang="scss" scoped> .fdidabkb { - text-align: center; + &.center { + text-align: center; + } > .back { height: var(--height); @@ -111,8 +118,13 @@ export default defineComponent({ right: 0; } + &.center { + > .titleContainer { + margin: 0 auto; + } + } + > .titleContainer { - margin: 0 auto; overflow: auto; white-space: nowrap; diff --git a/src/client/ui/chat/date-separated-list.vue b/src/client/ui/chat/date-separated-list.vue new file mode 100644 index 0000000000..b209330656 --- /dev/null +++ b/src/client/ui/chat/date-separated-list.vue @@ -0,0 +1,157 @@ +<script lang="ts"> +import { defineComponent, h, TransitionGroup } from 'vue'; +import { faAngleUp, faAngleDown } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'; + +export default defineComponent({ + props: { + items: { + type: Array, + required: true, + }, + reversed: { + type: Boolean, + required: false, + default: false + } + }, + + methods: { + focus() { + this.$slots.default[0].elm.focus(); + } + }, + + render() { + const getDateText = (time: string) => { + const date = new Date(time).getDate(); + const month = new Date(time).getMonth() + 1; + return this.$t('monthAndDay', { + month: month.toString(), + day: date.toString() + }); + } + + return h(TransitionGroup, { + class: 'hmjzthxl', + name: this.reversed ? 'list-reversed' : 'list', + tag: 'div', + }, this.items.map((item, i) => { + const el = this.$slots.default({ + item: item + })[0]; + if (el.key == null && item.id) el.key = item.id; + + if ( + i != this.items.length - 1 && + new Date(item.createdAt).getDate() != new Date(this.items[i + 1].createdAt).getDate() && + !item._prId_ && + !this.items[i + 1]._prId_ && + !item._featuredId_ && + !this.items[i + 1]._featuredId_ + ) { + const separator = h('div', { + class: 'separator', + key: item.id + ':separator', + }, h('p', { + class: 'date' + }, [ + h('span', [ + h(FontAwesomeIcon, { + class: 'icon', + icon: faAngleUp, + }), + getDateText(item.createdAt) + ]), + h('span', [ + getDateText(this.items[i + 1].createdAt), + h(FontAwesomeIcon, { + class: 'icon', + icon: faAngleDown, + }) + ]) + ])); + + return [el, separator]; + } else { + return el; + } + })); + }, +}); +</script> + +<style lang="scss"> +.hmjzthxl { + > .list-move { + transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1); + } + > .list-enter-active { + transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1); + } + > .list-enter-from { + opacity: 0; + transform: translateY(-64px); + } + + > .list-reversed-enter-active, > .list-reversed-leave-active { + transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1); + } + > .list-reversed-enter-from { + opacity: 0; + transform: translateY(64px); + } +} +</style> + +<style lang="scss"> +.hmjzthxl { + > .separator { + text-align: center; + position: relative; + + &:before { + content: ""; + display: block; + position: absolute; + top: 50%; + left: 0; + right: 0; + margin: auto; + width: calc(100% - 32px); + height: 1px; + background: var(--divider); + } + + > .date { + display: inline-block; + position: relative; + margin: 0; + padding: 0 16px; + line-height: 32px; + text-align: center; + font-size: 12px; + color: var(--dateLabelFg); + background: var(--panel); + + > span { + &:first-child { + margin-right: 8px; + + > .icon { + margin-right: 8px; + } + } + + &:last-child { + margin-left: 8px; + + > .icon { + margin-left: 8px; + } + } + } + } + } +} +</style> diff --git a/src/client/ui/chat/header-clock.vue b/src/client/ui/chat/header-clock.vue new file mode 100644 index 0000000000..65573d460b --- /dev/null +++ b/src/client/ui/chat/header-clock.vue @@ -0,0 +1,44 @@ +<template> +<div class="_monospace"> + <span> + <span v-text="hh"></span> + <span :style="{ visibility: showColon ? 'visible' : 'hidden' }">:</span> + <span v-text="mm"></span> + <span :style="{ visibility: showColon ? 'visible' : 'hidden' }">:</span> + <span v-text="ss"></span> + </span> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as os from '@/os'; + +export default defineComponent({ + data() { + return { + clock: null, + hh: null, + mm: null, + ss: null, + showColon: true, + }; + }, + created() { + this.tick(); + this.clock = setInterval(this.tick, 1000); + }, + beforeUnmount() { + clearInterval(this.clock); + }, + methods: { + tick() { + const now = new Date(); + this.hh = now.getHours().toString().padStart(2, '0'); + this.mm = now.getMinutes().toString().padStart(2, '0'); + this.ss = now.getSeconds().toString().padStart(2, '0'); + this.showColon = now.getSeconds() % 2 === 0; + } + } +}); +</script> diff --git a/src/client/ui/chat/index.vue b/src/client/ui/chat/index.vue new file mode 100644 index 0000000000..44f47447a7 --- /dev/null +++ b/src/client/ui/chat/index.vue @@ -0,0 +1,607 @@ +<template> +<div class="mk-app" @contextmenu.self.prevent="onContextmenu"> + <XSidebar ref="menu" class="menu" :default-hidden="true"/> + + <div class="nav"> + <header class="header"> + <div class="left"> + <button class="_button account" @click="openAccountMenu"> + <MkAvatar :user="$i" class="avatar"/><!--<MkAcct class="text" :user="$i"/>--> + </button> + </div> + <div class="right"> + <MkA class="item" to="/my/messaging" v-tooltip="$ts.messaging"><Fa class="icon" :icon="faComments"/><i v-if="$i.hasUnreadMessagingMessage"><Fa :icon="faCircle"/></i></MkA> + <MkA class="item" to="/my/messages" v-tooltip="$ts.directNotes"><Fa class="icon" :icon="faEnvelope"/><i v-if="$i.hasUnreadSpecifiedNotes"><Fa :icon="faCircle"/></i></MkA> + <MkA class="item" to="/my/mentions" v-tooltip="$ts.mentions"><Fa class="icon" :icon="faAt"/><i v-if="$i.hasUnreadMentions"><Fa :icon="faCircle"/></i></MkA> + <MkA class="item" to="/my/notifications" v-tooltip="$ts.notifications"><Fa class="icon" :icon="faBell"/><i v-if="$i.hasUnreadNotification"><Fa :icon="faCircle"/></i></MkA> + </div> + </header> + <div class="body"> + <div class="container"> + <div class="header">{{ $ts.timeline }}</div> + <div class="body"> + <MkA to="/timeline/home" class="item" :class="{ active: tl === 'home' }"><Fa :icon="faHome" class="icon"/>{{ $ts._timelines.home }}</MkA> + <MkA to="/timeline/local" class="item" :class="{ active: tl === 'local' }"><Fa :icon="faComments" class="icon"/>{{ $ts._timelines.local }}</MkA> + <MkA to="/timeline/social" class="item" :class="{ active: tl === 'social' }"><Fa :icon="faShareAlt" class="icon"/>{{ $ts._timelines.social }}</MkA> + <MkA to="/timeline/global" class="item" :class="{ active: tl === 'global' }"><Fa :icon="faGlobe" class="icon"/>{{ $ts._timelines.global }}</MkA> + </div> + </div> + <div class="container" v-if="followedChannels"> + <div class="header">{{ $ts.channel }} ({{ $ts.following }})<button class="_button add" @click="addChannel"><Fa :icon="faPlus"/></button></div> + <div class="body"> + <MkA v-for="channel in followedChannels" :key="channel.id" :to="`/channels/${ channel.id }`" class="item" :class="{ active: tl === `channel:${ channel.id }`, read: !channel.hasUnreadNote }"><Fa :icon="faSatelliteDish" class="icon"/>{{ channel.name }}</MkA> + </div> + </div> + <div class="container" v-if="featuredChannels"> + <div class="header">{{ $ts.channel }}<button class="_button add" @click="addChannel"><Fa :icon="faPlus"/></button></div> + <div class="body"> + <MkA v-for="channel in featuredChannels" :key="channel.id" :to="`/channels/${ channel.id }`" class="item" :class="{ active: tl === `channel:${ channel.id }` }"><Fa :icon="faSatelliteDish" class="icon"/>{{ channel.name }}</MkA> + </div> + </div> + <div class="container" v-if="lists"> + <div class="header">{{ $ts.lists }}<button class="_button add" @click="addList"><Fa :icon="faPlus"/></button></div> + <div class="body"> + <MkA v-for="list in lists" :key="list.id" :to="`/my/list/${ list.id }`" class="item" :class="{ active: tl === `list:${ list.id }` }"><Fa :icon="faListUl" class="icon"/>{{ list.name }}</MkA> + </div> + </div> + <div class="container" v-if="antennas"> + <div class="header">{{ $ts.antennas }}<button class="_button add" @click="addAntenna"><Fa :icon="faPlus"/></button></div> + <div class="body"> + <MkA v-for="antenna in antennas" :key="antenna.id" :to="`/my/antenna/${ antenna.id }`" class="item" :class="{ active: tl === `antenna:${ antenna.id }` }"><Fa :icon="faSatellite" class="icon"/>{{ antenna.name }}</MkA> + </div> + </div> + <div class="container"> + <div class="body"> + <MkA to="/my/favorites" class="item"><Fa :icon="faStar" class="icon"/>{{ $ts.favorites }}</MkA> + </div> + </div> + </div> + <footer class="footer"> + <div class="left"> + <button class="_button menu" @click="showMenu"> + <Fa class="icon" :icon="faBars"/> + </button> + </div> + <div class="right"> + <button class="_button item search" @click="search" v-tooltip="$ts.search"> + <Fa :icon="faSearch"/> + </button> + <MkA class="item" to="/settings" v-tooltip="$ts.settings"><Fa class="icon" :icon="faCog"/></MkA> + </div> + </footer> + </div> + + <main class="main" @contextmenu.stop="onContextmenu"> + <header class="header" ref="header" @click="onHeaderClick"> + <div class="left"> + <template v-if="tl === 'home'"> + <Fa :icon="faHome" class="icon"/> + <div class="title">{{ $ts._timelines.home }}</div> + </template> + <template v-else-if="tl === 'local'"> + <Fa :icon="faComments" class="icon"/> + <div class="title">{{ $ts._timelines.local }}</div> + </template> + <template v-else-if="tl === 'social'"> + <Fa :icon="faShareAlt" class="icon"/> + <div class="title">{{ $ts._timelines.social }}</div> + </template> + <template v-else-if="tl === 'global'"> + <Fa :icon="faGlobe" class="icon"/> + <div class="title">{{ $ts._timelines.global }}</div> + </template> + <template v-else-if="tl.startsWith('channel:')"> + <Fa :icon="faSatelliteDish" class="icon"/> + <div class="title" v-if="currentChannel">{{ currentChannel.name }}<div class="description">{{ currentChannel.description }}</div></div> + </template> + </div> + + <div class="right"> + <div class="instance">{{ instanceName }}</div> + <XHeaderClock class="clock"/> + <button class="_button button search" v-if="tl.startsWith('channel:') && currentChannel" @click="inChannelSearch" v-tooltip="$ts.inChannelSearch"> + <Fa :icon="faSearch"/> + </button> + <button class="_button button search" v-else @click="search" v-tooltip="$ts.search"> + <Fa :icon="faSearch"/> + </button> + <button class="_button button follow" v-if="tl.startsWith('channel:') && currentChannel" :class="{ followed: currentChannel.isFollowing }" @click="toggleChannelFollow" v-tooltip="currentChannel.isFollowing ? $ts.unfollow : $ts.follow"> + <Fa v-if="currentChannel.isFollowing" :icon="faStar"/> + <Fa v-else :icon="farStar"/> + </button> + <button class="_button button menu" v-if="tl.startsWith('channel:') && currentChannel" @click="openChannelMenu"> + <Fa :icon="faEllipsisH"/> + </button> + </div> + </header> + <div class="body"> + <XTimeline v-if="tl.startsWith('channel:')" src="channel" :key="tl" :channel="tl.replace('channel:', '')"/> + <XTimeline v-else :src="tl" :key="tl"/> + </div> + <footer class="footer"> + <XPostForm v-if="tl.startsWith('channel:')" :key="tl" :channel="tl.replace('channel:', '')"/> + <XPostForm v-else/> + </footer> + </main> + + <XSide class="side" ref="side" @open="sideViewOpening = true" @close="sideViewOpening = false"/> + <div class="side widgets" :class="{ sideViewOpening }"> + <XWidgets/> + </div> + + <XCommon/> +</div> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue'; +import { faLayerGroup, faBars, faHome, faCircle, faWindowMaximize, faColumns, faPencilAlt, faShareAlt, faSatelliteDish, faListUl, faSatellite, faCog, faSearch, faPlus, faStar, faAt, faLink, faEllipsisH, faGlobe } from '@fortawesome/free-solid-svg-icons'; +import { faBell, faStar as farStar, faEnvelope, faComments } from '@fortawesome/free-regular-svg-icons'; +import { instanceName, url } from '@/config'; +import XSidebar from '@/components/sidebar.vue'; +import XWidgets from './widgets.vue'; +import XCommon from '../_common_/common.vue'; +import XSide from './side.vue'; +import XTimeline from './timeline.vue'; +import XPostForm from './post-form.vue'; +import XHeaderClock from './header-clock.vue'; +import * as os from '@/os'; +import { router } from '@/router'; +import { sidebarDef } from '@/sidebar'; +import { search } from '@/scripts/search'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; + +export default defineComponent({ + components: { + XCommon, + XSidebar, + XWidgets, + XSide, // NOTE: dynamic importするとAsyncComponentWrapperが間に入るせいでref取得できなくて面倒になる + XTimeline, + XPostForm, + XHeaderClock, + }, + + provide() { + return { + navHook: (path) => { + switch (path) { + case '/timeline/home': this.tl = 'home'; return; + case '/timeline/local': this.tl = 'local'; return; + case '/timeline/social': this.tl = 'social'; return; + case '/timeline/global': this.tl = 'global'; return; + + default: + if (path.startsWith('/channels/')) { + this.tl = `channel:${ path.replace('/channels/', '') }`; + return; + } + //os.pageWindow(path); + this.$refs.side.navigate(path); + break; + } + }, + sideViewHook: (path) => { + this.$refs.side.navigate(path); + } + }; + }, + + data() { + return { + tl: 'home', + lists: null, + antennas: null, + followedChannels: null, + featuredChannels: null, + currentChannel: null, + menuDef: sidebarDef, + sideViewOpening: false, + instanceName, + faLayerGroup, faBars, faBell, faHome, faCircle, faPencilAlt, faShareAlt, faSatelliteDish, faListUl, faSatellite, faCog, faSearch, faPlus, faStar, farStar, faAt, faLink, faEllipsisH, faGlobe, faComments, faEnvelope, + }; + }, + + created() { + if (window.innerWidth < 1024) { + localStorage.setItem('ui', 'default'); + location.reload(); + } + + router.beforeEach((to, from) => { + this.$refs.side.navigate(to.fullPath); + // search?q=foo のようなクエリを受け取れるようにするため、return falseはできない + //return false; + }); + + os.api('users/lists/list').then(lists => { + this.lists = lists; + }); + + os.api('antennas/list').then(antennas => { + this.antennas = antennas; + }); + + os.api('channels/followed').then(channels => { + this.followedChannels = channels; + }); + + os.api('channels/featured').then(channels => { + this.featuredChannels = channels; + }); + + this.$watch('tl', () => { + if (this.tl.startsWith('channel:')) { + os.api('channels/show', { channelId: this.tl.replace('channel:', '') }).then(channel => { + this.currentChannel = channel; + }); + } + }, { immediate: true }); + }, + + methods: { + showMenu() { + this.$refs.menu.show(); + }, + + post() { + os.post(); + }, + + search() { + search(); + }, + + async inChannelSearch() { + const { canceled, result: query } = await os.dialog({ + title: this.$ts.inChannelSearch, + input: true + }); + if (canceled || query == null || query === '') return; + router.push(`/search?q=${encodeURIComponent(query)}&channel=${this.currentChannel.id}`); + }, + + top() { + window.scroll({ top: 0, behavior: 'smooth' }); + }, + + async toggleChannelFollow() { + if (this.currentChannel.isFollowing) { + await os.apiWithDialog('channels/unfollow', { + channelId: this.currentChannel.id + }); + this.currentChannel.isFollowing = false; + } else { + await os.apiWithDialog('channels/follow', { + channelId: this.currentChannel.id + }); + this.currentChannel.isFollowing = true; + } + }, + + openChannelMenu(ev) { + os.modalMenu([{ + text: this.$ts.copyUrl, + icon: faLink, + action: () => { + copyToClipboard(`${url}/channels/${this.currentChannel.id}`); + } + }], ev.currentTarget || ev.target); + }, + + onTransition() { + if (window._scroll) window._scroll(); + }, + + onHeaderClick() { + window.scroll({ top: 0, behavior: 'smooth' }); + }, + + onContextmenu(e) { + const isLink = (el: HTMLElement) => { + if (el.tagName === 'A') return true; + if (el.parentElement) { + return isLink(el.parentElement); + } + }; + if (isLink(e.target)) return; + if (['INPUT', 'TEXTAREA'].includes(e.target.tagName) || e.target.attributes['contenteditable']) return; + if (window.getSelection().toString() !== '') return; + const path = this.$route.path; + os.contextMenu([{ + type: 'label', + text: path, + }, { + icon: faColumns, + text: this.$ts.openInSideView, + action: () => { + this.$refs.side.navigate(path); + } + }, { + icon: faWindowMaximize, + text: this.$ts.openInWindow, + action: () => { + os.pageWindow(path); + } + }], e); + }, + } +}); +</script> + +<style lang="scss" scoped> +.mk-app { + $header-height: 54px; // TODO: どこかに集約したい + $ui-font-size: 1em; // TODO: どこかに集約したい + + // ほんとは単に 100vh と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ + height: calc(var(--vh, 1vh) * 100); + display: flex; + + > .nav { + display: flex; + flex-direction: column; + width: 250px; + height: 100vh; + border-right: solid 1px var(--divider); + + > .header, > .footer { + $padding: 8px; + display: flex; + align-items: center; + z-index: 1000; + height: $header-height; + padding: $padding; + box-sizing: border-box; + user-select: none; + + &.header { + border-bottom: solid 1px var(--divider); + } + + &.footer { + border-top: solid 1px var(--divider); + } + + > .left, > .right { + > .item, > .menu { + display: inline-block; + vertical-align: middle; + height: ($header-height - ($padding * 2)); + width: ($header-height - ($padding * 2)); + box-sizing: border-box; + //opacity: 0.6; + position: relative; + border-radius: 5px; + + &:hover { + background: rgba(0, 0, 0, 0.05); + } + + > .icon { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + margin: auto; + } + + > i { + position: absolute; + top: 8px; + right: 8px; + color: var(--indicator); + font-size: 8px; + line-height: 8px; + animation: blink 1s infinite; + } + } + } + + > .left { + flex: 1; + min-width: 0; + + > .account { + display: flex; + align-items: center; + padding: 0 8px; + + > .avatar { + width: 26px; + height: 26px; + margin-right: 8px; + } + + > .text { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 0.9em; + } + } + } + + > .right { + margin-left: auto; + } + } + + > .body { + flex: 1; + min-width: 0; + overflow: auto; + + > .container { + margin-top: 8px; + margin-bottom: 8px; + + & + .container { + margin-top: 16px; + } + + > .header { + display: flex; + font-size: 0.9em; + padding: 8px 16px; + position: sticky; + top: 0; + background: var(--X17); + -webkit-backdrop-filter: blur(8px); + backdrop-filter: blur(8px); + z-index: 1; + color: var(--fgTransparentWeak); + + > .add { + margin-left: auto; + color: var(--fgTransparentWeak); + + &:hover { + color: var(--fg); + } + } + } + + > .body { + padding: 0 8px; + + > .item { + display: block; + padding: 6px 8px; + border-radius: 4px; + + &:hover { + text-decoration: none; + background: rgba(0, 0, 0, 0.05); + } + + &.active, &.active:hover { + background: var(--accent); + color: #fff !important; + } + + &.read { + color: var(--fgTransparent); + } + + > .icon { + margin-right: 8px; + opacity: 0.6; + } + } + } + } + } + } + + > .main { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; + height: 100vh; + position: relative; + background: var(--panel); + + > .header { + $padding: 8px; + display: flex; + z-index: 1000; + height: $header-height; + padding: $padding; + box-sizing: border-box; + background-color: var(--panel); + border-bottom: solid 1px var(--divider); + user-select: none; + + > .left { + display: flex; + align-items: center; + flex: 1; + min-width: 0; + + > .icon { + height: ($header-height - ($padding * 2)); + width: ($header-height - ($padding * 2)); + padding: 10px; + box-sizing: border-box; + margin-right: 4px; + opacity: 0.6; + } + + > .title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + font-weight: bold; + + > .description { + opacity: 0.6; + font-size: 0.8em; + font-weight: normal; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + } + + > .right { + display: flex; + align-items: center; + min-width: 0; + margin-left: auto; + padding-left: 8px; + + > .instance { + margin-right: 16px; + font-size: 0.9em; + } + + > .clock { + margin-right: 16px; + } + + > .button { + height: ($header-height - ($padding * 2)); + width: ($header-height - ($padding * 2)); + box-sizing: border-box; + position: relative; + border-radius: 5px; + + &:hover { + background: rgba(0, 0, 0, 0.05); + } + + &.follow.followed { + color: var(--accent); + } + } + } + } + + > .footer { + padding: 0 16px 16px 16px; + } + + > .body { + flex: 1; + min-width: 0; + overflow: auto; + } + } + + > .side { + width: 350px; + border-left: solid 1px var(--divider); + + &.widgets.sideViewOpening { + @media (max-width: 1400px) { + display: none; + } + } + } +} +</style> diff --git a/src/client/ui/chat/note-header.vue b/src/client/ui/chat/note-header.vue new file mode 100644 index 0000000000..cda8ae00e2 --- /dev/null +++ b/src/client/ui/chat/note-header.vue @@ -0,0 +1,115 @@ +<template> +<header class="dehvdgxo"> + <MkA class="name" :to="userPage(note.user)" v-user-preview="note.user.id"> + <MkUserName :user="note.user"/> + </MkA> + <span class="is-bot" v-if="note.user.isBot">bot</span> + <span class="username"><MkAcct :user="note.user"/></span> + <span class="admin" v-if="note.user.isAdmin"><Fa :icon="faBookmark"/></span> + <span class="moderator" v-if="!note.user.isAdmin && note.user.isModerator"><Fa :icon="farBookmark"/></span> + <div class="info"> + <span class="mobile" v-if="note.viaMobile"><Fa :icon="faMobileAlt"/></span> + <MkA class="created-at" :to="notePage(note)"> + <MkTime :time="note.createdAt"/> + </MkA> + <span class="visibility" v-if="note.visibility !== 'public'"> + <Fa v-if="note.visibility === 'home'" :icon="faHome"/> + <Fa v-if="note.visibility === 'followers'" :icon="faUnlock"/> + <Fa v-if="note.visibility === 'specified'" :icon="faEnvelope"/> + </span> + <span class="localOnly" v-if="note.localOnly"><Fa :icon="faBiohazard"/></span> + </div> +</header> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { faHome, faUnlock, faEnvelope, faMobileAlt, faBookmark, faBiohazard } from '@fortawesome/free-solid-svg-icons'; +import { faBookmark as farBookmark } from '@fortawesome/free-regular-svg-icons'; +import notePage from '@/filters/note'; +import { userPage } from '@/filters/user'; +import * as os from '@/os'; + +export default defineComponent({ + props: { + note: { + type: Object, + required: true + }, + }, + + data() { + return { + faHome, faUnlock, faEnvelope, faMobileAlt, faBookmark, farBookmark, faBiohazard + }; + }, + + methods: { + notePage, + userPage + } +}); +</script> + +<style lang="scss" scoped> +.dehvdgxo { + display: flex; + align-items: baseline; + white-space: nowrap; + font-size: 0.9em; + + > .name { + display: block; + margin: 0 .5em 0 0; + padding: 0; + overflow: hidden; + font-size: 1em; + font-weight: bold; + text-decoration: none; + text-overflow: ellipsis; + + &:hover { + text-decoration: underline; + } + } + + > .is-bot { + flex-shrink: 0; + align-self: center; + margin: 0 .5em 0 0; + padding: 1px 6px; + font-size: 80%; + border: solid 1px var(--divider); + border-radius: 3px; + } + + > .admin, + > .moderator { + margin-right: 0.5em; + color: var(--badge); + } + + > .username { + margin: 0 .5em 0 0; + overflow: hidden; + text-overflow: ellipsis; + } + + > .info { + font-size: 0.9em; + opacity: 0.7; + + > .mobile { + margin-right: 8px; + } + + > .visibility { + margin-left: 8px; + } + + > .localOnly { + margin-left: 8px; + } + } +} +</style> diff --git a/src/client/ui/chat/note-preview.vue b/src/client/ui/chat/note-preview.vue new file mode 100644 index 0000000000..4861473701 --- /dev/null +++ b/src/client/ui/chat/note-preview.vue @@ -0,0 +1,112 @@ +<template> +<div class="hduudsxk"> + <MkAvatar class="avatar" :user="note.user"/> + <div class="main"> + <XNoteHeader class="header" :note="note" :mini="true"/> + <div class="body"> + <p v-if="note.cw != null" class="cw"> + <span class="text" v-if="note.cw != ''">{{ note.cw }}</span> + <XCwButton v-model:value="showContent" :note="note"/> + </p> + <div class="content" v-show="note.cw == null || showContent"> + <XSubNote-content class="text" :note="note"/> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XNoteHeader from './note-header.vue'; +import XSubNoteContent from './sub-note-content.vue'; +import XCwButton from '@/components/cw-button.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XNoteHeader, + XSubNoteContent, + XCwButton, + }, + + props: { + note: { + type: Object, + required: true + } + }, + + data() { + return { + showContent: false + }; + } +}); +</script> + +<style lang="scss" scoped> +.hduudsxk { + display: flex; + margin: 0; + padding: 0; + overflow: hidden; + font-size: 0.95em; + + > .avatar { + + @media (min-width: 350px) { + margin: 0 10px 0 0; + width: 44px; + height: 44px; + } + + @media (min-width: 500px) { + margin: 0 12px 0 0; + width: 48px; + height: 48px; + } + } + + > .avatar { + flex-shrink: 0; + display: block; + margin: 0 10px 0 0; + width: 40px; + height: 40px; + border-radius: 8px; + } + + > .main { + flex: 1; + min-width: 0; + + > .header { + margin-bottom: 2px; + } + + > .body { + + > .cw { + cursor: default; + display: block; + margin: 0; + padding: 0; + overflow-wrap: break-word; + + > .text { + margin-right: 8px; + } + } + + > .content { + > .text { + cursor: default; + margin: 0; + padding: 0; + } + } + } + } +} +</style> diff --git a/src/client/ui/chat/note.sub.vue b/src/client/ui/chat/note.sub.vue new file mode 100644 index 0000000000..6f365c29e9 --- /dev/null +++ b/src/client/ui/chat/note.sub.vue @@ -0,0 +1,137 @@ +<template> +<div class="wrpstxzv" :class="{ children }"> + <div class="main"> + <MkAvatar class="avatar" :user="note.user"/> + <div class="body"> + <XNoteHeader class="header" :note="note" :mini="true"/> + <div class="body"> + <p v-if="note.cw != null" class="cw"> + <Mfm v-if="note.cw != ''" class="text" :text="note.cw" :author="note.user" :i="$i" :custom-emojis="note.emojis" /> + <XCwButton v-model:value="showContent" :note="note"/> + </p> + <div class="content" v-show="note.cw == null || showContent"> + <XSubNote-content class="text" :note="note"/> + </div> + </div> + </div> + </div> + <XSub v-for="reply in replies" :key="reply.id" :note="reply" class="reply" :detail="true" :children="true"/> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XNoteHeader from './note-header.vue'; +import XSubNoteContent from './sub-note-content.vue'; +import XCwButton from '@/components/cw-button.vue'; +import * as os from '@/os'; + +export default defineComponent({ + name: 'XSub', + + components: { + XNoteHeader, + XSubNoteContent, + XCwButton, + }, + + props: { + note: { + type: Object, + required: true + }, + detail: { + type: Boolean, + required: false, + default: false + }, + children: { + type: Boolean, + required: false, + default: false + }, + // TODO + truncate: { + type: Boolean, + default: true + } + }, + + data() { + return { + showContent: false, + replies: [], + }; + }, + + created() { + if (this.detail) { + os.api('notes/children', { + noteId: this.note.id, + limit: 5 + }).then(replies => { + this.replies = replies; + }); + } + }, +}); +</script> + +<style lang="scss" scoped> +.wrpstxzv { + padding: 16px 16px; + font-size: 0.8em; + + &.children { + padding: 10px 0 0 16px; + font-size: 1em; + } + + > .main { + display: flex; + + > .avatar { + flex-shrink: 0; + display: block; + margin: 0 8px 0 0; + width: 36px; + height: 36px; + } + + > .body { + flex: 1; + min-width: 0; + + > .header { + margin-bottom: 2px; + } + + > .body { + > .cw { + cursor: default; + display: block; + margin: 0; + padding: 0; + overflow-wrap: break-word; + + > .text { + margin-right: 8px; + } + } + + > .content { + > .text { + margin: 0; + padding: 0; + } + } + } + } + } + + > .reply { + border-left: solid 1px var(--divider); + margin-top: 10px; + } +} +</style> diff --git a/src/client/ui/chat/note.vue b/src/client/ui/chat/note.vue new file mode 100644 index 0000000000..f4c9f063dc --- /dev/null +++ b/src/client/ui/chat/note.vue @@ -0,0 +1,1155 @@ +<template> +<div + class="vfzoeqcg" + v-if="!muted" + v-show="!isDeleted" + :tabindex="!isDeleted ? '-1' : null" + :class="{ renote: isRenote, highlighted: appearNote._prId_ || appearNote._featuredId_, operating }" + v-hotkey="keymap" +> + <XSub :note="appearNote.reply" class="reply-to" v-if="appearNote.reply"/> + <div class="info" v-if="pinned"><Fa :icon="faThumbtack"/> {{ $ts.pinnedNote }}</div> + <div class="info" v-if="appearNote._prId_"><Fa :icon="faBullhorn"/> {{ $ts.promotion }}<button class="_textButton hide" @click="readPromo()">{{ $ts.hideThisNote }} <Fa :icon="faTimes"/></button></div> + <div class="info" v-if="appearNote._featuredId_"><Fa :icon="faBolt"/> {{ $ts.featured }}</div> + <div class="renote" v-if="isRenote"> + <MkAvatar class="avatar" :user="note.user"/> + <Fa :icon="faRetweet"/> + <I18n :src="$ts.renotedBy" tag="span"> + <template #user> + <MkA class="name" :to="userPage(note.user)" v-user-preview="note.userId"> + <MkUserName :user="note.user"/> + </MkA> + </template> + </I18n> + <div class="info"> + <button class="_button time" @click="showRenoteMenu()" ref="renoteTime"> + <Fa class="dropdownIcon" v-if="isMyRenote" :icon="faEllipsisH"/> + <MkTime :time="note.createdAt"/> + </button> + <span class="visibility" v-if="note.visibility !== 'public'"> + <Fa v-if="note.visibility === 'home'" :icon="faHome"/> + <Fa v-if="note.visibility === 'followers'" :icon="faUnlock"/> + <Fa v-if="note.visibility === 'specified'" :icon="faEnvelope"/> + </span> + <span class="localOnly" v-if="note.localOnly"><Fa :icon="faBiohazard"/></span> + </div> + </div> + <article class="article" @contextmenu.stop="onContextmenu"> + <MkAvatar class="avatar" :user="appearNote.user"/> + <div class="main"> + <XNoteHeader class="header" :note="appearNote" :mini="true"/> + <MkInstanceTicker v-if="showTicker" class="ticker" :instance="appearNote.user.instance"/> + <div class="body"> + <p v-if="appearNote.cw != null" class="cw"> + <Mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/> + <XCwButton v-model:value="showContent" :note="appearNote"/> + </p> + <div class="content" :class="{ collapsed }" v-show="appearNote.cw == null || showContent"> + <div class="text"> + <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ $ts.private }})</span> + <MkA class="reply" v-if="appearNote.replyId" :to="`/notes/${appearNote.replyId}`"><Fa :icon="faReply"/></MkA> + <Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/> + <a class="rp" v-if="appearNote.renote != null">RN:</a> + </div> + <div class="files" v-if="appearNote.files.length > 0"> + <XMediaList :media-list="appearNote.files"/> + </div> + <XPoll v-if="appearNote.poll" :note="appearNote" ref="pollViewer" class="poll"/> + <MkUrlPreview v-for="url in urls" :url="url" :key="url" :compact="true" :detail="false" class="url-preview"/> + <div class="renote" v-if="appearNote.renote"><XNotePreview :note="appearNote.renote"/></div> + <button v-if="collapsed" class="fade _button" @click="collapsed = false"> + <span>{{ $ts.showMore }}</span> + </button> + </div> + <MkA v-if="appearNote.channel && !inChannel" class="channel" :to="`/channels/${appearNote.channel.id}`"><Fa :icon="faSatelliteDish"/> {{ appearNote.channel.name }}</MkA> + </div> + <XReactionsViewer :note="appearNote" ref="reactionsViewer"/> + <footer class="footer _panel"> + <button @click="reply()" class="button _button" v-tooltip="$ts.reply"> + <template v-if="appearNote.reply"><Fa :icon="faReplyAll"/></template> + <template v-else><Fa :icon="faReply"/></template> + <p class="count" v-if="appearNote.repliesCount > 0">{{ appearNote.repliesCount }}</p> + </button> + <button v-if="canRenote" @click="renote()" class="button _button" ref="renoteButton" v-tooltip="$ts.renote"> + <Fa :icon="faRetweet"/><p class="count" v-if="appearNote.renoteCount > 0">{{ appearNote.renoteCount }}</p> + </button> + <button v-else class="button _button"> + <Fa :icon="faBan"/> + </button> + <button v-if="appearNote.myReaction == null" class="button _button" @click="react()" ref="reactButton" v-tooltip="$ts.reaction"> + <Fa :icon="faPlus"/> + </button> + <button v-if="appearNote.myReaction != null" class="button _button reacted" @click="undoReact(appearNote)" ref="reactButton" v-tooltip="$ts.reaction"> + <Fa :icon="faMinus"/> + </button> + <button class="button _button" @click="menu()" ref="menuButton"> + <Fa :icon="faEllipsisH"/> + </button> + </footer> + </div> + </article> +</div> +<div v-else class="muted" @click="muted = false"> + <I18n :src="$ts.userSaysSomething" tag="small"> + <template #name> + <MkA class="name" :to="userPage(appearNote.user)" v-user-preview="appearNote.userId"> + <MkUserName :user="appearNote.user"/> + </MkA> + </template> + </I18n> +</div> +</template> + +<script lang="ts"> +import { computed, defineAsyncComponent, defineComponent, markRaw, ref } from 'vue'; +import { faSatelliteDish, faBolt, faTimes, faBullhorn, faStar, faLink, faExternalLinkSquareAlt, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faQuoteRight, faInfoCircle, faBiohazard, faPlug, faExclamationCircle, faPaperclip } from '@fortawesome/free-solid-svg-icons'; +import { faCopy, faTrashAlt, faEdit, faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons'; +import { parse } from '../../../mfm/parse'; +import { sum, unique } from '../../../prelude/array'; +import XSub from './note.sub.vue'; +import XNoteHeader from './note-header.vue'; +import XNotePreview from './note-preview.vue'; +import XReactionsViewer from '@/components/reactions-viewer.vue'; +import XMediaList from '@/components/media-list.vue'; +import XCwButton from '@/components/cw-button.vue'; +import XPoll from '@/components/poll.vue'; +import { pleaseLogin } from '@/scripts/please-login'; +import { focusPrev, focusNext } from '@/scripts/focus'; +import { url } from '@/config'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; +import { checkWordMute } from '@/scripts/check-word-mute'; +import { userPage } from '@/filters/user'; +import * as os from '@/os'; +import { noteActions, noteViewInterruptors } from '@/store'; + +function markRawAll(...xs) { + for (const x of xs) { + markRaw(x); + } +} + +markRawAll(faEdit, faBolt, faTimes, faBullhorn, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faBiohazard, faPlug, faSatelliteDish); + +export default defineComponent({ + components: { + XSub, + XNoteHeader, + XNotePreview, + XReactionsViewer, + XMediaList, + XCwButton, + XPoll, + MkUrlPreview: defineAsyncComponent(() => import('@/components/url-preview.vue')), + MkInstanceTicker: defineAsyncComponent(() => import('@/components/instance-ticker.vue')), + }, + + inject: { + inChannel: { + default: null + }, + }, + + props: { + note: { + type: Object, + required: true + }, + pinned: { + type: Boolean, + required: false, + default: false + }, + }, + + emits: ['update:note'], + + data() { + return { + connection: null, + replies: [], + showContent: false, + collapsed: false, + isDeleted: false, + muted: false, + operating: false, + faEdit, faBolt, faTimes, faBullhorn, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faBiohazard, faPlug, faSatelliteDish + }; + }, + + computed: { + rs() { + return this.$store.state.reactions; + }, + keymap(): any { + return { + 'r': () => this.reply(true), + 'e|a|plus': () => this.react(true), + 'q': () => this.renote(true), + 'f|b': this.favorite, + 'delete|ctrl+d': this.del, + 'ctrl+q': this.renoteDirectly, + 'up|k|shift+tab': this.focusBefore, + 'down|j|tab': this.focusAfter, + 'esc': this.blur, + 'm|o': () => this.menu(true), + 's': this.toggleShowContent, + '1': () => this.reactDirectly(this.rs[0]), + '2': () => this.reactDirectly(this.rs[1]), + '3': () => this.reactDirectly(this.rs[2]), + '4': () => this.reactDirectly(this.rs[3]), + '5': () => this.reactDirectly(this.rs[4]), + '6': () => this.reactDirectly(this.rs[5]), + '7': () => this.reactDirectly(this.rs[6]), + '8': () => this.reactDirectly(this.rs[7]), + '9': () => this.reactDirectly(this.rs[8]), + '0': () => this.reactDirectly(this.rs[9]), + }; + }, + + isRenote(): boolean { + return (this.note.renote && + this.note.text == null && + this.note.fileIds.length == 0 && + this.note.poll == null); + }, + + appearNote(): any { + return this.isRenote ? this.note.renote : this.note; + }, + + isMyNote(): boolean { + return this.$i && (this.$i.id === this.appearNote.userId); + }, + + isMyRenote(): boolean { + return this.$i && (this.$i.id === this.note.userId); + }, + + canRenote(): boolean { + return ['public', 'home'].includes(this.appearNote.visibility) || this.isMyNote; + }, + + reactionsCount(): number { + return this.appearNote.reactions + ? sum(Object.values(this.appearNote.reactions)) + : 0; + }, + + urls(): string[] { + if (this.appearNote.text) { + const ast = parse(this.appearNote.text); + // TODO: 再帰的にURL要素がないか調べる + const urls = unique(ast + .filter(t => ((t.node.type == 'url' || t.node.type == 'link') && t.node.props.url && !t.node.props.silent)) + .map(t => t.node.props.url)); + + // unique without hash + // [ http://a/#1, http://a/#2, http://b/#3 ] => [ http://a/#1, http://b/#3 ] + const removeHash = x => x.replace(/#[^#]*$/, ''); + + return urls.reduce((array, url) => { + const removed = removeHash(url); + if (!array.map(x => removeHash(x)).includes(removed)) array.push(url); + return array; + }, []); + } else { + return null; + } + }, + + showTicker() { + if (this.$store.state.instanceTicker === 'always') return true; + if (this.$store.state.instanceTicker === 'remote' && this.appearNote.user.instance) return true; + return false; + } + }, + + async created() { + if (this.$i) { + this.connection = os.stream; + } + + this.collapsed = this.appearNote.cw == null && this.appearNote.text && ( + (this.appearNote.text.split('\n').length > 9) || + (this.appearNote.text.length > 500) + ); + this.muted = await checkWordMute(this.appearNote, this.$i, this.$store.state.mutedWords); + + // plugin + if (noteViewInterruptors.length > 0) { + let result = this.note; + for (const interruptor of noteViewInterruptors) { + result = await interruptor.handler(JSON.parse(JSON.stringify(result))); + } + this.$emit('update:note', Object.freeze(result)); + } + }, + + mounted() { + this.capture(true); + + if (this.$i) { + this.connection.on('_connected_', this.onStreamConnected); + } + }, + + beforeUnmount() { + this.decapture(true); + + if (this.$i) { + this.connection.off('_connected_', this.onStreamConnected); + } + }, + + methods: { + updateAppearNote(v) { + this.$emit('update:note', Object.freeze(this.isRenote ? { + ...this.note, + renote: { + ...this.note.renote, + ...v + } + } : { + ...this.note, + ...v + })); + }, + + readPromo() { + os.api('promo/read', { + noteId: this.appearNote.id + }); + this.isDeleted = true; + }, + + capture(withHandler = false) { + if (this.$i) { + this.connection.send(document.body.contains(this.$el) ? 'sn' : 's', { id: this.appearNote.id }); + if (withHandler) this.connection.on('noteUpdated', this.onStreamNoteUpdated); + } + }, + + decapture(withHandler = false) { + if (this.$i) { + this.connection.send('un', { + id: this.appearNote.id + }); + if (withHandler) this.connection.off('noteUpdated', this.onStreamNoteUpdated); + } + }, + + onStreamConnected() { + this.capture(); + }, + + onStreamNoteUpdated(data) { + const { type, id, body } = data; + + if (id !== this.appearNote.id) return; + + switch (type) { + case 'reacted': { + const reaction = body.reaction; + + // DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので) + let n = { + ...this.appearNote, + }; + + if (body.emoji) { + const emojis = this.appearNote.emojis || []; + if (!emojis.includes(body.emoji)) { + n.emojis = [...emojis, body.emoji]; + } + } + + // TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる + const currentCount = (this.appearNote.reactions || {})[reaction] || 0; + + // Increment the count + n.reactions = { + ...this.appearNote.reactions, + [reaction]: currentCount + 1 + }; + + if (body.userId === this.$i.id) { + n.myReaction = reaction; + } + + this.updateAppearNote(n); + break; + } + + case 'unreacted': { + const reaction = body.reaction; + + // DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので) + let n = { + ...this.appearNote, + }; + + // TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる + const currentCount = (this.appearNote.reactions || {})[reaction] || 0; + + // Decrement the count + n.reactions = { + ...this.appearNote.reactions, + [reaction]: Math.max(0, currentCount - 1) + }; + + if (body.userId === this.$i.id) { + n.myReaction = null; + } + + this.updateAppearNote(n); + break; + } + + case 'pollVoted': { + const choice = body.choice; + + // DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので) + let n = { + ...this.appearNote, + }; + + const choices = [...this.appearNote.poll.choices]; + choices[choice] = { + ...choices[choice], + votes: choices[choice].votes + 1, + ...(body.userId === this.$i.id ? { + isVoted: true + } : {}) + }; + + n.poll = { + ...this.appearNote.poll, + choices: choices + }; + + this.updateAppearNote(n); + break; + } + + case 'deleted': { + this.isDeleted = true; + break; + } + } + }, + + reply(viaKeyboard = false) { + pleaseLogin(); + this.operating = true; + os.post({ + reply: this.appearNote, + animation: !viaKeyboard, + }, () => { + this.operating = false; + this.focus(); + }); + }, + + renote(viaKeyboard = false) { + pleaseLogin(); + this.operating = true; + this.blur(); + os.modalMenu([{ + text: this.$ts.renote, + icon: faRetweet, + action: () => { + os.api('notes/create', { + renoteId: this.appearNote.id + }); + } + }, { + text: this.$ts.quote, + icon: faQuoteRight, + action: () => { + os.post({ + renote: this.appearNote, + }); + } + }], this.$refs.renoteButton, { + viaKeyboard + }).then(() => { + this.operating = false; + }); + }, + + renoteDirectly() { + os.apiWithDialog('notes/create', { + renoteId: this.appearNote.id + }, undefined, (res: any) => { + os.dialog({ + type: 'success', + text: this.$ts.renoted, + }); + }, (e: Error) => { + if (e.id === 'b5c90186-4ab0-49c8-9bba-a1f76c282ba4') { + os.dialog({ + type: 'error', + text: this.$ts.cantRenote, + }); + } else if (e.id === 'fd4cc33e-2a37-48dd-99cc-9b806eb2031a') { + os.dialog({ + type: 'error', + text: this.$ts.cantReRenote, + }); + } + }); + }, + + async react(viaKeyboard = false) { + pleaseLogin(); + this.operating = true; + this.blur(); + const { dispose } = await os.popup(import('@/components/emoji-picker.vue'), { + src: this.$refs.reactButton, + asReactionPicker: true + }, { + done: reaction => { + if (reaction) { + os.api('notes/reactions/create', { + noteId: this.appearNote.id, + reaction: reaction + }); + } + }, + closed: () => { + this.operating = false; + this.focus(); + dispose(); + } + }); + }, + + reactDirectly(reaction) { + os.api('notes/reactions/create', { + noteId: this.appearNote.id, + reaction: reaction + }); + }, + + undoReact(note) { + const oldReaction = note.myReaction; + if (!oldReaction) return; + os.api('notes/reactions/delete', { + noteId: note.id + }); + }, + + favorite() { + pleaseLogin(); + os.apiWithDialog('notes/favorites/create', { + noteId: this.appearNote.id + }, undefined, (res: any) => { + os.dialog({ + type: 'success', + text: this.$ts.favorited, + }); + }, (e: Error) => { + if (e.id === 'a402c12b-34dd-41d2-97d8-4d2ffd96a1a6') { + os.dialog({ + type: 'error', + text: this.$ts.alreadyFavorited, + }); + } else if (e.id === '6dd26674-e060-4816-909a-45ba3f4da458') { + os.dialog({ + type: 'error', + text: this.$ts.cantFavorite, + }); + } + }); + }, + + del() { + os.dialog({ + type: 'warning', + text: this.$ts.noteDeleteConfirm, + showCancelButton: true + }).then(({ canceled }) => { + if (canceled) return; + + os.api('notes/delete', { + noteId: this.appearNote.id + }); + }); + }, + + delEdit() { + os.dialog({ + type: 'warning', + text: this.$ts.deleteAndEditConfirm, + showCancelButton: true + }).then(({ canceled }) => { + if (canceled) return; + + os.api('notes/delete', { + noteId: this.appearNote.id + }); + + os.post({ initialNote: this.appearNote, renote: this.appearNote.renote, reply: this.appearNote.reply, channel: this.appearNote.channel }); + }); + }, + + toggleFavorite(favorite: boolean) { + os.apiWithDialog(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', { + noteId: this.appearNote.id + }); + }, + + toggleWatch(watch: boolean) { + os.apiWithDialog(watch ? 'notes/watching/create' : 'notes/watching/delete', { + noteId: this.appearNote.id + }); + }, + + getMenu() { + let menu; + if (this.$i) { + const statePromise = os.api('notes/state', { + noteId: this.appearNote.id + }); + + menu = [{ + icon: faCopy, + text: this.$ts.copyContent, + action: this.copyContent + }, { + icon: faLink, + text: this.$ts.copyLink, + action: this.copyLink + }, (this.appearNote.url || this.appearNote.uri) ? { + icon: faExternalLinkSquareAlt, + text: this.$ts.showOnRemote, + action: () => { + window.open(this.appearNote.url || this.appearNote.uri, '_blank'); + } + } : undefined, + null, + statePromise.then(state => state.isFavorited ? { + icon: faStar, + text: this.$ts.unfavorite, + action: () => this.toggleFavorite(false) + } : { + icon: faStar, + text: this.$ts.favorite, + action: () => this.toggleFavorite(true) + }), + { + icon: faPaperclip, + text: this.$ts.clip, + action: () => this.clip() + }, + (this.appearNote.userId != this.$i.id) ? statePromise.then(state => state.isWatching ? { + icon: faEyeSlash, + text: this.$ts.unwatch, + action: () => this.toggleWatch(false) + } : { + icon: faEye, + text: this.$ts.watch, + action: () => this.toggleWatch(true) + }) : undefined, + this.appearNote.userId == this.$i.id ? (this.$i.pinnedNoteIds || []).includes(this.appearNote.id) ? { + icon: faThumbtack, + text: this.$ts.unpin, + action: () => this.togglePin(false) + } : { + icon: faThumbtack, + text: this.$ts.pin, + action: () => this.togglePin(true) + } : undefined, + ...(this.$i.isModerator || this.$i.isAdmin ? [ + null, + { + icon: faBullhorn, + text: this.$ts.promote, + action: this.promote + }] + : [] + ), + ...(this.appearNote.userId != this.$i.id ? [ + null, + { + icon: faExclamationCircle, + text: this.$ts.reportAbuse, + action: () => { + const u = `${url}/notes/${this.appearNote.id}`; + os.popup(import('@/components/abuse-report-window.vue'), { + user: this.appearNote.user, + initialComment: `Note: ${u}\n-----\n` + }, {}, 'closed'); + } + }] + : [] + ), + ...(this.appearNote.userId == this.$i.id || this.$i.isModerator || this.$i.isAdmin ? [ + null, + this.appearNote.userId == this.$i.id ? { + icon: faEdit, + text: this.$ts.deleteAndEdit, + action: this.delEdit + } : undefined, + { + icon: faTrashAlt, + text: this.$ts.delete, + danger: true, + action: this.del + }] + : [] + )] + .filter(x => x !== undefined); + } else { + menu = [{ + icon: faCopy, + text: this.$ts.copyContent, + action: this.copyContent + }, { + icon: faLink, + text: this.$ts.copyLink, + action: this.copyLink + }, (this.appearNote.url || this.appearNote.uri) ? { + icon: faExternalLinkSquareAlt, + text: this.$ts.showOnRemote, + action: () => { + window.open(this.appearNote.url || this.appearNote.uri, '_blank'); + } + } : undefined] + .filter(x => x !== undefined); + } + + if (noteActions.length > 0) { + menu = menu.concat([null, ...noteActions.map(action => ({ + icon: faPlug, + text: action.title, + action: () => { + action.handler(this.appearNote); + } + }))]); + } + + return menu; + }, + + onContextmenu(e) { + const isLink = (el: HTMLElement) => { + if (el.tagName === 'A') return true; + if (el.parentElement) { + return isLink(el.parentElement); + } + }; + if (isLink(e.target)) return; + if (window.getSelection().toString() !== '') return; + os.contextMenu(this.getMenu(), e).then(this.focus); + }, + + menu(viaKeyboard = false) { + this.operating = true; + os.modalMenu(this.getMenu(), this.$refs.menuButton, { + viaKeyboard + }).then(() => { + this.operating = false; + this.focus(); + }); + }, + + showRenoteMenu(viaKeyboard = false) { + if (!this.isMyRenote) return; + os.modalMenu([{ + text: this.$ts.unrenote, + icon: faTrashAlt, + danger: true, + action: () => { + os.api('notes/delete', { + noteId: this.note.id + }); + this.isDeleted = true; + } + }], this.$refs.renoteTime, { + viaKeyboard: viaKeyboard + }); + }, + + toggleShowContent() { + this.showContent = !this.showContent; + }, + + copyContent() { + copyToClipboard(this.appearNote.text); + os.success(); + }, + + copyLink() { + copyToClipboard(`${url}/notes/${this.appearNote.id}`); + os.success(); + }, + + togglePin(pin: boolean) { + os.apiWithDialog(pin ? 'i/pin' : 'i/unpin', { + noteId: this.appearNote.id + }, undefined, null, e => { + if (e.id === '72dab508-c64d-498f-8740-a8eec1ba385a') { + os.dialog({ + type: 'error', + text: this.$ts.pinLimitExceeded + }); + } + }); + }, + + async clip() { + const clips = await os.api('clips/list'); + os.modalMenu([{ + icon: faPlus, + text: this.$ts.createNew, + action: async () => { + const { canceled, result } = await os.form(this.$ts.createNewClip, { + name: { + type: 'string', + label: this.$ts.name + }, + description: { + type: 'string', + required: false, + multiline: true, + label: this.$ts.description + }, + isPublic: { + type: 'boolean', + label: this.$ts.public, + default: false + } + }); + if (canceled) return; + + const clip = await os.apiWithDialog('clips/create', result); + + os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: this.appearNote.id }); + } + }, null, ...clips.map(clip => ({ + text: clip.name, + action: () => { + os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: this.appearNote.id }); + } + }))], this.$refs.menuButton, { + }).then(this.focus); + }, + + async promote() { + const { canceled, result: days } = await os.dialog({ + title: this.$ts.numberOfDays, + input: { type: 'number' } + }); + + if (canceled) return; + + os.apiWithDialog('admin/promo/create', { + noteId: this.appearNote.id, + expiresAt: Date.now() + (86400000 * days) + }); + }, + + focus() { + this.$el.focus(); + }, + + blur() { + this.$el.blur(); + }, + + focusBefore() { + focusPrev(this.$el); + }, + + focusAfter() { + focusNext(this.$el); + }, + + userPage + } +}); +</script> + +<style lang="scss" scoped> +.vfzoeqcg { + position: relative; + contain: content; + + // これらの指定はパフォーマンス向上には有効だが、ノートの高さは一定でないため、 + // 下の方までスクロールすると上のノートの高さがここで決め打ちされたものに変化し、表示しているノートの位置が変わってしまう + // ノートがマウントされたときに自身の高さを取得し contain-intrinsic-size を設定しなおせばほぼ解決できそうだが、 + // 今度はその処理自体がパフォーマンス低下の原因にならないか懸念される。また、被リアクションでも高さは変化するため、やはり多少のズレは生じる + // 一度レンダリングされた要素はブラウザがよしなにサイズを覚えておいてくれるような実装になるまで待った方が良さそう(なるのか?) + //content-visibility: auto; + //contain-intrinsic-size: 0 128px; + + &:focus { + outline: none; + } + + &:hover { + background: rgba(0, 0, 0, 0.05); + } + + &:hover, &.operating { + > .article > .main > .footer { + display: block; + } + } + + &.renote { + background: rgba(128, 255, 0, 0.05); + } + + &.highlighted { + background: rgba(255, 128, 0, 0.05); + } + + > .info { + display: flex; + align-items: center; + padding: 12px 16px 4px 16px; + line-height: 24px; + font-size: 85%; + white-space: pre; + color: #d28a3f; + + > [data-icon] { + margin-right: 4px; + } + + > .hide { + margin-left: 16px; + color: inherit; + opacity: 0.7; + } + } + + > .info + .article { + padding-top: 8px; + } + + > .reply-to { + opacity: 0.7; + padding-bottom: 0; + } + + > .renote { + display: flex; + align-items: center; + padding: 12px 16px 4px 16px; + line-height: 28px; + white-space: pre; + color: var(--renote); + font-size: 0.9em; + + > .avatar { + flex-shrink: 0; + display: inline-block; + width: 28px; + height: 28px; + margin: 0 8px 0 0; + border-radius: 6px; + } + + > [data-icon] { + margin-right: 4px; + } + + > span { + overflow: hidden; + flex-shrink: 1; + text-overflow: ellipsis; + white-space: nowrap; + + > .name { + font-weight: bold; + } + } + + > .info { + margin-left: 8px; + font-size: 0.9em; + opacity: 0.7; + + > .time { + flex-shrink: 0; + color: inherit; + + > .dropdownIcon { + margin-right: 4px; + } + } + + > .visibility { + margin-left: 8px; + } + + > .localOnly { + margin-left: 8px; + } + } + } + + > .renote + .article { + padding-top: 8px; + } + + > .article { + display: flex; + padding: 12px 16px; + + > .avatar { + flex-shrink: 0; + display: block; + position: sticky; + top: 12px; + margin: 0 14px 0 0; + width: 46px; + height: 46px; + } + + > .main { + flex: 1; + min-width: 0; + + > .body { + > .cw { + cursor: default; + display: block; + margin: 0; + padding: 0; + overflow-wrap: break-word; + + > .text { + margin-right: 8px; + } + } + + > .content { + &.collapsed { + position: relative; + max-height: 9em; + overflow: hidden; + + > .fade { + display: block; + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 64px; + background: linear-gradient(0deg, var(--panel), var(--X15)); + + > span { + display: inline-block; + background: var(--panel); + padding: 6px 10px; + font-size: 0.8em; + border-radius: 999px; + box-shadow: 0 2px 6px rgb(0 0 0 / 20%); + } + + &:hover { + > span { + background: var(--panelHighlight); + } + } + } + } + + > .text { + overflow-wrap: break-word; + + > .reply { + color: var(--accent); + margin-right: 0.5em; + } + + > .rp { + margin-left: 4px; + font-style: oblique; + color: var(--renote); + } + } + + > .files { + max-width: 500px; + } + + > .url-preview { + margin-top: 8px; + max-width: 500px; + } + + > .poll { + font-size: 80%; + } + + > .renote { + padding: 8px 0; + + > * { + padding: 16px; + border: dashed 1px var(--renote); + border-radius: 8px; + } + } + } + + > .channel { + opacity: 0.7; + font-size: 80%; + } + } + + > .footer { + display: none; + position: absolute; + top: 8px; + right: 8px; + padding: 0 6px; + opacity: 0.7; + + &:hover { + opacity: 1; + } + + > .button { + margin: 0; + padding: 8px; + opacity: 0.7; + + &:hover { + color: var(--accent); + } + + > .count { + display: inline; + margin: 0 0 0 8px; + opacity: 0.7; + } + + &.reacted { + color: var(--accent); + } + } + } + } + } + + > .reply { + border-top: solid 1px var(--divider); + } +} + +.muted { + padding: 8px 16px; + opacity: 0.7; + + &:hover { + background: rgba(0, 0, 0, 0.05); + } +} +</style> diff --git a/src/client/ui/chat/notes.vue b/src/client/ui/chat/notes.vue new file mode 100644 index 0000000000..1fa2870cee --- /dev/null +++ b/src/client/ui/chat/notes.vue @@ -0,0 +1,91 @@ +<template> +<div class="" :ref="mounted"> + <div class="_fullinfo" v-if="empty"> + <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> + <div>{{ $ts.noNotes }}</div> + </div> + + <MkError v-if="error" @retry="init()"/> + + <div v-show="more && reversed" style="margin-bottom: var(--margin);"> + <button class="_buttonPrimary" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> + <template v-if="!moreFetching">{{ $ts.loadMore }}</template> + <template v-if="moreFetching"><MkLoading inline/></template> + </button> + </div> + + <XList ref="notes" :items="notes" v-slot="{ item: note }" :direction="reversed ? 'up' : 'down'" :reversed="reversed"> + <XNote :note="note" @update:note="updated(note, $event)" :key="note._featuredId_ || note._prId_ || note.id"/> + </XList> + + <div v-show="more && !reversed" style="margin-top: var(--margin);"> + <button class="_buttonPrimary" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> + <template v-if="!moreFetching">{{ $ts.loadMore }}</template> + <template v-if="moreFetching"><MkLoading inline/></template> + </button> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import paging from '@/scripts/paging'; +import XNote from './note.vue'; +import XList from './date-separated-list.vue'; + +export default defineComponent({ + components: { + XNote, XList, + }, + + mixins: [ + paging({ + before: (self) => { + self.$emit('before'); + }, + + after: (self, e) => { + self.$emit('after', e); + } + }), + ], + + props: { + pagination: { + required: true + }, + + prop: { + type: String, + required: false + } + }, + + emits: ['before', 'after'], + + computed: { + notes(): any[] { + return this.prop ? this.items.map(item => item[this.prop]) : this.items; + }, + + reversed(): boolean { + return this.pagination.reversed; + } + }, + + methods: { + updated(oldValue, newValue) { + const i = this.notes.findIndex(n => n === oldValue); + if (this.prop) { + this.items[i][this.prop] = newValue; + } else { + this.items[i] = newValue; + } + }, + + focus() { + this.$refs.notes.focus(); + } + } +}); +</script> diff --git a/src/client/ui/chat/post-form.vue b/src/client/ui/chat/post-form.vue new file mode 100644 index 0000000000..38fe48cc62 --- /dev/null +++ b/src/client/ui/chat/post-form.vue @@ -0,0 +1,772 @@ +<template> +<div class="pxiwixjf" + @dragover.stop="onDragover" + @dragenter="onDragenter" + @dragleave="onDragleave" + @drop.stop="onDrop" +> + <div class="form"> + <div class="with-quote" v-if="quoteId"><Fa icon="quote-left"/> {{ $ts.quoteAttached }}<button @click="quoteId = null"><Fa icon="times"/></button></div> + <div v-if="visibility === 'specified'" class="to-specified"> + <span style="margin-right: 8px;">{{ $ts.recipient }}</span> + <div class="visibleUsers"> + <span v-for="u in visibleUsers" :key="u.id"> + <MkAcct :user="u"/> + <button class="_button" @click="removeVisibleUser(u)"><Fa :icon="faTimes"/></button> + </span> + <button @click="addVisibleUser" class="_buttonPrimary"><Fa :icon="faPlus" fixed-width/></button> + </div> + </div> + <input v-show="useCw" ref="cw" class="cw" v-model="cw" :placeholder="$ts.annotation" @keydown="onKeydown"> + <textarea v-model="text" class="text" :class="{ withCw: useCw }" ref="text" :disabled="posting" :placeholder="placeholder" @keydown="onKeydown" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd" /> + <XPostFormAttaches class="attaches" :files="files" @updated="updateFiles" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName"/> + <XPollEditor v-if="poll" :poll="poll" @destroyed="poll = null" @updated="onPollUpdate"/> + <footer> + <div class="left"> + <button class="_button" @click="chooseFileFrom" v-tooltip="$ts.attachFile"><Fa :icon="faPhotoVideo"/></button> + <button class="_button" @click="togglePoll" :class="{ active: poll }" v-tooltip="$ts.poll"><Fa :icon="faPollH"/></button> + <button class="_button" @click="useCw = !useCw" :class="{ active: useCw }" v-tooltip="$ts.useCw"><Fa :icon="faEyeSlash"/></button> + <button class="_button" @click="insertMention" v-tooltip="$ts.mention"><Fa :icon="faAt"/></button> + <button class="_button" @click="insertEmoji" v-tooltip="$ts.emoji"><Fa :icon="faLaughSquint"/></button> + <button class="_button" @click="showActions" v-tooltip="$ts.plugin" v-if="postFormActions.length > 0"><Fa :icon="faPlug"/></button> + </div> + <div class="right"> + <span class="text-count" :class="{ over: textLength > max }">{{ max - textLength }}</span> + <span class="local-only" v-if="localOnly"><Fa :icon="faBiohazard"/></span> + <button class="_button visibility" @click="setVisibility" ref="visibilityButton" v-tooltip="$ts.visibility" :disabled="channel != null"> + <span v-if="visibility === 'public'"><Fa :icon="faGlobe"/></span> + <span v-if="visibility === 'home'"><Fa :icon="faHome"/></span> + <span v-if="visibility === 'followers'"><Fa :icon="faUnlock"/></span> + <span v-if="visibility === 'specified'"><Fa :icon="faEnvelope"/></span> + </button> + <button class="submit _buttonPrimary" :disabled="!canPost" @click="post">{{ submitText }}<Fa :icon="reply ? faReply : renote ? faQuoteRight : faPaperPlane"/></button> + </div> + </footer> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue'; +import { faReply, faQuoteRight, faPaperPlane, faTimes, faUpload, faPollH, faGlobe, faHome, faUnlock, faEnvelope, faPlus, faPhotoVideo, faAt, faBiohazard, faPlug } from '@fortawesome/free-solid-svg-icons'; +import { faEyeSlash, faLaughSquint } from '@fortawesome/free-regular-svg-icons'; +import insertTextAtCursor from 'insert-text-at-cursor'; +import { length } from 'stringz'; +import { toASCII } from 'punycode'; +import { parse } from '../../../mfm/parse'; +import { host, url } from '@/config'; +import { erase, unique } from '../../../prelude/array'; +import extractMentions from '../../../misc/extract-mentions'; +import getAcct from '../../../misc/acct/render'; +import { formatTimeString } from '../../../misc/format-time-string'; +import { Autocomplete } from '@/scripts/autocomplete'; +import { noteVisibilities } from '../../../types'; +import * as os from '@/os'; +import { selectFile } from '@/scripts/select-file'; +import { notePostInterruptors, postFormActions } from '@/store'; +import { isMobile } from '@/scripts/is-mobile'; + +export default defineComponent({ + components: { + XPostFormAttaches: defineAsyncComponent(() => import('@/components/post-form-attaches.vue')), + XPollEditor: defineAsyncComponent(() => import('@/components/poll-editor.vue')) + }, + + props: { + reply: { + type: Object, + required: false + }, + renote: { + type: Object, + required: false + }, + channel: { + type: String, + required: false + }, + mention: { + type: Object, + required: false + }, + specified: { + type: Object, + required: false + }, + initialText: { + type: String, + required: false + }, + initialNote: { + type: Object, + required: false + }, + instant: { + type: Boolean, + required: false, + default: false + }, + autofocus: { + type: Boolean, + required: false, + default: false + }, + }, + + emits: ['posted', 'cancel', 'esc'], + + data() { + return { + posting: false, + text: '', + files: [], + poll: null, + useCw: false, + cw: null, + localOnly: this.$store.state.rememberNoteVisibility ? this.$store.state.localOnly : this.$store.state.defaultNoteLocalOnly, + visibility: this.$store.state.rememberNoteVisibility ? this.$store.state.visibility : this.$store.state.defaultNoteVisibility, + visibleUsers: [], + autocomplete: null, + draghover: false, + quoteId: null, + recentHashtags: JSON.parse(localStorage.getItem('hashtags') || '[]'), + imeText: '', + postFormActions, + faReply, faQuoteRight, faPaperPlane, faTimes, faUpload, faPollH, faGlobe, faHome, faUnlock, faEnvelope, faEyeSlash, faLaughSquint, faPlus, faPhotoVideo, faAt, faBiohazard, faPlug + }; + }, + + computed: { + draftKey(): string { + let key = this.channel ? `channel:${this.channel}` : ''; + + if (this.renote) { + key += `renote:${this.renote.id}`; + } else if (this.reply) { + key += `reply:${this.reply.id}`; + } else { + key += 'note'; + } + + return key; + }, + + placeholder(): string { + if (this.renote) { + return this.$ts._postForm.quotePlaceholder; + } else if (this.reply) { + return this.$ts._postForm.replyPlaceholder; + } else if (this.channel) { + return this.$ts._postForm.channelPlaceholder; + } else { + const xs = [ + this.$ts._postForm._placeholders.a, + this.$ts._postForm._placeholders.b, + this.$ts._postForm._placeholders.c, + this.$ts._postForm._placeholders.d, + this.$ts._postForm._placeholders.e, + this.$ts._postForm._placeholders.f + ]; + return xs[Math.floor(Math.random() * xs.length)]; + } + }, + + submitText(): string { + return this.renote + ? this.$ts.quote + : this.reply + ? this.$ts.reply + : this.$ts.note; + }, + + textLength(): number { + return length((this.text + this.imeText).trim()); + }, + + canPost(): boolean { + return !this.posting && + (1 <= this.textLength || 1 <= this.files.length || !!this.poll || !!this.renote) && + (this.textLength <= this.max) && + (!this.poll || this.poll.choices.length >= 2); + }, + + max(): number { + return this.$instance ? this.$instance.maxNoteTextLength : 1000; + } + }, + + mounted() { + if (this.initialText) { + this.text = this.initialText; + } + + if (this.mention) { + this.text = this.mention.host ? `@${this.mention.username}@${toASCII(this.mention.host)}` : `@${this.mention.username}`; + this.text += ' '; + } + + if (this.reply && this.reply.user.host != null) { + this.text = `@${this.reply.user.username}@${toASCII(this.reply.user.host)} `; + } + + if (this.reply && this.reply.text != null) { + const ast = parse(this.reply.text); + + for (const x of extractMentions(ast)) { + const mention = x.host ? `@${x.username}@${toASCII(x.host)}` : `@${x.username}`; + + // 自分は除外 + if (this.$i.username == x.username && x.host == null) continue; + if (this.$i.username == x.username && x.host == host) continue; + + // 重複は除外 + if (this.text.indexOf(`${mention} `) != -1) continue; + + this.text += `${mention} `; + } + } + + if (this.channel) { + this.visibility = 'public'; + this.localOnly = true; // TODO: チャンネルが連合するようになった折には消す + } + + // 公開以外へのリプライ時は元の公開範囲を引き継ぐ + if (this.reply && ['home', 'followers', 'specified'].includes(this.reply.visibility)) { + this.visibility = this.reply.visibility; + if (this.reply.visibility === 'specified') { + os.api('users/show', { + userIds: this.reply.visibleUserIds.filter(uid => uid !== this.$i.id && uid !== this.reply.userId) + }).then(users => { + this.visibleUsers.push(...users); + }); + + if (this.reply.userId !== this.$i.id) { + os.api('users/show', { userId: this.reply.userId }).then(user => { + this.visibleUsers.push(user); + }); + } + } + } + + if (this.specified) { + this.visibility = 'specified'; + this.visibleUsers.push(this.specified); + } + + // keep cw when reply + if (this.$store.state.keepCw && this.reply && this.reply.cw) { + this.useCw = true; + this.cw = this.reply.cw; + } + + if (this.autofocus) { + this.focus(); + + this.$nextTick(() => { + this.focus(); + }); + } + + // TODO: detach when unmount + new Autocomplete(this.$refs.text, this, { model: 'text' }); + new Autocomplete(this.$refs.cw, this, { model: 'cw' }); + + this.$nextTick(() => { + // 書きかけの投稿を復元 + if (!this.instant && !this.mention && !this.specified) { + const draft = JSON.parse(localStorage.getItem('drafts') || '{}')[this.draftKey]; + if (draft) { + this.text = draft.data.text; + this.useCw = draft.data.useCw; + this.cw = draft.data.cw; + this.visibility = draft.data.visibility; + this.localOnly = draft.data.localOnly; + this.files = (draft.data.files || []).filter(e => e); + if (draft.data.poll) { + this.poll = draft.data.poll; + } + } + } + + // 削除して編集 + if (this.initialNote) { + const init = this.initialNote; + this.text = init.text ? init.text : ''; + this.files = init.files; + this.cw = init.cw; + this.useCw = init.cw != null; + if (init.poll) { + this.poll = init.poll; + } + this.visibility = init.visibility; + this.localOnly = init.localOnly; + this.quoteId = init.renote ? init.renote.id : null; + } + + this.$nextTick(() => this.watch()); + }); + }, + + methods: { + watch() { + this.$watch('text', () => this.saveDraft()); + this.$watch('useCw', () => this.saveDraft()); + this.$watch('cw', () => this.saveDraft()); + this.$watch('poll', () => this.saveDraft()); + this.$watch('files', () => this.saveDraft(), { deep: true }); + this.$watch('visibility', () => this.saveDraft()); + this.$watch('localOnly', () => this.saveDraft()); + }, + + togglePoll() { + if (this.poll) { + this.poll = null; + } else { + this.poll = { + choices: ['', ''], + multiple: false, + expiresAt: null, + expiredAfter: null, + }; + } + }, + + addTag(tag: string) { + insertTextAtCursor(this.$refs.text, ` #${tag} `); + }, + + focus() { + (this.$refs.text as any).focus(); + }, + + chooseFileFrom(ev) { + selectFile(ev.currentTarget || ev.target, this.$ts.attachFile, true).then(files => { + for (const file of files) { + this.files.push(file); + } + }); + }, + + detachFile(id) { + this.files = this.files.filter(x => x.id != id); + }, + + updateFiles(files) { + this.files = files; + }, + + updateFileSensitive(file, sensitive) { + this.files[this.files.findIndex(x => x.id === file.id)].isSensitive = sensitive; + }, + + updateFileName(file, name) { + this.files[this.files.findIndex(x => x.id === file.id)].name = name; + }, + + upload(file: File, name?: string) { + os.upload(file, this.$store.state.uploadFolder, name).then(res => { + this.files.push(res); + }); + }, + + onPollUpdate(poll) { + this.poll = poll; + this.saveDraft(); + }, + + setVisibility() { + if (this.channel) { + // TODO: information dialog + return; + } + + os.popup(import('@/components/visibility-picker.vue'), { + currentVisibility: this.visibility, + currentLocalOnly: this.localOnly, + src: this.$refs.visibilityButton + }, { + changeVisibility: visibility => { + this.visibility = visibility; + if (this.$store.state.rememberNoteVisibility) { + this.$store.set('visibility', visibility); + } + }, + changeLocalOnly: localOnly => { + this.localOnly = localOnly; + if (this.$store.state.rememberNoteVisibility) { + this.$store.set('localOnly', localOnly); + } + } + }, 'closed'); + }, + + addVisibleUser() { + os.selectUser().then(user => { + this.visibleUsers.push(user); + }); + }, + + removeVisibleUser(user) { + this.visibleUsers = erase(user, this.visibleUsers); + }, + + clear() { + this.text = ''; + this.files = []; + this.poll = null; + this.quoteId = null; + }, + + onKeydown(e: KeyboardEvent) { + if ((e.which === 10 || e.which === 13) && (e.ctrlKey || e.metaKey) && this.canPost) this.post(); + if (e.which === 27) this.$emit('esc'); + }, + + onCompositionUpdate(e: CompositionEvent) { + this.imeText = e.data; + }, + + onCompositionEnd(e: CompositionEvent) { + this.imeText = ''; + }, + + async onPaste(e: ClipboardEvent) { + for (const { item, i } of Array.from(e.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), this.$store.state.pastedFileName).replace(/{{number}}/g, `${i + 1}`)}${ext}`; + this.upload(file, formatted); + } + } + + const paste = e.clipboardData.getData('text'); + + if (!this.renote && !this.quoteId && paste.startsWith(url + '/notes/')) { + e.preventDefault(); + + os.dialog({ + type: 'info', + text: this.$ts.quoteQuestion, + showCancelButton: true + }).then(({ canceled }) => { + if (canceled) { + insertTextAtCursor(this.$refs.text, paste); + return; + } + + this.quoteId = paste.substr(url.length).match(/^\/notes\/(.+?)\/?$/)[1]; + }); + } + }, + + onDragover(e) { + if (!e.dataTransfer.items[0]) return; + const isFile = e.dataTransfer.items[0].kind == 'file'; + const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_; + if (isFile || isDriveFile) { + e.preventDefault(); + this.draghover = true; + e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; + } + }, + + onDragenter(e) { + this.draghover = true; + }, + + onDragleave(e) { + this.draghover = false; + }, + + onDrop(e): void { + this.draghover = false; + + // ファイルだったら + if (e.dataTransfer.files.length > 0) { + e.preventDefault(); + for (const x of Array.from(e.dataTransfer.files)) this.upload(x); + return; + } + + //#region ドライブのファイル + const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); + if (driveFile != null && driveFile != '') { + const file = JSON.parse(driveFile); + this.files.push(file); + e.preventDefault(); + } + //#endregion + }, + + saveDraft() { + if (this.instant) return; + + const data = JSON.parse(localStorage.getItem('drafts') || '{}'); + + data[this.draftKey] = { + updatedAt: new Date(), + data: { + text: this.text, + useCw: this.useCw, + cw: this.cw, + visibility: this.visibility, + localOnly: this.localOnly, + files: this.files, + poll: this.poll + } + }; + + localStorage.setItem('drafts', JSON.stringify(data)); + }, + + deleteDraft() { + const data = JSON.parse(localStorage.getItem('drafts') || '{}'); + + delete data[this.draftKey]; + + localStorage.setItem('drafts', JSON.stringify(data)); + }, + + async post() { + let data = { + text: this.text == '' ? undefined : this.text, + fileIds: this.files.length > 0 ? this.files.map(f => f.id) : undefined, + replyId: this.reply ? this.reply.id : undefined, + renoteId: this.renote ? this.renote.id : this.quoteId ? this.quoteId : undefined, + channelId: this.channel ? this.channel : undefined, + poll: this.poll, + cw: this.useCw ? this.cw || '' : undefined, + localOnly: this.localOnly, + visibility: this.visibility, + visibleUserIds: this.visibility == 'specified' ? this.visibleUsers.map(u => u.id) : undefined, + viaMobile: isMobile + }; + + // plugin + if (notePostInterruptors.length > 0) { + for (const interruptor of notePostInterruptors) { + data = await interruptor.handler(JSON.parse(JSON.stringify(data))); + } + } + + this.posting = true; + os.api('notes/create', data).then(() => { + this.clear(); + this.$nextTick(() => { + this.deleteDraft(); + this.$emit('posted'); + if (this.text && this.text != '') { + const hashtags = parse(this.text).filter(x => x.node.type === 'hashtag').map(x => x.node.props.hashtag); + const history = JSON.parse(localStorage.getItem('hashtags') || '[]') as string[]; + localStorage.setItem('hashtags', JSON.stringify(unique(hashtags.concat(history)))); + } + this.posting = false; + }); + }).catch(err => { + this.posting = false; + os.dialog({ + type: 'error', + text: err.message + '\n' + (err as any).id, + }); + }); + }, + + cancel() { + this.$emit('cancel'); + }, + + insertMention() { + os.selectUser().then(user => { + insertTextAtCursor(this.$refs.text, '@' + getAcct(user) + ' '); + }); + }, + + async insertEmoji(ev) { + os.pickEmoji(ev.currentTarget || ev.target).then(emoji => { + insertTextAtCursor(this.$refs.text, emoji); + }); + }, + + showActions(ev) { + os.modalMenu(postFormActions.map(action => ({ + text: action.title, + action: () => { + action.handler({ + text: this.text + }, (key, value) => { + if (key === 'text') { this.text = value; } + }); + } + })), ev.currentTarget || ev.target); + } + } +}); +</script> + +<style lang="scss" scoped> +.pxiwixjf { + position: relative; + border: solid 1px var(--divider); + border-radius: 8px; + + > .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; + } + } + } + } + + > .cw, + > .text { + display: block; + box-sizing: border-box; + padding: 16px; + 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 1px var(--divider); + } + + > .text { + max-width: 100%; + min-width: 100%; + min-height: 60px; + + &.withCw { + padding-top: 8px; + } + } + + > footer { + $height: 44px; + display: flex; + padding: 0 8px 8px 8px; + line-height: $height; + + > .left { + > button { + display: inline-block; + padding: 0; + margin: 0; + font-size: 16px; + width: $height; + height: $height; + border-radius: 6px; + + &:hover { + background: var(--X5); + } + + &.active { + color: var(--accent); + } + } + } + + > .right { + margin-left: auto; + + > .text-count { + opacity: 0.7; + } + + > .visibility { + width: $height; + margin: 0 8px; + + & + .localOnly { + margin-left: 0 !important; + } + } + + > .local-only { + margin: 0 0 0 12px; + opacity: 0.7; + } + + > .submit { + margin: 0; + padding: 0 12px; + line-height: 34px; + font-weight: bold; + border-radius: 4px; + + &:disabled { + opacity: 0.7; + } + + > [data-icon] { + margin-left: 6px; + } + } + } + } + } +} +</style> diff --git a/src/client/ui/chat/side.vue b/src/client/ui/chat/side.vue new file mode 100644 index 0000000000..0003158e53 --- /dev/null +++ b/src/client/ui/chat/side.vue @@ -0,0 +1,159 @@ +<template> +<div class="mrajymqm _narrow_" v-if="component"> + <header class="header" @contextmenu.prevent.stop="onContextmenu"> + <button class="_button" @click="back()" v-if="history.length > 0"><Fa :icon="faChevronLeft"/></button> + <XHeader class="title" :info="pageInfo" :with-back="false" :center="false"/> + <button class="_button" @click="close()"><Fa :icon="faTimes"/></button> + </header> + <component :is="component" v-bind="props" :ref="changePage"/> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { faTimes, faChevronLeft, faExpandAlt, faWindowMaximize, faExternalLinkAlt, faLink } from '@fortawesome/free-solid-svg-icons'; +import XHeader from '../_common_/header.vue'; +import * as os from '@/os'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; +import { resolve } from '@/router'; +import { url } from '@/config'; + +export default defineComponent({ + components: { + XHeader + }, + + provide() { + return { + navHook: (path) => { + this.navigate(path); + } + }; + }, + + data() { + return { + path: null, + component: null, + props: {}, + pageInfo: null, + history: [], + faTimes, faChevronLeft, + }; + }, + + computed: { + url(): string { + return url + this.path; + } + }, + + methods: { + changePage(page) { + if (page == null) return; + if (page.INFO) { + this.pageInfo = page.INFO; + } + }, + + navigate(path, record = true) { + if (record && this.path) this.history.push(this.path); + this.path = path; + const { component, props } = resolve(path); + this.component = component; + this.props = props; + this.$emit('open'); + }, + + back() { + this.navigate(this.history.pop(), false); + }, + + close() { + this.path = null; + this.component = null; + this.props = {}; + this.$emit('close'); + }, + + onContextmenu(e) { + os.contextMenu([{ + type: 'label', + text: this.path, + }, { + icon: faExpandAlt, + text: this.$ts.showInPage, + action: () => { + this.$router.push(this.path); + this.close(); + } + }, { + icon: faWindowMaximize, + text: this.$ts.openInWindow, + action: () => { + os.pageWindow(this.path); + this.close(); + } + }, null, { + icon: faExternalLinkAlt, + text: this.$ts.openInNewTab, + action: () => { + window.open(this.url, '_blank'); + this.close(); + } + }, { + icon: faLink, + text: this.$ts.copyLink, + action: () => { + copyToClipboard(this.url); + } + }], e); + } + } +}); +</script> + +<style lang="scss" scoped> +.mrajymqm { + $header-height: 54px; // TODO: どこかに集約したい + + --section-padding: 16px; + --margin: var(--marginHalf); + + height: 100%; + overflow: auto; + box-sizing: border-box; + + > .header { + display: flex; + position: sticky; + z-index: 1000; + top: 0; + height: $header-height; + width: 100%; + line-height: $header-height; + font-weight: bold; + //background-color: var(--panel); + -webkit-backdrop-filter: blur(32px); + backdrop-filter: blur(32px); + background-color: var(--header); + border-bottom: solid 1px var(--divider); + box-sizing: border-box; + + > ._button { + height: $header-height; + width: $header-height; + + &:hover { + color: var(--fgHighlighted); + } + } + + > .title { + flex: 1; + position: relative; + } + } +} +</style> + diff --git a/src/client/ui/chat/store.ts b/src/client/ui/chat/store.ts new file mode 100644 index 0000000000..a869debd61 --- /dev/null +++ b/src/client/ui/chat/store.ts @@ -0,0 +1,13 @@ +import { markRaw } from 'vue'; +import { Storage } from '../../pizzax'; + +export const store = markRaw(new Storage('chatUi', { + widgets: { + where: 'account', + default: [] as { + name: string; + id: string; + data: Record<string, any>; + }[] + }, +})); diff --git a/src/client/ui/chat/sub-note-content.vue b/src/client/ui/chat/sub-note-content.vue new file mode 100644 index 0000000000..7e742b8e54 --- /dev/null +++ b/src/client/ui/chat/sub-note-content.vue @@ -0,0 +1,64 @@ +<template> +<div class="wrmlmaau"> + <div class="body"> + <span v-if="note.isHidden" style="opacity: 0.5">({{ $ts.private }})</span> + <span v-if="note.deletedAt" style="opacity: 0.5">({{ $ts.deleted }})</span> + <MkA class="reply" v-if="note.replyId" :to="`/notes/${note.replyId}`"><Fa :icon="faReply"/></MkA> + <Mfm v-if="note.text" :text="note.text" :author="note.user" :i="$i" :custom-emojis="note.emojis"/> + <MkA class="rp" v-if="note.renoteId" :to="`/notes/${note.renoteId}`">RN: ...</MkA> + </div> + <details v-if="note.files.length > 0"> + <summary>({{ $t('withNFiles', { n: note.files.length }) }})</summary> + <XMediaList :media-list="note.files"/> + </details> + <details v-if="note.poll"> + <summary>{{ $ts.poll }}</summary> + <XPoll :note="note"/> + </details> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { faReply } from '@fortawesome/free-solid-svg-icons'; +import XPoll from '@/components/poll.vue'; +import XMediaList from '@/components/media-list.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XPoll, + XMediaList, + }, + props: { + note: { + type: Object, + required: true + } + }, + data() { + return { + faReply + }; + } +}); +</script> + +<style lang="scss" scoped> +.wrmlmaau { + overflow-wrap: break-word; + + > .body { + > .reply { + margin-right: 6px; + color: var(--accent); + } + + > .rp { + margin-left: 4px; + font-style: oblique; + color: var(--renote); + } + } +} +</style> diff --git a/src/client/ui/chat/timeline.vue b/src/client/ui/chat/timeline.vue new file mode 100644 index 0000000000..f96a48a776 --- /dev/null +++ b/src/client/ui/chat/timeline.vue @@ -0,0 +1,234 @@ +<template> +<div class="dbiokgaf"> + <div class="new" v-if="queue > 0" :style="{ width: width + 'px', [pagination.reversed ? 'bottom' : 'top']: pagination.reversed ? bottom + 'px' : top + 'px' }"><button class="_buttonPrimary" @click="goTop()">{{ $ts.newNoteRecived }}</button></div> + <XNotes class="tl" ref="tl" :pagination="pagination" @queue="queueUpdated" v-follow="pagination.reversed"/> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XNotes from './notes.vue'; +import * as os from '@/os'; +import * as sound from '@/scripts/sound'; +import { scrollToBottom, getScrollPosition, getScrollContainer } from '@/scripts/scroll'; +import follow from '@/directives/follow-append'; + +export default defineComponent({ + components: { + XNotes + }, + + directives: { + follow + }, + + provide() { + return { + inChannel: this.src === 'channel' + }; + }, + + props: { + src: { + type: String, + required: true + }, + list: { + type: String, + required: false + }, + antenna: { + type: String, + required: false + }, + channel: { + type: String, + required: false + }, + sound: { + type: Boolean, + required: false, + default: false, + } + }, + + emits: ['note', 'queue', 'before', 'after'], + + data() { + return { + connection: null, + connection2: null, + pagination: null, + baseQuery: { + includeMyRenotes: this.$store.state.showMyRenotes, + includeRenotedMyNotes: this.$store.state.showRenotedMyNotes, + includeLocalRenotes: this.$store.state.showLocalRenotes + }, + query: {}, + queue: 0, + width: 0, + top: 0, + bottom: 0, + }; + }, + + created() { + const prepend = note => { + (this.$refs.tl as any).prepend(note); + + this.$emit('note'); + + if (this.sound) { + sound.play(note.userId === this.$i.id ? 'noteMy' : 'note'); + } + }; + + const onUserAdded = () => { + (this.$refs.tl as any).reload(); + }; + + const onUserRemoved = () => { + (this.$refs.tl as any).reload(); + }; + + const onChangeFollowing = () => { + if (!this.$refs.tl.backed) { + this.$refs.tl.reload(); + } + }; + + let endpoint; + let reversed = false; + + if (this.src == 'antenna') { + endpoint = 'antennas/notes'; + this.query = { + antennaId: this.antenna + }; + this.connection = os.stream.connectToChannel('antenna', { + antennaId: this.antenna + }); + this.connection.on('note', prepend); + } else if (this.src == 'home') { + endpoint = 'notes/timeline'; + this.connection = os.stream.useSharedConnection('homeTimeline'); + this.connection.on('note', prepend); + + this.connection2 = os.stream.useSharedConnection('main'); + this.connection2.on('follow', onChangeFollowing); + this.connection2.on('unfollow', onChangeFollowing); + } else if (this.src == 'local') { + endpoint = 'notes/local-timeline'; + this.connection = os.stream.useSharedConnection('localTimeline'); + this.connection.on('note', prepend); + } else if (this.src == 'social') { + endpoint = 'notes/hybrid-timeline'; + this.connection = os.stream.useSharedConnection('hybridTimeline'); + this.connection.on('note', prepend); + } else if (this.src == 'global') { + endpoint = 'notes/global-timeline'; + this.connection = os.stream.useSharedConnection('globalTimeline'); + this.connection.on('note', prepend); + } else if (this.src == 'mentions') { + endpoint = 'notes/mentions'; + this.connection = os.stream.useSharedConnection('main'); + this.connection.on('mention', prepend); + } else if (this.src == 'directs') { + endpoint = 'notes/mentions'; + this.query = { + visibility: 'specified' + }; + const onNote = note => { + if (note.visibility == 'specified') { + prepend(note); + } + }; + this.connection = os.stream.useSharedConnection('main'); + this.connection.on('mention', onNote); + } else if (this.src == 'list') { + endpoint = 'notes/user-list-timeline'; + this.query = { + listId: this.list + }; + this.connection = os.stream.connectToChannel('userList', { + listId: this.list + }); + this.connection.on('note', prepend); + this.connection.on('userAdded', onUserAdded); + this.connection.on('userRemoved', onUserRemoved); + } else if (this.src == 'channel') { + endpoint = 'channels/timeline'; + reversed = true; + this.query = { + channelId: this.channel + }; + this.connection = os.stream.connectToChannel('channel', { + channelId: this.channel + }); + this.connection.on('note', prepend); + } + + this.pagination = { + endpoint: endpoint, + reversed, + limit: 10, + params: init => ({ + untilDate: init ? undefined : (this.date ? this.date.getTime() : undefined), + ...this.baseQuery, ...this.query + }) + }; + }, + + mounted() { + + }, + + beforeUnmount() { + this.connection.dispose(); + if (this.connection2) this.connection2.dispose(); + }, + + methods: { + focus() { + this.$refs.tl.focus(); + }, + + goTop() { + const container = getScrollContainer(this.$el); + container.scrollTop = 0; + }, + + queueUpdated(q) { + if (this.$el.offsetWidth !== 0) { + const rect = this.$el.getBoundingClientRect(); + const scrollTop = getScrollPosition(this.$el); + this.width = this.$el.offsetWidth; + this.top = rect.top + scrollTop; + this.bottom = this.$el.offsetHeight; + } + this.queue = q; + }, + } +}); +</script> + +<style lang="scss" scoped> +.dbiokgaf { + padding: 16px 0; + + // TODO: これはノート追加アニメーションによるスクロール発生を抑えるために必要だが、position stickyが効かなくなるので、両者を両立させる良い方法を考える + overflow: hidden; + + > .new { + position: fixed; + z-index: 1000; + + > button { + display: block; + margin: 16px auto; + padding: 8px 16px; + border-radius: 32px; + } + } +} +</style> diff --git a/src/client/ui/chat/widgets.vue b/src/client/ui/chat/widgets.vue new file mode 100644 index 0000000000..6becaa22e3 --- /dev/null +++ b/src/client/ui/chat/widgets.vue @@ -0,0 +1,61 @@ +<template> +<div class="qydbhufi"> + <XWidgets :edit="edit" :widgets="widgets" @add-widget="addWidget" @remove-widget="removeWidget" @update-widget="updateWidget" @update-widgets="updateWidgets" @exit="edit = false"/> + + <button v-if="edit" @click="edit = false" class="_textButton" style="font-size: 0.9em;">{{ $ts.editWidgetsExit }}</button> + <button v-else @click="edit = true" class="_textButton" style="font-size: 0.9em;">{{ $ts.editWidgets }}</button> +</div> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue'; +import XWidgets from '@/components/widgets.vue'; +import { store } from './store.ts'; + +export default defineComponent({ + components: { + XWidgets, + }, + + data() { + return { + edit: false, + widgets: store.reactiveState.widgets + }; + }, + + methods: { + addWidget(widget) { + store.set('widgets', [widget, ...store.state.widgets]); + }, + + removeWidget(widget) { + store.set('widgets', store.state.widgets.filter(w => w.id != widget.id)); + }, + + updateWidget({ id, data }) { + store.set('widgets', store.state.widgets.map(w => w.id === id ? { + ...w, + data: data + } : w)); + }, + + updateWidgets(widgets) { + store.set('widgets', widgets); + } + } +}); +</script> + +<style lang="scss" scoped> +.qydbhufi { + height: 100%; + box-sizing: border-box; + overflow: auto; + padding: var(--margin); + + ::v-deep(._panel) { + box-shadow: none; + } +} +</style> diff --git a/src/client/ui/deck/widgets-column.vue b/src/client/ui/deck/widgets-column.vue index 5cf7dde26f..b7740c270d 100644 --- a/src/client/ui/deck/widgets-column.vue +++ b/src/client/ui/deck/widgets-column.vue @@ -3,49 +3,22 @@ <template #header><Fa :icon="faWindowMaximize" style="margin-right: 8px;"/>{{ column.name }}</template> <div class="wtdtxvec"> - <template v-if="edit"> - <header> - <MkSelect v-model:value="widgetAdderSelected" style="margin-bottom: var(--margin)"> - <template #label>{{ $ts.selectWidget }}</template> - <option v-for="widget in widgets" :value="widget" :key="widget">{{ $t(`_widgets.${widget}`) }}</option> - </MkSelect> - <MkButton inline @click="addWidget" primary><Fa :icon="faPlus"/> {{ $ts.add }}</MkButton> - <MkButton inline @click="edit = false">{{ $ts.close }}</MkButton> - </header> - <XDraggable - v-model="_widgets" - item-key="id" - animation="150" - > - <template #item="{element}"> - <div class="customize-container" @click="widgetFunc(element.id)"> - <button class="remove _button" @click.prevent.stop="removeWidget(element)"><Fa :icon="faTimes"/></button> - <component :is="`mkw-${element.name}`" :widget="element" :setting-callback="setting => settings[element.id] = setting" :column="column" @updateProps="saveWidget(element.id, $event)"/> - </div> - </template> - </XDraggable> - </template> - <component v-else class="widget" v-for="widget in column.widgets" :is="`mkw-${widget.name}`" :key="widget.id" :widget="widget" :column="column" @updateProps="saveWidget(widget.id, $event)"/> + <XWidgets :edit="edit" :widgets="column.widgets" @add-widget="addWidget" @remove-widget="removeWidget" @update-widget="updateWidget" @update-widgets="updateWidgets" @exit="edit = false"/> </div> </XColumn> </template> <script lang="ts"> import { defineComponent, defineAsyncComponent } from 'vue'; -import { v4 as uuid } from 'uuid'; import { faWindowMaximize, faTimes, faCog, faPlus } from '@fortawesome/free-solid-svg-icons'; -import MkSelect from '@/components/ui/select.vue'; -import MkButton from '@/components/ui/button.vue'; +import XWidgets from '@/components/widgets.vue'; import XColumn from './column.vue'; -import { widgets } from '../../widgets'; import { addColumnWidget, removeColumnWidget, setColumnWidgets, updateColumnWidget } from './deck-store'; export default defineComponent({ components: { XColumn, - XDraggable: defineAsyncComponent(() => import('vuedraggable').then(x => x.default)), - MkSelect, - MkButton, + XWidgets, }, props: { @@ -62,49 +35,27 @@ export default defineComponent({ data() { return { edit: false, - widgetAdderSelected: null, - widgets, - settings: {}, faWindowMaximize, faTimes, faPlus }; }, - computed: { - _widgets: { - get() { - return this.column.widgets; - }, - set(value) { - setColumnWidgets(this.column.id, value); - } - } - }, - methods: { - widgetFunc(id) { - this.settings[id](); - }, - - addWidget() { - if (this.widgetAdderSelected == null) return; - - addColumnWidget(this.column.id, { - name: this.widgetAdderSelected, - id: uuid(), - data: {} - }); - - this.widgetAdderSelected = null; + addWidget(widget) { + addColumnWidget(this.column.id, widget); }, removeWidget(widget) { removeColumnWidget(this.column.id, widget); }, - saveWidget(id, data) { + updateWidget({ id, data }) { updateColumnWidget(this.column.id, id, data); }, + updateWidgets(widgets) { + setColumnWidgets(this.column.id, widgets); + }, + func() { this.edit = !this.edit; } @@ -114,46 +65,12 @@ export default defineComponent({ <style lang="scss" scoped> .wtdtxvec { - ._panel { - box-shadow: none; - } + --margin: 8px; - > header { - padding: 16px; + padding: 0 var(--margin); - > * { - width: 100%; - padding: 4px; - } - } - - > .widget, .customize-container { - margin: 8px; - - &:first-of-type { - margin-top: 0; - } - } - - .customize-container { - position: relative; - cursor: move; - - > *:not(.remove) { - pointer-events: none; - } - - > .remove { - position: absolute; - z-index: 2; - top: 8px; - right: 8px; - width: 32px; - height: 32px; - color: #fff; - background: rgba(#000, 0.7); - border-radius: 4px; - } + ::v-deep(._panel) { + box-shadow: none; } } </style> diff --git a/src/client/ui/default.widgets.vue b/src/client/ui/default.widgets.vue index ec73c42777..ff7cdf1140 100644 --- a/src/client/ui/default.widgets.vue +++ b/src/client/ui/default.widgets.vue @@ -1,46 +1,21 @@ <template> <div class="efzpzdvf"> - <template v-if="editMode"> - <MkButton primary @click="addWidget" class="add"><Fa :icon="faPlus"/></MkButton> - <XDraggable - v-model="widgets" - item-key="id" - handle=".handle" - animation="150" - class="sortable" - > - <template #item="{element}"> - <div class="customize-container _panel"> - <header> - <span class="handle"><Fa :icon="faBars"/></span>{{ $t('_widgets.' + element.name) }}<button class="remove _button" @click="removeWidget(element)"><Fa :icon="faTimes"/></button> - </header> - <div @click="widgetFunc(element.id)"> - <component class="_inContainer_ _forceContainerFull_" :is="`mkw-${element.name}`" :widget="element" :ref="element.id" :setting-callback="setting => settings[element.id] = setting" @updateProps="saveWidget(element.id, $event)"/> - </div> - </div> - </template> - </XDraggable> - <button @click="editMode = false" class="_textButton" style="font-size: 0.9em;"><Fa :icon="faCheck"/> {{ $ts.editWidgetsExit }}</button> - </template> - <template v-else> - <component v-for="widget in widgets" class="_inContainer_ _forceContainerFull_" :is="`mkw-${widget.name}`" :key="widget.id" :widget="widget" @updateProps="saveWidget(widget.id, $event)"/> - <button @click="editMode = true" class="_textButton" style="font-size: 0.9em;"><Fa :icon="faPencilAlt"/> {{ $ts.editWidgets }}</button> - </template> + <XWidgets :edit="editMode" :widgets="$store.reactiveState.widgets.value" @add-widget="addWidget" @remove-widget="removeWidget" @update-widget="updateWidget" @update-widgets="updateWidgets" @exit="editMode = false"/> + + <button v-if="editMode" @click="editMode = false" class="_textButton" style="font-size: 0.9em;"><Fa :icon="faCheck"/> {{ $ts.editWidgetsExit }}</button> + <button v-else @click="editMode = true" class="_textButton" style="font-size: 0.9em;"><Fa :icon="faPencilAlt"/> {{ $ts.editWidgets }}</button> </div> </template> <script lang="ts"> import { defineComponent, defineAsyncComponent } from 'vue'; -import { v4 as uuid } from 'uuid'; import { faPencilAlt, faPlus, faBars, faTimes, faCheck } from '@fortawesome/free-solid-svg-icons'; -import { widgets } from '@/widgets'; +import XWidgets from '@/components/widgets.vue'; import * as os from '@/os'; -import MkButton from '@/components/ui/button.vue'; export default defineComponent({ components: { - MkButton, - XDraggable: defineAsyncComponent(() => import('vuedraggable').then(x => x.default)), + XWidgets }, emits: ['mounted'], @@ -48,62 +23,35 @@ export default defineComponent({ data() { return { editMode: false, - settings: {}, faPencilAlt, faPlus, faBars, faTimes, faCheck, }; }, - computed: { - widgets: { - get() { - return this.$store.reactiveState.widgets.value; - }, - set(value) { - this.$store.set('widgets', value); - } - }, - }, - mounted() { this.$emit('mounted', this.$el); }, methods: { - widgetFunc(id) { - this.settings[id](); - }, - - async addWidget() { - const { canceled, result: widget } = await os.dialog({ - type: null, - title: this.$ts.chooseWidget, - select: { - items: widgets.map(widget => ({ - value: widget, - text: this.$t('_widgets.' + widget), - })) - }, - showCancelButton: true - }); - if (canceled) return; - - this.$store.set('widgets', [...this.$store.state.widgets, { - name: widget, - id: uuid(), + addWidget(widget) { + this.$store.set('widgets', [{ + ...widget, place: null, - data: {} - }]); + }, ...this.$store.state.widgets]); }, removeWidget(widget) { this.$store.set('widgets', this.$store.state.widgets.filter(w => w.id != widget.id)); }, - saveWidget(id, data) { + updateWidget({ id, data }) { this.$store.set('widgets', this.$store.state.widgets.map(w => w.id === id ? { ...w, data: data } : w)); + }, + + updateWidgets(widgets) { + this.$store.set('widgets', widgets); } } }); @@ -129,35 +77,5 @@ export default defineComponent({ > .add { margin: 0 auto; } - - .customize-container { - margin: 8px 0; - - > header { - position: relative; - line-height: 32px; - - > .handle { - padding: 0 8px; - cursor: move; - } - - > .remove { - position: absolute; - top: 0; - right: 0; - padding: 0 8px; - line-height: 32px; - } - } - - > div { - padding: 8px; - - > * { - pointer-events: none; - } - } - } } </style> diff --git a/src/client/widgets/aiscript.vue b/src/client/widgets/aiscript.vue index 4e788b4b4a..f885db886b 100644 --- a/src/client/widgets/aiscript.vue +++ b/src/client/widgets/aiscript.vue @@ -54,7 +54,8 @@ export default defineComponent({ async run() { this.logs = []; const aiscript = new AiScript(createAiScriptEnv({ - storageKey: 'widget' + storageKey: 'widget', + token: this.$i?.token, }), { in: (q) => { return new Promise(ok => { diff --git a/src/client/widgets/button.vue b/src/client/widgets/button.vue index db247d36b5..3417181d0c 100644 --- a/src/client/widgets/button.vue +++ b/src/client/widgets/button.vue @@ -45,7 +45,8 @@ export default defineComponent({ methods: { async run() { const aiscript = new AiScript(createAiScriptEnv({ - storageKey: 'scratchpad' + storageKey: 'widget', + token: this.$i?.token, }), { in: (q) => { return new Promise(ok => { |