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 | |
| 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')
153 files changed, 4774 insertions, 711 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/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 => { diff --git a/src/docs/en-US/create-plugin.md b/src/docs/en-US/create-plugin.md index e0be7d3d4f..1544c0d850 100644 --- a/src/docs/en-US/create-plugin.md +++ b/src/docs/en-US/create-plugin.md @@ -1,4 +1,4 @@ -# Creating plugins +# New Plugin If you use the plugin function of the Misskey web client, you can expand the web client with a variety of different functionality. This page will list metadata definitions for plugin creation as well as an AiScript API reference for plugins. ## Metadata diff --git a/src/docs/id-ID/follow.md b/src/docs/id-ID/follow.md index 3c1ea7bbe0..4799dc9d85 100644 --- a/src/docs/id-ID/follow.md +++ b/src/docs/id-ID/follow.md @@ -1,2 +1,2 @@ -# フォロー +# Ikuti ユーザーをフォローすると、タイムラインにそのユーザーの投稿が表示されるようになります。ただし、他のユーザーに対する返信は含まれません。 ユーザーをフォローするには、ユーザーページの「フォロー」ボタンをクリックします。フォローを解除するには、もう一度クリックします。 diff --git a/src/docs/it-IT/aiscript.md b/src/docs/it-IT/aiscript.md index 6c28b446e3..c016cb324e 100644 --- a/src/docs/it-IT/aiscript.md +++ b/src/docs/it-IT/aiscript.md @@ -1,4 +1,4 @@ # AiScript -## 関数 +## Funzione デフォルトで値渡しです。 diff --git a/src/docs/it-IT/create-plugin.md b/src/docs/it-IT/create-plugin.md index 0d2fa19178..a263408fca 100644 --- a/src/docs/it-IT/create-plugin.md +++ b/src/docs/it-IT/create-plugin.md @@ -1,7 +1,7 @@ # プラグインの作成 Misskey Webクライアントのプラグイン機能を使うと、クライアントを拡張し、様々な機能を追加できます。 ここではプラグインの作成にあたってのメタデータ定義や、AiScript APIリファレンスを掲載します。 -## メタデータ +## Metadato プラグインは、AiScriptのメタデータ埋め込み機能を使って、デフォルトとしてプラグインのメタデータを定義する必要があります。 メタデータは次のプロパティを含むオブジェクトです。 ### name diff --git a/src/docs/it-IT/follow.md b/src/docs/it-IT/follow.md index 3c1ea7bbe0..f636a710f3 100644 --- a/src/docs/it-IT/follow.md +++ b/src/docs/it-IT/follow.md @@ -1,2 +1,2 @@ -# フォロー +# Seiguiti ユーザーをフォローすると、タイムラインにそのユーザーの投稿が表示されるようになります。ただし、他のユーザーに対する返信は含まれません。 ユーザーをフォローするには、ユーザーページの「フォロー」ボタンをクリックします。フォローを解除するには、もう一度クリックします。 diff --git a/src/docs/it-IT/pages.md b/src/docs/it-IT/pages.md index a7311b95e6..81c19dd20a 100644 --- a/src/docs/it-IT/pages.md +++ b/src/docs/it-IT/pages.md @@ -1,6 +1,6 @@ # Pages -## 変数 +## Variabili 変数を使うことで動的なページを作成できます。テキスト内で <b>{ 変数名 }</b> と書くとそこに変数の値を埋め込めます。例えば <b>Hello { thing } world!</b> というテキストで、変数(thing)の値が <b>ai</b> だった場合、テキストは <b>Hello ai world!</b> になります。 変数の評価(値を算出すること)は上から下に行われるので、ある変数の中で自分より下の変数を参照することはできません。例えば上から <b>A、B、C</b> と3つの変数を定義したとき、<b>C</b>の中で<b>A</b>や<b>B</b>を参照することはできますが、<b>A</b>の中で<b>B</b>や<b>C</b>を参照することはできません。 diff --git a/src/docs/it-IT/reaction.md b/src/docs/it-IT/reaction.md index c26ead75b3..eac6cc88e9 100644 --- a/src/docs/it-IT/reaction.md +++ b/src/docs/it-IT/reaction.md @@ -1,4 +1,4 @@ -# リアクション +# Reazione 他の人のノートに、絵文字を付けて簡単にあなたの反応を伝えられる機能です。 リアクションするには、ノートの + アイコンをクリックしてピッカーを表示し、絵文字を選択します。 リアクションには[カスタム絵文字](./custom-emoji)も使用できます。 ## リアクションピッカーのカスタマイズ diff --git a/src/docs/it-IT/stream.md b/src/docs/it-IT/stream.md index 9011c37c5b..2141b0d091 100644 --- a/src/docs/it-IT/stream.md +++ b/src/docs/it-IT/stream.md @@ -31,7 +31,7 @@ **ストリームでのやり取りはすべてJSONです。** -## チャンネル +## Canale MisskeyのストリーミングAPIにはチャンネルという概念があります。これは、送受信する情報を分離するための仕組みです。 Misskeyのストリームに接続しただけでは、まだリアルタイムでタイムラインの投稿を受信したりはできません。 ストリーム上でチャンネルに接続することで、様々な情報を受け取ったり情報を送信したりすることができるようになります。 ### チャンネルに接続する diff --git a/src/docs/it-IT/theme.md b/src/docs/it-IT/theme.md index 4e52ee8cd7..b877bcd467 100644 --- a/src/docs/it-IT/theme.md +++ b/src/docs/it-IT/theme.md @@ -1,4 +1,4 @@ -# テーマ +# Tema テーマを設定して、Misskeyクライアントの見た目を変更できます。 @@ -61,8 +61,8 @@ * 関数(後述) * `:{関数名}<{引数}<{色}` -#### 定数 +#### Costante 「CSS変数として出力はしたくないが、他のCSS変数の値として使いまわしたい」値があるときは、定数を使うと便利です。 キー名を`$`で始めると、そのキーはCSS変数として出力されません。 -#### 関数 +#### Funzione wip diff --git a/src/docs/it-IT/timelines.md b/src/docs/it-IT/timelines.md index 36ba61bd2d..eb2bb65cad 100644 --- a/src/docs/it-IT/timelines.md +++ b/src/docs/it-IT/timelines.md @@ -2,10 +2,10 @@ https://docs.google.com/spreadsheets/d/1lxQ2ugKrhz58Bg96HTDK_2F98BUritkMyIiBkOByjHA/edit?usp=sharing -## ホーム +## Home 自分のフォローしているユーザーの投稿 -## ローカル +## Locale 全てのローカルユーザーの「ホーム」指定されていない投稿 ## ソーシャル diff --git a/src/docs/uk-UA/api.md b/src/docs/uk-UA/api.md index 76019b6145..432525b0a5 100644 --- a/src/docs/uk-UA/api.md +++ b/src/docs/uk-UA/api.md @@ -18,21 +18,21 @@ APIを使い始めるには、まずアクセストークンを取得する必 ### アプリケーション利用者にアクセストークンの発行をリクエストする アプリケーション利用者のアクセストークンを取得するには、以下の手順で発行をリクエストします。 -#### Step 1 +#### Крок 1 -UUIDを生成する。以後これをセッションIDと呼びます。 +Створити UUID.以後これをセッションIDと呼びます。 > このセッションIDは毎回生成し、使いまわさないようにしてください。 -#### Step 2 +#### Крок 2 `{_URL_}/miauth/{session}`をユーザーのブラウザで表示させる。`{session}`の部分は、セッションIDに置き換えてください。 > 例: `{_URL_}/miauth/c1f6d42b-468b-4fd2-8274-e58abdedef6f` 表示する際、URLにクエリパラメータとしていくつかのオプションを設定できます: -* `name` ... アプリケーション名 +* `name` ... Назва додатка * > 例: `MissDeck` -* `icon` ... アプリケーションのアイコン画像URL +* `icon` ... URL піктограми додатка * > 例: `https://missdeck.example.com/icon.png` * `callback` ... 認証が終わった後にリダイレクトするURL * > 例: `https://missdeck.example.com/callback` @@ -42,7 +42,7 @@ UUIDを生成する。以後これをセッションIDと呼びます。 * 要求する権限を`,`で区切って列挙します * どのような権限があるかは[APIリファレンス](/api-doc)で確認できます -#### Step 3 +#### Крок 3 ユーザーが発行を許可した後、`{_URL_}/api/miauth/{session}/check`にPOSTリクエストすると、レスポンスとしてアクセストークンを含むJSONが返ります。 レスポンスに含まれるプロパティ: @@ -51,8 +51,8 @@ UUIDを生成する。以後これをセッションIDと呼びます。 [「APIの使い方」へ進む](#APIの使い方) -## APIの使い方 -**APIはすべてPOSTで、リクエスト/レスポンスともにJSON形式です。RESTではありません。** アクセストークンは、`i`というパラメータ名でリクエストに含めます。 +## Використання API +**APIはすべてPOSTで、リクエスト/レスポンスともにJSON形式です。Підтримка REST відсутня.** アクセストークンは、`i`というパラメータ名でリクエストに含めます。 -* [APIリファレンス](/api-doc) -* [ストリーミングAPI](./stream) +* [Довідник API](/api-doc) +* [Потокове API](./stream) diff --git a/src/docs/uk-UA/create-plugin.md b/src/docs/uk-UA/create-plugin.md index 5c60b47800..17b42f70a6 100644 --- a/src/docs/uk-UA/create-plugin.md +++ b/src/docs/uk-UA/create-plugin.md @@ -1,4 +1,4 @@ -# プラグインの作成 +# Створення плагінів Misskey Webクライアントのプラグイン機能を使うと、クライアントを拡張し、様々な機能を追加できます。 ここではプラグインの作成にあたってのメタデータ定義や、AiScript APIリファレンスを掲載します。 ## Метадані @@ -34,7 +34,7 @@ Misskey Webクライアントのプラグイン機能を使うと、クライア #### default 設定のデフォルト値 -## APIリファレンス +## Довідник API AiScript標準で組み込まれているAPIは掲載しません。 ### Mk:dialog(title text type) diff --git a/src/docs/uk-UA/stream.md b/src/docs/uk-UA/stream.md index 734291c870..bb9c4fbc31 100644 --- a/src/docs/uk-UA/stream.md +++ b/src/docs/uk-UA/stream.md @@ -1,4 +1,4 @@ -# ストリーミングAPI +# Потокове API ストリーミングAPIを使うと、リアルタイムで様々な情報(例えばタイムラインに新しい投稿が流れてきた、メッセージが届いた、フォローされた、など)を受け取ったり、様々な操作を行ったりすることができます。 diff --git a/src/misc/check-hit-antenna.ts b/src/misc/check-hit-antenna.ts index 562d054563..0464f14131 100644 --- a/src/misc/check-hit-antenna.ts +++ b/src/misc/check-hit-antenna.ts @@ -4,7 +4,6 @@ import { User } from '../models/entities/user'; import { UserListJoinings, UserGroupJoinings } from '../models'; import parseAcct from './acct/parse'; import { getFullApAccount } from './convert-host'; -import { ensure } from '../prelude/ensure'; export async function checkHitAntenna(antenna: Antenna, note: Note, noteUser: User, followers: User['id'][]): Promise<boolean> { if (note.visibility === 'specified') return false; @@ -24,7 +23,7 @@ export async function checkHitAntenna(antenna: Antenna, note: Note, noteUser: Us if (!listUsers.includes(note.userId)) return false; } else if (antenna.src === 'group') { - const joining = await UserGroupJoinings.findOne(antenna.userGroupJoiningId!).then(ensure); + const joining = await UserGroupJoinings.findOneOrFail(antenna.userGroupJoiningId!); const groupUsers = (await UserGroupJoinings.find({ userGroupId: joining.userGroupId diff --git a/src/misc/fetch-proxy-account.ts b/src/misc/fetch-proxy-account.ts index 0094ab8a56..537edf2891 100644 --- a/src/misc/fetch-proxy-account.ts +++ b/src/misc/fetch-proxy-account.ts @@ -1,10 +1,9 @@ import { fetchMeta } from './fetch-meta'; import { ILocalUser } from '../models/entities/user'; import { Users } from '../models'; -import { ensure } from '../prelude/ensure'; export async function fetchProxyAccount(): Promise<ILocalUser | null> { const meta = await fetchMeta(); if (meta.proxyAccountId == null) return null; - return await Users.findOne(meta.proxyAccountId).then(ensure) as ILocalUser; + return await Users.findOneOrFail(meta.proxyAccountId) as ILocalUser; } diff --git a/src/client/scripts/i18n.ts b/src/misc/i18n.ts index d535e236bb..4fa398763a 100644 --- a/src/client/scripts/i18n.ts +++ b/src/misc/i18n.ts @@ -1,14 +1,9 @@ -// 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 @@ -20,12 +15,6 @@ export class I18n<T extends Record<string, any>> { 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); @@ -33,11 +22,7 @@ export class I18n<T extends Record<string, any>> { } return str; } catch (e) { - if (_DEV_) { - console.warn(`missing localization '${key}'`); - return `⚠'${key}'⚠`; - } - + console.warn(`missing localization '${key}'`); return key; } } diff --git a/src/models/entities/user-profile.ts b/src/models/entities/user-profile.ts index 4fab52868f..3a9043fac6 100644 --- a/src/models/entities/user-profile.ts +++ b/src/models/entities/user-profile.ts @@ -4,6 +4,8 @@ import { User } from './user'; import { Page } from './page'; import { notificationTypes } from '../../types'; +// TODO: このテーブルで管理している情報すべてレジストリで管理するようにしても良いかも +// ただ、「emailVerified が true なユーザーを find する」のようなクエリは書けなくなるからウーン @Entity() export class UserProfile { @PrimaryColumn(id()) @@ -42,6 +44,11 @@ export class UserProfile { }[]; @Column('varchar', { + length: 32, nullable: true, + }) + public lang: string | null; + + @Column('varchar', { length: 512, nullable: true, comment: 'Remote URL of the user.' }) @@ -63,6 +70,11 @@ export class UserProfile { }) public emailVerified: boolean; + @Column('jsonb', { + default: ['follow', 'receiveFollowRequest', 'groupInvited'] + }) + public emailNotificationTypes: string[]; + @Column('varchar', { length: 128, nullable: true, }) diff --git a/src/models/repositories/abuse-user-report.ts b/src/models/repositories/abuse-user-report.ts index dbdaa5ee15..cb33d2506e 100644 --- a/src/models/repositories/abuse-user-report.ts +++ b/src/models/repositories/abuse-user-report.ts @@ -1,7 +1,6 @@ import { EntityRepository, Repository } from 'typeorm'; import { Users } from '..'; import { AbuseUserReport } from '../entities/abuse-user-report'; -import { ensure } from '../../prelude/ensure'; import { awaitAll } from '../../prelude/await-all'; @EntityRepository(AbuseUserReport) @@ -9,7 +8,7 @@ export class AbuseUserReportRepository extends Repository<AbuseUserReport> { public async pack( src: AbuseUserReport['id'] | AbuseUserReport, ) { - const report = typeof src === 'object' ? src : await this.findOne(src).then(ensure); + const report = typeof src === 'object' ? src : await this.findOneOrFail(src); return await awaitAll({ id: report.id, diff --git a/src/models/repositories/antenna.ts b/src/models/repositories/antenna.ts index 16ef2e5a39..b20da26c5d 100644 --- a/src/models/repositories/antenna.ts +++ b/src/models/repositories/antenna.ts @@ -1,6 +1,5 @@ import { EntityRepository, Repository } from 'typeorm'; import { Antenna } from '../entities/antenna'; -import { ensure } from '../../prelude/ensure'; import { SchemaType } from '../../misc/schema'; import { AntennaNotes, UserGroupJoinings } from '..'; @@ -11,7 +10,7 @@ export class AntennaRepository extends Repository<Antenna> { public async pack( src: Antenna['id'] | Antenna, ): Promise<PackedAntenna> { - const antenna = typeof src === 'object' ? src : await this.findOne(src).then(ensure); + const antenna = typeof src === 'object' ? src : await this.findOneOrFail(src); const hasUnreadNote = (await AntennaNotes.findOne({ antennaId: antenna.id, read: false })) != null; const userGroupJoining = antenna.userGroupJoiningId ? await UserGroupJoinings.findOne(antenna.userGroupJoiningId) : null; diff --git a/src/models/repositories/app.ts b/src/models/repositories/app.ts index 45d8d16c51..f5cc10787a 100644 --- a/src/models/repositories/app.ts +++ b/src/models/repositories/app.ts @@ -1,7 +1,6 @@ import { EntityRepository, Repository } from 'typeorm'; import { App } from '../entities/app'; import { AccessTokens } from '..'; -import { ensure } from '../../prelude/ensure'; import { SchemaType } from '../../misc/schema'; export type PackedApp = SchemaType<typeof packedAppSchema>; @@ -23,7 +22,7 @@ export class AppRepository extends Repository<App> { includeProfileImageIds: false }, options); - const app = typeof src === 'object' ? src : await this.findOne(src).then(ensure); + const app = typeof src === 'object' ? src : await this.findOneOrFail(src); return { id: app.id, diff --git a/src/models/repositories/auth-session.ts b/src/models/repositories/auth-session.ts index a6a4d46de6..e985d6925f 100644 --- a/src/models/repositories/auth-session.ts +++ b/src/models/repositories/auth-session.ts @@ -1,7 +1,6 @@ import { EntityRepository, Repository } from 'typeorm'; import { Apps } from '..'; import { AuthSession } from '../entities/auth-session'; -import { ensure } from '../../prelude/ensure'; import { awaitAll } from '../../prelude/await-all'; @EntityRepository(AuthSession) @@ -10,7 +9,7 @@ export class AuthSessionRepository extends Repository<AuthSession> { src: AuthSession['id'] | AuthSession, me?: any ) { - const session = typeof src === 'object' ? src : await this.findOne(src).then(ensure); + const session = typeof src === 'object' ? src : await this.findOneOrFail(src); return await awaitAll({ id: session.id, diff --git a/src/models/repositories/blocking.ts b/src/models/repositories/blocking.ts index 9ebe6bbf59..314f459e65 100644 --- a/src/models/repositories/blocking.ts +++ b/src/models/repositories/blocking.ts @@ -1,7 +1,6 @@ import { EntityRepository, Repository } from 'typeorm'; import { Users } from '..'; import { Blocking } from '../entities/blocking'; -import { ensure } from '../../prelude/ensure'; import { awaitAll } from '../../prelude/await-all'; import { SchemaType } from '../../misc/schema'; @@ -13,7 +12,7 @@ export class BlockingRepository extends Repository<Blocking> { src: Blocking['id'] | Blocking, me?: any ): Promise<PackedBlocking> { - const blocking = typeof src === 'object' ? src : await this.findOne(src).then(ensure); + const blocking = typeof src === 'object' ? src : await this.findOneOrFail(src); return await awaitAll({ id: blocking.id, diff --git a/src/models/repositories/channel.ts b/src/models/repositories/channel.ts index 2a90419922..2654c9d6f3 100644 --- a/src/models/repositories/channel.ts +++ b/src/models/repositories/channel.ts @@ -1,6 +1,5 @@ import { EntityRepository, Repository } from 'typeorm'; import { Channel } from '../entities/channel'; -import { ensure } from '../../prelude/ensure'; import { SchemaType } from '../../misc/schema'; import { DriveFiles, ChannelFollowings, NoteUnreads } from '..'; import { User } from '../entities/user'; @@ -13,7 +12,7 @@ export class ChannelRepository extends Repository<Channel> { src: Channel['id'] | Channel, me?: User['id'] | User | null | undefined, ): Promise<PackedChannel> { - const channel = typeof src === 'object' ? src : await this.findOne(src).then(ensure); + const channel = typeof src === 'object' ? src : await this.findOneOrFail(src); const meId = me ? typeof me === 'string' ? me : me.id : null; const banner = channel.bannerId ? await DriveFiles.findOne(channel.bannerId) : null; diff --git a/src/models/repositories/clip.ts b/src/models/repositories/clip.ts index 11f743349f..84891a4372 100644 --- a/src/models/repositories/clip.ts +++ b/src/models/repositories/clip.ts @@ -1,6 +1,5 @@ import { EntityRepository, Repository } from 'typeorm'; import { Clip } from '../entities/clip'; -import { ensure } from '../../prelude/ensure'; import { SchemaType } from '../../misc/schema'; import { Users } from '..'; import { awaitAll } from '../../prelude/await-all'; @@ -12,7 +11,7 @@ export class ClipRepository extends Repository<Clip> { public async pack( src: Clip['id'] | Clip, ): Promise<PackedClip> { - const clip = typeof src === 'object' ? src : await this.findOne(src).then(ensure); + const clip = typeof src === 'object' ? src : await this.findOneOrFail(src); return await awaitAll({ id: clip.id, diff --git a/src/models/repositories/drive-file.ts b/src/models/repositories/drive-file.ts index ab22d2dc09..3d013b8bb2 100644 --- a/src/models/repositories/drive-file.ts +++ b/src/models/repositories/drive-file.ts @@ -3,7 +3,6 @@ import { DriveFile } from '../entities/drive-file'; import { Users, DriveFolders } from '..'; import { User } from '../entities/user'; import { toPuny } from '../../misc/convert-host'; -import { ensure } from '../../prelude/ensure'; import { awaitAll } from '../../prelude/await-all'; import { SchemaType } from '../../misc/schema'; import config from '../../config'; @@ -103,7 +102,7 @@ export class DriveFileRepository extends Repository<DriveFile> { self: false }, options); - const file = typeof src === 'object' ? src : await this.findOne(src).then(ensure); + const file = typeof src === 'object' ? src : await this.findOneOrFail(src); const meta = await fetchMeta(); diff --git a/src/models/repositories/drive-folder.ts b/src/models/repositories/drive-folder.ts index dee424cccb..2a18220384 100644 --- a/src/models/repositories/drive-folder.ts +++ b/src/models/repositories/drive-folder.ts @@ -1,7 +1,6 @@ import { EntityRepository, Repository } from 'typeorm'; import { DriveFolders, DriveFiles } from '..'; import { DriveFolder } from '../entities/drive-folder'; -import { ensure } from '../../prelude/ensure'; import { awaitAll } from '../../prelude/await-all'; import { SchemaType } from '../../misc/schema'; @@ -26,7 +25,7 @@ export class DriveFolderRepository extends Repository<DriveFolder> { detail: false }, options); - const folder = typeof src === 'object' ? src : await this.findOne(src).then(ensure); + const folder = typeof src === 'object' ? src : await this.findOneOrFail(src); return await awaitAll({ id: folder.id, diff --git a/src/models/repositories/emoji.ts b/src/models/repositories/emoji.ts index b485b37da5..3490a6ac86 100644 --- a/src/models/repositories/emoji.ts +++ b/src/models/repositories/emoji.ts @@ -1,13 +1,12 @@ import { EntityRepository, Repository } from 'typeorm'; import { Emoji } from '../entities/emoji'; -import { ensure } from '../../prelude/ensure'; @EntityRepository(Emoji) export class EmojiRepository extends Repository<Emoji> { public async pack( src: Emoji['id'] | Emoji, ) { - const emoji = typeof src === 'object' ? src : await this.findOne(src).then(ensure); + const emoji = typeof src === 'object' ? src : await this.findOneOrFail(src); return { id: emoji.id, diff --git a/src/models/repositories/follow-request.ts b/src/models/repositories/follow-request.ts index 451ed8e2d5..0d96b8eb53 100644 --- a/src/models/repositories/follow-request.ts +++ b/src/models/repositories/follow-request.ts @@ -1,7 +1,6 @@ import { EntityRepository, Repository } from 'typeorm'; import { FollowRequest } from '../entities/follow-request'; import { Users } from '..'; -import { ensure } from '../../prelude/ensure'; @EntityRepository(FollowRequest) export class FollowRequestRepository extends Repository<FollowRequest> { @@ -9,7 +8,7 @@ export class FollowRequestRepository extends Repository<FollowRequest> { src: FollowRequest['id'] | FollowRequest, me?: any ) { - const request = typeof src === 'object' ? src : await this.findOne(src).then(ensure); + const request = typeof src === 'object' ? src : await this.findOneOrFail(src); return { id: request.id, diff --git a/src/models/repositories/following.ts b/src/models/repositories/following.ts index 58728a3380..986f107e7d 100644 --- a/src/models/repositories/following.ts +++ b/src/models/repositories/following.ts @@ -1,7 +1,6 @@ import { EntityRepository, Repository } from 'typeorm'; import { Users } from '..'; import { Following } from '../entities/following'; -import { ensure } from '../../prelude/ensure'; import { awaitAll } from '../../prelude/await-all'; import { SchemaType } from '../../misc/schema'; @@ -57,7 +56,7 @@ export class FollowingRepository extends Repository<Following> { populateFollower?: boolean; } ): Promise<PackedFollowing> { - const following = typeof src === 'object' ? src : await this.findOne(src).then(ensure); + const following = typeof src === 'object' ? src : await this.findOneOrFail(src); if (opts == null) opts = {}; diff --git a/src/models/repositories/games/reversi/game.ts b/src/models/repositories/games/reversi/game.ts index c380f5251e..e23247f664 100644 --- a/src/models/repositories/games/reversi/game.ts +++ b/src/models/repositories/games/reversi/game.ts @@ -1,7 +1,6 @@ import { EntityRepository, Repository } from 'typeorm'; import { Users } from '../../..'; import { ReversiGame } from '../../../entities/games/reversi/game'; -import { ensure } from '../../../../prelude/ensure'; @EntityRepository(ReversiGame) export class ReversiGameRepository extends Repository<ReversiGame> { @@ -16,7 +15,7 @@ export class ReversiGameRepository extends Repository<ReversiGame> { detail: true }, options); - const game = typeof src === 'object' ? src : await this.findOne(src).then(ensure); + const game = typeof src === 'object' ? src : await this.findOneOrFail(src); const meId = me ? typeof me === 'string' ? me : me.id : null; return { diff --git a/src/models/repositories/games/reversi/matching.ts b/src/models/repositories/games/reversi/matching.ts index 86c9204456..51f17c9a4e 100644 --- a/src/models/repositories/games/reversi/matching.ts +++ b/src/models/repositories/games/reversi/matching.ts @@ -1,7 +1,6 @@ import { EntityRepository, Repository } from 'typeorm'; import { ReversiMatching } from '../../../entities/games/reversi/matching'; import { Users } from '../../..'; -import { ensure } from '../../../../prelude/ensure'; import { awaitAll } from '../../../../prelude/await-all'; @EntityRepository(ReversiMatching) @@ -10,7 +9,7 @@ export class ReversiMatchingRepository extends Repository<ReversiMatching> { src: ReversiMatching['id'] | ReversiMatching, me: any ) { - const matching = typeof src === 'object' ? src : await this.findOne(src).then(ensure); + const matching = typeof src === 'object' ? src : await this.findOneOrFail(src); return await awaitAll({ id: matching.id, diff --git a/src/models/repositories/messaging-message.ts b/src/models/repositories/messaging-message.ts index d70e95bc12..0e04c25864 100644 --- a/src/models/repositories/messaging-message.ts +++ b/src/models/repositories/messaging-message.ts @@ -1,7 +1,6 @@ import { EntityRepository, Repository } from 'typeorm'; import { MessagingMessage } from '../entities/messaging-message'; import { Users, DriveFiles, UserGroups } from '..'; -import { ensure } from '../../prelude/ensure'; import { SchemaType } from '../../misc/schema'; export type PackedMessagingMessage = SchemaType<typeof packedMessagingMessageSchema>; @@ -25,7 +24,7 @@ export class MessagingMessageRepository extends Repository<MessagingMessage> { populateGroup: true, }; - const message = typeof src === 'object' ? src : await this.findOne(src).then(ensure); + const message = typeof src === 'object' ? src : await this.findOneOrFail(src); return { id: message.id, diff --git a/src/models/repositories/moderation-logs.ts b/src/models/repositories/moderation-logs.ts index d6e04795bb..3d4c075701 100644 --- a/src/models/repositories/moderation-logs.ts +++ b/src/models/repositories/moderation-logs.ts @@ -1,7 +1,6 @@ import { EntityRepository, Repository } from 'typeorm'; import { Users } from '..'; import { ModerationLog } from '../entities/moderation-log'; -import { ensure } from '../../prelude/ensure'; import { awaitAll } from '../../prelude/await-all'; @EntityRepository(ModerationLog) @@ -9,7 +8,7 @@ export class ModerationLogRepository extends Repository<ModerationLog> { public async pack( src: ModerationLog['id'] | ModerationLog, ) { - const log = typeof src === 'object' ? src : await this.findOne(src).then(ensure); + const log = typeof src === 'object' ? src : await this.findOneOrFail(src); return await awaitAll({ id: log.id, diff --git a/src/models/repositories/muting.ts b/src/models/repositories/muting.ts index 763e04bb3d..5fd409df78 100644 --- a/src/models/repositories/muting.ts +++ b/src/models/repositories/muting.ts @@ -1,7 +1,6 @@ import { EntityRepository, Repository } from 'typeorm'; import { Users } from '..'; import { Muting } from '../entities/muting'; -import { ensure } from '../../prelude/ensure'; import { awaitAll } from '../../prelude/await-all'; import { SchemaType } from '../../misc/schema'; @@ -13,7 +12,7 @@ export class MutingRepository extends Repository<Muting> { src: Muting['id'] | Muting, me?: any ): Promise<PackedMuting> { - const muting = typeof src === 'object' ? src : await this.findOne(src).then(ensure); + const muting = typeof src === 'object' ? src : await this.findOneOrFail(src); return await awaitAll({ id: muting.id, diff --git a/src/models/repositories/note-favorite.ts b/src/models/repositories/note-favorite.ts index 37cfbc0025..eb2ffff4c1 100644 --- a/src/models/repositories/note-favorite.ts +++ b/src/models/repositories/note-favorite.ts @@ -1,7 +1,6 @@ import { EntityRepository, Repository } from 'typeorm'; import { NoteFavorite } from '../entities/note-favorite'; import { Notes } from '..'; -import { ensure } from '../../prelude/ensure'; @EntityRepository(NoteFavorite) export class NoteFavoriteRepository extends Repository<NoteFavorite> { @@ -9,7 +8,7 @@ export class NoteFavoriteRepository extends Repository<NoteFavorite> { src: NoteFavorite['id'] | NoteFavorite, me?: any ) { - const favorite = typeof src === 'object' ? src : await this.findOne(src).then(ensure); + const favorite = typeof src === 'object' ? src : await this.findOneOrFail(src); return { id: favorite.id, diff --git a/src/models/repositories/note-reaction.ts b/src/models/repositories/note-reaction.ts index 3439f3c8cb..785a876bf8 100644 --- a/src/models/repositories/note-reaction.ts +++ b/src/models/repositories/note-reaction.ts @@ -1,7 +1,6 @@ import { EntityRepository, Repository } from 'typeorm'; import { NoteReaction } from '../entities/note-reaction'; import { Users } from '..'; -import { ensure } from '../../prelude/ensure'; import { SchemaType } from '../../misc/schema'; import { convertLegacyReaction } from '../../misc/reaction-lib'; @@ -13,7 +12,7 @@ export class NoteReactionRepository extends Repository<NoteReaction> { src: NoteReaction['id'] | NoteReaction, me?: any ): Promise<PackedNoteReaction> { - const reaction = typeof src === 'object' ? src : await this.findOne(src).then(ensure); + const reaction = typeof src === 'object' ? src : await this.findOneOrFail(src); return { id: reaction.id, diff --git a/src/models/repositories/note.ts b/src/models/repositories/note.ts index b60744bb2b..32552db2fe 100644 --- a/src/models/repositories/note.ts +++ b/src/models/repositories/note.ts @@ -2,7 +2,6 @@ import { EntityRepository, Repository, In } from 'typeorm'; import { Note } from '../entities/note'; import { User } from '../entities/user'; import { Emojis, Users, PollVotes, DriveFiles, NoteReactions, Followings, Polls, Channels } from '..'; -import { ensure } from '../../prelude/ensure'; import { SchemaType } from '../../misc/schema'; import { awaitAll } from '../../prelude/await-all'; import { convertLegacyReaction, convertLegacyReactions, decodeReaction } from '../../misc/reaction-lib'; @@ -92,11 +91,11 @@ export class NoteRepository extends Repository<Note> { }, options); const meId = me ? typeof me === 'string' ? me : me.id : null; - const note = typeof src === 'object' ? src : await this.findOne(src).then(ensure); + const note = typeof src === 'object' ? src : await this.findOneOrFail(src); const host = note.userHost; async function populatePoll() { - const poll = await Polls.findOne(note.id).then(ensure); + const poll = await Polls.findOneOrFail(note.id); const choices = poll.choices.map(c => ({ text: c, votes: poll.votes[poll.choices.indexOf(c)], @@ -413,5 +412,63 @@ export const packedNoteSchema = { optional: true as const, nullable: true as const, ref: 'Channel' }, + localOnly: { + type: 'boolean' as const, + optional: false as const, nullable: true as const, + }, + emojis: { + type: 'array' as const, + optional: false as const, nullable: false as const, + items: { + type: 'object' as const, + optional: false as const, nullable: false as const, + properties: { + name: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + url: { + type: 'string' as const, + optional: false as const, nullable: false as const, + }, + }, + }, + }, + reactions: { + type: 'object' as const, + optional: false as const, nullable: false as const, + description: 'Key is either Unicode emoji or custom emoji, value is count of that emoji reaction.', + }, + renoteCount: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + repliesCount: { + type: 'number' as const, + optional: false as const, nullable: false as const, + }, + uri: { + type: 'string' as const, + optional: false as const, nullable: true as const, + description: 'The URI of a note. it will be null when the note is local.', + }, + url: { + type: 'string' as const, + optional: false as const, nullable: true as const, + description: 'The human readable url of a note. it will be null when the note is local.', + }, + _featuredId_: { + type: 'string' as const, + optional: false as const, nullable: true as const, + }, + _prId_: { + type: 'string' as const, + optional: false as const, nullable: true as const, + }, + myReaction: { + type: 'object' as const, + optional: true as const, nullable: true as const, + description: 'Key is either Unicode emoji or custom emoji, value is count of that emoji reaction.', + }, }, }; diff --git a/src/models/repositories/notification.ts b/src/models/repositories/notification.ts index 40f43d6c15..16de6c8c25 100644 --- a/src/models/repositories/notification.ts +++ b/src/models/repositories/notification.ts @@ -1,7 +1,6 @@ import { EntityRepository, Repository } from 'typeorm'; import { Users, Notes, UserGroupInvitations, AccessTokens } from '..'; import { Notification } from '../entities/notification'; -import { ensure } from '../../prelude/ensure'; import { awaitAll } from '../../prelude/await-all'; import { SchemaType } from '../../misc/schema'; @@ -12,8 +11,8 @@ export class NotificationRepository extends Repository<Notification> { public async pack( src: Notification['id'] | Notification, ): Promise<PackedNotification> { - const notification = typeof src === 'object' ? src : await this.findOne(src).then(ensure); - const token = notification.appAccessTokenId ? await AccessTokens.findOne(notification.appAccessTokenId).then(ensure) : null; + const notification = typeof src === 'object' ? src : await this.findOneOrFail(src); + const token = notification.appAccessTokenId ? await AccessTokens.findOneOrFail(notification.appAccessTokenId) : null; return await awaitAll({ id: notification.id, diff --git a/src/models/repositories/page-like.ts b/src/models/repositories/page-like.ts index 3e7e803fdb..94b1685e5e 100644 --- a/src/models/repositories/page-like.ts +++ b/src/models/repositories/page-like.ts @@ -1,7 +1,6 @@ import { EntityRepository, Repository } from 'typeorm'; import { PageLike } from '../entities/page-like'; import { Pages } from '..'; -import { ensure } from '../../prelude/ensure'; @EntityRepository(PageLike) export class PageLikeRepository extends Repository<PageLike> { @@ -9,7 +8,7 @@ export class PageLikeRepository extends Repository<PageLike> { src: PageLike['id'] | PageLike, me?: any ) { - const like = typeof src === 'object' ? src : await this.findOne(src).then(ensure); + const like = typeof src === 'object' ? src : await this.findOneOrFail(src); return { id: like.id, diff --git a/src/models/repositories/page.ts b/src/models/repositories/page.ts index 3889bf59a7..1b30b6645b 100644 --- a/src/models/repositories/page.ts +++ b/src/models/repositories/page.ts @@ -5,7 +5,6 @@ import { Users, DriveFiles, PageLikes } from '..'; import { awaitAll } from '../../prelude/await-all'; import { DriveFile } from '../entities/drive-file'; import { User } from '../entities/user'; -import { ensure } from '../../prelude/ensure'; export type PackedPage = SchemaType<typeof packedPageSchema>; @@ -16,7 +15,7 @@ export class PageRepository extends Repository<Page> { me?: User['id'] | User | null | undefined, ): Promise<PackedPage> { const meId = me ? typeof me === 'string' ? me : me.id : null; - const page = typeof src === 'object' ? src : await this.findOne(src).then(ensure); + const page = typeof src === 'object' ? src : await this.findOneOrFail(src); const attachedFiles: Promise<DriveFile | undefined>[] = []; const collectFile = (xs: any[]) => { diff --git a/src/models/repositories/user-group-invitation.ts b/src/models/repositories/user-group-invitation.ts index 0d3ad525c3..2aa890361c 100644 --- a/src/models/repositories/user-group-invitation.ts +++ b/src/models/repositories/user-group-invitation.ts @@ -1,14 +1,13 @@ import { EntityRepository, Repository } from 'typeorm'; import { UserGroupInvitation } from '../entities/user-group-invitation'; import { UserGroups } from '..'; -import { ensure } from '../../prelude/ensure'; @EntityRepository(UserGroupInvitation) export class UserGroupInvitationRepository extends Repository<UserGroupInvitation> { public async pack( src: UserGroupInvitation['id'] | UserGroupInvitation, ) { - const invitation = typeof src === 'object' ? src : await this.findOne(src).then(ensure); + const invitation = typeof src === 'object' ? src : await this.findOneOrFail(src); return { id: invitation.id, diff --git a/src/models/repositories/user-group.ts b/src/models/repositories/user-group.ts index 5ff75047c8..a1b226f154 100644 --- a/src/models/repositories/user-group.ts +++ b/src/models/repositories/user-group.ts @@ -1,6 +1,5 @@ import { EntityRepository, Repository } from 'typeorm'; import { UserGroup } from '../entities/user-group'; -import { ensure } from '../../prelude/ensure'; import { UserGroupJoinings } from '..'; import { SchemaType } from '../../misc/schema'; @@ -11,7 +10,7 @@ export class UserGroupRepository extends Repository<UserGroup> { public async pack( src: UserGroup['id'] | UserGroup, ): Promise<PackedUserGroup> { - const userGroup = typeof src === 'object' ? src : await this.findOne(src).then(ensure); + const userGroup = typeof src === 'object' ? src : await this.findOneOrFail(src); const users = await UserGroupJoinings.find({ userGroupId: userGroup.id diff --git a/src/models/repositories/user-list.ts b/src/models/repositories/user-list.ts index 8842118be4..9421aeb0c7 100644 --- a/src/models/repositories/user-list.ts +++ b/src/models/repositories/user-list.ts @@ -1,6 +1,5 @@ import { EntityRepository, Repository } from 'typeorm'; import { UserList } from '../entities/user-list'; -import { ensure } from '../../prelude/ensure'; import { UserListJoinings } from '..'; import { SchemaType } from '../../misc/schema'; @@ -11,7 +10,7 @@ export class UserListRepository extends Repository<UserList> { public async pack( src: UserList['id'] | UserList, ): Promise<PackedUserList> { - const userList = typeof src === 'object' ? src : await this.findOne(src).then(ensure); + const userList = typeof src === 'object' ? src : await this.findOneOrFail(src); const users = await UserListJoinings.find({ userListId: userList.id diff --git a/src/models/repositories/user.ts b/src/models/repositories/user.ts index 88861224a4..7502e7a08e 100644 --- a/src/models/repositories/user.ts +++ b/src/models/repositories/user.ts @@ -2,7 +2,6 @@ import $ from 'cafy'; import { EntityRepository, Repository, In, Not } from 'typeorm'; import { User, ILocalUser, IRemoteUser } from '../entities/user'; import { Emojis, Notes, NoteUnreads, FollowRequests, Notifications, MessagingMessages, UserNotePinings, Followings, Blockings, Mutings, UserProfiles, UserSecurityKeys, UserGroupJoinings, Pages, Announcements, AnnouncementReads, Antennas, AntennaNotes, ChannelFollowings, Instances } from '..'; -import { ensure } from '../../prelude/ensure'; import config from '../../config'; import { SchemaType } from '../../misc/schema'; import { awaitAll } from '../../prelude/await-all'; @@ -157,7 +156,7 @@ export class UserRepository extends Repository<User> { includeSecrets: false }, options); - const user = typeof src === 'object' ? src : await this.findOne(src).then(ensure); + const user = typeof src === 'object' ? src : await this.findOneOrFail(src); const meId = me ? typeof me === 'string' ? me : me.id : null; const relation = meId && (meId !== user.id) && opts.detail ? await this.getRelation(meId, user.id) : null; @@ -165,7 +164,7 @@ export class UserRepository extends Repository<User> { where: { userId: user.id }, order: { id: 'DESC' } }) : []; - const profile = opts.detail ? await UserProfiles.findOne(user.id).then(ensure) : null; + const profile = opts.detail ? await UserProfiles.findOneOrFail(user.id) : null; const falsy = opts.detail ? false : undefined; @@ -213,6 +212,7 @@ export class UserRepository extends Repository<User> { description: profile!.description, location: profile!.location, birthday: profile!.birthday, + lang: profile!.lang, fields: profile!.fields, followersCount: user.followersCount, followingCount: user.followingCount, @@ -258,7 +258,8 @@ export class UserRepository extends Repository<User> { hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id), integrations: profile!.integrations, mutedWords: profile!.mutedWords, - mutingNotificationTypes: profile?.mutingNotificationTypes, + mutingNotificationTypes: profile!.mutingNotificationTypes, + emailNotificationTypes: profile!.emailNotificationTypes, } : {}), ...(opts.includeSecrets ? { diff --git a/src/prelude/ensure.ts b/src/prelude/ensure.ts deleted file mode 100644 index 1af281c056..0000000000 --- a/src/prelude/ensure.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * 値が null または undefined の場合はエラーを発生させ、そうでない場合は値をそのまま返します - */ -export function ensure<T>(x: T): NonNullable<T> { - if (x == null) { - throw new Error('ぬるぽ'); - } else { - return x!; - } -} diff --git a/src/queue/processors/db/export-notes.ts b/src/queue/processors/db/export-notes.ts index 0fd8c02c4a..f76a47aacd 100644 --- a/src/queue/processors/db/export-notes.ts +++ b/src/queue/processors/db/export-notes.ts @@ -9,7 +9,6 @@ import { Users, Notes, Polls } from '../../../models'; import { MoreThan } from 'typeorm'; import { Note } from '../../../models/entities/note'; import { Poll } from '../../../models/entities/poll'; -import { ensure } from '../../../prelude/ensure'; const logger = queueLogger.createSubLogger('export-notes'); @@ -70,7 +69,7 @@ export async function exportNotes(job: Bull.Job, done: any): Promise<void> { for (const note of notes) { let poll: Poll | undefined; if (note.hasPoll) { - poll = await Polls.findOne({ noteId: note.id }).then(ensure); + poll = await Polls.findOneOrFail({ noteId: note.id }); } const content = JSON.stringify(serialize(note, poll)); await new Promise((res, rej) => { diff --git a/src/remote/activitypub/db-resolver.ts b/src/remote/activitypub/db-resolver.ts index cad2212b70..26162b6b23 100644 --- a/src/remote/activitypub/db-resolver.ts +++ b/src/remote/activitypub/db-resolver.ts @@ -6,7 +6,6 @@ import { MessagingMessage } from '../../models/entities/messaging-message'; import { Notes, Users, UserPublickeys, MessagingMessages } from '../../models'; import { IObject, getApId } from './type'; import { resolvePerson } from './models/person'; -import { ensure } from '../../prelude/ensure'; import escapeRegexp = require('escape-regexp'); export default class DbResolver { @@ -99,7 +98,7 @@ export default class DbResolver { if (user == null) return null; - const key = await UserPublickeys.findOne(user.id).then(ensure); + const key = await UserPublickeys.findOneOrFail(user.id); return { user, diff --git a/src/remote/activitypub/models/image.ts b/src/remote/activitypub/models/image.ts index 84a1040b2e..dd8086cbeb 100644 --- a/src/remote/activitypub/models/image.ts +++ b/src/remote/activitypub/models/image.ts @@ -5,7 +5,6 @@ import { fetchMeta } from '../../../misc/fetch-meta'; import { apLogger } from '../logger'; import { DriveFile } from '../../../models/entities/drive-file'; import { DriveFiles } from '../../../models'; -import { ensure } from '../../../prelude/ensure'; const logger = apLogger; @@ -40,7 +39,7 @@ export async function createImage(actor: IRemoteUser, value: any): Promise<Drive uri: image.url }); - file = await DriveFiles.findOne(file.id).then(ensure); + file = await DriveFiles.findOneOrFail(file.id); } } diff --git a/src/remote/activitypub/models/note.ts b/src/remote/activitypub/models/note.ts index feaee2f630..e3488800ca 100644 --- a/src/remote/activitypub/models/note.ts +++ b/src/remote/activitypub/models/note.ts @@ -21,7 +21,6 @@ import { IObject, getOneApId, getApId, getOneApHrefNullable, validPost, IPost, i import { Emoji } from '../../../models/entities/emoji'; import { genId } from '../../../misc/gen-id'; import { fetchMeta } from '../../../misc/fetch-meta'; -import { ensure } from '../../../prelude/ensure'; import { getApLock } from '../../../misc/app-lock'; import { createMessage } from '../../../services/messages/create'; import { parseAudience } from '../audience'; @@ -201,7 +200,7 @@ export async function createNote(value: string | IObject, resolver?: Resolver, s // vote if (reply && reply.hasPoll) { - const poll = await Polls.findOne(reply.id).then(ensure); + const poll = await Polls.findOneOrFail(reply.id); const tryCreateVote = async (name: string, index: number): Promise<null> => { if (poll.expiresAt && Date.now() > new Date(poll.expiresAt).getTime()) { diff --git a/src/remote/activitypub/models/person.ts b/src/remote/activitypub/models/person.ts index 73a2ebc023..93e95df0a4 100644 --- a/src/remote/activitypub/models/person.ts +++ b/src/remote/activitypub/models/person.ts @@ -12,7 +12,7 @@ import { extractApHashtags } from './tag'; import { apLogger } from '../logger'; import { Note } from '../../../models/entities/note'; import { updateUsertags } from '../../../services/update-hashtag'; -import { Users, UserNotePinings, Instances, DriveFiles, Followings, UserProfiles, UserPublickeys } from '../../../models'; +import { Users, Instances, DriveFiles, Followings, UserProfiles, UserPublickeys } from '../../../models'; import { User, IRemoteUser } from '../../../models/entities/user'; import { Emoji } from '../../../models/entities/emoji'; import { UserNotePining } from '../../../models/entities/user-note-pining'; @@ -24,7 +24,6 @@ import { toPuny } from '../../../misc/convert-host'; import { UserProfile } from '../../../models/entities/user-profile'; import { validActor } from '../../../remote/activitypub/type'; import { getConnection } from 'typeorm'; -import { ensure } from '../../../prelude/ensure'; import { toArray } from '../../../prelude/array'; import { fetchInstanceMetadata } from '../../../services/fetch-instance-metadata'; import { normalizeForSearch } from '../../../misc/normalize-for-search'; @@ -457,7 +456,7 @@ export function analyzeAttachments(attachments: IObject | IObject[] | undefined) } export async function updateFeatured(userId: User['id']) { - const user = await Users.findOne(userId).then(ensure); + const user = await Users.findOneOrFail(userId); if (!Users.isRemoteUser(user)) return; if (!user.featured) return; @@ -480,18 +479,19 @@ export async function updateFeatured(userId: User['id']) { .slice(0, 5) .map(item => limit(() => resolveNote(item, resolver)))); - // delete - await UserNotePinings.delete({ userId: user.id }); + await getConnection().transaction(async transactionalEntityManager => { + await transactionalEntityManager.delete(UserNotePining, { userId: user.id }); - // とりあえずidを別の時間で生成して順番を維持 - let td = 0; - for (const note of featuredNotes.filter(note => note != null)) { - td -= 1000; - UserNotePinings.save({ - id: genId(new Date(Date.now() + td)), - createdAt: new Date(), - userId: user.id, - noteId: note!.id - } as UserNotePining); - } + // とりあえずidを別の時間で生成して順番を維持 + let td = 0; + for (const note of featuredNotes.filter(note => note != null)) { + td -= 1000; + transactionalEntityManager.insert(UserNotePining, { + id: genId(new Date(Date.now() + td)), + createdAt: new Date(), + userId: user.id, + noteId: note!.id + }); + } + }); } diff --git a/src/remote/activitypub/renderer/follow-user.ts b/src/remote/activitypub/renderer/follow-user.ts index 6d354803e5..bfc91bb4cb 100644 --- a/src/remote/activitypub/renderer/follow-user.ts +++ b/src/remote/activitypub/renderer/follow-user.ts @@ -1,13 +1,12 @@ import config from '../../../config'; import { Users } from '../../../models'; import { User } from '../../../models/entities/user'; -import { ensure } from '../../../prelude/ensure'; /** * Convert (local|remote)(Follower|Followee)ID to URL * @param id Follower|Followee ID */ export default async function renderFollowUser(id: User['id']): Promise<any> { - const user = await Users.findOne(id).then(ensure); + const user = await Users.findOneOrFail(id); return Users.isLocalUser(user) ? `${config.url}/users/${user.id}` : user.uri; } diff --git a/src/remote/activitypub/renderer/index.ts b/src/remote/activitypub/renderer/index.ts index a34febff2f..e74affdadf 100644 --- a/src/remote/activitypub/renderer/index.ts +++ b/src/remote/activitypub/renderer/index.ts @@ -4,7 +4,6 @@ import { IActivity } from '../type'; import { LdSignature } from '../misc/ld-signature'; import { ILocalUser } from '../../../models/entities/user'; import { UserKeypairs } from '../../../models'; -import { ensure } from '../../../prelude/ensure'; export const renderActivity = (x: any): IActivity | null => { if (x == null) return null; @@ -24,9 +23,9 @@ export const renderActivity = (x: any): IActivity | null => { export const attachLdSignature = async (activity: any, user: ILocalUser): Promise<IActivity | null> => { if (activity == null) return null; - const keypair = await UserKeypairs.findOne({ + const keypair = await UserKeypairs.findOneOrFail({ userId: user.id - }).then(ensure); + }); const obj = { // as non-standards diff --git a/src/remote/activitypub/renderer/note.ts b/src/remote/activitypub/renderer/note.ts index 6878a402b8..8e3e0e9ba1 100644 --- a/src/remote/activitypub/renderer/note.ts +++ b/src/remote/activitypub/renderer/note.ts @@ -10,7 +10,6 @@ import { DriveFiles, Notes, Users, Emojis, Polls } from '../../../models'; import { In } from 'typeorm'; import { Emoji } from '../../../models/entities/emoji'; import { Poll } from '../../../models/entities/poll'; -import { ensure } from '../../../prelude/ensure'; export default async function renderNote(note: Note, dive = true, isTalk = false): Promise<any> { const getPromisedFiles = async (ids: string[]) => { @@ -54,7 +53,7 @@ export default async function renderNote(note: Note, dive = true, isTalk = false } } - const user = await Users.findOne(note.userId).then(ensure); + const user = await Users.findOneOrFail(note.userId); const attributedTo = `${config.url}/users/${user.id}`; diff --git a/src/remote/activitypub/renderer/person.ts b/src/remote/activitypub/renderer/person.ts index 4462f88315..4907e3bc6f 100644 --- a/src/remote/activitypub/renderer/person.ts +++ b/src/remote/activitypub/renderer/person.ts @@ -9,7 +9,6 @@ import renderEmoji from './emoji'; import { IIdentifier } from '../models/identifier'; import renderHashtag from './hashtag'; import { DriveFiles, UserProfiles, UserKeypairs } from '../../../models'; -import { ensure } from '../../../prelude/ensure'; export async function renderPerson(user: ILocalUser) { const id = `${config.url}/users/${user.id}`; @@ -18,7 +17,7 @@ export async function renderPerson(user: ILocalUser) { const [avatar, banner, profile] = await Promise.all([ user.avatarId ? DriveFiles.findOne(user.avatarId) : Promise.resolve(undefined), user.bannerId ? DriveFiles.findOne(user.bannerId) : Promise.resolve(undefined), - UserProfiles.findOne(user.id).then(ensure) + UserProfiles.findOneOrFail(user.id) ]); const attachment: { @@ -50,7 +49,7 @@ export async function renderPerson(user: ILocalUser) { ...hashtagTags, ]; - const keypair = await UserKeypairs.findOne(user.id).then(ensure); + const keypair = await UserKeypairs.findOneOrFail(user.id); const person = { type: isSystem ? 'Application' : user.isBot ? 'Service' : 'Person', diff --git a/src/remote/activitypub/request.ts b/src/remote/activitypub/request.ts index 0edfcee1e3..2f07351635 100644 --- a/src/remote/activitypub/request.ts +++ b/src/remote/activitypub/request.ts @@ -6,7 +6,6 @@ import * as crypto from 'crypto'; import config from '../../config'; import { ILocalUser } from '../../models/entities/user'; import { UserKeypairs } from '../../models'; -import { ensure } from '../../prelude/ensure'; import { getAgentByUrl } from '../../misc/fetch'; import { URL } from 'url'; import got from 'got'; @@ -23,9 +22,9 @@ export default async (user: ILocalUser, url: string, object: any) => { sha256.update(data); const hash = sha256.digest('base64'); - const keypair = await UserKeypairs.findOne({ + const keypair = await UserKeypairs.findOneOrFail({ userId: user.id - }).then(ensure); + }); await new Promise((resolve, reject) => { const req = https.request({ @@ -75,9 +74,9 @@ export default async (user: ILocalUser, url: string, object: any) => { export async function signedGet(url: string, user: ILocalUser) { const timeout = 10 * 1000; - const keypair = await UserKeypairs.findOne({ + const keypair = await UserKeypairs.findOneOrFail({ userId: user.id - }).then(ensure); + }); const req = got.get<any>(url, { headers: { diff --git a/src/server/activitypub.ts b/src/server/activitypub.ts index c665fe28ca..bf71258625 100644 --- a/src/server/activitypub.ts +++ b/src/server/activitypub.ts @@ -16,7 +16,6 @@ import { isSelfHost } from '../misc/convert-host'; import { Notes, Users, Emojis, UserKeypairs, NoteReactions } from '../models'; import { ILocalUser, User } from '../models/entities/user'; import { In } from 'typeorm'; -import { ensure } from '../prelude/ensure'; import { renderLike } from '../remote/activitypub/renderer/like'; // Init router @@ -136,7 +135,7 @@ router.get('/users/:user/publickey', async ctx => { return; } - const keypair = await UserKeypairs.findOne(user.id).then(ensure); + const keypair = await UserKeypairs.findOneOrFail(user.id); if (Users.isLocalUser(user)) { ctx.body = renderActivity(renderKey(user, keypair)); diff --git a/src/server/activitypub/featured.ts b/src/server/activitypub/featured.ts index 80a7852f59..66ad2aa86e 100644 --- a/src/server/activitypub/featured.ts +++ b/src/server/activitypub/featured.ts @@ -5,7 +5,6 @@ import renderOrderedCollection from '../../remote/activitypub/renderer/ordered-c import { setResponseType } from '../activitypub'; import renderNote from '../../remote/activitypub/renderer/note'; import { Users, Notes, UserNotePinings } from '../../models'; -import { ensure } from '../../prelude/ensure'; export default async (ctx: Router.RouterContext) => { const userId = ctx.params.user; @@ -27,7 +26,7 @@ export default async (ctx: Router.RouterContext) => { }); const pinnedNotes = await Promise.all(pinings.map(pining => - Notes.findOne(pining.noteId).then(ensure))); + Notes.findOneOrFail(pining.noteId))); const renderedNotes = await Promise.all(pinnedNotes.map(note => renderNote(note))); diff --git a/src/server/activitypub/outbox.ts b/src/server/activitypub/outbox.ts index 03cf65bce6..3c1b07a679 100644 --- a/src/server/activitypub/outbox.ts +++ b/src/server/activitypub/outbox.ts @@ -15,7 +15,6 @@ import { Users, Notes } from '../../models'; import { makePaginationQuery } from '../api/common/make-pagination-query'; import { Brackets } from 'typeorm'; import { Note } from '../../models/entities/note'; -import { ensure } from '../../prelude/ensure'; export default async (ctx: Router.RouterContext) => { const userId = ctx.params.user; @@ -101,7 +100,7 @@ export default async (ctx: Router.RouterContext) => { */ export async function packActivity(note: Note): Promise<any> { if (note.renoteId && note.text == null && !note.hasPoll && (note.fileIds == null || note.fileIds.length === 0)) { - const renote = await Notes.findOne(note.renoteId).then(ensure); + const renote = await Notes.findOneOrFail(note.renoteId); return renderAnnounce(renote.uri ? renote.uri : `${config.url}/notes/${renote.id}`, note); } diff --git a/src/server/api/authenticate.ts b/src/server/api/authenticate.ts index 0785372b3b..0374ca35ea 100644 --- a/src/server/api/authenticate.ts +++ b/src/server/api/authenticate.ts @@ -1,7 +1,6 @@ import isNativeToken from './common/is-native-token'; import { User } from '../../models/entities/user'; import { Users, AccessTokens, Apps } from '../../models'; -import { ensure } from '../../prelude/ensure'; import { AccessToken } from '../../models/entities/access-token'; export default async (token: string): Promise<[User | null | undefined, AccessToken | null | undefined]> => { @@ -43,7 +42,7 @@ export default async (token: string): Promise<[User | null | undefined, AccessTo if (accessToken.appId) { const app = await Apps - .findOne(accessToken.appId).then(ensure); + .findOneOrFail(accessToken.appId); return [user, { id: accessToken.id, diff --git a/src/server/api/common/inject-featured.ts b/src/server/api/common/inject-featured.ts index 098d20e72d..3f47c13385 100644 --- a/src/server/api/common/inject-featured.ts +++ b/src/server/api/common/inject-featured.ts @@ -3,7 +3,6 @@ import { Note } from '../../../models/entities/note'; import { User } from '../../../models/entities/user'; import { Notes, UserProfiles, NoteReactions } from '../../../models'; import { generateMutedUserQuery } from './generate-muted-user-query'; -import { ensure } from '../../../prelude/ensure'; // TODO: リアクション、Renote、返信などをしたノートは除外する @@ -11,7 +10,7 @@ export async function injectFeatured(timeline: Note[], user?: User | null) { if (timeline.length < 5) return; if (user) { - const profile = await UserProfiles.findOne(user.id).then(ensure); + const profile = await UserProfiles.findOneOrFail(user.id); if (!profile.injectFeaturedNote) return; } diff --git a/src/server/api/common/inject-promo.ts b/src/server/api/common/inject-promo.ts index f694ce6ea0..2c16ca4cf7 100644 --- a/src/server/api/common/inject-promo.ts +++ b/src/server/api/common/inject-promo.ts @@ -2,7 +2,6 @@ import rndstr from 'rndstr'; import { Note } from '../../../models/entities/note'; import { User } from '../../../models/entities/user'; import { PromoReads, PromoNotes, Notes, Users } from '../../../models'; -import { ensure } from '../../../prelude/ensure'; export async function injectPromo(timeline: Note[], user?: User | null) { if (timeline.length < 5) return; @@ -23,10 +22,10 @@ export async function injectPromo(timeline: Note[], user?: User | null) { // Pick random promo const promo = promos[Math.floor(Math.random() * promos.length)]; - const note = await Notes.findOne(promo.noteId).then(ensure); + const note = await Notes.findOneOrFail(promo.noteId); // Join - note.user = await Users.findOne(note.userId).then(ensure); + note.user = await Users.findOneOrFail(note.userId); (note as any)._prId_ = rndstr('a-z0-9', 8); diff --git a/src/server/api/endpoints/admin/federation/remove-all-following.ts b/src/server/api/endpoints/admin/federation/remove-all-following.ts index 76497c0dd8..e593193552 100644 --- a/src/server/api/endpoints/admin/federation/remove-all-following.ts +++ b/src/server/api/endpoints/admin/federation/remove-all-following.ts @@ -2,7 +2,6 @@ import $ from 'cafy'; import define from '../../../define'; import deleteFollowing from '../../../../../services/following/delete'; import { Followings, Users } from '../../../../../models'; -import { ensure } from '../../../../../prelude/ensure'; export const meta = { tags: ['admin'], @@ -23,8 +22,8 @@ export default define(meta, async (ps, me) => { }); const pairs = await Promise.all(followings.map(f => Promise.all([ - Users.findOne(f.followerId).then(ensure), - Users.findOne(f.followeeId).then(ensure) + Users.findOneOrFail(f.followerId), + Users.findOneOrFail(f.followeeId) ]))); for (const pair of pairs) { diff --git a/src/server/api/endpoints/admin/send-email.ts b/src/server/api/endpoints/admin/send-email.ts index 9af931ad99..c0e77e1621 100644 --- a/src/server/api/endpoints/admin/send-email.ts +++ b/src/server/api/endpoints/admin/send-email.ts @@ -22,5 +22,5 @@ export const meta = { }; export default define(meta, async (ps) => { - await sendEmail(ps.to, ps.subject, ps.text); + await sendEmail(ps.to, ps.subject, ps.text, ps.text); }); diff --git a/src/server/api/endpoints/auth/accept.ts b/src/server/api/endpoints/auth/accept.ts index e98242a3c3..6d4d31fa1e 100644 --- a/src/server/api/endpoints/auth/accept.ts +++ b/src/server/api/endpoints/auth/accept.ts @@ -4,7 +4,6 @@ import define from '../../define'; import { ApiError } from '../../error'; import { AuthSessions, AccessTokens, Apps } from '../../../../models'; import { genId } from '../../../../misc/gen-id'; -import { ensure } from '../../../../prelude/ensure'; import { secureRndstr } from '../../../../misc/secure-rndstr'; export const meta = { @@ -49,7 +48,7 @@ export default define(meta, async (ps, user) => { if (exist == null) { // Lookup app - const app = await Apps.findOne(session.appId).then(ensure); + const app = await Apps.findOneOrFail(session.appId); // Generate Hash const sha256 = crypto.createHash('sha256'); diff --git a/src/server/api/endpoints/auth/session/userkey.ts b/src/server/api/endpoints/auth/session/userkey.ts index 7b474c8295..68d0c7bdf1 100644 --- a/src/server/api/endpoints/auth/session/userkey.ts +++ b/src/server/api/endpoints/auth/session/userkey.ts @@ -2,7 +2,6 @@ import $ from 'cafy'; import define from '../../../define'; import { ApiError } from '../../../error'; import { Apps, AuthSessions, AccessTokens, Users } from '../../../../../models'; -import { ensure } from '../../../../../prelude/ensure'; export const meta = { tags: ['auth'], @@ -92,10 +91,10 @@ export default define(meta, async (ps) => { } // Lookup access token - const accessToken = await AccessTokens.findOne({ + const accessToken = await AccessTokens.findOneOrFail({ appId: app.id, userId: session.userId - }).then(ensure); + }); // Delete session AuthSessions.delete(session.id); diff --git a/src/server/api/endpoints/i.ts b/src/server/api/endpoints/i.ts index 3d0c092adb..e5b65e0930 100644 --- a/src/server/api/endpoints/i.ts +++ b/src/server/api/endpoints/i.ts @@ -1,6 +1,5 @@ import define from '../define'; import { RegistryItems, UserProfiles, Users } from '../../../models'; -import { ensure } from '../../../prelude/ensure'; import { genId } from '../../../misc/gen-id'; export const meta = { @@ -25,7 +24,7 @@ export default define(meta, async (ps, user, token) => { const isSecure = token == null; // TODO: そのうち消す - const profile = await UserProfiles.findOne(user.id).then(ensure); + const profile = await UserProfiles.findOneOrFail(user.id); for (const [k, v] of Object.entries(profile.clientData)) { await RegistryItems.insert({ id: genId(), diff --git a/src/server/api/endpoints/i/2fa/done.ts b/src/server/api/endpoints/i/2fa/done.ts index 7d35f929e0..9a74d7675b 100644 --- a/src/server/api/endpoints/i/2fa/done.ts +++ b/src/server/api/endpoints/i/2fa/done.ts @@ -2,7 +2,6 @@ import $ from 'cafy'; import * as speakeasy from 'speakeasy'; import define from '../../../define'; import { UserProfiles } from '../../../../../models'; -import { ensure } from '../../../../../prelude/ensure'; export const meta = { requireCredential: true as const, @@ -19,7 +18,7 @@ export const meta = { export default define(meta, async (ps, user) => { const token = ps.token.replace(/\s/g, ''); - const profile = await UserProfiles.findOne(user.id).then(ensure); + const profile = await UserProfiles.findOneOrFail(user.id); if (profile.twoFactorTempSecret == null) { throw new Error('二段階認証の設定が開始されていません'); diff --git a/src/server/api/endpoints/i/2fa/key-done.ts b/src/server/api/endpoints/i/2fa/key-done.ts index 8ac165e629..4634944ca7 100644 --- a/src/server/api/endpoints/i/2fa/key-done.ts +++ b/src/server/api/endpoints/i/2fa/key-done.ts @@ -9,7 +9,6 @@ import { AttestationChallenges, Users } from '../../../../../models'; -import { ensure } from '../../../../../prelude/ensure'; import config from '../../../../../config'; import { procedures, hash } from '../../../2fa'; import { publishMainStream } from '../../../../../services/stream'; @@ -43,7 +42,7 @@ export const meta = { const rpIdHashReal = hash(Buffer.from(config.hostname, 'utf-8')); export default define(meta, async (ps, user) => { - const profile = await UserProfiles.findOne(user.id).then(ensure); + const profile = await UserProfiles.findOneOrFail(user.id); // Compare password const same = await bcrypt.compare(ps.password, profile.password!); diff --git a/src/server/api/endpoints/i/2fa/register-key.ts b/src/server/api/endpoints/i/2fa/register-key.ts index e189519a4c..d5cc11c7fb 100644 --- a/src/server/api/endpoints/i/2fa/register-key.ts +++ b/src/server/api/endpoints/i/2fa/register-key.ts @@ -2,7 +2,6 @@ import $ from 'cafy'; import * as bcrypt from 'bcryptjs'; import define from '../../../define'; import { UserProfiles, AttestationChallenges } from '../../../../../models'; -import { ensure } from '../../../../../prelude/ensure'; import { promisify } from 'util'; import * as crypto from 'crypto'; import { genId } from '../../../../../misc/gen-id'; @@ -23,7 +22,7 @@ export const meta = { }; export default define(meta, async (ps, user) => { - const profile = await UserProfiles.findOne(user.id).then(ensure); + const profile = await UserProfiles.findOneOrFail(user.id); // Compare password const same = await bcrypt.compare(ps.password, profile.password!); diff --git a/src/server/api/endpoints/i/2fa/register.ts b/src/server/api/endpoints/i/2fa/register.ts index 784b276a26..a39b2963e9 100644 --- a/src/server/api/endpoints/i/2fa/register.ts +++ b/src/server/api/endpoints/i/2fa/register.ts @@ -5,7 +5,6 @@ import * as QRCode from 'qrcode'; import config from '../../../../../config'; import define from '../../../define'; import { UserProfiles } from '../../../../../models'; -import { ensure } from '../../../../../prelude/ensure'; export const meta = { requireCredential: true as const, @@ -20,7 +19,7 @@ export const meta = { }; export default define(meta, async (ps, user) => { - const profile = await UserProfiles.findOne(user.id).then(ensure); + const profile = await UserProfiles.findOneOrFail(user.id); // Compare password const same = await bcrypt.compare(ps.password, profile.password!); diff --git a/src/server/api/endpoints/i/2fa/remove-key.ts b/src/server/api/endpoints/i/2fa/remove-key.ts index 3eb92ba19d..135f0eb284 100644 --- a/src/server/api/endpoints/i/2fa/remove-key.ts +++ b/src/server/api/endpoints/i/2fa/remove-key.ts @@ -2,7 +2,6 @@ import $ from 'cafy'; import * as bcrypt from 'bcryptjs'; import define from '../../../define'; import { UserProfiles, UserSecurityKeys, Users } from '../../../../../models'; -import { ensure } from '../../../../../prelude/ensure'; import { publishMainStream } from '../../../../../services/stream'; export const meta = { @@ -21,7 +20,7 @@ export const meta = { }; export default define(meta, async (ps, user) => { - const profile = await UserProfiles.findOne(user.id).then(ensure); + const profile = await UserProfiles.findOneOrFail(user.id); // Compare password const same = await bcrypt.compare(ps.password, profile.password!); diff --git a/src/server/api/endpoints/i/2fa/unregister.ts b/src/server/api/endpoints/i/2fa/unregister.ts index f1287b2dca..e809f40c71 100644 --- a/src/server/api/endpoints/i/2fa/unregister.ts +++ b/src/server/api/endpoints/i/2fa/unregister.ts @@ -2,7 +2,6 @@ import $ from 'cafy'; import * as bcrypt from 'bcryptjs'; import define from '../../../define'; import { UserProfiles } from '../../../../../models'; -import { ensure } from '../../../../../prelude/ensure'; export const meta = { requireCredential: true as const, @@ -17,7 +16,7 @@ export const meta = { }; export default define(meta, async (ps, user) => { - const profile = await UserProfiles.findOne(user.id).then(ensure); + const profile = await UserProfiles.findOneOrFail(user.id); // Compare password const same = await bcrypt.compare(ps.password, profile.password!); diff --git a/src/server/api/endpoints/i/change-password.ts b/src/server/api/endpoints/i/change-password.ts index bc2ec3d7b5..0a8b86e665 100644 --- a/src/server/api/endpoints/i/change-password.ts +++ b/src/server/api/endpoints/i/change-password.ts @@ -2,7 +2,6 @@ import $ from 'cafy'; import * as bcrypt from 'bcryptjs'; import define from '../../define'; import { UserProfiles } from '../../../../models'; -import { ensure } from '../../../../prelude/ensure'; export const meta = { requireCredential: true as const, @@ -21,7 +20,7 @@ export const meta = { }; export default define(meta, async (ps, user) => { - const profile = await UserProfiles.findOne(user.id).then(ensure); + const profile = await UserProfiles.findOneOrFail(user.id); // Compare password const same = await bcrypt.compare(ps.currentPassword, profile.password!); diff --git a/src/server/api/endpoints/i/delete-account.ts b/src/server/api/endpoints/i/delete-account.ts index 49a3349170..0f04c4c92d 100644 --- a/src/server/api/endpoints/i/delete-account.ts +++ b/src/server/api/endpoints/i/delete-account.ts @@ -2,7 +2,6 @@ import $ from 'cafy'; import * as bcrypt from 'bcryptjs'; import define from '../../define'; import { Users, UserProfiles } from '../../../../models'; -import { ensure } from '../../../../prelude/ensure'; import { doPostSuspend } from '../../../../services/suspend-user'; export const meta = { @@ -18,7 +17,7 @@ export const meta = { }; export default define(meta, async (ps, user) => { - const profile = await UserProfiles.findOne(user.id).then(ensure); + const profile = await UserProfiles.findOneOrFail(user.id); // Compare password const same = await bcrypt.compare(ps.password, profile.password!); diff --git a/src/server/api/endpoints/i/regenerate-token.ts b/src/server/api/endpoints/i/regenerate-token.ts index 5d322aa122..3596e20197 100644 --- a/src/server/api/endpoints/i/regenerate-token.ts +++ b/src/server/api/endpoints/i/regenerate-token.ts @@ -4,7 +4,6 @@ import { publishMainStream } from '../../../../services/stream'; import generateUserToken from '../../common/generate-native-user-token'; import define from '../../define'; import { Users, UserProfiles } from '../../../../models'; -import { ensure } from '../../../../prelude/ensure'; export const meta = { requireCredential: true as const, @@ -19,7 +18,7 @@ export const meta = { }; export default define(meta, async (ps, user) => { - const profile = await UserProfiles.findOne(user.id).then(ensure); + const profile = await UserProfiles.findOneOrFail(user.id); // Compare password const same = await bcrypt.compare(ps.password, profile.password!); diff --git a/src/server/api/endpoints/i/update-email.ts b/src/server/api/endpoints/i/update-email.ts index 20d9703320..730918aebe 100644 --- a/src/server/api/endpoints/i/update-email.ts +++ b/src/server/api/endpoints/i/update-email.ts @@ -6,7 +6,6 @@ import config from '../../../../config'; import * as ms from 'ms'; import * as bcrypt from 'bcryptjs'; import { Users, UserProfiles } from '../../../../models'; -import { ensure } from '../../../../prelude/ensure'; import { sendEmail } from '../../../../services/send-email'; import { ApiError } from '../../error'; @@ -40,7 +39,7 @@ export const meta = { }; export default define(meta, async (ps, user) => { - const profile = await UserProfiles.findOne(user.id).then(ensure); + const profile = await UserProfiles.findOneOrFail(user.id); // Compare password const same = await bcrypt.compare(ps.password, profile.password!); @@ -72,7 +71,9 @@ export default define(meta, async (ps, user) => { const link = `${config.url}/verify-email/${code}`; - sendEmail(ps.email, 'Email verification', `To verify email, please click this link: ${link}`); + sendEmail(ps.email, 'Email verification', + `To verify email, please click this link:<br><a href="${link}">${link}</a>`, + `To verify email, please click this link: ${link}`); } return iObj; diff --git a/src/server/api/endpoints/i/update.ts b/src/server/api/endpoints/i/update.ts index e4c0e8cec9..3d7f1fa76f 100644 --- a/src/server/api/endpoints/i/update.ts +++ b/src/server/api/endpoints/i/update.ts @@ -13,7 +13,6 @@ import { ApiError } from '../../error'; import { Users, DriveFiles, UserProfiles, Pages } from '../../../../models'; import { User } from '../../../../models/entities/user'; import { UserProfile } from '../../../../models/entities/user-profile'; -import { ensure } from '../../../../prelude/ensure'; import { notificationTypes } from '../../../../types'; import { normalizeForSearch } from '../../../../misc/normalize-for-search'; @@ -161,6 +160,10 @@ export const meta = { mutingNotificationTypes: { validator: $.optional.arr($.str.or(notificationTypes as unknown as string[])) }, + + emailNotificationTypes: { + validator: $.optional.arr($.str) + }, }, errors: { @@ -202,11 +205,11 @@ export default define(meta, async (ps, user, token) => { const updates = {} as Partial<User>; const profileUpdates = {} as Partial<UserProfile>; - const profile = await UserProfiles.findOne(user.id).then(ensure); + const profile = await UserProfiles.findOneOrFail(user.id); if (ps.name !== undefined) updates.name = ps.name; if (ps.description !== undefined) profileUpdates.description = ps.description; - //if (ps.lang !== undefined) updates.lang = ps.lang; + if (ps.lang !== undefined) profileUpdates.lang = ps.lang; if (ps.location !== undefined) profileUpdates.location = ps.location; if (ps.birthday !== undefined) profileUpdates.birthday = ps.birthday; if (ps.avatarId !== undefined) updates.avatarId = ps.avatarId; @@ -226,6 +229,7 @@ export default define(meta, async (ps, user, token) => { if (typeof ps.injectFeaturedNote === 'boolean') profileUpdates.injectFeaturedNote = ps.injectFeaturedNote; if (typeof ps.receiveAnnouncementEmail === 'boolean') profileUpdates.receiveAnnouncementEmail = ps.receiveAnnouncementEmail; if (typeof ps.alwaysMarkNsfw === 'boolean') profileUpdates.alwaysMarkNsfw = ps.alwaysMarkNsfw; + if (ps.emailNotificationTypes !== undefined) profileUpdates.emailNotificationTypes = ps.emailNotificationTypes; if (ps.avatarId) { const avatar = await DriveFiles.findOne(ps.avatarId); diff --git a/src/server/api/endpoints/notes/delete.ts b/src/server/api/endpoints/notes/delete.ts index 65565ee3ab..5afd911ca9 100644 --- a/src/server/api/endpoints/notes/delete.ts +++ b/src/server/api/endpoints/notes/delete.ts @@ -6,7 +6,6 @@ import * as ms from 'ms'; import { getNote } from '../../common/getters'; import { ApiError } from '../../error'; import { Users } from '../../../../models'; -import { ensure } from '../../../../prelude/ensure'; export const meta = { desc: { @@ -62,5 +61,5 @@ export default define(meta, async (ps, user) => { } // この操作を行うのが投稿者とは限らない(例えばモデレーター)ため - await deleteNote(await Users.findOne(note.userId).then(ensure), note); + await deleteNote(await Users.findOneOrFail(note.userId), note); }); diff --git a/src/server/api/endpoints/notes/polls/vote.ts b/src/server/api/endpoints/notes/polls/vote.ts index 1e6ab472cc..6113d7ea9a 100644 --- a/src/server/api/endpoints/notes/polls/vote.ts +++ b/src/server/api/endpoints/notes/polls/vote.ts @@ -13,7 +13,6 @@ import { PollVotes, NoteWatchings, Users, Polls } from '../../../../../models'; import { Not } from 'typeorm'; import { IRemoteUser } from '../../../../../models/entities/user'; import { genId } from '../../../../../misc/gen-id'; -import { ensure } from '../../../../../prelude/ensure'; export const meta = { desc: { @@ -87,7 +86,7 @@ export default define(meta, async (ps, user) => { throw new ApiError(meta.errors.noPoll); } - const poll = await Polls.findOne({ noteId: note.id }).then(ensure); + const poll = await Polls.findOneOrFail({ noteId: note.id }); if (poll.expiresAt && poll.expiresAt < createdAt) { throw new ApiError(meta.errors.alreadyExpired); @@ -153,7 +152,7 @@ export default define(meta, async (ps, user) => { // リモート投票の場合リプライ送信 if (note.userHost != null) { - const pollOwner = await Users.findOne(note.userId).then(ensure) as IRemoteUser; + const pollOwner = await Users.findOneOrFail(note.userId) as IRemoteUser; deliver(user, renderActivity(await renderVote(user, vote, note, poll, pollOwner)), pollOwner.inbox); } diff --git a/src/server/api/endpoints/notes/search.ts b/src/server/api/endpoints/notes/search.ts index 2c75d2a55a..1aca056299 100644 --- a/src/server/api/endpoints/notes/search.ts +++ b/src/server/api/endpoints/notes/search.ts @@ -46,6 +46,11 @@ export const meta = { validator: $.optional.nullable.type(ID), default: null }, + + channelId: { + validator: $.optional.nullable.type(ID), + default: null + }, }, res: { @@ -64,7 +69,15 @@ export const meta = { export default define(meta, async (ps, me) => { if (es == null) { - const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId) + const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId); + + if (ps.userId) { + query.andWhere('note.userId = :userId', { userId: ps.userId }); + } else if (ps.channelId) { + query.andWhere('note.channelId = :channelId', { channelId: ps.channelId }); + } + + query .andWhere('note.text ILIKE :q', { q: `%${ps.query}%` }) .leftJoinAndSelect('note.user', 'user'); diff --git a/src/server/api/endpoints/room/show.ts b/src/server/api/endpoints/room/show.ts index 96248a8c0c..e8af68956d 100644 --- a/src/server/api/endpoints/room/show.ts +++ b/src/server/api/endpoints/room/show.ts @@ -3,7 +3,6 @@ import define from '../../define'; import { ApiError } from '../../error'; import { Users, UserProfiles } from '../../../../models'; import { ID } from '../../../../misc/cafy-id'; -import { ensure } from '../../../../prelude/ensure'; import { toPunyNullable } from '../../../../misc/convert-host'; export const meta = { @@ -51,7 +50,7 @@ export default define(meta, async (ps, me) => { throw new ApiError(meta.errors.noSuchUser); } - const profile = await UserProfiles.findOne(user.id).then(ensure); + const profile = await UserProfiles.findOneOrFail(user.id); if (profile.room.furnitures == null) { await UserProfiles.update(user.id, { diff --git a/src/server/api/private/signin.ts b/src/server/api/private/signin.ts index 734758d63d..7a5efc6cc9 100644 --- a/src/server/api/private/signin.ts +++ b/src/server/api/private/signin.ts @@ -6,7 +6,6 @@ import config from '../../../config'; import { Users, Signins, UserProfiles, UserSecurityKeys, AttestationChallenges } from '../../../models'; import { ILocalUser } from '../../../models/entities/user'; import { genId } from '../../../misc/gen-id'; -import { ensure } from '../../../prelude/ensure'; import { verifyLogin, hash } from '../2fa'; import { randomBytes } from 'crypto'; @@ -47,7 +46,7 @@ export default async (ctx: Koa.Context) => { return; } - const profile = await UserProfiles.findOne(user.id).then(ensure); + const profile = await UserProfiles.findOneOrFail(user.id); // Compare password const same = await bcrypt.compare(password, profile.password!); diff --git a/src/server/api/service/discord.ts b/src/server/api/service/discord.ts index 007458066d..fce840cde5 100644 --- a/src/server/api/service/discord.ts +++ b/src/server/api/service/discord.ts @@ -10,7 +10,6 @@ import signin from '../common/signin'; import { fetchMeta } from '../../../misc/fetch-meta'; import { Users, UserProfiles } from '../../../models'; import { ILocalUser } from '../../../models/entities/user'; -import { ensure } from '../../../prelude/ensure'; function getUserToken(ctx: Koa.Context) { return ((ctx.headers['cookie'] || '').match(/igi=(\w+)/) || [null, null])[1]; @@ -41,12 +40,12 @@ router.get('/disconnect/discord', async ctx => { return; } - const user = await Users.findOne({ + const user = await Users.findOneOrFail({ host: null, token: userToken - }).then(ensure); + }); - const profile = await UserProfiles.findOne(user.id).then(ensure); + const profile = await UserProfiles.findOneOrFail(user.id); delete profile.integrations.discord; @@ -253,12 +252,12 @@ router.get('/dc/cb', async ctx => { return; } - const user = await Users.findOne({ + const user = await Users.findOneOrFail({ host: null, token: userToken - }).then(ensure); + }); - const profile = await UserProfiles.findOne(user.id).then(ensure); + const profile = await UserProfiles.findOneOrFail(user.id); await UserProfiles.update(user.id, { integrations: { diff --git a/src/server/api/service/github.ts b/src/server/api/service/github.ts index 663c3cc754..2b10fa02a0 100644 --- a/src/server/api/service/github.ts +++ b/src/server/api/service/github.ts @@ -10,7 +10,6 @@ import signin from '../common/signin'; import { fetchMeta } from '../../../misc/fetch-meta'; import { Users, UserProfiles } from '../../../models'; import { ILocalUser } from '../../../models/entities/user'; -import { ensure } from '../../../prelude/ensure'; function getUserToken(ctx: Koa.Context) { return ((ctx.headers['cookie'] || '').match(/igi=(\w+)/) || [null, null])[1]; @@ -41,12 +40,12 @@ router.get('/disconnect/github', async ctx => { return; } - const user = await Users.findOne({ + const user = await Users.findOneOrFail({ host: null, token: userToken - }).then(ensure); + }); - const profile = await UserProfiles.findOne(user.id).then(ensure); + const profile = await UserProfiles.findOneOrFail(user.id); delete profile.integrations.github; @@ -227,12 +226,12 @@ router.get('/gh/cb', async ctx => { return; } - const user = await Users.findOne({ + const user = await Users.findOneOrFail({ host: null, token: userToken - }).then(ensure); + }); - const profile = await UserProfiles.findOne(user.id).then(ensure); + const profile = await UserProfiles.findOneOrFail(user.id); await UserProfiles.update(user.id, { integrations: { diff --git a/src/server/api/service/twitter.ts b/src/server/api/service/twitter.ts index 000eb57c1b..97e9d3a7fc 100644 --- a/src/server/api/service/twitter.ts +++ b/src/server/api/service/twitter.ts @@ -9,7 +9,6 @@ import signin from '../common/signin'; import { fetchMeta } from '../../../misc/fetch-meta'; import { Users, UserProfiles } from '../../../models'; import { ILocalUser } from '../../../models/entities/user'; -import { ensure } from '../../../prelude/ensure'; function getUserToken(ctx: Koa.Context) { return ((ctx.headers['cookie'] || '').match(/igi=(\w+)/) || [null, null])[1]; @@ -40,12 +39,12 @@ router.get('/disconnect/twitter', async ctx => { return; } - const user = await Users.findOne({ + const user = await Users.findOneOrFail({ host: null, token: userToken - }).then(ensure); + }); - const profile = await UserProfiles.findOne(user.id).then(ensure); + const profile = await UserProfiles.findOneOrFail(user.id); delete profile.integrations.twitter; @@ -163,12 +162,12 @@ router.get('/tw/cb', async ctx => { const result = await twAuth!.done(JSON.parse(twCtx), verifier); - const user = await Users.findOne({ + const user = await Users.findOneOrFail({ host: null, token: userToken - }).then(ensure); + }); - const profile = await UserProfiles.findOne(user.id).then(ensure); + const profile = await UserProfiles.findOneOrFail(user.id); await UserProfiles.update(user.id, { integrations: { diff --git a/src/server/web/feed.ts b/src/server/web/feed.ts index 9c7591d744..6078fbe0f3 100644 --- a/src/server/web/feed.ts +++ b/src/server/web/feed.ts @@ -3,7 +3,6 @@ import config from '../../config'; import { User } from '../../models/entities/user'; import { Notes, DriveFiles, UserProfiles } from '../../models'; import { In } from 'typeorm'; -import { ensure } from '../../prelude/ensure'; export default async function(user: User) { const author = { @@ -11,7 +10,7 @@ export default async function(user: User) { name: user.name || user.username }; - const profile = await UserProfiles.findOne(user.id).then(ensure); + const profile = await UserProfiles.findOneOrFail(user.id); const notes = await Notes.find({ where: { diff --git a/src/server/web/index.ts b/src/server/web/index.ts index f3442c6199..8ea7e15751 100644 --- a/src/server/web/index.ts +++ b/src/server/web/index.ts @@ -20,7 +20,6 @@ import config from '../../config'; import { Users, Notes, Emojis, UserProfiles, Pages, Channels, Clips } from '../../models'; import parseAcct from '../../misc/acct/parse'; import { getNoteSummary } from '../../misc/get-note-summary'; -import { ensure } from '../../prelude/ensure'; import { getConnection } from 'typeorm'; import redis from '../../db/redis'; import locales = require('../../../locales'); @@ -199,7 +198,7 @@ router.get(['/@:user', '/@:user/:sub'], async (ctx, next) => { }); if (user != null) { - const profile = await UserProfiles.findOne(user.id).then(ensure); + const profile = await UserProfiles.findOneOrFail(user.id); const meta = await fetchMeta(); const me = profile.fields ? profile.fields @@ -242,7 +241,7 @@ router.get('/notes/:note', async ctx => { if (note) { const _note = await Notes.pack(note); - const profile = await UserProfiles.findOne(note.userId).then(ensure); + const profile = await UserProfiles.findOneOrFail(note.userId); const meta = await fetchMeta(); await ctx.render('note', { note: _note, @@ -282,7 +281,7 @@ router.get('/@:user/pages/:page', async ctx => { if (page) { const _page = await Pages.pack(page); - const profile = await UserProfiles.findOne(page.userId).then(ensure); + const profile = await UserProfiles.findOneOrFail(page.userId); const meta = await fetchMeta(); await ctx.render('page', { page: _page, @@ -311,7 +310,7 @@ router.get('/clips/:clip', async ctx => { if (clip) { const _clip = await Clips.pack(clip); - const profile = await UserProfiles.findOne(clip.userId).then(ensure); + const profile = await UserProfiles.findOneOrFail(clip.userId); const meta = await fetchMeta(); await ctx.render('clip', { clip: _clip, diff --git a/src/services/add-note-to-antenna.ts b/src/services/add-note-to-antenna.ts index f11607fd43..2c893488c3 100644 --- a/src/services/add-note-to-antenna.ts +++ b/src/services/add-note-to-antenna.ts @@ -3,7 +3,6 @@ import { Note } from '../models/entities/note'; import { AntennaNotes, Mutings, Notes } from '../models'; import { genId } from '../misc/gen-id'; import { isMutedUserRelated } from '../misc/is-muted-user-related'; -import { ensure } from '../prelude/ensure'; import { publishAntennaStream, publishMainStream } from './stream'; import { User } from '../models/entities/user'; @@ -34,10 +33,10 @@ export async function addNoteToAntenna(antenna: Antenna, note: Note, noteUser: U }; if (note.replyId != null) { - _note.reply = await Notes.findOne(note.replyId).then(ensure); + _note.reply = await Notes.findOneOrFail(note.replyId); } if (note.renoteId != null) { - _note.renote = await Notes.findOne(note.renoteId).then(ensure); + _note.renote = await Notes.findOneOrFail(note.renoteId); } if (isMutedUserRelated(_note, new Set<string>(mutings.map(x => x.muteeId)))) { diff --git a/src/services/create-notification.ts b/src/services/create-notification.ts index 5dddaa5727..6cd116040a 100644 --- a/src/services/create-notification.ts +++ b/src/services/create-notification.ts @@ -4,6 +4,7 @@ import { Notifications, Mutings, UserProfiles } from '../models'; import { genId } from '../misc/gen-id'; import { User } from '../models/entities/user'; import { Notification } from '../models/entities/notification'; +import { sendEmailNotification } from './send-email-notification'; export async function createNotification( notifieeId: User['id'], @@ -38,20 +39,22 @@ export async function createNotification( setTimeout(async () => { const fresh = await Notifications.findOne(notification.id); if (fresh == null) return; // 既に削除されているかもしれない - if (!fresh.isRead) { - //#region ただしミュートしているユーザーからの通知なら無視 - const mutings = await Mutings.find({ - muterId: notifieeId - }); - if (data.notifierId && mutings.map(m => m.muteeId).includes(data.notifierId)) { - return; - } - //#endregion + if (fresh.isRead) return; - publishMainStream(notifieeId, 'unreadNotification', packed); - - pushSw(notifieeId, 'notification', packed); + //#region ただしミュートしているユーザーからの通知なら無視 + const mutings = await Mutings.find({ + muterId: notifieeId + }); + if (data.notifierId && mutings.map(m => m.muteeId).includes(data.notifierId)) { + return; } + //#endregion + + publishMainStream(notifieeId, 'unreadNotification', packed); + + pushSw(notifieeId, 'notification', packed); + if (type === 'follow') sendEmailNotification.follow(notifieeId, data); + if (type === 'receiveFollowRequest') sendEmailNotification.receiveFollowRequest(notifieeId, data); }, 2000); return notification; diff --git a/src/services/following/create.ts b/src/services/following/create.ts index c5f130f49f..c0583cdb86 100644 --- a/src/services/following/create.ts +++ b/src/services/following/create.ts @@ -14,7 +14,6 @@ import { instanceChart, perUserFollowingChart } from '../chart'; import { genId } from '../../misc/gen-id'; import { createNotification } from '../create-notification'; import { isDuplicateKeyValueError } from '../../misc/is-duplicate-key-value-error'; -import { ensure } from '../../prelude/ensure'; const logger = new Logger('following/create'); @@ -130,7 +129,7 @@ export default async function(follower: User, followee: User, requestId?: string if (blocked != null) throw new IdentifiableError('3338392a-f764-498d-8855-db939dcf8c48', 'blocked'); } - const followeeProfile = await UserProfiles.findOne(followee.id).then(ensure); + const followeeProfile = await UserProfiles.findOneOrFail(followee.id); // フォロー対象が鍵アカウントである or // フォロワーがBotであり、フォロー対象がBotからのフォローに慎重である or diff --git a/src/services/following/requests/accept-all.ts b/src/services/following/requests/accept-all.ts index 70e7448aad..da39965349 100644 --- a/src/services/following/requests/accept-all.ts +++ b/src/services/following/requests/accept-all.ts @@ -1,7 +1,6 @@ import accept from './accept'; import { User } from '../../../models/entities/user'; import { FollowRequests, Users } from '../../../models'; -import { ensure } from '../../../prelude/ensure'; /** * 指定したユーザー宛てのフォローリクエストをすべて承認 @@ -13,7 +12,7 @@ export default async function(user: User) { }); for (const request of requests) { - const follower = await Users.findOne(request.followerId).then(ensure); + const follower = await Users.findOneOrFail(request.followerId); accept(user, follower); } } diff --git a/src/services/messages/delete.ts b/src/services/messages/delete.ts index 0efff85f39..c94a7b67db 100644 --- a/src/services/messages/delete.ts +++ b/src/services/messages/delete.ts @@ -1,5 +1,4 @@ import config from '../../config'; -import { ensure } from '../../prelude/ensure'; import { MessagingMessages, Users } from '../../models'; import { MessagingMessage } from '../../models/entities/messaging-message'; import { publishGroupMessagingStream, publishMessagingStream } from '../stream'; @@ -15,8 +14,8 @@ export async function deleteMessage(message: MessagingMessage) { async function postDeleteMessage(message: MessagingMessage) { if (message.recipientId) { - const user = await Users.findOne(message.userId).then(ensure); - const recipient = await Users.findOne(message.recipientId).then(ensure); + const user = await Users.findOneOrFail(message.userId); + const recipient = await Users.findOneOrFail(message.recipientId); if (Users.isLocalUser(user)) publishMessagingStream(message.userId, message.recipientId, 'deleted', message.id); if (Users.isLocalUser(recipient)) publishMessagingStream(message.recipientId, message.userId, 'deleted', message.id); diff --git a/src/services/note/create.ts b/src/services/note/create.ts index 62ec92f2bc..563eaac758 100644 --- a/src/services/note/create.ts +++ b/src/services/note/create.ts @@ -26,7 +26,6 @@ import { notesChart, perUserNotesChart, activeUsersChart, instanceChart } from ' import { Poll, IPoll } from '../../models/entities/poll'; import { createNotification } from '../create-notification'; import { isDuplicateKeyValueError } from '../../misc/is-duplicate-key-value-error'; -import { ensure } from '../../prelude/ensure'; import { checkHitAntenna } from '../../misc/check-hit-antenna'; import { checkWordMute } from '../../misc/check-word-mute'; import { addNoteToAntenna } from '../add-note-to-antenna'; @@ -200,7 +199,7 @@ export default async (user: User, data: Option, silent = false) => new Promise<N tags = tags.filter(tag => Array.from(tag || '').length <= 128).splice(0, 32); if (data.reply && (user.id !== data.reply.userId) && !mentionedUsers.some(u => u.id === data.reply!.userId)) { - mentionedUsers.push(await Users.findOne(data.reply.userId).then(ensure)); + mentionedUsers.push(await Users.findOneOrFail(data.reply.userId)); } if (data.visibility == 'specified') { @@ -213,7 +212,7 @@ export default async (user: User, data: Option, silent = false) => new Promise<N } if (data.reply && !data.visibleUsers.some(x => x.id === data.reply!.userId)) { - data.visibleUsers.push(await Users.findOne(data.reply.userId).then(ensure)); + data.visibleUsers.push(await Users.findOneOrFail(data.reply.userId)); } } diff --git a/src/services/send-email-notification.ts b/src/services/send-email-notification.ts new file mode 100644 index 0000000000..7579d5b674 --- /dev/null +++ b/src/services/send-email-notification.ts @@ -0,0 +1,28 @@ +import { UserProfiles } from '../models'; +import { User } from '../models/entities/user'; +import { sendEmail } from './send-email'; +import * as locales from '../../locales/'; +import { I18n } from '../misc/i18n'; + +// TODO: locale ファイルをクライアント用とサーバー用で分けたい + +async function follow(userId: User['id'], args: {}) { + const userProfile = await UserProfiles.findOneOrFail({ userId: userId }); + if (!userProfile.email || !userProfile.emailNotificationTypes.includes('follow')) return; + const locale = locales[userProfile.lang || 'ja-JP']; + const i18n = new I18n(locale); + sendEmail(userProfile.email, i18n.t('_email._follow.title'), 'test', 'test'); +} + +async function receiveFollowRequest(userId: User['id'], args: {}) { + const userProfile = await UserProfiles.findOneOrFail({ userId: userId }); + if (!userProfile.email || !userProfile.emailNotificationTypes.includes('receiveFollowRequest')) return; + const locale = locales[userProfile.lang || 'ja-JP']; + const i18n = new I18n(locale); + sendEmail(userProfile.email, i18n.t('_email._receiveFollowRequest.title'), 'test', 'test'); +} + +export const sendEmailNotification = { + follow, + receiveFollowRequest, +}; diff --git a/src/services/send-email.ts b/src/services/send-email.ts index 5a8f92be54..c716b36715 100644 --- a/src/services/send-email.ts +++ b/src/services/send-email.ts @@ -5,9 +5,12 @@ import config from '../config'; export const logger = new Logger('email'); -export async function sendEmail(to: string, subject: string, text: string) { +export async function sendEmail(to: string, subject: string, html: string, text: string) { const meta = await fetchMeta(true); + const iconUrl = `${config.url}/assets/mi-white.png`; + const emailSettingUrl = `${config.url}/settings/email`; + const enableAuth = meta.smtpUser != null && meta.smtpUser !== ''; const transporter = nodemailer.createTransport({ @@ -23,11 +26,93 @@ export async function sendEmail(to: string, subject: string, text: string) { } as any); try { + // TODO: htmlサニタイズ const info = await transporter.sendMail({ from: meta.email!, to: to, - subject: subject || 'Misskey', - text: text + subject: subject, + text: text, + html: `<!doctype html> + <html> + <head> + <meta charset="utf-8"> + <title>${ subject }</title> + <style> + html { + background: #eee; + } + + body { + padding: 16px; + margin: 0; + font-family: sans-serif; + font-size: 14px; + } + + a { + text-decoration: none; + color: #86b300; + } + a:hover { + text-decoration: underline; + } + + main { + max-width: 500px; + margin: 0 auto; + background: #fff; + color: #555; + } + main > header { + padding: 32px; + background: #86b300; + } + main > header > img { + max-width: 128px; + max-height: 28px; + vertical-align: bottom; + } + main > article { + padding: 32px; + } + main > article > h1 { + margin: 0 0 1em 0; + } + main > footer { + padding: 32px; + border-top: solid 1px #eee; + } + + nav { + box-sizing: border-box; + max-width: 500px; + margin: 16px auto 0 auto; + padding: 0 32px; + } + nav > a { + color: #888; + } + </style> + </head> + <body> + <main> + <header> + <img src="${ meta.logoImageUrl || meta.iconUrl || iconUrl }"/> + </header> + <article> + <h1>${ subject }</h1> + <div>${ html }</div> + </article> + <footer> + <a href="${ emailSettingUrl }">${ 'Email setting' }</a> + </footer> + </main> + <nav> + <a href="${ config.url }">${ config.host }</a> + </nav> + </body> + </html> + ` }); logger.info('Message sent: %s', info.messageId); |