モバイル版でもデッキを使えるように (#4366)

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* Fix bug

* wip

* Update notifications.vue

* Update user-menu.vue

* deck settings

* indicate
This commit is contained in:
syuilo
2019-02-25 19:45:00 +09:00
committed by GitHub
parent 3654f247c4
commit c0a60260c2
53 changed files with 711 additions and 666 deletions

View File

@@ -13,7 +13,7 @@ import fuckAdBlock from '../common/scripts/fuck-ad-block';
import composeNotification from '../common/scripts/compose-notification';
import MkHome from './views/home/home.vue';
import MkDeck from './views/deck/deck.vue';
import MkDeck from '../common/views/deck/deck.vue';
import MkSelectDrive from './views/pages/selectdrive.vue';
import MkDrive from './views/pages/drive.vue';
import MkMessagingRoom from './views/pages/messaging-room.vue';
@@ -123,30 +123,24 @@ init(async (launch, os) => {
require('./views/components');
require('./views/widgets');
os.store.commit('device/set', {
key: 'inDeckMode',
value: os.store.getters.isSignedIn && os.store.state.device.deckMode
&& (document.location.pathname === '/' || window.performance.navigation.type === 1)
});
// Init router
const router = new VueRouter({
mode: 'history',
routes: [
os.store.state.device.inDeckMode
? { path: '/', name: 'index', component: MkDeck, children: [
{ path: '/@:user', component: () => import('./views/deck/deck.user-column.vue').then(m => m.default), children: [
{ path: '', name: 'user', component: () => import('./views/deck/deck.user-column.home.vue').then(m => m.default) },
{ path: '/@:user', component: () => import('../common/views/deck/deck.user-column.vue').then(m => m.default), children: [
{ path: '', name: 'user', component: () => import('../common/views/deck/deck.user-column.home.vue').then(m => m.default) },
{ path: 'following', component: () => import('../common/views/pages/following.vue').then(m => m.default) },
{ path: 'followers', component: () => import('../common/views/pages/followers.vue').then(m => m.default) },
]},
{ path: '/notes/:note', name: 'note', component: () => import('./views/deck/deck.note-column.vue').then(m => m.default) },
{ path: '/search', component: () => import('./views/deck/deck.search-column.vue').then(m => m.default) },
{ path: '/tags/:tag', name: 'tag', component: () => import('./views/deck/deck.hashtag-column.vue').then(m => m.default) },
{ path: '/featured', name: 'featured', component: () => import('./views/deck/deck.featured-column.vue').then(m => m.default) },
{ path: '/explore', name: 'explore', component: () => import('./views/deck/deck.explore-column.vue').then(m => m.default) },
{ path: '/explore/tags/:tag', name: 'explore-tag', props: true, component: () => import('./views/deck/deck.explore-column.vue').then(m => m.default) },
{ path: '/i/favorites', component: () => import('./views/deck/deck.favorites-column.vue').then(m => m.default) }
{ path: '/notes/:note', name: 'note', component: () => import('../common/views/deck/deck.note-column.vue').then(m => m.default) },
{ path: '/search', component: () => import('../common/views/deck/deck.search-column.vue').then(m => m.default) },
{ path: '/tags/:tag', name: 'tag', component: () => import('../common/views/deck/deck.hashtag-column.vue').then(m => m.default) },
{ path: '/featured', name: 'featured', component: () => import('../common/views/deck/deck.featured-column.vue').then(m => m.default) },
{ path: '/explore', name: 'explore', component: () => import('../common/views/deck/deck.explore-column.vue').then(m => m.default) },
{ path: '/explore/tags/:tag', name: 'explore-tag', props: true, component: () => import('../common/views/deck/deck.explore-column.vue').then(m => m.default) },
{ path: '/i/favorites', component: () => import('../common/views/deck/deck.favorites-column.vue').then(m => m.default) }
]}
: { path: '/', component: MkHome, children: [
{ path: '', name: 'index', component: MkHomeTimeline },

View File

@@ -2,6 +2,7 @@ import Vue from 'vue';
import ui from './ui.vue';
import uiNotification from './ui-notification.vue';
import note from './note.vue';
import notes from './notes.vue';
import subNoteContent from './sub-note-content.vue';
import window from './window.vue';
@@ -20,6 +21,7 @@ import uiContainer from './ui-container.vue';
Vue.component('mk-ui', ui);
Vue.component('mk-ui-notification', uiNotification);
Vue.component('mk-note', note);
Vue.component('mk-notes', notes);
Vue.component('mk-sub-note-content', subNoteContent);
Vue.component('mk-window', window);

View File

@@ -1,6 +1,6 @@
<template>
<div class="qiziqtywpuaucsgarwajitwaakggnisj" :title="title">
<mk-avatar class="avatar" :user="note.user" v-if="!mini"/>
<mk-avatar class="avatar" :user="note.user" v-if="!narrow"/>
<div class="main">
<mk-note-header class="header" :note="note" :mini="true"/>
<div class="body">
@@ -25,9 +25,10 @@ export default Vue.extend({
type: Object,
required: true
},
mini: {
type: Boolean,
required: false,
},
inject: {
narrow: {
default: false
}
},

View File

@@ -1,5 +1,5 @@
<template>
<div class="tkfdzaxtkdeianobciwadajxzbddorql" :class="{ mini }" :title="title">
<div class="tkfdzaxtkdeianobciwadajxzbddorql" :class="{ mini: narrow }" :title="title">
<mk-avatar class="avatar" :user="note.user"/>
<div class="main">
<mk-note-header class="header" :note="note"/>
@@ -24,10 +24,11 @@ export default Vue.extend({
note: {
type: Object,
required: true
},
mini: {
type: Boolean,
required: false,
}
},
inject: {
narrow: {
default: false
}
},

View File

@@ -1,23 +1,23 @@
<template>
<div
class="note"
:class="{ mini }"
:class="{ mini: narrow }"
v-show="(this.$store.state.settings.remainDeletedNote || appearNote.deletedAt == null) && !hideThisNote"
:tabindex="appearNote.deletedAt == null ? '-1' : null"
v-hotkey="keymap"
:title="title"
>
<div class="conversation" v-if="detail && conversation.length > 0">
<x-sub v-for="note in conversation" :key="note.id" :note="note" :mini="mini"/>
<x-sub v-for="note in conversation" :key="note.id" :note="note"/>
</div>
<div class="reply-to" v-if="appearNote.reply && (!$store.getters.isSignedIn || $store.state.settings.showReplyTarget)">
<x-sub :note="appearNote.reply" :mini="mini"/>
<x-sub :note="appearNote.reply"/>
</div>
<mk-renote class="renote" v-if="isRenote" :note="note"/>
<article>
<article class="article">
<mk-avatar class="avatar" :user="appearNote.user"/>
<div class="main">
<mk-note-header class="header" :note="appearNote" :mini="mini"/>
<mk-note-header class="header" :note="appearNote"/>
<div class="body" v-if="appearNote.deletedAt == null">
<p v-if="appearNote.cw != null" class="cw">
<mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :i="$store.state.i" :custom-emojis="appearNote.emojis" />
@@ -35,12 +35,12 @@
</div>
<mk-poll v-if="appearNote.poll" :note="appearNote" ref="pollViewer"/>
<a class="location" v-if="appearNote.geo" :href="`https://maps.google.com/maps?q=${appearNote.geo.coordinates[1]},${appearNote.geo.coordinates[0]}`" target="_blank"><fa icon="map-marker-alt"/> 位置情報</a>
<div class="renote" v-if="appearNote.renote"><mk-note-preview :note="appearNote.renote" :mini="mini"/></div>
<mk-url-preview v-for="url in urls" :url="url" :key="url" :mini="mini" :compact="compact"/>
<div class="renote" v-if="appearNote.renote"><mk-note-preview :note="appearNote.renote"/></div>
<mk-url-preview v-for="url in urls" :url="url" :key="url" :compact="compact"/>
</div>
</div>
<footer v-if="appearNote.deletedAt == null">
<span class="app" v-if="appearNote.app && mini && $store.state.settings.showVia">via <b>{{ appearNote.app.name }}</b></span>
<span class="app" v-if="appearNote.app && narrow && $store.state.settings.showVia">via <b>{{ appearNote.app.name }}</b></span>
<mk-reactions-viewer :note="appearNote" ref="reactionsViewer"/>
<button class="replyButton" @click="reply()" :title="$t('reply')">
<template v-if="appearNote.reply"><fa icon="reply-all"/></template>
@@ -70,7 +70,7 @@
</div>
</article>
<div class="replies" v-if="detail && replies.length > 0">
<x-sub v-for="note in replies" :key="note.id" :note="note" :mini="mini"/>
<x-sub v-for="note in replies" :key="note.id" :note="note"/>
</div>
</div>
</template>
@@ -110,9 +110,10 @@ export default Vue.extend({
required: false,
default: false
},
mini: {
type: Boolean,
required: false,
},
inject: {
narrow: {
default: false
}
},
@@ -152,7 +153,7 @@ export default Vue.extend({
border-bottom solid var(--lineWidth) var(--faceDivider)
&.mini
font-size 14px
font-size 13px
> .renote
padding 8px 16px 0 16px
@@ -161,7 +162,7 @@ export default Vue.extend({
width 20px
height 20px
> article
> .article
padding 16px 16px 4px
> .avatar
@@ -189,7 +190,7 @@ export default Vue.extend({
> .renote + article
padding-top 8px
> article
> .article
display flex
padding 28px 32px 18px 32px

View File

@@ -17,7 +17,7 @@
<!-- トランジションを有効にするとなぜかメモリリークする -->
<component :is="!$store.state.device.reduceMotion ? 'transition-group' : 'div'" name="mk-notes" class="notes transition" tag="div" ref="notes">
<template v-for="(note, i) in _notes">
<x-note :note="note" :key="note.id" @update:note="onNoteUpdated(i, $event)" :compact="true" ref="note"/>
<mk-note :note="note" :key="note.id" @update:note="onNoteUpdated(i, $event)" :compact="true" ref="note"/>
<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>
@@ -39,17 +39,12 @@ import Vue from 'vue';
import i18n from '../../../i18n';
import * as config from '../../../config';
import shouldMuteNote from '../../../common/scripts/should-mute-note';
import XNote from './note.vue';
const displayLimit = 30;
export default Vue.extend({
i18n: i18n(),
components: {
XNote
},
props: {
makePromise: {
required: true

View File

@@ -115,18 +115,18 @@
<ui-switch v-model="remainDeletedNote">{{ $t('remain-deleted-note') }}</ui-switch>
</section>
<section>
<header>{{ $t('deck-column-align') }}</header>
<ui-radio v-model="deckColumnAlign" value="center">{{ $t('deck-column-align-center') }}</ui-radio>
<ui-radio v-model="deckColumnAlign" value="left">{{ $t('deck-column-align-left') }}</ui-radio>
<ui-radio v-model="deckColumnAlign" value="flexible">{{ $t('deck-column-align-flexible') }}</ui-radio>
<header>{{ $t('@.deck-column-align') }}</header>
<ui-radio v-model="deckColumnAlign" value="center">{{ $t('@.deck-column-align-center') }}</ui-radio>
<ui-radio v-model="deckColumnAlign" value="left">{{ $t('@.deck-column-align-left') }}</ui-radio>
<ui-radio v-model="deckColumnAlign" value="flexible">{{ $t('@.deck-column-align-flexible') }}</ui-radio>
</section>
<section>
<header>{{ $t('deck-column-width') }}</header>
<ui-radio v-model="deckColumnWidth" value="narrow">{{ $t('deck-column-width-narrow') }}</ui-radio>
<ui-radio v-model="deckColumnWidth" value="narrower">{{ $t('deck-column-width-narrower') }}</ui-radio>
<ui-radio v-model="deckColumnWidth" value="normal">{{ $t('deck-column-width-normal') }}</ui-radio>
<ui-radio v-model="deckColumnWidth" value="wider">{{ $t('deck-column-width-wider') }}</ui-radio>
<ui-radio v-model="deckColumnWidth" value="wide">{{ $t('deck-column-width-wide') }}</ui-radio>
<header>{{ $t('@.deck-column-width') }}</header>
<ui-radio v-model="deckColumnWidth" value="narrow">{{ $t('@.deck-column-width-narrow') }}</ui-radio>
<ui-radio v-model="deckColumnWidth" value="narrower">{{ $t('@.deck-column-width-narrower') }}</ui-radio>
<ui-radio v-model="deckColumnWidth" value="normal">{{ $t('@.deck-column-width-normal') }}</ui-radio>
<ui-radio v-model="deckColumnWidth" value="wider">{{ $t('@.deck-column-width-wider') }}</ui-radio>
<ui-radio v-model="deckColumnWidth" value="wide">{{ $t('@.deck-column-width-wide') }}</ui-radio>
</section>
<section>
<ui-switch v-model="games_reversi_showBoardLabels">{{ $t('@.show-reversi-board-labels') }}</ui-switch>

View File

@@ -1,49 +0,0 @@
<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>

View File

@@ -1,422 +0,0 @@
<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';
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
};
},
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.settings.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.dispatch('settings/renameDeckColumn', { id: this.column.id, name });
});
}
}, null, {
icon: 'arrow-left',
text: this.$t('swap-left'),
action: () => {
this.$store.dispatch('settings/swapLeftDeckColumn', this.column.id);
}
}, {
icon: 'arrow-right',
text: this.$t('swap-right'),
action: () => {
this.$store.dispatch('settings/swapRightDeckColumn', this.column.id);
}
}, this.isStacked ? {
icon: 'arrow-up',
text: this.$t('swap-up'),
action: () => {
this.$store.dispatch('settings/swapUpDeckColumn', this.column.id);
}
} : undefined, this.isStacked ? {
icon: 'arrow-down',
text: this.$t('swap-down'),
action: () => {
this.$store.dispatch('settings/swapDownDeckColumn', this.column.id);
}
} : undefined, null, {
icon: ['far', 'window-restore'],
text: this.$t('stack-left'),
action: () => {
this.$store.dispatch('settings/stackLeftDeckColumn', this.column.id);
}
}, this.isStacked ? {
icon: ['far', 'window-maximize'],
text: this.$t('pop-right'),
action: () => {
this.$store.dispatch('settings/popRightDeckColumn', this.column.id);
}
} : undefined, null, {
icon: ['far', 'trash-alt'],
text: this.$t('remove'),
action: () => {
this.$store.dispatch('settings/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.dispatch('settings/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
</style>

View File

@@ -1,46 +0,0 @@
<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>

View File

@@ -1,65 +0,0 @@
<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>

View File

@@ -1,34 +0,0 @@
<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>

View File

@@ -1,58 +0,0 @@
<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>

View File

@@ -1,46 +0,0 @@
<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>

View File

@@ -1,119 +0,0 @@
<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>

View File

@@ -1,85 +0,0 @@
<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>

View File

@@ -1,95 +0,0 @@
<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>

View File

@@ -1,46 +0,0 @@
<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>

View File

@@ -1,61 +0,0 @@
<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>

View File

@@ -1,77 +0,0 @@
<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>
<x-note :note="note" :detail="true" :mini="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';
import XNote from '../components/note.vue';
export default Vue.extend({
i18n: i18n(),
components: {
XColumn,
XNotes,
XNote
},
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>

View File

@@ -1,249 +0,0 @@
<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">
<x-note
:note="note"
:key="note.id"
@update:note="onNoteUpdated(i, $event)"
:compact="true"
:mini="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';
import XNote from '../components/note.vue';
const displayLimit = 20;
export default Vue.extend({
i18n: i18n(),
components: {
XNote
},
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>

View File

@@ -1,193 +0,0 @@
<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'">
<x-note :note="notification.note" @update:note="onNoteUpdated" :mini="true"/>
</template>
<template v-if="notification.type == 'reply'">
<x-note :note="notification.note" @update:note="onNoteUpdated" :mini="true"/>
</template>
<template v-if="notification.type == 'mention'">
<x-note :note="notification.note" @update:note="onNoteUpdated" :mini="true"/>
</template>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import getNoteSummary from '../../../../../misc/get-note-summary';
import XNote from '../components/note.vue';
export default Vue.extend({
components: {
XNote
},
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>

View File

@@ -1,40 +0,0 @@
<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>

View File

@@ -1,223 +0,0 @@
<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 #555
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>

View File

@@ -1,61 +0,0 @@
<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>

View File

@@ -1,101 +0,0 @@
<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.dispatch('settings/saveDeck');
},
focus() {
this.$refs.tl.focus();
}
}
});
</script>

View File

@@ -1,147 +0,0 @@
<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>

View File

@@ -1,233 +0,0 @@
<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>
<x-note v-for="n in user.pinnedNotes" :key="n.id" :note="n" :mini="true"/>
</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 XNote from '../components/note.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,
XNote
},
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>

View File

@@ -1,263 +0,0 @@
<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>

View File

@@ -1,384 +0,0 @@
<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 MkUserListsWindow from '../components/user-lists-window.vue';
import * as uuid from 'uuid';
export default Vue.extend({
i18n: i18n('deck'),
components: {
XColumnCore
},
computed: {
columns(): any[] {
if (this.$store.state.settings.deck == null) return [];
return this.$store.state.settings.deck.columns;
},
layout(): any[] {
if (this.$store.state.settings.deck == null) return [];
if (this.$store.state.settings.deck.layout == null) return this.$store.state.settings.deck.columns.map(c => [c.id]);
return this.$store.state.settings.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
};
},
created() {
if (this.$store.state.settings.deck == null) {
const deck = {
columns: [/*{
type: 'widgets',
widgets: []
}, */{
id: uuid(),
type: 'home'
}, {
id: uuid(),
type: 'notifications'
}, {
id: uuid(),
type: 'local'
}, {
id: uuid(),
type: 'global'
}]
};
deck.layout = deck.columns.map(c => [c.id]);
this.$store.dispatch('settings/set', {
key: 'deck',
value: deck
});
}
// 互換性のため
if (this.$store.state.settings.deck != null && this.$store.state.settings.deck.layout == null) {
this.$store.dispatch('settings/set', {
key: 'deck',
value: Object.assign({}, this.$store.state.settings.deck, {
layout: this.$store.state.settings.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.dispatch('settings/addDeckColumn', {
id: uuid(),
type: 'home'
});
}
}, {
icon: ['far', 'comments'],
text: this.$t('@deck.local'),
action: () => {
this.$store.dispatch('settings/addDeckColumn', {
id: uuid(),
type: 'local'
});
}
}, {
icon: 'share-alt',
text: this.$t('@deck.hybrid'),
action: () => {
this.$store.dispatch('settings/addDeckColumn', {
id: uuid(),
type: 'hybrid'
});
}
}, {
icon: 'globe',
text: this.$t('@deck.global'),
action: () => {
this.$store.dispatch('settings/addDeckColumn', {
id: uuid(),
type: 'global'
});
}
}, {
icon: 'at',
text: this.$t('@deck.mentions'),
action: () => {
this.$store.dispatch('settings/addDeckColumn', {
id: uuid(),
type: 'mentions'
});
}
}, {
icon: ['far', 'envelope'],
text: this.$t('@deck.direct'),
action: () => {
this.$store.dispatch('settings/addDeckColumn', {
id: uuid(),
type: 'direct'
});
}
}, {
icon: 'list',
text: this.$t('@deck.list'),
action: () => {
const w = this.$root.new(MkUserListsWindow);
w.$once('choosen', list => {
this.$store.dispatch('settings/addDeckColumn', {
id: uuid(),
type: 'list',
list: list
});
w.close();
});
}
}, {
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.dispatch('settings/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.dispatch('settings/addDeckColumn', {
id: uuid(),
type: 'notifications'
});
}
}, {
icon: 'calculator',
text: this.$t('@deck.widgets'),
action: () => {
this.$store.dispatch('settings/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
> 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>

View File

@@ -1,172 +0,0 @@
<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"/>
</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"/>
</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.dispatch('settings/addDeckWidget', {
id: this.column.id,
widget: {
name: this.widgetAdderSelected,
id: uuid(),
data: {}
}
});
this.widgetAdderSelected = null;
},
removeWidget(widget) {
this.$store.dispatch('settings/removeDeckWidget', {
id: this.column.id,
widget
});
},
saveWidgets() {
this.$store.dispatch('settings/saveDeck');
}
}
});
</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>