summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorsyuilo <syuilotan@yahoo.co.jp>2018-06-05 21:36:21 +0900
committersyuilo <syuilotan@yahoo.co.jp>2018-06-05 21:36:21 +0900
commit9ce0f96de3ba32e25893f6d248f35badaa522479 (patch)
tree37a0d9702b810bfe5eba6a044d1190c0fb102553 /src
parentUpdate README.md (diff)
downloadmisskey-9ce0f96de3ba32e25893f6d248f35badaa522479.tar.gz
misskey-9ce0f96de3ba32e25893f6d248f35badaa522479.tar.bz2
misskey-9ce0f96de3ba32e25893f6d248f35badaa522479.zip
wip
Diffstat (limited to 'src')
-rw-r--r--src/client/app/desktop/script.ts2
-rw-r--r--src/client/app/desktop/views/components/ui.header.nav.vue6
-rw-r--r--src/client/app/desktop/views/components/ui.vue9
-rw-r--r--src/client/app/desktop/views/pages/deck/deck.column.vue59
-rw-r--r--src/client/app/desktop/views/pages/deck/deck.note.sub.vue153
-rw-r--r--src/client/app/desktop/views/pages/deck/deck.note.vue539
-rw-r--r--src/client/app/desktop/views/pages/deck/deck.notes.vue248
-rw-r--r--src/client/app/desktop/views/pages/deck/deck.tl.vue143
-rw-r--r--src/client/app/desktop/views/pages/deck/deck.vue42
9 files changed, 1201 insertions, 0 deletions
diff --git a/src/client/app/desktop/script.ts b/src/client/app/desktop/script.ts
index 8fb6096afa..61f1f5b870 100644
--- a/src/client/app/desktop/script.ts
+++ b/src/client/app/desktop/script.ts
@@ -23,6 +23,7 @@ import updateAvatar from './api/update-avatar';
import updateBanner from './api/update-banner';
import MkIndex from './views/pages/index.vue';
+import MkDeck from './views/pages/deck/deck.vue';
import MkUser from './views/pages/user/user.vue';
import MkFavorites from './views/pages/favorites.vue';
import MkSelectDrive from './views/pages/selectdrive.vue';
@@ -50,6 +51,7 @@ init(async (launch) => {
mode: 'history',
routes: [
{ path: '/', name: 'index', component: MkIndex },
+ { path: '/deck', name: 'deck', component: MkDeck },
{ path: '/i/customize-home', component: MkHomeCustomize },
{ path: '/i/favorites', component: MkFavorites },
{ path: '/i/messaging/:user', component: MkMessagingRoom },
diff --git a/src/client/app/desktop/views/components/ui.header.nav.vue b/src/client/app/desktop/views/components/ui.header.nav.vue
index 4780c57cb4..8e792b3df5 100644
--- a/src/client/app/desktop/views/components/ui.header.nav.vue
+++ b/src/client/app/desktop/views/components/ui.header.nav.vue
@@ -8,6 +8,12 @@
<p>%i18n:@home%</p>
</router-link>
</li>
+ <li class="deck" :class="{ active: $route.name == 'deck' }">
+ <router-link to="/deck">
+ %fa:columns%
+ <p>%i18n:@deck%</p>
+ </router-link>
+ </li>
<li class="messaging">
<a @click="messaging">
%fa:comments%
diff --git a/src/client/app/desktop/views/components/ui.vue b/src/client/app/desktop/views/components/ui.vue
index 32cc71e4b0..ad6fc69dfa 100644
--- a/src/client/app/desktop/views/components/ui.vue
+++ b/src/client/app/desktop/views/components/ui.vue
@@ -37,7 +37,16 @@ export default Vue.extend({
<style lang="stylus" scoped>
.mk-ui
+ display flex
+ flex-direction column
+ flex 1
+
> .header
@media (max-width 1000px)
display none
+
+ > .content
+ display flex
+ flex-direction column
+ flex 1
</style>
diff --git a/src/client/app/desktop/views/pages/deck/deck.column.vue b/src/client/app/desktop/views/pages/deck/deck.column.vue
new file mode 100644
index 0000000000..4e06798293
--- /dev/null
+++ b/src/client/app/desktop/views/pages/deck/deck.column.vue
@@ -0,0 +1,59 @@
+<template>
+<div class="dnpfarvgbnfmyzbdquhhzyxcmstpdqzs">
+ <header>
+ <slot name="header">Timeline</slot>
+ </header>
+ <div ref="body">
+ <x-tl ref="tl"/>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import XTl from './deck.tl.vue';
+
+export default Vue.extend({
+ components: {
+ XTl
+ },
+ mounted() {
+ this.$nextTick(() => {
+ this.$refs.tl.mount(this.$refs.body);
+ });
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+@import '~const.styl'
+
+root(isDark)
+ flex 1
+ max-width 330px
+ height 100%
+ margin-right 16px
+ background isDark ? #282C37 : #fff
+ border-radius 6px
+ box-shadow 0 2px 16px rgba(#000, 0.1)
+ overflow hidden
+
+ > header
+ z-index 1
+ line-height 48px
+ padding 0 16px
+ color isDark ? #e3e5e8 : #888
+ background isDark ? #313543 : #fff
+ box-shadow 0 1px rgba(#000, 0.15)
+
+ > div
+ height calc(100% - 48px)
+ overflow auto
+
+.dnpfarvgbnfmyzbdquhhzyxcmstpdqzs[data-darkmode]
+ root(true)
+
+.dnpfarvgbnfmyzbdquhhzyxcmstpdqzs:not([data-darkmode])
+ root(false)
+
+</style>
diff --git a/src/client/app/desktop/views/pages/deck/deck.note.sub.vue b/src/client/app/desktop/views/pages/deck/deck.note.sub.vue
new file mode 100644
index 0000000000..b458b74186
--- /dev/null
+++ b/src/client/app/desktop/views/pages/deck/deck.note.sub.vue
@@ -0,0 +1,153 @@
+<template>
+<div class="fnlfosztlhtptnongximhlbykxblytcq">
+ <mk-avatar class="avatar" :user="note.user"/>
+ <div class="main">
+ <header>
+ <router-link class="name" :to="note.user | userPage">{{ note.user | userName }}</router-link>
+ <span class="is-admin" v-if="note.user.isAdmin">%i18n:@admin%</span>
+ <span class="is-bot" v-if="note.user.isBot">%i18n:@bot%</span>
+ <span class="is-cat" v-if="note.user.isCat">%i18n:@cat%</span>
+ <span class="username"><mk-acct :user="note.user"/></span>
+ <div class="info">
+ <span class="mobile" v-if="note.viaMobile">%fa:mobile-alt%</span>
+ <router-link class="created-at" :to="note | notePage">
+ <mk-time :time="note.createdAt"/>
+ </router-link>
+ <span class="visibility" v-if="note.visibility != 'public'">
+ <template v-if="note.visibility == 'home'">%fa:home%</template>
+ <template v-if="note.visibility == 'followers'">%fa:unlock%</template>
+ <template v-if="note.visibility == 'specified'">%fa:envelope%</template>
+ <template v-if="note.visibility == 'private'">%fa:lock%</template>
+ </span>
+ </div>
+ </header>
+ <div class="body">
+ <mk-sub-note-content class="text" :note="note"/>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+
+export default Vue.extend({
+ props: {
+ note: {
+ type: Object,
+ required: true
+ },
+ // TODO
+ truncate: {
+ type: Boolean,
+ default: true
+ }
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+root(isDark)
+ display flex
+ padding 16px
+ font-size 10px
+ background isDark ? #21242d : #fcfcfc
+
+ &.smart
+ > .main
+ width 100%
+
+ > header
+ align-items center
+
+ > .avatar
+ flex-shrink 0
+ display block
+ margin 0 8px 0 0
+ width 38px
+ height 38px
+ border-radius 8px
+
+ > .main
+ flex 1
+ min-width 0
+
+ > header
+ display flex
+ align-items baseline
+ margin-bottom 2px
+ white-space nowrap
+
+ > .avatar
+ flex-shrink 0
+ margin-right 8px
+ width 18px
+ height 18px
+ border-radius 100%
+
+ > .name
+ display block
+ margin 0 0.5em 0 0
+ padding 0
+ overflow hidden
+ color isDark ? #fff : #607073
+ font-size 1em
+ font-weight 700
+ text-align left
+ text-decoration none
+ text-overflow ellipsis
+
+ &:hover
+ text-decoration underline
+
+ > .is-admin
+ > .is-bot
+ > .is-cat
+ align-self center
+ margin 0 0.5em 0 0
+ padding 1px 5px
+ font-size 0.8em
+ color isDark ? #758188 : #aaa
+ border solid 1px isDark ? #57616f : #ddd
+ border-radius 3px
+
+ &.is-admin
+ border-color isDark ? #d42c41 : #f56a7b
+ color isDark ? #d42c41 : #f56a7b
+
+ > .username
+ text-align left
+ margin 0
+ color isDark ? #606984 : #d1d8da
+
+ > .info
+ margin-left auto
+ font-size 0.9em
+
+ > *
+ color isDark ? #606984 : #b2b8bb
+
+ > .mobile
+ margin-right 6px
+
+ > .visibility
+ margin-left 6px
+
+ > .body
+
+ > .text
+ margin 0
+ padding 0
+ color isDark ? #959ba7 : #717171
+
+ pre
+ max-height 120px
+ font-size 80%
+
+.fnlfosztlhtptnongximhlbykxblytcq[data-darkmode]
+ root(true)
+
+.fnlfosztlhtptnongximhlbykxblytcq:not([data-darkmode])
+ root(false)
+
+</style>
diff --git a/src/client/app/desktop/views/pages/deck/deck.note.vue b/src/client/app/desktop/views/pages/deck/deck.note.vue
new file mode 100644
index 0000000000..8582a37b91
--- /dev/null
+++ b/src/client/app/desktop/views/pages/deck/deck.note.vue
@@ -0,0 +1,539 @@
+<template>
+<div class="zyjjkidcqjnlegkqebitfviomuqmseqk" :class="{ renote: isRenote }">
+ <div class="reply-to" v-if="p.reply && (!$store.getters.isSignedIn || $store.state.settings.showReplyTarget)">
+ <x-sub :note="p.reply"/>
+ </div>
+ <div class="renote" v-if="isRenote">
+ <mk-avatar class="avatar" :user="note.user"/>
+ %fa:retweet%
+ <span>{{ '%i18n:@reposted-by%'.substr(0, '%i18n:@reposted-by%'.indexOf('{')) }}</span>
+ <router-link class="name" :to="note.user | userPage">{{ note.user | userName }}</router-link>
+ <span>{{ '%i18n:@reposted-by%'.substr('%i18n:@reposted-by%'.indexOf('}') + 1) }}</span>
+ <mk-time :time="note.createdAt"/>
+ </div>
+ <article>
+ <mk-avatar class="avatar" :user="p.user"/>
+ <div class="main">
+ <header>
+ <router-link class="name" :to="p.user | userPage">{{ p.user | userName }}</router-link>
+ <span class="is-admin" v-if="p.user.isAdmin">admin</span>
+ <span class="is-bot" v-if="p.user.isBot">bot</span>
+ <span class="is-cat" v-if="p.user.isCat">cat</span>
+ <span class="username"><mk-acct :user="p.user"/></span>
+ <div class="info">
+ <span class="mobile" v-if="p.viaMobile">%fa:mobile-alt%</span>
+ <router-link class="created-at" :to="p | notePage">
+ <mk-time :time="p.createdAt"/>
+ </router-link>
+ <span class="visibility" v-if="p.visibility != 'public'">
+ <template v-if="p.visibility == 'home'">%fa:home%</template>
+ <template v-if="p.visibility == 'followers'">%fa:unlock%</template>
+ <template v-if="p.visibility == 'specified'">%fa:envelope%</template>
+ <template v-if="p.visibility == 'private'">%fa:lock%</template>
+ </span>
+ </div>
+ </header>
+ <div class="body">
+ <p v-if="p.cw != null" class="cw">
+ <span class="text" v-if="p.cw != ''">{{ p.cw }}</span>
+ <span class="toggle" @click="showContent = !showContent">{{ showContent ? '%i18n:@less%' : '%i18n:@more%' }}</span>
+ </p>
+ <div class="content" v-show="p.cw == null || showContent">
+ <div class="text">
+ <span v-if="p.isHidden" style="opacity: 0.5">(%i18n:@private%)</span>
+ <span v-if="p.deletedAt" style="opacity: 0.5">(%i18n:@deleted%)</span>
+ <a class="reply" v-if="p.reply">%fa:reply%</a>
+ <mk-note-html v-if="p.text && !canHideText(p)" :text="p.text" :i="$store.state.i"/>
+ <a class="rp" v-if="p.renote != null">RP:</a>
+ </div>
+ <div class="media" v-if="p.media.length > 0">
+ <mk-media-list :media-list="p.media"/>
+ </div>
+ <mk-poll v-if="p.poll" :note="p" ref="pollViewer"/>
+ <div class="tags" v-if="p.tags && p.tags.length > 0">
+ <router-link v-for="tag in p.tags" :key="tag" :to="`/search?q=#${tag}`">{{ tag }}</router-link>
+ </div>
+ <a class="location" v-if="p.geo" :href="`http://maps.google.com/maps?q=${p.geo.coordinates[1]},${p.geo.coordinates[0]}`" target="_blank">%fa:map-marker-alt% %i18n:@location%</a>
+ <div class="renote" v-if="p.renote">
+ <mk-note-preview :note="p.renote"/>
+ </div>
+ </div>
+ <span class="app" v-if="p.app">via <b>{{ p.app.name }}</b></span>
+ </div>
+ <footer>
+ <mk-reactions-viewer :note="p" ref="reactionsViewer"/>
+ <button @click="reply">
+ <template v-if="p.reply">%fa:reply-all%</template>
+ <template v-else>%fa:reply%</template>
+ </button>
+ <button @click="renote" title="Renote">%fa:retweet%</button>
+ <button :class="{ reacted: p.myReaction != null }" @click="react" ref="reactButton">%fa:plus%</button>
+ <button class="menu" @click="menu" ref="menuButton">%fa:ellipsis-h%</button>
+ </footer>
+ </div>
+ </article>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import parse from '../../../../../../text/parse';
+import canHideText from '../../../../common/scripts/can-hide-text';
+
+import MkNoteMenu from '../../../../common/views/components/note-menu.vue';
+import MkReactionPicker from '../../../../common/views/components/reaction-picker.vue';
+import XSub from './deck.note.sub.vue';
+
+export default Vue.extend({
+ components: {
+ XSub
+ },
+
+ props: ['note'],
+
+ data() {
+ return {
+ showContent: false,
+ connection: null,
+ connectionId: null
+ };
+ },
+
+ computed: {
+ isRenote(): boolean {
+ return (this.note.renote &&
+ this.note.text == null &&
+ this.note.mediaIds.length == 0 &&
+ this.note.poll == null);
+ },
+
+ p(): any {
+ return this.isRenote ? this.note.renote : this.note;
+ },
+
+ reactionsCount(): number {
+ return this.p.reactionCounts
+ ? Object.keys(this.p.reactionCounts)
+ .map(key => this.p.reactionCounts[key])
+ .reduce((a, b) => a + b)
+ : 0;
+ },
+
+ urls(): string[] {
+ if (this.p.text) {
+ const ast = parse(this.p.text);
+ return ast
+ .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent)
+ .map(t => t.url);
+ } else {
+ return null;
+ }
+ }
+ },
+
+ created() {
+ if (this.$store.getters.isSignedIn) {
+ this.connection = (this as any).os.stream.getConnection();
+ this.connectionId = (this as any).os.stream.use();
+ }
+ },
+
+ mounted() {
+ this.capture(true);
+
+ if (this.$store.getters.isSignedIn) {
+ this.connection.on('_connected_', this.onStreamConnected);
+ }
+
+ // Draw map
+ if (this.p.geo) {
+ const shouldShowMap = this.$store.getters.isSignedIn ? this.$store.state.settings.showMaps : true;
+ if (shouldShowMap) {
+ (this as any).os.getGoogleMaps().then(maps => {
+ const uluru = new maps.LatLng(this.p.geo.coordinates[1], this.p.geo.coordinates[0]);
+ const map = new maps.Map(this.$refs.map, {
+ center: uluru,
+ zoom: 15
+ });
+ new maps.Marker({
+ position: uluru,
+ map: map
+ });
+ });
+ }
+ }
+ },
+
+ beforeDestroy() {
+ this.decapture(true);
+
+ if (this.$store.getters.isSignedIn) {
+ this.connection.off('_connected_', this.onStreamConnected);
+ (this as any).os.stream.dispose(this.connectionId);
+ }
+ },
+
+ methods: {
+ canHideText,
+
+ capture(withHandler = false) {
+ if (this.$store.getters.isSignedIn) {
+ this.connection.send({
+ type: 'capture',
+ id: this.p.id
+ });
+ if (withHandler) this.connection.on('note-updated', this.onStreamNoteUpdated);
+ }
+ },
+
+ decapture(withHandler = false) {
+ if (this.$store.getters.isSignedIn) {
+ this.connection.send({
+ type: 'decapture',
+ id: this.p.id
+ });
+ if (withHandler) this.connection.off('note-updated', this.onStreamNoteUpdated);
+ }
+ },
+
+ onStreamConnected() {
+ this.capture();
+ },
+
+ onStreamNoteUpdated(data) {
+ const note = data.note;
+ if (note.id == this.note.id) {
+ this.$emit('update:note', note);
+ } else if (note.id == this.note.renoteId) {
+ this.note.renote = note;
+ }
+ },
+
+ reply() {
+ (this as any).apis.post({
+ reply: this.p
+ });
+ },
+
+ renote() {
+ (this as any).apis.post({
+ renote: this.p
+ });
+ },
+
+ react() {
+ (this as any).os.new(MkReactionPicker, {
+ source: this.$refs.reactButton,
+ note: this.p,
+ compact: true
+ });
+ },
+
+ menu() {
+ (this as any).os.new(MkNoteMenu, {
+ source: this.$refs.menuButton,
+ note: this.p,
+ compact: true
+ });
+ }
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+@import '~const.styl'
+
+root(isDark)
+ font-size 12px
+ border-bottom solid 1px isDark ? #1c2023 : #eaeaea
+
+ &:last-of-type
+ border-bottom none
+
+ &.smart
+ > article
+ > .main
+ > header
+ align-items center
+ margin-bottom 4px
+
+ > .renote
+ display flex
+ align-items center
+ padding 8px 16px
+ line-height 28px
+ white-space pre
+ color #9dbb00
+ background isDark ? linear-gradient(to bottom, #314027 0%, #282c37 100%) : linear-gradient(to bottom, #edfde2 0%, #fff 100%)
+
+ .avatar
+ flex-shrink 0
+ display inline-block
+ width 20px
+ height 20px
+ margin 0 8px 0 0
+ border-radius 6px
+
+ [data-fa]
+ margin-right 4px
+
+ > span
+ flex-shrink 0
+
+ &:last-of-type
+ margin-right 8px
+
+ .name
+ overflow hidden
+ flex-shrink 1
+ text-overflow ellipsis
+ white-space nowrap
+ font-weight bold
+
+ > .mk-time
+ display block
+ margin-left auto
+ flex-shrink 0
+ font-size 0.9em
+
+ & + article
+ padding-top 8px
+
+ > article
+ display flex
+ padding 16px 16px 9px
+
+ > .avatar
+ flex-shrink 0
+ display block
+ margin 0 10px 8px 0
+ width 42px
+ height 42px
+ border-radius 6px
+ //position -webkit-sticky
+ //position sticky
+ //top 62px
+
+ > .main
+ flex 1
+ min-width 0
+
+ > header
+ display flex
+ align-items baseline
+ white-space nowrap
+
+ > .avatar
+ flex-shrink 0
+ margin-right 8px
+ width 20px
+ height 20px
+ border-radius 100%
+
+ > .name
+ display block
+ margin 0 0.5em 0 0
+ padding 0
+ overflow hidden
+ color isDark ? #fff : #627079
+ font-weight bold
+ text-decoration none
+ text-overflow ellipsis
+
+ > .is-admin
+ > .is-bot
+ > .is-cat
+ align-self center
+ margin 0 0.5em 0 0
+ padding 1px 6px
+ font-size 0.8em
+ color isDark ? #758188 : #aaa
+ border solid 1px isDark ? #57616f : #ddd
+ border-radius 3px
+
+ &.is-admin
+ border-color isDark ? #d42c41 : #f56a7b
+ color isDark ? #d42c41 : #f56a7b
+
+ > .username
+ margin 0 0.5em 0 0
+ overflow hidden
+ text-overflow ellipsis
+ color isDark ? #606984 : #ccc
+
+ > .info
+ margin-left auto
+ font-size 0.9em
+
+ > *
+ color isDark ? #606984 : #c0c0c0
+
+ > .mobile
+ margin-right 6px
+
+ > .visibility
+ margin-left 6px
+
+ > .body
+
+ > .cw
+ cursor default
+ display block
+ margin 0
+ padding 0
+ overflow-wrap break-word
+ color isDark ? #fff : #717171
+
+ > .text
+ margin-right 8px
+
+ > .toggle
+ display inline-block
+ padding 4px 8px
+ font-size 0.7em
+ color isDark ? #393f4f : #fff
+ background isDark ? #687390 : #b1b9c1
+ border-radius 2px
+ cursor pointer
+ user-select none
+
+ &:hover
+ background isDark ? #707b97 : #bbc4ce
+
+ > .content
+
+ > .text
+ display block
+ margin 0
+ padding 0
+ overflow-wrap break-word
+ color isDark ? #fff : #717171
+
+ >>> .title
+ display block
+ margin-bottom 4px
+ padding 4px
+ font-size 90%
+ text-align center
+ background isDark ? #2f3944 : #eef1f3
+ border-radius 4px
+
+ >>> .code
+ margin 8px 0
+
+ >>> .quote
+ margin 8px
+ padding 6px 12px
+ color isDark ? #6f808e : #aaa
+ border-left solid 3px isDark ? #637182 : #eee
+
+ > .reply
+ margin-right 8px
+ color isDark ? #99abbf : #717171
+
+ > .rp
+ margin-left 4px
+ font-style oblique
+ color #a0bf46
+
+ [data-is-me]:after
+ content "you"
+ padding 0 4px
+ margin-left 4px
+ font-size 80%
+ color $theme-color-foreground
+ background $theme-color
+ border-radius 4px
+
+ .mk-url-preview
+ margin-top 8px
+
+ > .tags
+ margin 4px 0 0 0
+
+ > *
+ display inline-block
+ margin 0 8px 0 0
+ padding 2px 8px 2px 16px
+ font-size 90%
+ color #8d969e
+ background isDark ? #313543 : #edf0f3
+ border-radius 4px
+
+ &:before
+ content ""
+ display block
+ position absolute
+ top 0
+ bottom 0
+ left 4px
+ width 8px
+ height 8px
+ margin auto 0
+ background isDark ? #282c37 : #fff
+ border-radius 100%
+
+ > .media
+ > img
+ display block
+ max-width 100%
+
+ > .location
+ margin 4px 0
+ font-size 12px
+ color #ccc
+
+ > .map
+ width 100%
+ height 200px
+
+ &:empty
+ display none
+
+ > .mk-poll
+ font-size 80%
+
+ > .renote
+ margin 8px 0
+
+ > .mk-note-preview
+ padding 16px
+ border dashed 1px isDark ? #4e945e : #c0dac6
+ border-radius 8px
+
+ > .app
+ font-size 12px
+ color #ccc
+
+ > footer
+ > button
+ margin 0
+ padding 8px
+ background transparent
+ border none
+ box-shadow none
+ font-size 1em
+ color isDark ? #606984 : #ddd
+ cursor pointer
+
+ &:not(:last-child)
+ margin-right 28px
+
+ &:hover
+ color isDark ? #9198af : #666
+
+ > .count
+ display inline
+ margin 0 0 0 8px
+ color #999
+
+ &.reacted
+ color $theme-color
+
+.zyjjkidcqjnlegkqebitfviomuqmseqk[data-darkmode]
+ root(true)
+
+.zyjjkidcqjnlegkqebitfviomuqmseqk:not([data-darkmode])
+ root(false)
+
+</style>
diff --git a/src/client/app/desktop/views/pages/deck/deck.notes.vue b/src/client/app/desktop/views/pages/deck/deck.notes.vue
new file mode 100644
index 0000000000..ff871b049d
--- /dev/null
+++ b/src/client/app/desktop/views/pages/deck/deck.notes.vue
@@ -0,0 +1,248 @@
+<template>
+<div class="eamppglmnmimdhrlzhplwpvyeaqmmhxu">
+ <div class="newer-indicator" v-show="queue.length > 0"></div>
+
+ <slot name="empty" v-if="notes.length == 0 && !fetching && requestInitPromise == null"></slot>
+
+ <div v-if="!fetching && requestInitPromise != null">
+ <p>%i18n:@error%</p>
+ <button @click="resolveInitPromise">%i18n:@retry%</button>
+ </div>
+
+ <transition-group name="mk-notes" class="transition">
+ <template v-for="(note, i) in _notes">
+ <x-note :note="note" :key="note.id" @update:note="onNoteUpdated(i, $event)"/>
+ <p class="date" :key="note.id + '_date'" v-if="i != notes.length - 1 && note._date != _notes[i + 1]._date">
+ <span>%fa:angle-up%{{ note._datetext }}</span>
+ <span>%fa:angle-down%{{ _notes[i + 1]._datetext }}</span>
+ </p>
+ </template>
+ </transition-group>
+
+ <footer v-if="more">
+ <button @click="loadMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }">
+ <template v-if="!moreFetching">%i18n:@load-more%</template>
+ <template v-if="moreFetching">%fa:spinner .pulse .fw%</template>
+ </button>
+ </footer>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { url } from '../../../config';
+import getNoteSummary from '../../../../../renderers/get-note-summary';
+
+import XNote from './deck.note.vue';
+
+const displayLimit = 30;
+
+export default Vue.extend({
+ components: {
+ XNote
+ },
+
+ props: {
+ more: {
+ type: Function,
+ required: false
+ }
+ },
+
+ data() {
+ return {
+ rootEl: null,
+ requestInitPromise: null as () => Promise<any[]>,
+ notes: [],
+ queue: [],
+ unreadCount: 0,
+ fetching: true,
+ moreFetching: false
+ };
+ },
+
+ 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 = `${month}月 ${date}日`;
+ return note;
+ });
+ }
+ },
+
+ beforeDestroy() {
+ this.root.removeEventListener('scroll', this.onScroll);
+ },
+
+ methods: {
+ mount(root) {
+ this.rootEl = root;
+ this.rootEl.addEventListener('scroll', this.onScroll);
+ },
+
+ isScrollTop() {
+ if (this.rootEl == null) return true;
+ return this.rootEl.scrollTop <= 8;
+ },
+
+ focus() {
+ (this.$el as any).children[0].focus();
+ },
+
+ onNoteUpdated(i, note) {
+ Vue.set((this as any).notes, i, note);
+ },
+
+ init(promiseGenerator: () => Promise<any[]>) {
+ this.requestInitPromise = promiseGenerator;
+ this.resolveInitPromise();
+ },
+
+ resolveInitPromise() {
+ this.queue = [];
+ this.notes = [];
+ this.fetching = true;
+
+ const promise = this.requestInitPromise();
+
+ promise.then(notes => {
+ this.notes = notes;
+ this.requestInitPromise = null;
+ this.fetching = false;
+ }, e => {
+ this.fetching = false;
+ });
+ },
+
+ prepend(note, silent = false) {
+ //#region 弾く
+ const isMyNote = note.userId == this.$store.state.i.id;
+ const isPureRenote = note.renoteId != null && note.text == null && note.mediaIds.length == 0 && note.poll == null;
+
+ if (this.$store.state.settings.showMyRenotes === false) {
+ if (isMyNote && isPureRenote) {
+ return;
+ }
+ }
+
+ if (this.$store.state.settings.showRenotedMyNotes === false) {
+ if (isPureRenote && (note.renote.userId == this.$store.state.i.id)) {
+ return;
+ }
+ }
+ //#endregion
+
+ 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);
+ },
+
+ tail() {
+ return this.notes[this.notes.length - 1];
+ },
+
+ releaseQueue() {
+ this.queue.forEach(n => this.prepend(n, true));
+ this.queue = [];
+ },
+
+ async loadMore() {
+ if (this.more == null) return;
+ if (this.moreFetching) return;
+
+ this.moreFetching = true;
+ await this.more();
+ this.moreFetching = false;
+ },
+
+ onScroll() {
+ if (this.isScrollTop()) {
+ this.releaseQueue();
+ }
+
+ if (this.rootEl && this.$store.state.settings.fetchOnScroll !== false) {
+ const current = this.rootEl.scrollTop + this.rootEl.clientHeight;
+ if (current > this.rootEl.scrollHeight - 8) this.loadMore();
+ }
+ }
+ }
+});
+</script>
+
+<style lang="stylus" scoped>
+@import '~const.styl'
+
+root(isDark)
+ .transition
+ .mk-notes-enter
+ .mk-notes-leave-to
+ opacity 0
+ transform translateY(-30px)
+
+ > *
+ transition transform .3s ease, opacity .3s ease
+
+ > .date
+ display block
+ margin 0
+ line-height 32px
+ font-size 14px
+ text-align center
+ color isDark ? #666b79 : #aaa
+ background isDark ? #242731 : #fdfdfd
+ border-bottom solid 1px isDark ? #1c2023 : #eaeaea
+
+ span
+ margin 0 16px
+
+ [data-fa]
+ margin-right 8px
+
+ > .newer-indicator
+ position -webkit-sticky
+ position sticky
+ z-index 100
+ height 3px
+ background $theme-color
+
+ > footer
+ > button
+ display block
+ margin 0
+ padding 16px
+ width 100%
+ text-align center
+ color #ccc
+ background isDark ? #282C37 : #fff
+ border-top solid 1px isDark ? #1c2023 : #eaeaea
+ border-bottom-left-radius 6px
+ border-bottom-right-radius 6px
+
+ &:hover
+ background isDark ? #2e3440 : #f5f5f5
+
+ &:active
+ background isDark ? #21242b : #eee
+
+.eamppglmnmimdhrlzhplwpvyeaqmmhxu[data-darkmode]
+ root(true)
+
+.eamppglmnmimdhrlzhplwpvyeaqmmhxu:not([data-darkmode])
+ root(false)
+
+</style>
diff --git a/src/client/app/desktop/views/pages/deck/deck.tl.vue b/src/client/app/desktop/views/pages/deck/deck.tl.vue
new file mode 100644
index 0000000000..ce9a77703f
--- /dev/null
+++ b/src/client/app/desktop/views/pages/deck/deck.tl.vue
@@ -0,0 +1,143 @@
+<template>
+ <x-notes ref="timeline" :more="existMore ? more : null"/>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import XNotes from './deck.notes.vue';
+
+const fetchLimit = 10;
+
+export default Vue.extend({
+ components: {
+ XNotes
+ },
+
+ props: {
+ root: {
+ type: Object,
+ required: false
+ },
+ src: {
+ type: String,
+ required: false,
+ default: 'home'
+ }
+ },
+
+ data() {
+ return {
+ fetching: true,
+ moreFetching: false,
+ existMore: false,
+ connection: null,
+ connectionId: null,
+ unreadCount: 0,
+ date: null
+ };
+ },
+
+ computed: {
+ stream(): any {
+ return this.src == 'home'
+ ? (this as any).os.stream
+ : this.src == 'local'
+ ? (this as any).os.streams.localTimelineStream
+ : (this as any).os.streams.globalTimelineStream;
+ },
+
+ endpoint(): string {
+ return this.src == 'home'
+ ? 'notes/timeline'
+ : this.src == 'local'
+ ? 'notes/local-timeline'
+ : 'notes/global-timeline';
+ }
+ },
+
+ mounted() {
+ this.connection = this.stream.getConnection();
+ this.connectionId = this.stream.use();
+
+ this.connection.on('note', this.onNote);
+ if (this.src == 'home') {
+ this.connection.on('follow', this.onChangeFollowing);
+ this.connection.on('unfollow', this.onChangeFollowing);
+ }
+
+ this.fetch();
+ },
+
+ beforeDestroy() {
+ this.connection.off('note', this.onNote);
+ if (this.src == 'home') {
+ this.connection.off('follow', this.onChangeFollowing);
+ this.connection.off('unfollow', this.onChangeFollowing);
+ }
+ this.stream.dispose(this.connectionId);
+ },
+
+ methods: {
+ mount(root) {
+ this.$refs.timeline.mount(root);
+ },
+
+ fetch() {
+ this.fetching = true;
+
+ (this.$refs.timeline as any).init(() => new Promise((res, rej) => {
+ (this as any).api(this.endpoint, {
+ limit: fetchLimit + 1,
+ untilDate: this.date ? this.date.getTime() : undefined,
+ includeMyRenotes: this.$store.state.settings.showMyRenotes,
+ includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes
+ }).then(notes => {
+ if (notes.length == fetchLimit + 1) {
+ notes.pop();
+ this.existMore = true;
+ }
+ res(notes);
+ this.fetching = false;
+ this.$emit('loaded');
+ }, rej);
+ }));
+ },
+
+ more() {
+ this.moreFetching = true;
+
+ const promise = (this as any).api(this.endpoint, {
+ limit: fetchLimit + 1,
+ untilId: (this.$refs.timeline as any).tail().id,
+ includeMyRenotes: this.$store.state.settings.showMyRenotes,
+ includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes
+ });
+
+ promise.then(notes => {
+ if (notes.length == fetchLimit + 1) {
+ notes.pop();
+ } else {
+ this.existMore = false;
+ }
+ notes.forEach(n => (this.$refs.timeline as any).append(n));
+ this.moreFetching = false;
+ });
+
+ return promise;
+ },
+
+ onNote(note) {
+ // Prepend a note
+ (this.$refs.timeline as any).prepend(note);
+ },
+
+ onChangeFollowing() {
+ this.fetch();
+ },
+
+ focus() {
+ (this.$refs.timeline as any).focus();
+ }
+ }
+});
+</script>
diff --git a/src/client/app/desktop/views/pages/deck/deck.vue b/src/client/app/desktop/views/pages/deck/deck.vue
new file mode 100644
index 0000000000..afb65d2335
--- /dev/null
+++ b/src/client/app/desktop/views/pages/deck/deck.vue
@@ -0,0 +1,42 @@
+<template>
+<mk-ui :class="$style.root">
+ <div class="qlvquzbjribqcaozciifydkngcwtyzje">
+ <x-column src="home"/>
+ <x-column src="home"/>
+ <x-column src="home"/>
+ <x-column src="home"/>
+ </div>
+</mk-ui>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import XColumn from './deck.column.vue';
+
+export default Vue.extend({
+ components: {
+ XColumn
+ }
+});
+</script>
+
+<style lang="stylus" module>
+.root
+ height 100vh
+</style>
+
+<style lang="stylus" scoped>
+@import '~const.styl'
+
+root(isDark)
+ display flex
+ flex 1
+ padding 16px
+
+.qlvquzbjribqcaozciifydkngcwtyzje[data-darkmode]
+ root(true)
+
+.qlvquzbjribqcaozciifydkngcwtyzje:not([data-darkmode])
+ root(false)
+
+</style>