summaryrefslogtreecommitdiff
path: root/src/client/app/common/views/deck
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2019-02-25 19:45:00 +0900
committerGitHub <noreply@github.com>2019-02-25 19:45:00 +0900
commitc0a60260c25c8f7e0c4975b6a1a4342f2b430210 (patch)
tree5bab5271e74eb52bd13fc79d359ef30f829d6dc3 /src/client/app/common/views/deck
parentFix error (diff)
downloadmisskey-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')
-rw-r--r--src/client/app/common/views/deck/deck.column-core.vue49
-rw-r--r--src/client/app/common/views/deck/deck.column.vue426
-rw-r--r--src/client/app/common/views/deck/deck.direct-column.vue46
-rw-r--r--src/client/app/common/views/deck/deck.direct.vue65
-rw-r--r--src/client/app/common/views/deck/deck.explore-column.vue34
-rw-r--r--src/client/app/common/views/deck/deck.favorites-column.vue58
-rw-r--r--src/client/app/common/views/deck/deck.featured-column.vue46
-rw-r--r--src/client/app/common/views/deck/deck.hashtag-column.vue119
-rw-r--r--src/client/app/common/views/deck/deck.hashtag-tl.vue85
-rw-r--r--src/client/app/common/views/deck/deck.list-tl.vue95
-rw-r--r--src/client/app/common/views/deck/deck.mentions-column.vue46
-rw-r--r--src/client/app/common/views/deck/deck.mentions.vue61
-rw-r--r--src/client/app/common/views/deck/deck.note-column.vue75
-rw-r--r--src/client/app/common/views/deck/deck.notes.vue244
-rw-r--r--src/client/app/common/views/deck/deck.notification.vue189
-rw-r--r--src/client/app/common/views/deck/deck.notifications-column.vue40
-rw-r--r--src/client/app/common/views/deck/deck.notifications.vue223
-rw-r--r--src/client/app/common/views/deck/deck.search-column.vue61
-rw-r--r--src/client/app/common/views/deck/deck.tl-column.vue101
-rw-r--r--src/client/app/common/views/deck/deck.tl.vue147
-rw-r--r--src/client/app/common/views/deck/deck.user-column.home.vue231
-rw-r--r--src/client/app/common/views/deck/deck.user-column.vue263
-rw-r--r--src/client/app/common/views/deck/deck.vue398
-rw-r--r--src/client/app/common/views/deck/deck.widgets-column.vue172
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>
+