diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2019-02-25 19:45:00 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2019-02-25 19:45:00 +0900 |
| commit | c0a60260c25c8f7e0c4975b6a1a4342f2b430210 (patch) | |
| tree | 5bab5271e74eb52bd13fc79d359ef30f829d6dc3 /src/client/app/common/views/deck | |
| parent | Fix error (diff) | |
| download | misskey-c0a60260c25c8f7e0c4975b6a1a4342f2b430210.tar.gz misskey-c0a60260c25c8f7e0c4975b6a1a4342f2b430210.tar.bz2 misskey-c0a60260c25c8f7e0c4975b6a1a4342f2b430210.zip | |
モバイル版でもデッキを使えるように (#4366)
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* Fix bug
* wip
* Update notifications.vue
* Update user-menu.vue
* deck settings
* indicate
Diffstat (limited to 'src/client/app/common/views/deck')
24 files changed, 3274 insertions, 0 deletions
diff --git a/src/client/app/common/views/deck/deck.column-core.vue b/src/client/app/common/views/deck/deck.column-core.vue new file mode 100644 index 0000000000..974c58235d --- /dev/null +++ b/src/client/app/common/views/deck/deck.column-core.vue @@ -0,0 +1,49 @@ +<template> +<x-widgets-column v-if="column.type == 'widgets'" :column="column" :is-stacked="isStacked" v-on="$listeners"/> +<x-notifications-column v-else-if="column.type == 'notifications'" :column="column" :is-stacked="isStacked" v-on="$listeners"/> +<x-tl-column v-else-if="column.type == 'home'" :column="column" :is-stacked="isStacked" v-on="$listeners"/> +<x-tl-column v-else-if="column.type == 'local'" :column="column" :is-stacked="isStacked" v-on="$listeners"/> +<x-tl-column v-else-if="column.type == 'hybrid'" :column="column" :is-stacked="isStacked" v-on="$listeners"/> +<x-tl-column v-else-if="column.type == 'global'" :column="column" :is-stacked="isStacked" v-on="$listeners"/> +<x-tl-column v-else-if="column.type == 'list'" :column="column" :is-stacked="isStacked" v-on="$listeners"/> +<x-tl-column v-else-if="column.type == 'hashtag'" :column="column" :is-stacked="isStacked" v-on="$listeners"/> +<x-mentions-column v-else-if="column.type == 'mentions'" :column="column" :is-stacked="isStacked" v-on="$listeners"/> +<x-direct-column v-else-if="column.type == 'direct'" :column="column" :is-stacked="isStacked" v-on="$listeners"/> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XTlColumn from './deck.tl-column.vue'; +import XNotificationsColumn from './deck.notifications-column.vue'; +import XWidgetsColumn from './deck.widgets-column.vue'; +import XMentionsColumn from './deck.mentions-column.vue'; +import XDirectColumn from './deck.direct-column.vue'; + +export default Vue.extend({ + components: { + XTlColumn, + XNotificationsColumn, + XWidgetsColumn, + XMentionsColumn, + XDirectColumn + }, + + props: { + column: { + type: Object, + required: true + }, + isStacked: { + type: Boolean, + required: false, + default: false + } + }, + + methods: { + focus() { + this.$children[0].focus(); + } + } +}); +</script> diff --git a/src/client/app/common/views/deck/deck.column.vue b/src/client/app/common/views/deck/deck.column.vue new file mode 100644 index 0000000000..2d5cfdd843 --- /dev/null +++ b/src/client/app/common/views/deck/deck.column.vue @@ -0,0 +1,426 @@ +<template> +<div class="dnpfarvgbnfmyzbdquhhzyxcmstpdqzs" :class="{ naked, narrow, active, isStacked, draghover, dragging, dropready }" + @dragover.prevent.stop="onDragover" + @dragleave="onDragleave" + @drop.prevent.stop="onDrop" + v-hotkey="keymap"> + <header :class="{ indicate: count > 0 }" + draggable="true" + @click="goTop" + @dragstart="onDragstart" + @dragend="onDragend" + @contextmenu.prevent.stop="onContextmenu"> + <button class="toggleActive" @click="toggleActive" v-if="isStacked"> + <template v-if="active"><fa icon="angle-up"/></template> + <template v-else><fa icon="angle-down"/></template> + </button> + <span><slot name="header"></slot></span> + <span class="count" v-if="count > 0">({{ count }})</span> + <button v-if="!isTemporaryColumn" class="menu" ref="menu" @click.stop="showMenu"><fa icon="caret-down"/></button> + <button v-else class="close" @click.stop="close"><fa icon="times"/></button> + </header> + <div ref="body" v-show="active"> + <slot></slot> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../../i18n'; +import Menu from '../../../common/views/components/menu.vue'; +import { countIf } from '../../../../../prelude/array'; +import { faArrowUp, faArrowDown } from '@fortawesome/free-solid-svg-icons'; +import { faWindowMaximize } from '@fortawesome/free-regular-svg-icons'; + +export default Vue.extend({ + i18n: i18n('deck'), + props: { + column: { + type: Object, + required: false, + default: null + }, + isStacked: { + type: Boolean, + required: false, + default: false + }, + name: { + type: String, + required: false + }, + menu: { + type: Array, + required: false, + default: null + }, + naked: { + type: Boolean, + required: false, + default: false + }, + narrow: { + type: Boolean, + required: false, + default: false + } + }, + + data() { + return { + count: 0, + active: true, + dragging: false, + draghover: false, + dropready: false, + faArrowUp, faArrowDown + }; + }, + + computed: { + isTemporaryColumn(): boolean { + return this.column == null; + }, + + keymap(): any { + return { + 'shift+up': () => this.$parent.$emit('parentFocus', 'up'), + 'shift+down': () => this.$parent.$emit('parentFocus', 'down'), + 'shift+left': () => this.$parent.$emit('parentFocus', 'left'), + 'shift+right': () => this.$parent.$emit('parentFocus', 'right'), + }; + } + }, + + inject: { + getColumnVm: { from: 'getColumnVm' } + }, + + watch: { + active(v) { + if (v && this.isScrollTop()) { + this.$emit('top'); + } + }, + dragging(v) { + this.$root.$emit(v ? 'deck.column.dragStart' : 'deck.column.dragEnd'); + } + }, + + provide() { + return { + column: this, + isScrollTop: this.isScrollTop, + count: v => this.count = v, + inDeck: !this.naked + }; + }, + + mounted() { + this.$refs.body.addEventListener('scroll', this.onScroll, { passive: true }); + + if (!this.isTemporaryColumn) { + this.$root.$on('deck.column.dragStart', this.onOtherDragStart); + this.$root.$on('deck.column.dragEnd', this.onOtherDragEnd); + } + }, + + beforeDestroy() { + this.$refs.body.removeEventListener('scroll', this.onScroll); + + if (!this.isTemporaryColumn) { + this.$root.$off('deck.column.dragStart', this.onOtherDragStart); + this.$root.$off('deck.column.dragEnd', this.onOtherDragEnd); + } + }, + + methods: { + onOtherDragStart() { + this.dropready = true; + }, + + onOtherDragEnd() { + this.dropready = false; + }, + + toggleActive() { + if (!this.isStacked) return; + const vms = this.$store.state.device.deck.layout.find(ids => ids.indexOf(this.column.id) != -1).map(id => this.getColumnVm(id)); + if (this.active && countIf(vm => vm.$el.classList.contains('active'), vms) == 1) return; + this.active = !this.active; + }, + + isScrollTop() { + return this.active && this.$refs.body.scrollTop == 0; + }, + + onScroll() { + if (this.isScrollTop()) { + this.$emit('top'); + } + + if (this.$store.state.settings.fetchOnScroll !== false) { + const current = this.$refs.body.scrollTop + this.$refs.body.clientHeight; + if (current > this.$refs.body.scrollHeight - 1) this.$emit('bottom'); + } + }, + + getMenu() { + const items = [{ + icon: 'pencil-alt', + text: this.$t('rename'), + action: () => { + this.$root.dialog({ + title: this.$t('rename'), + input: { + default: this.name, + allowEmpty: false + } + }).then(({ canceled, result: name }) => { + if (canceled) return; + this.$store.commit('device/renameDeckColumn', { id: this.column.id, name }); + }); + } + }, null, { + icon: 'arrow-left', + text: this.$t('swap-left'), + action: () => { + this.$store.commit('device/swapLeftDeckColumn', this.column.id); + } + }, { + icon: 'arrow-right', + text: this.$t('swap-right'), + action: () => { + this.$store.commit('device/swapRightDeckColumn', this.column.id); + } + }, this.isStacked ? { + icon: faArrowUp, + text: this.$t('swap-up'), + action: () => { + this.$store.commit('device/swapUpDeckColumn', this.column.id); + } + } : undefined, this.isStacked ? { + icon: faArrowDown, + text: this.$t('swap-down'), + action: () => { + this.$store.commit('device/swapDownDeckColumn', this.column.id); + } + } : undefined, null, { + icon: ['far', 'window-restore'], + text: this.$t('stack-left'), + action: () => { + this.$store.commit('device/stackLeftDeckColumn', this.column.id); + } + }, this.isStacked ? { + icon: faWindowMaximize, + text: this.$t('pop-right'), + action: () => { + this.$store.commit('device/popRightDeckColumn', this.column.id); + } + } : undefined, null, { + icon: ['far', 'trash-alt'], + text: this.$t('remove'), + action: () => { + this.$store.commit('device/removeDeckColumn', this.column.id); + } + }]; + + if (this.menu) { + items.unshift(null); + for (const i of this.menu.reverse()) { + items.unshift(i); + } + } + + return items; + }, + + onContextmenu(e) { + if (this.isTemporaryColumn) return; + this.$contextmenu(e, this.getMenu()); + }, + + showMenu() { + this.$root.new(Menu, { + source: this.$refs.menu, + items: this.getMenu() + }); + }, + + close() { + this.$router.push('/'); + }, + + goTop() { + this.$refs.body.scrollTo({ + top: 0, + behavior: 'smooth' + }); + }, + + onDragstart(e) { + // テンポラリカラムはドラッグさせない + if (this.isTemporaryColumn) { + e.preventDefault(); + return; + } + + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('mk-deck-column', this.column.id); + this.dragging = true; + }, + + onDragend(e) { + this.dragging = false; + }, + + onDragover(e) { + // テンポラリカラムにはドロップさせない + if (this.isTemporaryColumn) { + e.dataTransfer.dropEffect = 'none'; + return; + } + + // 自分自身がドラッグされている場合 + if (this.dragging) { + // 自分自身にはドロップさせない + e.dataTransfer.dropEffect = 'none'; + return; + } + + const isDeckColumn = e.dataTransfer.types[0] == 'mk-deck-column'; + + e.dataTransfer.dropEffect = isDeckColumn ? 'move' : 'none'; + + if (!this.dragging && isDeckColumn) this.draghover = true; + }, + + onDragleave() { + this.draghover = false; + }, + + onDrop(e) { + this.draghover = false; + this.$root.$emit('deck.column.dragEnd'); + + const id = e.dataTransfer.getData('mk-deck-column'); + if (id != null && id != '') { + this.$store.commit('device/swapDeckColumn', { + a: this.column.id, + b: id + }); + } + } + } +}); +</script> + +<style lang="stylus" scoped> +.dnpfarvgbnfmyzbdquhhzyxcmstpdqzs + $header-height = 42px + + height 100% + background var(--face) + border-radius var(--round) + box-shadow var(--shadow) + overflow hidden + + &.draghover + box-shadow 0 0 0 2px var(--primaryAlpha08) + + &:after + content "" + display block + position absolute + z-index 1000 + top 0 + left 0 + width 100% + height 100% + background var(--primaryAlpha02) + + &.dragging + box-shadow 0 0 0 2px var(--primaryAlpha04) + + &.dropready + * + pointer-events none + + &:not(.active) + flex-basis $header-height + min-height $header-height + + &:not(.isStacked).narrow + width 285px + min-width 285px + flex-grow 0 !important + + &.naked + background var(--deckAcrylicColumnBg) + + > header + background transparent + box-shadow none + + > button + color var(--text) + + > header + display flex + z-index 2 + line-height $header-height + padding 0 16px + font-size 14px + color var(--faceHeaderText) + background var(--faceHeader) + box-shadow 0 var(--lineWidth) rgba(#000, 0.15) + cursor pointer + + &, * + user-select none + + *:not(button) + pointer-events none + + &.indicate + box-shadow 0 3px 0 0 var(--primary) + + > span + [data-icon] + margin-right 8px + + > .count + margin-left 4px + opacity 0.5 + + > .toggleActive + > .menu + > .close + padding 0 + width $header-height + line-height $header-height + font-size 16px + color var(--faceTextButton) + + &:hover + color var(--faceTextButtonHover) + + &:active + color var(--faceTextButtonActive) + + > .toggleActive + margin-left -16px + + > .menu + > .close + margin-left auto + margin-right -16px + + > div + height "calc(100% - %s)" % $header-height + overflow auto + overflow-x hidden + -webkit-overflow-scrolling touch + +</style> diff --git a/src/client/app/common/views/deck/deck.direct-column.vue b/src/client/app/common/views/deck/deck.direct-column.vue new file mode 100644 index 0000000000..c68a361a9f --- /dev/null +++ b/src/client/app/common/views/deck/deck.direct-column.vue @@ -0,0 +1,46 @@ +<template> +<x-column :name="name" :column="column" :is-stacked="isStacked"> + <template #header><fa :icon="['far', 'envelope']"/>{{ name }}</template> + + <x-direct/> +</x-column> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../../i18n'; +import XColumn from './deck.column.vue'; +import XDirect from './deck.direct.vue'; + +export default Vue.extend({ + i18n: i18n(), + components: { + XColumn, + XDirect + }, + + props: { + column: { + type: Object, + required: true + }, + isStacked: { + type: Boolean, + required: true + } + }, + + computed: { + name(): string { + if (this.column.name) return this.column.name; + return this.$t('@deck.direct'); + } + }, + + methods: { + focus() { + this.$refs.tl.focus(); + } + } +}); +</script> diff --git a/src/client/app/common/views/deck/deck.direct.vue b/src/client/app/common/views/deck/deck.direct.vue new file mode 100644 index 0000000000..2618363b14 --- /dev/null +++ b/src/client/app/common/views/deck/deck.direct.vue @@ -0,0 +1,65 @@ +<template> +<x-notes ref="timeline" :make-promise="makePromise" @inited="() => $emit('loaded')"/> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XNotes from './deck.notes.vue'; + +const fetchLimit = 10; + +export default Vue.extend({ + components: { + XNotes + }, + + data() { + return { + connection: null, + makePromise: cursor => this.$root.api('notes/mentions', { + limit: fetchLimit + 1, + untilId: cursor ? cursor : undefined, + includeMyRenotes: this.$store.state.settings.showMyRenotes, + includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, + includeLocalRenotes: this.$store.state.settings.showLocalRenotes, + visibility: 'specified' + }).then(notes => { + if (notes.length == fetchLimit + 1) { + notes.pop(); + return { + notes: notes, + cursor: notes[notes.length - 1].id + }; + } else { + return { + notes: notes, + cursor: null + }; + } + }) + }; + }, + + mounted() { + this.connection = this.$root.stream.useSharedConnection('main'); + this.connection.on('mention', this.onNote); + }, + + beforeDestroy() { + this.connection.dispose(); + }, + + methods: { + onNote(note) { + // Prepend a note + if (note.visibility == 'specified') { + (this.$refs.timeline as any).prepend(note); + } + }, + + focus() { + this.$refs.timeline.focus(); + } + } +}); +</script> diff --git a/src/client/app/common/views/deck/deck.explore-column.vue b/src/client/app/common/views/deck/deck.explore-column.vue new file mode 100644 index 0000000000..53db677b37 --- /dev/null +++ b/src/client/app/common/views/deck/deck.explore-column.vue @@ -0,0 +1,34 @@ +<template> +<x-column> + <template #header> + <fa :icon="faHashtag"/>{{ $t('@.explore') }} + </template> + + <div> + <x-explore v-bind="$attrs"/> + </div> +</x-column> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../../i18n'; +import XColumn from './deck.column.vue'; +import XExplore from '../../../common/views/pages/explore.vue'; +import { faHashtag } from '@fortawesome/free-solid-svg-icons'; + +export default Vue.extend({ + i18n: i18n(), + + components: { + XColumn, + XExplore, + }, + + data() { + return { + faHashtag + }; + } +}); +</script> diff --git a/src/client/app/common/views/deck/deck.favorites-column.vue b/src/client/app/common/views/deck/deck.favorites-column.vue new file mode 100644 index 0000000000..67610f8b9f --- /dev/null +++ b/src/client/app/common/views/deck/deck.favorites-column.vue @@ -0,0 +1,58 @@ +<template> +<x-column> + <template #header> + <fa :icon="['fa', 'star']"/>{{ $t('favorites') }} + </template> + + <div> + <x-notes ref="timeline" :make-promise="makePromise" @inited="() => $emit('loaded')"/> + </div> +</x-column> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../../i18n'; +import XColumn from './deck.column.vue'; +import XNotes from './deck.notes.vue'; + +const fetchLimit = 10; + +export default Vue.extend({ + i18n: i18n(), + + components: { + XColumn, + XNotes, + }, + + data() { + return { + makePromise: cursor => this.$root.api('i/favorites', { + limit: fetchLimit + 1, + untilId: cursor ? cursor : undefined, + }).then(notes => { + notes = notes.map(x => x.note); + if (notes.length == fetchLimit + 1) { + notes.pop(); + return { + notes: notes, + cursor: notes[notes.length - 1].id + }; + } else { + return { + notes: notes, + cursor: null + }; + } + }) + }; + }, + + methods: { + focus() { + this.$refs.timeline.focus(); + } + } +}); +</script> diff --git a/src/client/app/common/views/deck/deck.featured-column.vue b/src/client/app/common/views/deck/deck.featured-column.vue new file mode 100644 index 0000000000..776957cc4d --- /dev/null +++ b/src/client/app/common/views/deck/deck.featured-column.vue @@ -0,0 +1,46 @@ +<template> +<x-column> + <template #header> + <fa :icon="faNewspaper"/>{{ $t('@.featured-notes') }} + </template> + + <div> + <x-notes ref="timeline" :make-promise="makePromise" @inited="() => $emit('loaded')"/> + </div> +</x-column> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../../i18n'; +import XColumn from './deck.column.vue'; +import XNotes from './deck.notes.vue'; +import { faNewspaper } from '@fortawesome/free-solid-svg-icons'; + +export default Vue.extend({ + i18n: i18n(), + + components: { + XColumn, + XNotes, + }, + + data() { + return { + faNewspaper, + makePromise: cursor => this.$root.api('notes/featured', { + limit: 20, + }).then(notes => { + notes.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + return notes; + }) + }; + }, + + methods: { + focus() { + this.$refs.timeline.focus(); + } + } +}); +</script> diff --git a/src/client/app/common/views/deck/deck.hashtag-column.vue b/src/client/app/common/views/deck/deck.hashtag-column.vue new file mode 100644 index 0000000000..db67df5305 --- /dev/null +++ b/src/client/app/common/views/deck/deck.hashtag-column.vue @@ -0,0 +1,119 @@ +<template> +<x-column> + <template #header> + <fa icon="hashtag"/><span>{{ tag }}</span> + </template> + + <div class="xroyrflcmhhtmlwmyiwpfqiirqokfueb"> + <div ref="chart" class="chart"></div> + <x-hashtag-tl :tag-tl="tagTl" class="tl"/> + </div> +</x-column> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XColumn from './deck.column.vue'; +import XHashtagTl from './deck.hashtag-tl.vue'; +import ApexCharts from 'apexcharts'; + +export default Vue.extend({ + components: { + XColumn, + XHashtagTl + }, + + computed: { + tag(): string { + return this.$route.params.tag; + }, + + tagTl(): any { + return { + query: [[this.tag]] + }; + } + }, + + watch: { + $route: 'fetch' + }, + + created() { + this.fetch(); + }, + + methods: { + fetch() { + this.$root.api('charts/hashtag', { + tag: this.tag, + span: 'hour', + limit: 24 + }).then(stats => { + const local = []; + const remote = []; + + const now = new Date(); + const y = now.getFullYear(); + const m = now.getMonth(); + const d = now.getDate(); + const h = now.getHours(); + + for (let i = 0; i < 24; i++) { + const x = new Date(y, m, d, h - i); + local.push([x, stats.local.count[i]]); + remote.push([x, stats.remote.count[i]]); + } + + const chart = new ApexCharts(this.$refs.chart, { + chart: { + type: 'area', + height: 70, + sparkline: { + enabled: true + }, + }, + grid: { + clipMarkers: false, + padding: { + top: 16, + right: 16, + bottom: 16, + left: 16 + } + }, + stroke: { + curve: 'straight', + width: 2 + }, + series: [{ + name: 'Local', + data: local + }, { + name: 'Remote', + data: remote + }], + xaxis: { + type: 'datetime', + } + }); + + chart.render(); + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.xroyrflcmhhtmlwmyiwpfqiirqokfueb + background var(--deckColumnBg) + + > .chart + margin-bottom 16px + background var(--face) + + > .tl + background var(--face) + +</style> diff --git a/src/client/app/common/views/deck/deck.hashtag-tl.vue b/src/client/app/common/views/deck/deck.hashtag-tl.vue new file mode 100644 index 0000000000..07d96f82c4 --- /dev/null +++ b/src/client/app/common/views/deck/deck.hashtag-tl.vue @@ -0,0 +1,85 @@ +<template> +<x-notes ref="timeline" :make-promise="makePromise" @inited="() => $emit('loaded')"/> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XNotes from './deck.notes.vue'; + +const fetchLimit = 10; + +export default Vue.extend({ + components: { + XNotes + }, + + props: { + tagTl: { + type: Object, + required: true + }, + mediaOnly: { + type: Boolean, + required: false, + default: false + } + }, + + data() { + return { + connection: null, + makePromise: cursor => this.$root.api('notes/search_by_tag', { + limit: fetchLimit + 1, + untilId: cursor ? cursor : undefined, + withFiles: this.mediaOnly, + includeMyRenotes: this.$store.state.settings.showMyRenotes, + includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, + includeLocalRenotes: this.$store.state.settings.showLocalRenotes, + query: this.tagTl.query + }).then(notes => { + if (notes.length == fetchLimit + 1) { + notes.pop(); + return { + notes: notes, + cursor: notes[notes.length - 1].id + }; + } else { + return { + notes: notes, + cursor: null + }; + } + }) + }; + }, + + watch: { + mediaOnly() { + this.$refs.timeline.reload(); + } + }, + + mounted() { + if (this.connection) this.connection.close(); + this.connection = this.$root.stream.connectToChannel('hashtag', { + q: this.tagTl.query + }); + this.connection.on('note', this.onNote); + }, + + beforeDestroy() { + this.connection.dispose(); + }, + + methods: { + onNote(note) { + if (this.mediaOnly && note.files.length == 0) return; + (this.$refs.timeline as any).prepend(note); + }, + + focus() { + this.$refs.timeline.focus(); + } + } +}); +</script> diff --git a/src/client/app/common/views/deck/deck.list-tl.vue b/src/client/app/common/views/deck/deck.list-tl.vue new file mode 100644 index 0000000000..d1887990f2 --- /dev/null +++ b/src/client/app/common/views/deck/deck.list-tl.vue @@ -0,0 +1,95 @@ +<template> +<x-notes ref="timeline" :make-promise="makePromise" @inited="() => $emit('loaded')"/> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XNotes from './deck.notes.vue'; + +const fetchLimit = 10; + +export default Vue.extend({ + components: { + XNotes + }, + + props: { + list: { + type: Object, + required: true + }, + mediaOnly: { + type: Boolean, + required: false, + default: false + } + }, + + data() { + return { + connection: null, + makePromise: cursor => this.$root.api('notes/user-list-timeline', { + listId: this.list.id, + limit: fetchLimit + 1, + untilId: cursor ? cursor : undefined, + withFiles: this.mediaOnly, + includeMyRenotes: this.$store.state.settings.showMyRenotes, + includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, + includeLocalRenotes: this.$store.state.settings.showLocalRenotes + }).then(notes => { + if (notes.length == fetchLimit + 1) { + notes.pop(); + return { + notes: notes, + cursor: notes[notes.length - 1].id + }; + } else { + return { + notes: notes, + cursor: null + }; + } + }) + }; + }, + + watch: { + mediaOnly() { + this.$refs.timeline.reload(); + } + }, + + mounted() { + if (this.connection) this.connection.dispose(); + this.connection = this.$root.stream.connectToChannel('userList', { + listId: this.list.id + }); + this.connection.on('note', this.onNote); + this.connection.on('userAdded', this.onUserAdded); + this.connection.on('userRemoved', this.onUserRemoved); + }, + + beforeDestroy() { + this.connection.dispose(); + }, + + methods: { + onNote(note) { + if (this.mediaOnly && note.files.length == 0) return; + (this.$refs.timeline as any).prepend(note); + }, + + onUserAdded() { + this.$refs.timeline.reload(); + }, + + onUserRemoved() { + this.$refs.timeline.reload(); + }, + + focus() { + this.$refs.timeline.focus(); + } + } +}); +</script> diff --git a/src/client/app/common/views/deck/deck.mentions-column.vue b/src/client/app/common/views/deck/deck.mentions-column.vue new file mode 100644 index 0000000000..b7f3290d0d --- /dev/null +++ b/src/client/app/common/views/deck/deck.mentions-column.vue @@ -0,0 +1,46 @@ +<template> +<x-column :name="name" :column="column" :is-stacked="isStacked"> + <template #header><fa icon="at"/>{{ name }}</template> + + <x-mentions ref="tl"/> +</x-column> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../../i18n'; +import XColumn from './deck.column.vue'; +import XMentions from './deck.mentions.vue'; + +export default Vue.extend({ + i18n: i18n(), + components: { + XColumn, + XMentions + }, + + props: { + column: { + type: Object, + required: true + }, + isStacked: { + type: Boolean, + required: true + } + }, + + computed: { + name(): string { + if (this.column.name) return this.column.name; + return this.$t('@deck.mentions'); + } + }, + + methods: { + focus() { + this.$refs.tl.focus(); + } + } +}); +</script> diff --git a/src/client/app/common/views/deck/deck.mentions.vue b/src/client/app/common/views/deck/deck.mentions.vue new file mode 100644 index 0000000000..1efd778226 --- /dev/null +++ b/src/client/app/common/views/deck/deck.mentions.vue @@ -0,0 +1,61 @@ +<template> +<x-notes ref="timeline" :make-promise="makePromise" @inited="() => $emit('loaded')"/> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XNotes from './deck.notes.vue'; + +const fetchLimit = 10; + +export default Vue.extend({ + components: { + XNotes + }, + + data() { + return { + connection: null, + makePromise: cursor => this.$root.api('notes/mentions', { + limit: fetchLimit + 1, + untilId: cursor ? cursor : undefined, + includeMyRenotes: this.$store.state.settings.showMyRenotes, + includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, + includeLocalRenotes: this.$store.state.settings.showLocalRenotes + }).then(notes => { + if (notes.length == fetchLimit + 1) { + notes.pop(); + return { + notes: notes, + cursor: notes[notes.length - 1].id + }; + } else { + return { + notes: notes, + cursor: null + }; + } + }) + }; + }, + + mounted() { + this.connection = this.$root.stream.useSharedConnection('main'); + this.connection.on('mention', this.onNote); + }, + + beforeDestroy() { + this.connection.dispose(); + }, + + methods: { + onNote(note) { + (this.$refs.timeline as any).prepend(note); + }, + + focus() { + this.$refs.timeline.focus(); + } + } +}); +</script> diff --git a/src/client/app/common/views/deck/deck.note-column.vue b/src/client/app/common/views/deck/deck.note-column.vue new file mode 100644 index 0000000000..ca798707bb --- /dev/null +++ b/src/client/app/common/views/deck/deck.note-column.vue @@ -0,0 +1,75 @@ +<template> +<x-column> + <template #header> + <fa :icon="['far', 'comment-alt']"/><mk-user-name :user="note.user" v-if="note"/> + </template> + + <div class="rvtscbadixhhbsczoorqoaygovdeecsx" v-if="note"> + <div class="is-remote" v-if="note.user.host != null"> + <details> + <summary><fa icon="exclamation-triangle"/> {{ $t('@.is-remote-post') }}</summary> + <a :href="note.url || note.uri" target="_blank">{{ $t('@.view-on-remote') }}</a> + </details> + </div> + <mk-note :note="note" :detail="true"/> + </div> +</x-column> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../../i18n'; +import XColumn from './deck.column.vue'; +import XNotes from './deck.notes.vue'; + +export default Vue.extend({ + i18n: i18n(), + components: { + XColumn, + XNotes, + }, + + data() { + return { + note: null, + fetching: true + }; + }, + + watch: { + $route: 'fetch' + }, + + created() { + this.fetch(); + }, + + methods: { + fetch() { + this.fetching = true; + + this.$root.api('notes/show', { + noteId: this.$route.params.note + }).then(note => { + this.note = note; + this.fetching = false; + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.rvtscbadixhhbsczoorqoaygovdeecsx + > .is-remote + padding 8px 16px + font-size 12px + + &.is-remote + color var(--remoteInfoFg) + background var(--remoteInfoBg) + + > a + font-weight bold + +</style> diff --git a/src/client/app/common/views/deck/deck.notes.vue b/src/client/app/common/views/deck/deck.notes.vue new file mode 100644 index 0000000000..f94eb8fd38 --- /dev/null +++ b/src/client/app/common/views/deck/deck.notes.vue @@ -0,0 +1,244 @@ +<template> +<div class="eamppglmnmimdhrlzhplwpvyeaqmmhxu"> + <div class="empty" v-if="notes.length == 0 && !fetching && inited">{{ $t('@.no-notes') }}</div> + + <mk-error v-if="!fetching && !inited" @retry="init()"/> + + <div class="placeholder" v-if="fetching"> + <template v-for="i in 10"> + <mk-note-skeleton :key="i"/> + </template> + </div> + + <!-- トランジションを有効にするとなぜかメモリリークする --> + <component :is="!$store.state.device.reduceMotion ? 'transition-group' : 'div'" name="mk-notes" class="transition notes" ref="notes" tag="div"> + <template v-for="(note, i) in _notes"> + <mk-note + :note="note" + :key="note.id" + @update:note="onNoteUpdated(i, $event)" + :compact="true" + /> + <p class="date" :key="note.id + '_date'" v-if="i != notes.length - 1 && note._date != _notes[i + 1]._date"> + <span><fa icon="angle-up"/>{{ note._datetext }}</span> + <span><fa icon="angle-down"/>{{ _notes[i + 1]._datetext }}</span> + </p> + </template> + </component> + + <footer v-if="cursor != null"> + <button @click="more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> + <template v-if="!moreFetching">{{ $t('@.load-more') }}</template> + <template v-if="moreFetching"><fa icon="spinner" pulse fixed-width/></template> + </button> + </footer> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../../i18n'; +import shouldMuteNote from '../../../common/scripts/should-mute-note'; + +const displayLimit = 20; + +export default Vue.extend({ + i18n: i18n(), + + inject: ['column', 'isScrollTop', 'count'], + + props: { + makePromise: { + required: true + } + }, + + data() { + return { + rootEl: null, + notes: [], + queue: [], + fetching: true, + moreFetching: false, + inited: false, + cursor: null + }; + }, + + computed: { + _notes(): any[] { + return (this.notes as any).map(note => { + const date = new Date(note.createdAt).getDate(); + const month = new Date(note.createdAt).getMonth() + 1; + note._date = date; + note._datetext = this.$t('@.month-and-day').replace('{month}', month.toString()).replace('{day}', date.toString()); + return note; + }); + } + }, + + watch: { + queue(q) { + this.count(q.length); + }, + makePromise() { + this.init(); + } + }, + + created() { + this.column.$on('top', this.onTop); + this.column.$on('bottom', this.onBottom); + this.init(); + }, + + beforeDestroy() { + this.column.$off('top', this.onTop); + this.column.$off('bottom', this.onBottom); + }, + + methods: { + focus() { + (this.$refs.notes as any).children[0].focus ? (this.$refs.notes as any).children[0].focus() : (this.$refs.notes as any).$el.children[0].focus(); + }, + + onNoteUpdated(i, note) { + Vue.set((this as any).notes, i, note); + }, + + reload() { + this.init(); + }, + + init() { + this.queue = []; + this.notes = []; + this.fetching = true; + this.makePromise().then(x => { + if (Array.isArray(x)) { + this.notes = x; + } else { + this.notes = x.notes; + this.cursor = x.cursor; + } + this.inited = true; + this.fetching = false; + this.$emit('inited'); + }, e => { + this.fetching = false; + }); + }, + + more() { + if (this.cursor == null || this.moreFetching) return; + this.moreFetching = true; + this.makePromise(this.cursor).then(x => { + this.notes = this.notes.concat(x.notes); + this.cursor = x.cursor; + this.moreFetching = false; + }, e => { + this.moreFetching = false; + }); + }, + + prepend(note, silent = false) { + // 弾く + if (shouldMuteNote(this.$store.state.i, this.$store.state.settings, note)) return; + + // タブが非表示ならタイトルで通知 + if (document.hidden) { + this.$store.commit('pushBehindNote', note); + } + + if (this.isScrollTop()) { + // Prepend the note + this.notes.unshift(note); + + // オーバーフローしたら古い投稿は捨てる + if (this.notes.length >= displayLimit) { + this.notes = this.notes.slice(0, displayLimit); + } + } else { + this.queue.push(note); + } + }, + + append(note) { + this.notes.push(note); + }, + + releaseQueue() { + for (const n of this.queue) { + this.prepend(n, true); + } + this.queue = []; + }, + + onTop() { + this.releaseQueue(); + }, + + onBottom() { + this.more(); + } + } +}); +</script> + +<style lang="stylus" scoped> +.eamppglmnmimdhrlzhplwpvyeaqmmhxu + .transition + .mk-notes-enter + .mk-notes-leave-to + opacity 0 + transform translateY(-30px) + + > * + transition transform .3s ease, opacity .3s ease + + > .empty + padding 16px + text-align center + color var(--text) + + > .placeholder + padding 16px + opacity 0.3 + + > .notes + > .date + display block + margin 0 + line-height 28px + font-size 12px + text-align center + color var(--dateDividerFg) + background var(--dateDividerBg) + border-bottom solid var(--lineWidth) var(--faceDivider) + + span + margin 0 16px + + [data-icon] + margin-right 8px + + > footer + > button + display block + margin 0 + padding 16px + width 100% + text-align center + color #ccc + background var(--face) + border-top solid var(--lineWidth) var(--faceDivider) + border-bottom-left-radius 6px + border-bottom-right-radius 6px + + &:hover + box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.05) + + &:active + box-shadow 0 0 0 100px inset rgba(0, 0, 0, 0.1) + +</style> diff --git a/src/client/app/common/views/deck/deck.notification.vue b/src/client/app/common/views/deck/deck.notification.vue new file mode 100644 index 0000000000..6a116260e5 --- /dev/null +++ b/src/client/app/common/views/deck/deck.notification.vue @@ -0,0 +1,189 @@ +<template> +<div class="dsfykdcjpuwfvpefwufddclpjhzktmpw"> + <div class="notification reaction" v-if="notification.type == 'reaction'"> + <mk-avatar class="avatar" :user="notification.user"/> + <div> + <header> + <mk-reaction-icon :reaction="notification.reaction"/> + <router-link :to="notification.user | userPage"> + <mk-user-name :user="notification.user"/> + </router-link> + <mk-time :time="notification.createdAt"/> + </header> + <router-link class="note-ref" :to="notification.note | notePage" :title="getNoteSummary(notification.note)"> + <fa icon="quote-left"/> + <mfm :text="getNoteSummary(notification.note)" :should-break="false" :plain-text="true" :custom-emojis="notification.note.emojis"/> + <fa icon="quote-right"/> + </router-link> + </div> + </div> + + <div class="notification renote" v-if="notification.type == 'renote'"> + <mk-avatar class="avatar" :user="notification.user"/> + <div> + <header> + <fa icon="retweet"/> + <router-link :to="notification.user | userPage"> + <mk-user-name :user="notification.user"/> + </router-link> + <mk-time :time="notification.createdAt"/> + </header> + <router-link class="note-ref" :to="notification.note | notePage" :title="getNoteSummary(notification.note.renote)"> + <fa icon="quote-left"/> + <mfm :text="getNoteSummary(notification.note.renote)" :should-break="false" :plain-text="true" :custom-emojis="notification.note.renote.emojis"/> + <fa icon="quote-right"/> + </router-link> + </div> + </div> + + <div class="notification follow" v-if="notification.type == 'follow'"> + <mk-avatar class="avatar" :user="notification.user"/> + <div> + <header> + <fa icon="user-plus"/> + <router-link :to="notification.user | userPage"> + <mk-user-name :user="notification.user"/> + </router-link> + <mk-time :time="notification.createdAt"/> + </header> + </div> + </div> + + <div class="notification followRequest" v-if="notification.type == 'receiveFollowRequest'"> + <mk-avatar class="avatar" :user="notification.user"/> + <div> + <header> + <fa icon="user-clock"/> + <router-link :to="notification.user | userPage"> + <mk-user-name :user="notification.user"/> + </router-link> + <mk-time :time="notification.createdAt"/> + </header> + </div> + </div> + + <div class="notification poll_vote" v-if="notification.type == 'poll_vote'"> + <mk-avatar class="avatar" :user="notification.user"/> + <div> + <header> + <fa icon="chart-pie"/> + <router-link :to="notification.user | userPage"> + <mk-user-name :user="notification.user"/> + </router-link> + <mk-time :time="notification.createdAt"/> + </header> + <router-link class="note-ref" :to="notification.note | notePage" :title="getNoteSummary(notification.note)"> + <fa icon="quote-left"/> + <mfm :text="getNoteSummary(notification.note)" :should-break="false" :plain-text="true" :custom-emojis="notification.note.emojis"/> + <fa icon="quote-right"/> + </router-link> + </div> + </div> + + <template v-if="notification.type == 'quote'"> + <mk-note :note="notification.note" @update:note="onNoteUpdated"/> + </template> + + <template v-if="notification.type == 'reply'"> + <mk-note :note="notification.note" @update:note="onNoteUpdated"/> + </template> + + <template v-if="notification.type == 'mention'"> + <mk-note :note="notification.note" @update:note="onNoteUpdated"/> + </template> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import getNoteSummary from '../../../../../misc/get-note-summary'; + +export default Vue.extend({ + props: ['notification'], + data() { + return { + getNoteSummary + }; + }, + methods: { + onNoteUpdated(note) { + switch (this.notification.type) { + case 'quote': + case 'reply': + case 'mention': + Vue.set(this.notification, 'note', note); + break; + } + } + } +}); +</script> + +<style lang="stylus" scoped> +.dsfykdcjpuwfvpefwufddclpjhzktmpw + > .notification + padding 16px + font-size 12px + overflow-wrap break-word + + &:after + content "" + display block + clear both + + > .avatar + display block + float left + width 36px + height 36px + border-radius 6px + + > div + float right + width calc(100% - 36px) + padding-left 8px + + > header + display flex + align-items baseline + white-space nowrap + + [data-icon], .mk-reaction-icon + margin-right 4px + + > .mk-time + margin-left auto + color var(--noteHeaderInfo) + font-size 0.9em + + > .note-preview + color var(--noteText) + + > .note-ref + color var(--noteText) + display inline-block + width: 100% + overflow hidden + white-space nowrap + text-overflow ellipsis + + [data-icon] + font-size 1em + font-weight normal + font-style normal + display inline-block + margin-right 3px + + &.renote + > div > header [data-icon] + color #77B255 + + &.follow + > div > header [data-icon] + color #53c7ce + + &.receiveFollowRequest + > div > header [data-icon] + color #888 + +</style> diff --git a/src/client/app/common/views/deck/deck.notifications-column.vue b/src/client/app/common/views/deck/deck.notifications-column.vue new file mode 100644 index 0000000000..c81c8b7733 --- /dev/null +++ b/src/client/app/common/views/deck/deck.notifications-column.vue @@ -0,0 +1,40 @@ +<template> +<x-column :name="name" :column="column" :is-stacked="isStacked"> + <template #header><fa :icon="['far', 'bell']"/>{{ name }}</template> + + <x-notifications/> +</x-column> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../../i18n'; +import XColumn from './deck.column.vue'; +import XNotifications from './deck.notifications.vue'; + +export default Vue.extend({ + i18n: i18n(), + components: { + XColumn, + XNotifications + }, + + props: { + column: { + type: Object, + required: true + }, + isStacked: { + type: Boolean, + required: true + } + }, + + computed: { + name(): string { + if (this.column.name) return this.column.name; + return this.$t('@deck.notifications'); + } + }, +}); +</script> diff --git a/src/client/app/common/views/deck/deck.notifications.vue b/src/client/app/common/views/deck/deck.notifications.vue new file mode 100644 index 0000000000..bb93e5e75e --- /dev/null +++ b/src/client/app/common/views/deck/deck.notifications.vue @@ -0,0 +1,223 @@ +<template> +<div class="oxynyeqmfvracxnglgulyqfgqxnxmehl"> + <div class="placeholder" v-if="fetching"> + <template v-for="i in 10"> + <mk-note-skeleton :key="i"/> + </template> + </div> + + <!-- トランジションを有効にするとなぜかメモリリークする --> + <component :is="!$store.state.device.reduceMotion ? 'transition-group' : 'div'" name="mk-notifications" class="transition notifications" tag="div"> + <template v-for="(notification, i) in _notifications"> + <x-notification class="notification" :notification="notification" :key="notification.id"/> + <p class="date" v-if="i != notifications.length - 1 && notification._date != _notifications[i + 1]._date" :key="notification.id + '-time'"> + <span><fa icon="angle-up"/>{{ notification._datetext }}</span> + <span><fa icon="angle-down"/>{{ _notifications[i + 1]._datetext }}</span> + </p> + </template> + </component> + <button class="more" :class="{ fetching: fetchingMoreNotifications }" v-if="moreNotifications" @click="fetchMoreNotifications" :disabled="fetchingMoreNotifications"> + <template v-if="fetchingMoreNotifications"><fa icon="spinner" pulse fixed-width/></template>{{ fetchingMoreNotifications ? this.$t('@.loading') : this.$t('@.load-more') }} + </button> + <p class="empty" v-if="notifications.length == 0 && !fetching">{{ $t('empty') }}</p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../../i18n'; +import XNotification from './deck.notification.vue'; + +const displayLimit = 20; + +export default Vue.extend({ + i18n: i18n(), + components: { + XNotification + }, + + inject: ['column', 'isScrollTop', 'count'], + + data() { + return { + fetching: true, + fetchingMoreNotifications: false, + notifications: [], + queue: [], + moreNotifications: false, + connection: null + }; + }, + + computed: { + _notifications(): any[] { + return (this.notifications as any).map(notification => { + const date = new Date(notification.createdAt).getDate(); + const month = new Date(notification.createdAt).getMonth() + 1; + notification._date = date; + notification._datetext = this.$t('@.month-and-day').replace('{month}', month.toString()).replace('{day}', date.toString()); + return notification; + }); + } + }, + + watch: { + queue(q) { + this.count(q.length); + } + }, + + mounted() { + this.connection = this.$root.stream.useSharedConnection('main'); + + this.connection.on('notification', this.onNotification); + + this.column.$on('top', this.onTop); + this.column.$on('bottom', this.onBottom); + + const max = 10; + + this.$root.api('i/notifications', { + limit: max + 1 + }).then(notifications => { + if (notifications.length == max + 1) { + this.moreNotifications = true; + notifications.pop(); + } + + this.notifications = notifications; + this.fetching = false; + }); + }, + + beforeDestroy() { + this.connection.dispose(); + + this.column.$off('top', this.onTop); + this.column.$off('bottom', this.onBottom); + }, + + methods: { + fetchMoreNotifications() { + this.fetchingMoreNotifications = true; + + const max = 20; + + this.$root.api('i/notifications', { + limit: max + 1, + untilId: this.notifications[this.notifications.length - 1].id + }).then(notifications => { + if (notifications.length == max + 1) { + this.moreNotifications = true; + notifications.pop(); + } else { + this.moreNotifications = false; + } + this.notifications = this.notifications.concat(notifications); + this.fetchingMoreNotifications = false; + }); + }, + + onNotification(notification) { + // TODO: ユーザーが画面を見てないと思われるとき(ブラウザやタブがアクティブじゃないなど)は送信しない + this.$root.stream.send('readNotification', { + id: notification.id + }); + + this.prepend(notification); + }, + + prepend(notification) { + if (this.isScrollTop()) { + // Prepend the notification + this.notifications.unshift(notification); + + // オーバーフローしたら古い通知は捨てる + if (this.notifications.length >= displayLimit) { + this.notifications = this.notifications.slice(0, displayLimit); + } + } else { + this.queue.push(notification); + } + }, + + releaseQueue() { + for (const n of this.queue) { + this.prepend(n); + } + this.queue = []; + }, + + onTop() { + this.releaseQueue(); + }, + + onBottom() { + this.fetchMoreNotifications(); + } + } +}); +</script> + +<style lang="stylus" scoped> +.oxynyeqmfvracxnglgulyqfgqxnxmehl + .transition + .mk-notifications-enter + .mk-notifications-leave-to + opacity 0 + transform translateY(-30px) + + > * + transition transform .3s ease, opacity .3s ease + + > .placeholder + padding 16px + opacity 0.3 + + > .notifications + + > .notification:not(:last-child) + border-bottom solid var(--lineWidth) var(--faceDivider) + + > .date + display block + margin 0 + line-height 28px + text-align center + font-size 12px + color var(--dateDividerFg) + background var(--dateDividerBg) + border-bottom solid var(--lineWidth) var(--faceDivider) + + span + margin 0 16px + + [data-icon] + margin-right 8px + + > .more + display block + width 100% + padding 16px + color var(--text) + border-top solid var(--lineWidth) rgba(#000, 0.05) + + &:hover + background rgba(#000, 0.025) + + &:active + background rgba(#000, 0.05) + + &.fetching + cursor wait + + > [data-icon] + margin-right 4px + + > .empty + margin 0 + padding 16px + text-align center + color var(--text) + +</style> diff --git a/src/client/app/common/views/deck/deck.search-column.vue b/src/client/app/common/views/deck/deck.search-column.vue new file mode 100644 index 0000000000..fb0ba5f6e4 --- /dev/null +++ b/src/client/app/common/views/deck/deck.search-column.vue @@ -0,0 +1,61 @@ +<template> +<x-column> + <template #header> + <fa icon="search"/><span>{{ q }}</span> + </template> + + <div> + <x-notes ref="timeline" :make-promise="makePromise" @inited="() => $emit('loaded')"/> + </div> +</x-column> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XColumn from './deck.column.vue'; +import XNotes from './deck.notes.vue'; + +const limit = 20; + +export default Vue.extend({ + components: { + XColumn, + XNotes + }, + + data() { + return { + makePromise: cursor => this.$root.api('notes/search', { + limit: limit + 1, + offset: cursor ? cursor : undefined, + query: this.q + }).then(notes => { + if (notes.length == limit + 1) { + notes.pop(); + return { + notes: notes, + cursor: cursor ? cursor + limit : limit + }; + } else { + return { + notes: notes, + cursor: null + }; + } + }) + }; + }, + + computed: { + q(): string { + return this.$route.query.q; + } + }, + + watch: { + $route() { + this.$refs.timeline.reload(); + } + }, +}); +</script> diff --git a/src/client/app/common/views/deck/deck.tl-column.vue b/src/client/app/common/views/deck/deck.tl-column.vue new file mode 100644 index 0000000000..d53aabaea5 --- /dev/null +++ b/src/client/app/common/views/deck/deck.tl-column.vue @@ -0,0 +1,101 @@ +<template> +<x-column :menu="menu" :name="name" :column="column" :is-stacked="isStacked"> + <template #header> + <fa v-if="column.type == 'home'" icon="home"/> + <fa v-if="column.type == 'local'" :icon="['far', 'comments']"/> + <fa v-if="column.type == 'hybrid'" icon="share-alt"/> + <fa v-if="column.type == 'global'" icon="globe"/> + <fa v-if="column.type == 'list'" icon="list"/> + <fa v-if="column.type == 'hashtag'" icon="hashtag"/> + <span>{{ name }}</span> + </template> + + <div class="editor" style="padding:12px" v-if="edit"> + <ui-switch v-model="column.isMediaOnly" @change="onChangeSettings">{{ $t('is-media-only') }}</ui-switch> + </div> + + <x-list-tl v-if="column.type == 'list'" + :list="column.list" + :media-only="column.isMediaOnly" + ref="tl" + /> + <x-hashtag-tl v-else-if="column.type == 'hashtag'" + :tag-tl="$store.state.settings.tagTimelines.find(x => x.id == column.tagTlId)" + :media-only="column.isMediaOnly" + ref="tl" + /> + <x-tl v-else + :src="column.type" + :media-only="column.isMediaOnly" + ref="tl" + /> +</x-column> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../../i18n'; +import XColumn from './deck.column.vue'; +import XTl from './deck.tl.vue'; +import XListTl from './deck.list-tl.vue'; +import XHashtagTl from './deck.hashtag-tl.vue'; + +export default Vue.extend({ + i18n: i18n('deck/deck.tl-column.vue'), + components: { + XColumn, + XTl, + XListTl, + XHashtagTl + }, + + props: { + column: { + type: Object, + required: true + }, + isStacked: { + type: Boolean, + required: true + } + }, + + data() { + return { + edit: false, + menu: [{ + icon: 'cog', + text: this.$t('edit'), + action: () => { + this.edit = !this.edit; + } + }] + } + }, + + computed: { + name(): string { + if (this.column.name) return this.column.name; + + switch (this.column.type) { + case 'home': return this.$t('@deck.home'); + case 'local': return this.$t('@deck.local'); + case 'hybrid': return this.$t('@deck.hybrid'); + case 'global': return this.$t('@deck.global'); + case 'list': return this.column.list.title; + case 'hashtag': return this.$store.state.settings.tagTimelines.find(x => x.id == this.column.tagTlId).title; + } + } + }, + + methods: { + onChangeSettings(v) { + this.$store.commit('device/updateDeckColumn', this.column); + }, + + focus() { + this.$refs.tl.focus(); + } + } +}); +</script> diff --git a/src/client/app/common/views/deck/deck.tl.vue b/src/client/app/common/views/deck/deck.tl.vue new file mode 100644 index 0000000000..35cdfa704f --- /dev/null +++ b/src/client/app/common/views/deck/deck.tl.vue @@ -0,0 +1,147 @@ +<template> +<div class="iwaalbte" v-if="disabled"> + <p> + <fa :icon="faMinusCircle"/> + {{ $t('disabled-timeline.title') }} + </p> + <p class="desc">{{ $t('disabled-timeline.description') }}</p> +</div> +<x-notes v-else ref="timeline" :make-promise="makePromise" @inited="() => $emit('loaded')"/> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XNotes from './deck.notes.vue'; +import { faMinusCircle } from '@fortawesome/free-solid-svg-icons'; +import i18n from '../../../i18n'; + +const fetchLimit = 10; + +export default Vue.extend({ + i18n: i18n('deck'), + + components: { + XNotes + }, + + props: { + src: { + type: String, + required: false, + default: 'home' + }, + mediaOnly: { + type: Boolean, + required: false, + default: false + } + }, + + data() { + return { + connection: null, + disabled: false, + faMinusCircle, + makePromise: null + }; + }, + + computed: { + stream(): any { + switch (this.src) { + case 'home': return this.$root.stream.useSharedConnection('homeTimeline'); + case 'local': return this.$root.stream.useSharedConnection('localTimeline'); + case 'hybrid': return this.$root.stream.useSharedConnection('hybridTimeline'); + case 'global': return this.$root.stream.useSharedConnection('globalTimeline'); + } + }, + + endpoint(): string { + switch (this.src) { + case 'home': return 'notes/timeline'; + case 'local': return 'notes/local-timeline'; + case 'hybrid': return 'notes/hybrid-timeline'; + case 'global': return 'notes/global-timeline'; + } + }, + }, + + watch: { + mediaOnly() { + (this.$refs.timeline as any).reload(); + } + }, + + created() { + this.makePromise = cursor => this.$root.api(this.endpoint, { + limit: fetchLimit + 1, + untilId: cursor ? cursor : undefined, + withFiles: this.mediaOnly, + includeMyRenotes: this.$store.state.settings.showMyRenotes, + includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, + includeLocalRenotes: this.$store.state.settings.showLocalRenotes + }).then(notes => { + if (notes.length == fetchLimit + 1) { + notes.pop(); + return { + notes: notes, + cursor: notes[notes.length - 1].id + }; + } else { + return { + notes: notes, + cursor: null + }; + } + }); + }, + + mounted() { + this.connection = this.stream; + + this.connection.on('note', this.onNote); + if (this.src == 'home') { + this.connection.on('follow', this.onChangeFollowing); + this.connection.on('unfollow', this.onChangeFollowing); + } + + this.$root.getMeta().then(meta => { + this.disabled = !this.$store.state.i.isModerator && !this.$store.state.i.isAdmin && ( + meta.disableLocalTimeline && ['local', 'hybrid'].includes(this.src) || + meta.disableGlobalTimeline && ['global'].includes(this.src)); + }); + }, + + beforeDestroy() { + this.connection.dispose(); + }, + + methods: { + onNote(note) { + if (this.mediaOnly && note.files.length == 0) return; + (this.$refs.timeline as any).prepend(note); + }, + + onChangeFollowing() { + (this.$refs.timeline as any).reload(); + }, + + focus() { + (this.$refs.timeline as any).focus(); + } + } +}); +</script> + +<style lang="stylus" scoped> +.iwaalbte + color var(--text) + text-align center + + > p + margin 16px + + &.desc + font-size 14px + +</style> diff --git a/src/client/app/common/views/deck/deck.user-column.home.vue b/src/client/app/common/views/deck/deck.user-column.home.vue new file mode 100644 index 0000000000..15c7b794b9 --- /dev/null +++ b/src/client/app/common/views/deck/deck.user-column.home.vue @@ -0,0 +1,231 @@ +<template> +<div> + <ui-container v-if="user.pinnedNotes && user.pinnedNotes.length > 0" :body-togglable="true"> + <template #header><fa icon="thumbtack"/> {{ $t('pinned-notes') }}</template> + <div> + <mk-note v-for="n in user.pinnedNotes" :key="n.id" :note="n"/> + </div> + </ui-container> + <ui-container v-if="images.length > 0" :body-togglable="true" + :expanded="$store.state.device.expandUsersPhotos" + @toggle="expanded => $store.commit('device/set', { key: 'expandUsersPhotos', value: expanded })"> + <template #header><fa :icon="['far', 'images']"/> {{ $t('images') }}</template> + <div class="sainvnaq"> + <router-link v-for="image in images" + :style="`background-image: url(${image.thumbnailUrl})`" + :key="`${image.id}:${image._note.id}`" + :to="image._note | notePage" + :title="`${image.name}\n${(new Date(image.createdAt)).toLocaleString()}`" + ></router-link> + </div> + </ui-container> + <ui-container :body-togglable="true" + :expanded="$store.state.device.expandUsersActivity" + @toggle="expanded => $store.commit('device/set', { key: 'expandUsersActivity', value: expanded })"> + <template #header><fa :icon="['far', 'chart-bar']"/> {{ $t('activity') }}</template> + <div> + <div ref="chart"></div> + </div> + </ui-container> + <ui-container> + <template #header><fa :icon="['far', 'comment-alt']"/> {{ $t('timeline') }}</template> + <div> + <x-notes ref="timeline" :make-promise="makePromise" @inited="() => $emit('loaded')"/> + </div> + </ui-container> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../../i18n'; +import XNotes from './deck.notes.vue'; +import { concat } from '../../../../../prelude/array'; +import ApexCharts from 'apexcharts'; + +const fetchLimit = 10; + +export default Vue.extend({ + i18n: i18n('deck/deck.user-column.vue'), + + components: { + XNotes, + }, + + props: { + user: { + type: Object, + required: true + } + }, + + data() { + return { + withFiles: false, + images: [], + makePromise: null, + chart: null as ApexCharts + }; + }, + + watch: { + user() { + this.fetch(); + this.genPromiseMaker(); + } + }, + + created() { + this.fetch(); + this.genPromiseMaker(); + }, + + methods: { + genPromiseMaker() { + this.makePromise = cursor => this.$root.api('users/notes', { + userId: this.user.id, + limit: fetchLimit + 1, + untilId: cursor ? cursor : undefined, + withFiles: this.withFiles, + includeMyRenotes: this.$store.state.settings.showMyRenotes, + includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, + includeLocalRenotes: this.$store.state.settings.showLocalRenotes + }).then(notes => { + if (notes.length == fetchLimit + 1) { + notes.pop(); + return { + notes: notes, + cursor: notes[notes.length - 1].id + }; + } else { + return { + notes: notes, + cursor: null + }; + } + }); + }, + + fetch() { + const image = [ + 'image/jpeg', + 'image/png', + 'image/gif' + ]; + + this.$root.api('users/notes', { + userId: this.user.id, + fileType: image, + excludeNsfw: !this.$store.state.device.alwaysShowNsfw, + limit: 9, + untilDate: new Date().getTime() + 1000 * 86400 * 365 + }).then(notes => { + for (const note of notes) { + for (const file of note.files) { + file._note = note; + } + } + const files = concat(notes.map((n: any): any[] => n.files)); + this.images = files.filter(f => image.includes(f.type)).slice(0, 9); + }); + + this.$root.api('charts/user/notes', { + userId: this.user.id, + span: 'day', + limit: 21 + }).then(stats => { + const normal = []; + const reply = []; + const renote = []; + + const now = new Date(); + const y = now.getFullYear(); + const m = now.getMonth(); + const d = now.getDate(); + + for (let i = 0; i < 21; i++) { + const x = new Date(y, m, d - i); + normal.push([ + x, + stats.diffs.normal[i] + ]); + reply.push([ + x, + stats.diffs.reply[i] + ]); + renote.push([ + x, + stats.diffs.renote[i] + ]); + } + + if (this.chart) this.chart.destroy(); + + this.chart = new ApexCharts(this.$refs.chart, { + chart: { + type: 'bar', + stacked: true, + height: 100, + sparkline: { + enabled: true + }, + }, + plotOptions: { + bar: { + columnWidth: '90%' + } + }, + grid: { + clipMarkers: false, + padding: { + top: 16, + right: 16, + bottom: 16, + left: 16 + } + }, + tooltip: { + shared: true, + intersect: false + }, + series: [{ + name: 'Normal', + data: normal + }, { + name: 'Reply', + data: reply + }, { + name: 'Renote', + data: renote + }], + xaxis: { + type: 'datetime', + crosshairs: { + width: 1, + opacity: 1 + } + } + }); + + this.chart.render(); + }); + }, + } +}); +</script> + +<style lang="stylus" scoped> +.sainvnaq + display grid + grid-template-columns 1fr 1fr 1fr + gap 8px + padding 16px + + > * + height 70px + background-position center center + background-size cover + background-clip content-box + border-radius 4px + +</style> diff --git a/src/client/app/common/views/deck/deck.user-column.vue b/src/client/app/common/views/deck/deck.user-column.vue new file mode 100644 index 0000000000..47c291db06 --- /dev/null +++ b/src/client/app/common/views/deck/deck.user-column.vue @@ -0,0 +1,263 @@ +<template> +<x-column> + <template #header> + <fa icon="user"/><mk-user-name :user="user" v-if="user"/> + </template> + + <div class="zubukjlciycdsyynicqrnlsmdwmymzqu" v-if="user"> + <div class="is-remote" v-if="user.host != null"> + <details> + <summary><fa icon="exclamation-triangle"/> {{ $t('@.is-remote-user') }}</summary> + <a :href="user.url || user.uri" target="_blank">{{ $t('@.view-on-remote') }}</a> + </details> + </div> + <header :style="bannerStyle"> + <div> + <button class="menu" @click="menu" ref="menu"><fa icon="ellipsis-h"/></button> + <mk-follow-button v-if="$store.getters.isSignedIn && user.id != $store.state.i.id" :user="user" class="follow" mini/> + <mk-avatar class="avatar" :user="user" :disable-preview="true"/> + <router-link class="name" :to="user | userPage()"> + <mk-user-name :user="user"/> + </router-link> + <span class="acct">@{{ user | acct }} <fa v-if="user.isLocked == true" class="locked" icon="lock" fixed-width/></span> + <span class="followed" v-if="user.isFollowed">{{ $t('follows-you') }}</span> + </div> + </header> + <div class="info"> + <div class="description"> + <mfm v-if="user.description" :text="user.description" :is-note="false" :author="user" :i="$store.state.i" :custom-emojis="user.emojis"/> + </div> + <div class="fields" v-if="user.fields"> + <dl class="field" v-for="(field, i) in user.fields" :key="i"> + <dt class="name"> + <mfm :text="field.name" :should-break="false" :plain-text="true" :custom-emojis="user.emojis"/> + </dt> + <dd class="value"> + <mfm :text="field.value" :author="user" :i="$store.state.i" :custom-emojis="user.emojis"/> + </dd> + </dl> + </div> + <div class="counts"> + <div> + <router-link :to="user | userPage()"> + <b>{{ user.notesCount | number }}</b> + <span>{{ $t('posts') }}</span> + </router-link> + </div> + <div> + <router-link :to="user | userPage('following')"> + <b>{{ user.followingCount | number }}</b> + <span>{{ $t('following') }}</span> + </router-link> + </div> + <div> + <router-link :to="user | userPage('followers')"> + <b>{{ user.followersCount | number }}</b> + <span>{{ $t('followers') }}</span> + </router-link> + </div> + </div> + </div> + <router-view :user="user"></router-view> + </div> +</x-column> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../../i18n'; +import parseAcct from '../../../../../misc/acct/parse'; +import XColumn from './deck.column.vue'; +import XUserMenu from '../../../common/views/components/user-menu.vue'; + +export default Vue.extend({ + i18n: i18n('deck/deck.user-column.vue'), + components: { + XColumn, + }, + + data() { + return { + user: null, + fetching: true, + }; + }, + + computed: { + bannerStyle(): any { + if (this.user == null) return {}; + if (this.user.bannerUrl == null) return {}; + return { + backgroundColor: this.user.bannerColor && this.user.bannerColor.length == 3 ? `rgb(${ this.user.bannerColor.join(',') })` : null, + backgroundImage: `url(${ this.user.bannerUrl })` + }; + }, + }, + + watch: { + $route: 'fetch' + }, + + created() { + this.fetch(); + }, + + methods: { + fetch() { + this.fetching = true; + this.$root.api('users/show', parseAcct(this.$route.params.user)).then(user => { + this.user = user; + this.fetching = false; + }); + }, + + menu() { + this.$root.new(XUserMenu, { + source: this.$refs.menu, + user: this.user + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.zubukjlciycdsyynicqrnlsmdwmymzqu + background var(--deckColumnBg) + + > .is-remote + padding 8px 16px + font-size 12px + + &.is-remote + color var(--remoteInfoFg) + background var(--remoteInfoBg) + + > a + font-weight bold + + > header + overflow hidden + background-size cover + background-position center + + > div + padding 32px + background rgba(#000, 0.5) + color #fff + text-align center + + > .menu + position absolute + top 8px + left 8px + padding 8px + font-size 16px + text-shadow 0 0 8px #000 + + > .follow + position absolute + top 16px + right 16px + + > .avatar + display block + width 64px + height 64px + margin 0 auto + + > .name + display block + margin-top 8px + font-weight bold + text-shadow 0 0 8px #000 + color #fff + + > .acct + display block + font-size 14px + opacity 0.7 + text-shadow 0 0 8px #000 + + > .locked + opacity 0.8 + + > .followed + display inline-block + font-size 12px + background rgba(0, 0, 0, 0.5) + opacity 0.7 + margin-top: 2px + padding 4px + border-radius 4px + + > .info + padding 16px + font-size 12px + color var(--text) + text-align center + background var(--face) + + &:before + content "" + display blcok + position absolute + top -32px + left 0 + right 0 + width 0px + margin 0 auto + border-top solid 16px transparent + border-left solid 16px transparent + border-right solid 16px transparent + border-bottom solid 16px var(--face) + + > .fields + margin-top 8px + + > .field + display flex + padding 0 + margin 0 + align-items center + + > .name + padding 4px + margin 4px + width 30% + overflow hidden + white-space nowrap + text-overflow ellipsis + font-weight bold + + > .value + padding 4px + margin 4px + width 70% + overflow hidden + white-space nowrap + text-overflow ellipsis + + > .counts + display grid + grid-template-columns 2fr 2fr 2fr + margin-top 8px + border-top solid var(--lineWidth) var(--faceDivider) + + > div + padding 8px 8px 0 8px + text-align center + + > a + color var(--text) + + > b + display block + font-size 110% + + > span + display block + font-size 80% + opacity 0.7 + +</style> diff --git a/src/client/app/common/views/deck/deck.vue b/src/client/app/common/views/deck/deck.vue new file mode 100644 index 0000000000..b91df45620 --- /dev/null +++ b/src/client/app/common/views/deck/deck.vue @@ -0,0 +1,398 @@ +<template> +<mk-ui :class="$style.root"> + <div class="qlvquzbjribqcaozciifydkngcwtyzje" ref="body" :style="style" :class="`${$store.state.device.deckColumnAlign} ${$store.state.device.deckColumnWidth}`" v-hotkey.global="keymap"> + <template v-for="ids in layout"> + <div v-if="ids.length > 1" class="folder"> + <template v-for="id, i in ids"> + <x-column-core :ref="id" :key="id" :column="columns.find(c => c.id == id)" :is-stacked="true" @parentFocus="moveFocus(id, $event)"/> + </template> + </div> + <x-column-core v-else :ref="ids[0]" :key="ids[0]" :column="columns.find(c => c.id == ids[0])" @parentFocus="moveFocus(ids[0], $event)"/> + </template> + <router-view></router-view> + <button ref="add" @click="add" :title="$t('@deck.add-column')"><fa icon="plus"/></button> + </div> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../../i18n'; +import XColumnCore from './deck.column-core.vue'; +import Menu from '../../../common/views/components/menu.vue'; + +import * as uuid from 'uuid'; + +export default Vue.extend({ + i18n: i18n('deck'), + components: { + XColumnCore + }, + + computed: { + columns(): any[] { + if (this.$store.state.device.deck == null) return []; + return this.$store.state.device.deck.columns; + }, + + layout(): any[] { + if (this.$store.state.device.deck == null) return []; + if (this.$store.state.device.deck.layout == null) return this.$store.state.device.deck.columns.map(c => [c.id]); + return this.$store.state.device.deck.layout; + }, + + style(): any { + return { + height: `calc(100vh - ${this.$store.state.uiHeaderHeight}px)` + }; + }, + + keymap(): any { + return { + 't': this.focus + }; + } + }, + + watch: { + $route() { + if (this.$route.name == 'index') return; + this.$nextTick(() => { + this.$refs.body.scrollTo({ + left: this.$refs.body.scrollWidth - this.$refs.body.clientWidth, + behavior: 'smooth' + }); + }); + } + }, + + provide() { + return { + getColumnVm: this.getColumnVm, + narrow: true + }; + }, + + created() { + if (this.$store.state.device.deck == null) { + const deck = { + columns: [/*{ + type: 'widgets', + widgets: [] + }, */{ + id: uuid(), + type: 'home', + name: null, + }, { + id: uuid(), + type: 'notifications', + name: null, + }, { + id: uuid(), + type: 'local', + name: null, + }, { + id: uuid(), + type: 'global', + name: null, + }] + }; + + deck.layout = deck.columns.map(c => [c.id]); + + this.$store.commit('device/set', { + key: 'deck', + value: deck + }); + } + + // 互換性のため + if (this.$store.state.device.deck != null && this.$store.state.device.deck.layout == null) { + this.$store.commit('device/set', { + key: 'deck', + value: Object.assign({}, this.$store.state.device.deck, { + layout: this.$store.state.device.deck.columns.map(c => [c.id]) + }) + }); + } + }, + + mounted() { + document.title = this.$root.instanceName; + document.documentElement.style.overflow = 'hidden'; + }, + + beforeDestroy() { + document.documentElement.style.overflow = 'auto'; + }, + + methods: { + getColumnVm(id) { + return this.$refs[id][0]; + }, + + add() { + this.$root.new(Menu, { + source: this.$refs.add, + items: [{ + icon: 'home', + text: this.$t('@deck.home'), + action: () => { + this.$store.commit('device/addDeckColumn', { + id: uuid(), + type: 'home' + }); + } + }, { + icon: ['far', 'comments'], + text: this.$t('@deck.local'), + action: () => { + this.$store.commit('device/addDeckColumn', { + id: uuid(), + type: 'local' + }); + } + }, { + icon: 'share-alt', + text: this.$t('@deck.hybrid'), + action: () => { + this.$store.commit('device/addDeckColumn', { + id: uuid(), + type: 'hybrid' + }); + } + }, { + icon: 'globe', + text: this.$t('@deck.global'), + action: () => { + this.$store.commit('device/addDeckColumn', { + id: uuid(), + type: 'global' + }); + } + }, { + icon: 'at', + text: this.$t('@deck.mentions'), + action: () => { + this.$store.commit('device/addDeckColumn', { + id: uuid(), + type: 'mentions' + }); + } + }, { + icon: ['far', 'envelope'], + text: this.$t('@deck.direct'), + action: () => { + this.$store.commit('device/addDeckColumn', { + id: uuid(), + type: 'direct' + }); + } + }, { + icon: 'list', + text: this.$t('@deck.list'), + action: async () => { + const lists = await this.$root.api('users/lists/list'); + const { canceled, result: listId } = await this.$root.dialog({ + type: null, + title: this.$t('@deck.select-list'), + select: { + items: lists.map(list => ({ + value: list.id, text: list.title + })) + }, + showCancelButton: true + }); + if (canceled) return; + this.$store.commit('device/addDeckColumn', { + id: uuid(), + type: 'list', + list: lists.find(l => l.id === listId) + }); + } + }, { + icon: 'hashtag', + text: this.$t('@deck.hashtag'), + action: () => { + this.$root.dialog({ + title: this.$t('enter-hashtag-tl-title'), + input: true + }).then(({ canceled, result: title }) => { + if (canceled) return; + this.$store.commit('device/addDeckColumn', { + id: uuid(), + type: 'hashtag', + tagTlId: this.$store.state.settings.tagTimelines.find(x => x.title == title).id + }); + }); + } + }, { + icon: ['far', 'bell'], + text: this.$t('@deck.notifications'), + action: () => { + this.$store.commit('device/addDeckColumn', { + id: uuid(), + type: 'notifications' + }); + } + }, { + icon: 'calculator', + text: this.$t('@deck.widgets'), + action: () => { + this.$store.commit('device/addDeckColumn', { + id: uuid(), + type: 'widgets', + widgets: [] + }); + } + }] + }); + }, + + focus() { + // Flatten array of arrays + const ids = [].concat.apply([], this.layout); + const firstTl = ids.find(id => this.isTlColumn(id)); + + if (firstTl) { + this.$refs[firstTl][0].focus(); + } + }, + + moveFocus(id, direction) { + let targetColumn; + + if (direction == 'right') { + const currentColumnIndex = this.layout.findIndex(ids => ids.includes(id)); + this.layout.some((ids, i) => { + if (i <= currentColumnIndex) return false; + const tl = ids.find(id => this.isTlColumn(id)); + if (tl) { + targetColumn = tl; + return true; + } + }); + } else if (direction == 'left') { + const currentColumnIndex = [...this.layout].reverse().findIndex(ids => ids.includes(id)); + [...this.layout].reverse().some((ids, i) => { + if (i <= currentColumnIndex) return false; + const tl = ids.find(id => this.isTlColumn(id)); + if (tl) { + targetColumn = tl; + return true; + } + }); + } else if (direction == 'down') { + const currentColumn = this.layout.find(ids => ids.includes(id)); + const currentIndex = currentColumn.indexOf(id); + currentColumn.some((_id, i) => { + if (i <= currentIndex) return false; + if (this.isTlColumn(_id)) { + targetColumn = _id; + return true; + } + }); + } else if (direction == 'up') { + const currentColumn = [...this.layout.find(ids => ids.includes(id))].reverse(); + const currentIndex = currentColumn.indexOf(id); + currentColumn.some((_id, i) => { + if (i <= currentIndex) return false; + if (this.isTlColumn(_id)) { + targetColumn = _id; + return true; + } + }); + } + + if (targetColumn) { + this.$refs[targetColumn][0].focus(); + } + }, + + isTlColumn(id) { + const column = this.columns.find(c => c.id === id); + return ['home', 'local', 'hybrid', 'global', 'list', 'hashtag', 'mentions', 'direct'].includes(column.type); + } + } +}); +</script> + +<style lang="stylus" module> +.root + height 100vh +</style> + +<style lang="stylus" scoped> +.qlvquzbjribqcaozciifydkngcwtyzje + display flex + flex 1 + padding 16px 0 16px 16px + overflow auto + overflow-y hidden + -webkit-overflow-scrolling touch + + > div + margin-right 8px + width 330px + min-width 330px + + &:last-of-type + margin-right 0 + + &.folder + display flex + flex-direction column + + > *:not(:last-child) + margin-bottom 8px + + &.narrow + > div + width 303px + min-width 303px + + &.narrower + > div + width 316.5px + min-width 316.5px + + &.wider + > div + width 343.5px + min-width 343.5px + + &.wide + > div + width 357px + min-width 357px + + &.center + > * + &:first-child + margin-left auto + + &:last-child + margin-right auto + + &.:not(.flexible) + > * + flex-grow 0 + flex-shrink 0 + + &.flexible + > * + flex-grow 1 + flex-shrink 0 + + > button + padding 0 16px + color var(--faceTextButton) + flex-grow 0 !important + + &:hover + color var(--faceTextButtonHover) + + &:active + color var(--faceTextButtonActive) + +</style> diff --git a/src/client/app/common/views/deck/deck.widgets-column.vue b/src/client/app/common/views/deck/deck.widgets-column.vue new file mode 100644 index 0000000000..47a584a53a --- /dev/null +++ b/src/client/app/common/views/deck/deck.widgets-column.vue @@ -0,0 +1,172 @@ +<template> +<x-column :menu="menu" :naked="true" :narrow="true" :name="name" :column="column" :is-stacked="isStacked" class="wtdtxvecapixsepjtcupubtsmometobz"> + <template #header><fa icon="calculator"/>{{ name }}</template> + + <div class="gqpwvtwtprsbmnssnbicggtwqhmylhnq"> + <template v-if="edit"> + <header> + <select v-model="widgetAdderSelected" @change="addWidget"> + <option value="profile">{{ $t('@.widgets.profile') }}</option> + <option value="analog-clock">{{ $t('@.widgets.analog-clock') }}</option> + <option value="calendar">{{ $t('@.widgets.calendar') }}</option> + <option value="timemachine">{{ $t('@.widgets.timemachine') }}</option> + <option value="activity">{{ $t('@.widgets.activity') }}</option> + <option value="rss">{{ $t('@.widgets.rss') }}</option> + <option value="trends">{{ $t('@.widgets.trends') }}</option> + <option value="photo-stream">{{ $t('@.widgets.photo-stream') }}</option> + <option value="slideshow">{{ $t('@.widgets.slideshow') }}</option> + <option value="version">{{ $t('@.widgets.version') }}</option> + <option value="broadcast">{{ $t('@.widgets.broadcast') }}</option> + <option value="notifications">{{ $t('@.widgets.notifications') }}</option> + <option value="users">{{ $t('@.widgets.users') }}</option> + <option value="polls">{{ $t('@.widgets.polls') }}</option> + <option value="post-form">{{ $t('@.widgets.post-form') }}</option> + <option value="messaging">{{ $t('@.widgets.messaging') }}</option> + <option value="memo">{{ $t('@.widgets.memo') }}</option> + <option value="hashtags">{{ $t('@.widgets.hashtags') }}</option> + <option value="posts-monitor">{{ $t('@.widgets.posts-monitor') }}</option> + <option value="server">{{ $t('@.widgets.server') }}</option> + <option value="nav">{{ $t('@.widgets.nav') }}</option> + <option value="tips">{{ $t('@.widgets.tips') }}</option> + </select> + </header> + <x-draggable + :list="column.widgets" + :options="{ animation: 150 }" + @sort="onWidgetSort" + > + <div v-for="widget in column.widgets" class="customize-container" :key="widget.id" @contextmenu.stop.prevent="widgetFunc(widget.id)"> + <button class="remove" @click="removeWidget(widget)"><fa icon="times"/></button> + <component :is="`mkw-${widget.name}`" :widget="widget" :ref="widget.id" :is-customize-mode="true" platform="deck" :column="column"/> + </div> + </x-draggable> + </template> + <template v-else> + <component class="widget" v-for="widget in column.widgets" :is="`mkw-${widget.name}`" :key="widget.id" :ref="widget.id" :widget="widget" platform="deck" :column="column"/> + </template> + </div> +</x-column> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../../i18n'; +import XColumn from './deck.column.vue'; +import * as XDraggable from 'vuedraggable'; +import * as uuid from 'uuid'; + +export default Vue.extend({ + i18n: i18n(), + components: { + XColumn, + XDraggable + }, + + props: { + column: { + type: Object, + required: true + }, + isStacked: { + type: Boolean, + required: true + } + }, + + data() { + return { + edit: false, + menu: null, + widgetAdderSelected: null + } + }, + + computed: { + name(): string { + if (this.column.name) return this.column.name; + return this.$t('@deck.widgets'); + } + }, + + created() { + this.menu = [{ + icon: 'cog', + text: this.$t('edit'), + action: () => { + this.edit = !this.edit; + } + }]; + }, + + methods: { + widgetFunc(id) { + const w = this.$refs[id][0]; + if (w.func) w.func(); + }, + + onWidgetSort() { + this.saveWidgets(); + }, + + addWidget() { + this.$store.commit('device/addDeckWidget', { + id: this.column.id, + widget: { + name: this.widgetAdderSelected, + id: uuid(), + data: {} + } + }); + + this.widgetAdderSelected = null; + }, + + removeWidget(widget) { + this.$store.commit('device/removeDeckWidget', { + id: this.column.id, + widget + }); + }, + + saveWidgets() { + this.$store.commit('device/updateDeckColumn', this.column); + } + } +}); +</script> + +<style lang="stylus" scoped> +.wtdtxvecapixsepjtcupubtsmometobz + .gqpwvtwtprsbmnssnbicggtwqhmylhnq + > header + padding 16px + + > * + width 100% + padding 4px + + .widget, .customize-container + margin 8px + + &:first-of-type + margin-top 0 + + .customize-container + cursor move + + > *:not(.remove) + pointer-events none + + > .remove + position absolute + z-index 1 + top 8px + right 8px + width 32px + height 32px + color #fff + background rgba(#000, 0.7) + border-radius 4px + +</style> + |