diff --git a/locales/ja.yml b/locales/ja.yml index a62b341f69..026c2308c3 100644 --- a/locales/ja.yml +++ b/locales/ja.yml @@ -606,6 +606,7 @@ desktop/views/components/ui.header.account.vue: desktop/views/components/ui.header.nav.vue: home: "ホーム" + deck: "デッキ" messaging: "メッセージ" game: "ゲーム" 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>