diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2018-04-26 16:10:25 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2018-04-26 16:10:25 +0900 |
| commit | 5d4b884528e0533e32b9c827ae8ccf64df0085dc (patch) | |
| tree | 1f6a3238dfbf1f77da78d96e993f6d76cad73089 /src/client/app | |
| parent | Refactor (diff) | |
| parent | wip (diff) | |
| download | misskey-5d4b884528e0533e32b9c827ae8ccf64df0085dc.tar.gz misskey-5d4b884528e0533e32b9c827ae8ccf64df0085dc.tar.bz2 misskey-5d4b884528e0533e32b9c827ae8ccf64df0085dc.zip | |
Merge pull request #1550 from syuilo/user-list
User list
Diffstat (limited to 'src/client/app')
29 files changed, 1503 insertions, 614 deletions
diff --git a/src/client/app/common/mios.ts b/src/client/app/common/mios.ts index 463f763888..4e471cf96f 100644 --- a/src/client/app/common/mios.ts +++ b/src/client/app/common/mios.ts @@ -88,6 +88,7 @@ export default class MiOS extends EventEmitter { propsData: props }).$mount(); document.body.appendChild(w.$el); + return w; } /** diff --git a/src/client/app/common/scripts/streaming/user-list.ts b/src/client/app/common/scripts/streaming/user-list.ts new file mode 100644 index 0000000000..30a52b98dd --- /dev/null +++ b/src/client/app/common/scripts/streaming/user-list.ts @@ -0,0 +1,17 @@ +import Stream from './stream'; +import MiOS from '../../mios'; + +export class UserListStream extends Stream { + constructor(os: MiOS, me, listId) { + super(os, 'user-list', { + i: me.token, + listId + }); + + (this as any).on('_connected_', () => { + this.send({ + i: me.token + }); + }); + } +} diff --git a/src/client/app/desktop/api/update-banner.ts b/src/client/app/desktop/api/update-banner.ts index bc3f783e35..feb1c33103 100644 --- a/src/client/app/desktop/api/update-banner.ts +++ b/src/client/app/desktop/api/update-banner.ts @@ -95,7 +95,7 @@ export default (os: OS) => { multiple: false, title: '%fa:image%バナーにする画像を選択' }); - + return selectedFile .then(cropImage) .then(setBanner) diff --git a/src/client/app/desktop/script.ts b/src/client/app/desktop/script.ts index 3b0ed48cd0..2658a86b95 100644 --- a/src/client/app/desktop/script.ts +++ b/src/client/app/desktop/script.ts @@ -28,6 +28,7 @@ import MkUser from './views/pages/user/user.vue'; import MkFavorites from './views/pages/favorites.vue'; import MkSelectDrive from './views/pages/selectdrive.vue'; import MkDrive from './views/pages/drive.vue'; +import MkUserList from './views/pages/user-list.vue'; import MkHomeCustomize from './views/pages/home-customize.vue'; import MkMessagingRoom from './views/pages/messaging-room.vue'; import MkNote from './views/pages/note.vue'; @@ -55,6 +56,7 @@ init(async (launch) => { { path: '/i/messaging/:user', component: MkMessagingRoom }, { path: '/i/drive', component: MkDrive }, { path: '/i/drive/folder/:folder', component: MkDrive }, + { path: '/i/lists/:list', component: MkUserList }, { path: '/selectdrive', component: MkSelectDrive }, { path: '/search', component: MkSearch }, { path: '/othello', component: MkOthello }, diff --git a/src/client/app/desktop/views/components/follow-button.vue b/src/client/app/desktop/views/components/follow-button.vue index 30e8cab76f..60c6129f61 100644 --- a/src/client/app/desktop/views/components/follow-button.vue +++ b/src/client/app/desktop/views/components/follow-button.vue @@ -19,6 +19,7 @@ <script lang="ts"> import Vue from 'vue'; + export default Vue.extend({ props: { user: { @@ -30,6 +31,7 @@ export default Vue.extend({ default: 'compact' } }, + data() { return { wait: false, @@ -37,6 +39,7 @@ export default Vue.extend({ connectionId: null }; }, + mounted() { this.connection = (this as any).os.stream.getConnection(); this.connectionId = (this as any).os.stream.use(); @@ -44,13 +47,14 @@ export default Vue.extend({ this.connection.on('follow', this.onFollow); this.connection.on('unfollow', this.onUnfollow); }, + beforeDestroy() { this.connection.off('follow', this.onFollow); this.connection.off('unfollow', this.onUnfollow); (this as any).os.stream.dispose(this.connectionId); }, - methods: { + methods: { onFollow(user) { if (user.id == this.user.id) { this.user.isFollowing = user.isFollowing; diff --git a/src/client/app/desktop/views/components/index.ts b/src/client/app/desktop/views/components/index.ts index 4f61f43692..f58d0706df 100644 --- a/src/client/app/desktop/views/components/index.ts +++ b/src/client/app/desktop/views/components/index.ts @@ -28,6 +28,7 @@ import friendsMaker from './friends-maker.vue'; import followers from './followers.vue'; import following from './following.vue'; import usersList from './users-list.vue'; +import userListTimeline from './user-list-timeline.vue'; import widgetContainer from './widget-container.vue'; Vue.component('mk-ui', ui); @@ -58,4 +59,5 @@ Vue.component('mk-friends-maker', friendsMaker); Vue.component('mk-followers', followers); Vue.component('mk-following', following); Vue.component('mk-users-list', usersList); +Vue.component('mk-user-list-timeline', userListTimeline); Vue.component('mk-widget-container', widgetContainer); diff --git a/src/client/app/desktop/views/components/mentions.vue b/src/client/app/desktop/views/components/mentions.vue index fc3a7af75d..53d08a0eca 100644 --- a/src/client/app/desktop/views/components/mentions.vue +++ b/src/client/app/desktop/views/components/mentions.vue @@ -1,8 +1,8 @@ <template> <div class="mk-mentions"> <header> - <span :data-is-active="mode == 'all'" @click="mode = 'all'">すべて</span> - <span :data-is-active="mode == 'following'" @click="mode = 'following'">フォロー中</span> + <span :data-active="mode == 'all'" @click="mode = 'all'">すべて</span> + <span :data-active="mode == 'following'" @click="mode = 'following'">フォロー中</span> </header> <div class="fetching" v-if="fetching"> <mk-ellipsis-icon/> @@ -98,7 +98,7 @@ export default Vue.extend({ font-size 18px color #555 - &:not([data-is-active]) + &:not([data-active]) color $theme-color cursor pointer diff --git a/src/client/app/desktop/views/components/notes.vue b/src/client/app/desktop/views/components/notes.vue index 1a33a4240b..fa7a782b7b 100644 --- a/src/client/app/desktop/views/components/notes.vue +++ b/src/client/app/desktop/views/components/notes.vue @@ -1,5 +1,14 @@ <template> <div class="mk-notes"> + <div class="newer-indicator" :style="{ top: $store.state.uiHeaderHeight + 'px' }" 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>読み込みに失敗しました。</p> + <button @click="resolveInitPromise">リトライ</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)"/> @@ -9,26 +18,48 @@ </p> </template> </transition-group> - <footer> - <slot name="footer"></slot> + + <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 './notes.note.vue'; +const displayLimit = 30; + export default Vue.extend({ components: { XNote }, + props: { - notes: { - type: Array, - default: () => [] + more: { + type: Function, + required: false } }, + + data() { + return { + requestInitPromise: null as () => Promise<any[]>, + notes: [], + queue: [], + unreadCount: 0, + fetching: true, + moreFetching: false + }; + }, + computed: { _notes(): any[] { return (this.notes as any).map(note => { @@ -40,18 +71,146 @@ export default Vue.extend({ }); } }, + + mounted() { + document.addEventListener('visibilitychange', this.onVisibilitychange, false); + window.addEventListener('scroll', this.onScroll); + }, + + beforeDestroy() { + document.removeEventListener('visibilitychange', this.onVisibilitychange); + window.removeEventListener('scroll', this.onScroll); + }, + methods: { + isScrollTop() { + return window.scrollY <= 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 as any).os.i.id; + const isPureRenote = note.renoteId != null && note.text == null && note.mediaIds.length == 0 && note.poll == null; + + if ((this as any).os.i.clientSettings.showMyRenotes === false) { + if (isMyNote && isPureRenote) { + return; + } + } + + if ((this as any).os.i.clientSettings.showRenotedMyNotes === false) { + if (isPureRenote && (note.renote.userId == (this as any).os.i.id)) { + return; + } + } + //#endregion + + // 投稿が自分のものではないかつ、タブが非表示またはスクロール位置が最上部ではないならタイトルで通知 + if ((document.hidden || !this.isScrollTop()) && note.userId !== (this as any).os.i.id) { + this.unreadCount++; + document.title = `(${this.unreadCount}) ${getNoteSummary(note)}`; + } + + if (this.isScrollTop()) { + // Prepend the note + this.notes.unshift(note); + + // サウンドを再生する + if ((this as any).os.isEnableSounds && !silent) { + const sound = new Audio(`${url}/assets/post.mp3`); + sound.volume = localStorage.getItem('soundVolume') ? parseInt(localStorage.getItem('soundVolume'), 10) / 100 : 0.5; + sound.play(); + } + + // オーバーフローしたら古い投稿は捨てる + if (this.notes.length >= displayLimit) { + this.notes = this.notes.slice(0, displayLimit); + } + } else { + this.queue.unshift(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; + }, + + clearNotification() { + this.unreadCount = 0; + document.title = 'Misskey'; + }, + + onVisibilitychange() { + if (!document.hidden) { + this.clearNotification(); + } + }, + + onScroll() { + if (this.isScrollTop()) { + this.releaseQueue(); + this.clearNotification(); + } + + if ((this as any).os.i.clientSettings.fetchOnScroll !== false) { + const current = window.scrollY + window.innerHeight; + if (current > document.body.offsetHeight - 8) this.loadMore(); + } } } }); </script> <style lang="stylus" scoped> +@import '~const.styl' + root(isDark) .transition .mk-notes-enter @@ -78,24 +237,31 @@ root(isDark) [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 - border-top solid 1px #eaeaea - border-bottom-left-radius 4px - border-bottom-right-radius 4px + background isDark ? #282C37 : #fff + border-top solid 1px isDark ? #1c2023 : #eaeaea + border-bottom-left-radius 6px + border-bottom-right-radius 6px - > button &:hover - background #f5f5f5 + background isDark ? #2e3440 : #f5f5f5 &:active - background #eee + background isDark ? #21242b : #eee .mk-notes[data-darkmode] root(true) diff --git a/src/client/app/desktop/views/components/timeline.core.vue b/src/client/app/desktop/views/components/timeline.core.vue index 719425c3c7..a137a57070 100644 --- a/src/client/app/desktop/views/components/timeline.core.vue +++ b/src/client/app/desktop/views/components/timeline.core.vue @@ -1,28 +1,23 @@ <template> -<div class="mk-home-timeline"> - <div class="newer-indicator" :style="{ top: $store.state.uiHeaderHeight + 'px' }" v-show="queue.length > 0"></div> +<div class="mk-timeline-core"> <mk-friends-maker v-if="src == 'home' && alone"/> <div class="fetching" v-if="fetching"> <mk-ellipsis-icon/> </div> - <p class="empty" v-if="notes.length == 0 && !fetching"> - %fa:R comments%%i18n:@empty% - </p> - <mk-notes :notes="notes" ref="timeline"> - <button slot="footer" @click="more" :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> + + <mk-notes ref="timeline" :more="canFetchMore ? more : null"> + <p :class="$style.empty" slot="empty"> + %fa:R comments%%i18n:@empty% + </p> </mk-notes> </div> </template> <script lang="ts"> import Vue from 'vue'; -import { url } from '../../../config'; +import getNoteSummary from '../../../../../renderers/get-note-summary'; const fetchLimit = 10; -const displayLimit = 30; export default Vue.extend({ props: { @@ -37,10 +32,9 @@ export default Vue.extend({ fetching: true, moreFetching: false, existMore: false, - notes: [], - queue: [], connection: null, connectionId: null, + unreadCount: 0, date: null }; }, @@ -67,7 +61,7 @@ export default Vue.extend({ }, canFetchMore(): boolean { - return !this.moreFetching && !this.fetching && this.notes.length > 0 && this.existMore; + return !this.moreFetching && !this.fetching && this.existMore; } }, @@ -82,7 +76,7 @@ export default Vue.extend({ } document.addEventListener('keydown', this.onKeydown); - window.addEventListener('scroll', this.onScroll); + document.addEventListener('visibilitychange', this.onVisibilitychange, false); this.fetch(); }, @@ -96,33 +90,29 @@ export default Vue.extend({ this.stream.dispose(this.connectionId); document.removeEventListener('keydown', this.onKeydown); - window.removeEventListener('scroll', this.onScroll); + document.removeEventListener('visibilitychange', this.onVisibilitychange); }, methods: { - isScrollTop() { - return window.scrollY <= 8; - }, - - fetch(cb?) { - this.queue = []; + fetch() { this.fetching = true; - (this as any).api(this.endpoint, { - limit: fetchLimit + 1, - untilDate: this.date ? this.date.getTime() : undefined, - includeMyRenotes: (this as any).os.i.clientSettings.showMyRenotes, - includeRenotedMyNotes: (this as any).os.i.clientSettings.showRenotedMyNotes - }).then(notes => { - if (notes.length == fetchLimit + 1) { - notes.pop(); - this.existMore = true; - } - this.notes = notes; - this.fetching = false; - this.$emit('loaded'); - if (cb) cb(); - }); + (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 as any).os.i.clientSettings.showMyRenotes, + includeRenotedMyNotes: (this as any).os.i.clientSettings.showRenotedMyNotes + }).then(notes => { + if (notes.length == fetchLimit + 1) { + notes.pop(); + this.existMore = true; + } + res(notes); + this.fetching = false; + this.$emit('loaded'); + }, rej); + })); }, more() { @@ -132,7 +122,7 @@ export default Vue.extend({ (this as any).api(this.endpoint, { limit: fetchLimit + 1, - untilId: this.notes[this.notes.length - 1].id, + untilId: (this.$refs.timeline as any).tail().id, includeMyRenotes: (this as any).os.i.clientSettings.showMyRenotes, includeRenotedMyNotes: (this as any).os.i.clientSettings.showRenotedMyNotes }).then(notes => { @@ -141,56 +131,19 @@ export default Vue.extend({ } else { this.existMore = false; } - this.notes = this.notes.concat(notes); + notes.forEach(n => (this.$refs.timeline as any).append(n)); this.moreFetching = false; }); }, - prependNote(note, silent = false) { - // サウンドを再生する - if ((this as any).os.isEnableSounds && !silent) { - const sound = new Audio(`${url}/assets/post.mp3`); - sound.volume = localStorage.getItem('soundVolume') ? parseInt(localStorage.getItem('soundVolume'), 10) / 100 : 0.5; - sound.play(); - } - - // Prepent a note - this.notes.unshift(note); - - // オーバーフローしたら古い投稿は捨てる - if (this.notes.length >= displayLimit) { - this.notes = this.notes.slice(0, displayLimit); - } - }, - - releaseQueue() { - this.queue.forEach(n => this.prependNote(n, true)); - this.queue = []; - }, - onNote(note) { - //#region 弾く - const isMyNote = note.userId == (this as any).os.i.id; - const isPureRenote = note.renoteId != null && note.text == null && note.mediaIds.length == 0 && note.poll == null; - - if ((this as any).os.i.clientSettings.showMyRenotes === false) { - if (isMyNote && isPureRenote) { - return; - } + if (document.hidden && note.userId !== (this as any).os.i.id) { + this.unreadCount++; + document.title = `(${this.unreadCount}) ${getNoteSummary(note)}`; } - if ((this as any).os.i.clientSettings.showRenotedMyNotes === false) { - if (isPureRenote && (note.renote.userId == (this as any).os.i.id)) { - return; - } - } - //#endregion - - if (this.isScrollTop()) { - this.prependNote(note); - } else { - this.queue.unshift(note); - } + // Prepend a note + (this.$refs.timeline as any).prepend(note); }, onChangeFollowing() { @@ -206,14 +159,10 @@ export default Vue.extend({ this.fetch(); }, - onScroll() { - if ((this as any).os.i.clientSettings.fetchOnScroll !== false) { - const current = window.scrollY + window.innerHeight; - if (current > document.body.offsetHeight - 8) this.more(); - } - - if (this.isScrollTop()) { - this.releaseQueue(); + onVisibilitychange() { + if (!document.hidden) { + this.unreadCount = 0; + document.title = 'Misskey'; } }, @@ -223,7 +172,7 @@ export default Vue.extend({ this.focus(); } } - }, + } } }); </script> @@ -231,32 +180,28 @@ export default Vue.extend({ <style lang="stylus" scoped> @import '~const.styl' -.mk-home-timeline - > .newer-indicator - position -webkit-sticky - position sticky - z-index 100 - height 3px - background $theme-color - +.mk-timeline-core > .mk-friends-maker border-bottom solid 1px #eee > .fetching padding 64px 0 - > .empty - display block - margin 0 auto - padding 32px - max-width 400px - text-align center - color #999 +</style> - > [data-fa] - display block - margin-bottom 16px - font-size 3em - color #ccc +<style lang="stylus" module> +.empty + display block + margin 0 auto + padding 32px + max-width 400px + text-align center + color #999 + + > [data-fa] + display block + margin-bottom 16px + font-size 3em + color #ccc </style> diff --git a/src/client/app/desktop/views/components/timeline.vue b/src/client/app/desktop/views/components/timeline.vue index 8035510a14..f5f13cbd56 100644 --- a/src/client/app/desktop/views/components/timeline.vue +++ b/src/client/app/desktop/views/components/timeline.vue @@ -1,19 +1,23 @@ <template> <div class="mk-timeline"> <header> - <span :data-is-active="src == 'home'" @click="src = 'home'">%fa:home% ホーム</span> - <span :data-is-active="src == 'local'" @click="src = 'local'">%fa:R comments% ローカル</span> - <span :data-is-active="src == 'global'" @click="src = 'global'">%fa:globe% グローバル</span> + <span :data-active="src == 'home'" @click="src = 'home'">%fa:home% ホーム</span> + <span :data-active="src == 'local'" @click="src = 'local'">%fa:R comments% ローカル</span> + <span :data-active="src == 'global'" @click="src = 'global'">%fa:globe% グローバル</span> + <span :data-active="src == 'list'" @click="src = 'list'" v-if="list">%fa:list% {{ list.title }}</span> + <button @click="chooseList" title="リスト">%fa:list%</button> </header> <x-core v-if="src == 'home'" ref="tl" key="home" src="home"/> <x-core v-if="src == 'local'" ref="tl" key="local" src="local"/> <x-core v-if="src == 'global'" ref="tl" key="global" src="global"/> + <mk-user-list-timeline v-if="src == 'list'" ref="tl" key="list" :list="list"/> </div> </template> <script lang="ts"> import Vue from 'vue'; import XCore from './timeline.core.vue'; +import MkUserListsWindow from './user-lists-window.vue'; export default Vue.extend({ components: { @@ -22,7 +26,8 @@ export default Vue.extend({ data() { return { - src: 'home' + src: 'home', + list: null }; }, @@ -35,6 +40,15 @@ export default Vue.extend({ methods: { warp(date) { (this.$refs.tl as any).warp(date); + }, + + chooseList() { + const w = (this as any).os.new(MkUserListsWindow); + w.$once('choosen', list => { + this.list = list; + this.src = 'list'; + w.close(); + }); } } }); @@ -55,6 +69,23 @@ root(isDark) border-radius 6px 6px 0 0 box-shadow 0 1px rgba(0, 0, 0, 0.08) + > button + position absolute + z-index 2 + top 0 + right 0 + padding 0 + width 42px + font-size 0.9em + line-height 42px + color isDark ? #9baec8 : #ccc + + &:hover + color isDark ? #b2c1d5 : #aaa + + &:active + color isDark ? #b2c1d5 : #999 + > span display inline-block padding 0 10px @@ -62,7 +93,7 @@ root(isDark) font-size 12px user-select none - &[data-is-active] + &[data-active] color $theme-color cursor default font-weight bold @@ -77,7 +108,7 @@ root(isDark) height 2px background $theme-color - &:not([data-is-active]) + &:not([data-active]) color isDark ? #9aa2a7 : #6f7477 cursor pointer diff --git a/src/client/app/desktop/views/components/ui.header.account.vue b/src/client/app/desktop/views/components/ui.header.account.vue index 2d4d23933c..5148c5b967 100644 --- a/src/client/app/desktop/views/components/ui.header.account.vue +++ b/src/client/app/desktop/views/components/ui.header.account.vue @@ -16,6 +16,9 @@ <li> <router-link to="/i/favorites">%fa:star%<span>%i18n:@favorites%</span>%fa:angle-right%</router-link> </li> + <li @click="list"> + <p>%fa:list%<span>%i18n:@lists%</span>%fa:angle-right%</p> + </li> </ul> <ul> <li> @@ -42,6 +45,7 @@ <script lang="ts"> import Vue from 'vue'; +import MkUserListsWindow from './user-lists-window.vue'; import MkSettingsWindow from './settings-window.vue'; import MkDriveWindow from './drive-window.vue'; import contains from '../../../common/scripts/contains'; @@ -80,6 +84,13 @@ export default Vue.extend({ this.close(); (this as any).os.new(MkDriveWindow); }, + list() { + this.close(); + const w = (this as any).os.new(MkUserListsWindow); + w.$once('choosen', list => { + this.$router.push(`i/lists/${ list.id }`); + }); + }, settings() { this.close(); (this as any).os.new(MkSettingsWindow); diff --git a/src/client/app/desktop/views/components/user-list-timeline.vue b/src/client/app/desktop/views/components/user-list-timeline.vue new file mode 100644 index 0000000000..ee983a969c --- /dev/null +++ b/src/client/app/desktop/views/components/user-list-timeline.vue @@ -0,0 +1,93 @@ +<template> +<div> + <mk-notes ref="timeline" :more="existMore ? more : null"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { UserListStream } from '../../../common/scripts/streaming/user-list'; + +const fetchLimit = 10; + +export default Vue.extend({ + props: ['list'], + data() { + return { + fetching: true, + moreFetching: false, + existMore: false, + connection: null + }; + }, + watch: { + $route: 'init' + }, + mounted() { + this.init(); + }, + beforeDestroy() { + this.connection.close(); + }, + methods: { + init() { + if (this.connection) this.connection.close(); + this.connection = new UserListStream((this as any).os, (this as any).os.i, this.list.id); + this.connection.on('note', this.onNote); + this.connection.on('userAdded', this.onUserAdded); + this.connection.on('userRemoved', this.onUserRemoved); + + this.fetch(); + }, + fetch() { + this.fetching = true; + + (this.$refs.timeline as any).init(() => new Promise((res, rej) => { + (this as any).api('notes/user-list-timeline', { + listId: this.list.id, + limit: fetchLimit + 1, + includeMyRenotes: (this as any).os.i.clientSettings.showMyRenotes, + includeRenotedMyNotes: (this as any).os.i.clientSettings.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; + + (this as any).api('notes/list-timeline', { + listId: this.list.id, + limit: fetchLimit + 1, + untilId: (this.$refs.timeline as any).tail().id, + includeMyRenotes: (this as any).os.i.clientSettings.showMyRenotes, + includeRenotedMyNotes: (this as any).os.i.clientSettings.showRenotedMyNotes + }).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; + }); + }, + onNote(note) { + // Prepend a note + (this.$refs.timeline as any).prepend(note); + }, + onUserAdded() { + this.fetch(); + }, + onUserRemoved() { + this.fetch(); + } + } +}); +</script> diff --git a/src/client/app/desktop/views/components/user-lists-window.vue b/src/client/app/desktop/views/components/user-lists-window.vue new file mode 100644 index 0000000000..d082610132 --- /dev/null +++ b/src/client/app/desktop/views/components/user-lists-window.vue @@ -0,0 +1,69 @@ +<template> +<mk-window ref="window" is-modal width="450px" height="500px" @closed="$destroy"> + <span slot="header">%fa:list% リスト</span> + + <div data-id="6e4caea3-d8f9-4ab7-96de-ab67fe8d5c82" :data-darkmode="_darkmode_"> + <button class="ui" @click="add">リストを作成</button> + <a v-for="list in lists" :key="list.id" @click="choice(list)">{{ list.title }}</a> + </div> +</mk-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + data() { + return { + fetching: true, + lists: [] + }; + }, + mounted() { + (this as any).api('users/lists/list').then(lists => { + this.fetching = false; + this.lists = lists; + }); + }, + methods: { + add() { + (this as any).apis.input({ + title: 'リスト名', + }).then(async title => { + const list = await (this as any).api('users/lists/create', { + title + }); + + this.$emit('choosen', list); + }); + }, + choice(list) { + this.$emit('choosen', list); + }, + close() { + (this as any).$refs.window.close(); + } + } +}); +</script> + +<style lang="stylus" scoped> + +root(isDark) + padding 16px + + > button + margin-bottom 16px + + > a + display block + padding 16px + border solid 1px isDark ? #1c2023 : #eee + border-radius 4px + +[data-id="6e4caea3-d8f9-4ab7-96de-ab67fe8d5c82"][data-darkmode] + root(true) + +[data-id="6e4caea3-d8f9-4ab7-96de-ab67fe8d5c82"]:not([data-darkmode]) + root(false) + +</style> diff --git a/src/client/app/desktop/views/components/users-list.vue b/src/client/app/desktop/views/components/users-list.vue index a08e76f573..e8f4c94d42 100644 --- a/src/client/app/desktop/views/components/users-list.vue +++ b/src/client/app/desktop/views/components/users-list.vue @@ -2,8 +2,8 @@ <div class="mk-users-list"> <nav> <div> - <span :data-is-active="mode == 'all'" @click="mode = 'all'">すべて<span>{{ count }}</span></span> - <span v-if="os.isSignedIn && youKnowCount" :data-is-active="mode == 'iknow'" @click="mode = 'iknow'">知り合い<span>{{ youKnowCount }}</span></span> + <span :data-active="mode == 'all'" @click="mode = 'all'">すべて<span>{{ count }}</span></span> + <span v-if="os.isSignedIn && youKnowCount" :data-active="mode == 'iknow'" @click="mode = 'iknow'">知り合い<span>{{ youKnowCount }}</span></span> </div> </nav> <div class="users" v-if="!fetching && users.length != 0"> @@ -98,7 +98,7 @@ export default Vue.extend({ * pointer-events none - &[data-is-active] + &[data-active] font-weight bold color $theme-color border-color $theme-color diff --git a/src/client/app/desktop/views/components/widget-container.vue b/src/client/app/desktop/views/components/widget-container.vue index c3fac1399d..926d7702b9 100644 --- a/src/client/app/desktop/views/components/widget-container.vue +++ b/src/client/app/desktop/views/components/widget-container.vue @@ -58,7 +58,7 @@ root(isDark) box-shadow 0 1px rgba(0, 0, 0, 0.07) > [data-fa] - margin-right 4px + margin-right 6px &:empty display none diff --git a/src/client/app/desktop/views/pages/user-list.users.vue b/src/client/app/desktop/views/pages/user-list.users.vue new file mode 100644 index 0000000000..63070ed609 --- /dev/null +++ b/src/client/app/desktop/views/pages/user-list.users.vue @@ -0,0 +1,131 @@ +<template> +<div> + <mk-widget-container> + <template slot="header">%fa:users% ユーザー</template> + <button slot="func" title="ユーザーを追加" @click="add">%fa:plus%</button> + + <div data-id="d0b63759-a822-4556-a5ce-373ab966e08a"> + <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw% %i18n:common.loading%<mk-ellipsis/></p> + <template v-else-if="users.length != 0"> + <div class="user" v-for="_user in users"> + <router-link class="avatar-anchor" :to="_user | userPage"> + <img class="avatar" :src="`${_user.avatarUrl}?thumbnail&size=42`" alt="" v-user-preview="_user.id"/> + </router-link> + <div class="body"> + <router-link class="name" :to="_user | userPage" v-user-preview="_user.id">{{ _user | userName }}</router-link> + <p class="username">@{{ _user | acct }}</p> + </div> + </div> + </template> + <p class="empty" v-else>%i18n:@no-one%</p> + </div> + </mk-widget-container> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: { + list: { + type: Object, + required: true + } + }, + data() { + return { + fetching: true, + users: [] + }; + }, + mounted() { + (this as any).api('users/show', { + userIds: this.list.userIds + }).then(users => { + this.users = users; + this.fetching = false; + }); + }, + methods: { + add() { + (this as any).apis.input({ + title: 'ユーザー名', + }).then(async username => { + const user = await (this as any).api('users/show', { + username + }); + + (this as any).api('users/lists/push', { + listId: this.list.id, + userId: user.id + }); + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +root(isDark) + > .user + padding 16px + border-bottom solid 1px isDark ? #1c2023 : #eee + + &:last-child + border-bottom none + + &:after + content "" + display block + clear both + + > .avatar-anchor + display block + float left + margin 0 12px 0 0 + + > .avatar + display block + width 42px + height 42px + margin 0 + border-radius 8px + vertical-align bottom + + > .body + float left + width calc(100% - 54px) + + > .name + margin 0 + font-size 16px + line-height 24px + color isDark ? #fff : #555 + + > .username + display block + margin 0 + font-size 15px + line-height 16px + color isDark ? #606984 : #ccc + + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > .fetching + margin 0 + padding 16px + text-align center + color #aaa + +[data-id="d0b63759-a822-4556-a5ce-373ab966e08a"][data-darkmode] + root(true) + +[data-id="d0b63759-a822-4556-a5ce-373ab966e08a"]:not([data-darkmode]) + root(false) + +</style> diff --git a/src/client/app/desktop/views/pages/user-list.vue b/src/client/app/desktop/views/pages/user-list.vue new file mode 100644 index 0000000000..2241b84e5e --- /dev/null +++ b/src/client/app/desktop/views/pages/user-list.vue @@ -0,0 +1,71 @@ +<template> +<mk-ui> + <div v-if="!fetching" data-id="02010e15-cc48-4245-8636-16078a9b623c"> + <div> + <div><h1>{{ list.title }}</h1></div> + <x-users :list="list"/> + </div> + <main> + <mk-user-list-timeline :list="list"/> + </main> + </div> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XUsers from './user-list.users.vue'; + +export default Vue.extend({ + components: { + XUsers + }, + data() { + return { + fetching: true, + list: null + }; + }, + watch: { + $route: 'fetch' + }, + mounted() { + this.fetch(); + }, + methods: { + fetch() { + this.fetching = true; + + (this as any).api('users/lists/show', { + listId: this.$route.params.list + }).then(list => { + this.list = list; + this.fetching = false; + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +[data-id="02010e15-cc48-4245-8636-16078a9b623c"] + display flex + justify-content center + margin 0 auto + max-width 1200px + + > main + > div > div + > *:not(:last-child) + margin-bottom 16px + + > main + padding 16px + width calc(100% - 275px * 2) + + > div + width 275px + margin 0 + padding 16px 0 16px 16px + +</style> diff --git a/src/client/app/desktop/views/pages/user/user.profile.vue b/src/client/app/desktop/views/pages/user/user.profile.vue index 774f300a38..64acbd86b3 100644 --- a/src/client/app/desktop/views/pages/user/user.profile.vue +++ b/src/client/app/desktop/views/pages/user/user.profile.vue @@ -3,15 +3,18 @@ <div class="friend-form" v-if="os.isSignedIn && os.i.id != user.id"> <mk-follow-button :user="user" size="big"/> <p class="followed" v-if="user.isFollowed">%i18n:@follows-you%</p> - <p class="stalk"> - <span v-if="user.isStalking">%i18n:@stalking% <a @click="unstalk">%i18n:@unstalk%</a></span> - <span v-if="!user.isStalking"><a @click="stalk">%i18n:@stalk%</a></span> - </p> - <p class="mute"> - <span v-if="user.isMuted">%i18n:@muted% <a @click="unmute">%i18n:@unmute%</a></span> - <span v-if="!user.isMuted"><a @click="mute">%i18n:@mute%</a></span> + <p class="stalk" v-if="user.isFollowing"> + <span v-if="user.isStalking">%i18n:@stalking% <a @click="unstalk">%fa:meh% %i18n:@unstalk%</a></span> + <span v-if="!user.isStalking"><a @click="stalk">%fa:user-secret% %i18n:@stalk%</a></span> </p> </div> + <div class="action-form"> + <button class="mute ui" @click="user.isMuted ? unmute() : mute()"> + <span v-if="user.isMuted">%fa:eye% %i18n:@unmute%</span> + <span v-if="!user.isMuted">%fa:eye-slash% %i18n:@mute%</span> + </button> + <button class="mute ui" @click="list">%fa:list% リストに追加</button> + </div> <div class="description" v-if="user.description">{{ user.description }}</div> <div class="birthday" v-if="user.host === null && user.profile.birthday"> <p>%fa:birthday-cake%{{ user.profile.birthday.replace('-', '年').replace('-', '月') + '日' }} ({{ age }}歳)</p> @@ -32,6 +35,7 @@ import Vue from 'vue'; import * as age from 's-age'; import MkFollowingWindow from '../../components/following-window.vue'; import MkFollowersWindow from '../../components/followers-window.vue'; +import MkUserListsWindow from '../../components/user-lists-window.vue'; export default Vue.extend({ props: ['user'], @@ -91,6 +95,21 @@ export default Vue.extend({ }, () => { alert('error'); }); + }, + + list() { + const w = (this as any).os.new(MkUserListsWindow); + w.$once('choosen', async list => { + w.close(); + await (this as any).api('users/lists/push', { + listId: list.id, + userId: this.user.id + }); + (this as any).apis.dialog({ + title: 'Done!', + text: `${this.user.name}を${list.title}に追加しました。` + }); + }); } } }); @@ -107,11 +126,9 @@ export default Vue.extend({ > .friend-form padding 16px + text-align center border-top solid 1px #eee - > .mk-big-follow-button - width 100% - > .followed margin 12px 0 0 0 padding 0 @@ -122,6 +139,20 @@ export default Vue.extend({ background #eefaff border-radius 4px + > .stalk + margin 12px 0 0 0 + + > .action-form + padding 16px + text-align center + border-top solid 1px #eee + + > * + width 100% + + &:not(:last-child) + margin-bottom 12px + > .description padding 16px color #555 diff --git a/src/client/app/desktop/views/pages/user/user.timeline.vue b/src/client/app/desktop/views/pages/user/user.timeline.vue index 55d6072a9d..9c9840c190 100644 --- a/src/client/app/desktop/views/pages/user/user.timeline.vue +++ b/src/client/app/desktop/views/pages/user/user.timeline.vue @@ -1,42 +1,36 @@ <template> <div class="timeline"> <header> - <span :data-is-active="mode == 'default'" @click="mode = 'default'">投稿</span> - <span :data-is-active="mode == 'with-replies'" @click="mode = 'with-replies'">投稿と返信</span> - <span :data-is-active="mode == 'with-media'" @click="mode = 'with-media'">メディア</span> + <span :data-active="mode == 'default'" @click="mode = 'default'">投稿</span> + <span :data-active="mode == 'with-replies'" @click="mode = 'with-replies'">投稿と返信</span> + <span :data-active="mode == 'with-media'" @click="mode = 'with-media'">メディア</span> </header> <div class="loading" v-if="fetching"> <mk-ellipsis-icon/> </div> - <p class="empty" v-if="empty">%fa:R comments%このユーザーはまだ何も投稿していないようです。</p> - <mk-notes ref="timeline" :notes="notes"> - <div slot="footer"> - <template v-if="!moreFetching">%fa:moon%</template> - <template v-if="moreFetching">%fa:spinner .pulse .fw%</template> - </div> + <mk-notes ref="timeline" :more="existMore ? more : null"> + <p class="empty" slot="empty">%fa:R comments%このユーザーはまだ何も投稿していないようです。</p> </mk-notes> </div> </template> <script lang="ts"> import Vue from 'vue'; + +const fetchLimit = 10; + export default Vue.extend({ props: ['user'], data() { return { fetching: true, moreFetching: false, + existMore: false, mode: 'default', unreadCount: 0, - notes: [], date: null }; }, - computed: { - empty(): boolean { - return this.notes.length == 0; - } - }, watch: { mode() { this.fetch(); @@ -44,13 +38,11 @@ export default Vue.extend({ }, mounted() { document.addEventListener('keydown', this.onDocumentKeydown); - window.addEventListener('scroll', this.onScroll); this.fetch(() => this.$emit('loaded')); }, beforeDestroy() { document.removeEventListener('keydown', this.onDocumentKeydown); - window.removeEventListener('scroll', this.onScroll); }, methods: { onDocumentKeydown(e) { @@ -61,36 +53,43 @@ export default Vue.extend({ } }, fetch(cb?) { - (this as any).api('users/notes', { - userId: this.user.id, - untilDate: this.date ? this.date.getTime() : undefined, - includeReplies: this.mode == 'with-replies', - withMedia: this.mode == 'with-media' - }).then(notes => { - this.notes = notes; - this.fetching = false; - if (cb) cb(); - }); + this.fetching = true; + (this.$refs.timeline as any).init(() => new Promise((res, rej) => { + (this as any).api('users/notes', { + userId: this.user.id, + limit: fetchLimit + 1, + untilDate: this.date ? this.date.getTime() : undefined, + includeReplies: this.mode == 'with-replies', + withMedia: this.mode == 'with-media' + }).then(notes => { + if (notes.length == fetchLimit + 1) { + notes.pop(); + this.existMore = true; + } + res(notes); + this.fetching = false; + if (cb) cb(); + }, rej); + })); }, more() { - if (this.moreFetching || this.fetching || this.notes.length == 0) return; this.moreFetching = true; (this as any).api('users/notes', { userId: this.user.id, + limit: fetchLimit + 1, includeReplies: this.mode == 'with-replies', withMedia: this.mode == 'with-media', - untilId: this.notes[this.notes.length - 1].id + untilId: (this.$refs.timeline as any).tail().id }).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; - this.notes = this.notes.concat(notes); }); }, - onScroll() { - const current = window.scrollY + window.innerHeight; - if (current > document.body.offsetHeight - 16/*遊び*/) { - this.more(); - } - }, warp(date) { this.date = date; this.fetch(); @@ -115,7 +114,7 @@ export default Vue.extend({ font-size 18px color #555 - &:not([data-is-active]) + &:not([data-active]) color $theme-color cursor pointer diff --git a/src/client/app/mobile/views/components/index.ts b/src/client/app/mobile/views/components/index.ts index 9346700304..5ed8427b05 100644 --- a/src/client/app/mobile/views/components/index.ts +++ b/src/client/app/mobile/views/components/index.ts @@ -1,7 +1,6 @@ import Vue from 'vue'; import ui from './ui.vue'; -import timeline from './timeline.vue'; import note from './note.vue'; import notes from './notes.vue'; import mediaImage from './media-image.vue'; @@ -20,11 +19,11 @@ import notificationPreview from './notification-preview.vue'; import usersList from './users-list.vue'; import userPreview from './user-preview.vue'; import userTimeline from './user-timeline.vue'; +import userListTimeline from './user-list-timeline.vue'; import activity from './activity.vue'; import widgetContainer from './widget-container.vue'; Vue.component('mk-ui', ui); -Vue.component('mk-timeline', timeline); Vue.component('mk-note', note); Vue.component('mk-notes', notes); Vue.component('mk-media-image', mediaImage); @@ -43,5 +42,6 @@ Vue.component('mk-notification-preview', notificationPreview); Vue.component('mk-users-list', usersList); Vue.component('mk-user-preview', userPreview); Vue.component('mk-user-timeline', userTimeline); +Vue.component('mk-user-list-timeline', userListTimeline); Vue.component('mk-activity', activity); Vue.component('mk-widget-container', widgetContainer); diff --git a/src/client/app/mobile/views/components/notes.vue b/src/client/app/mobile/views/components/notes.vue index 999ab566ac..703b51d678 100644 --- a/src/client/app/mobile/views/components/notes.vue +++ b/src/client/app/mobile/views/components/notes.vue @@ -1,7 +1,20 @@ <template> <div class="mk-notes"> + <div class="newer-indicator" :style="{ top: $store.state.uiHeaderHeight + 'px' }" v-show="queue.length > 0"></div> + <slot name="head"></slot> - <slot></slot> + + <slot name="empty" v-if="notes.length == 0 && !fetching && requestInitPromise == null"></slot> + + <div class="init" v-if="fetching"> + %fa:spinner .pulse%%i18n:common.loading% + </div> + + <div v-if="!fetching && requestInitPromise != null"> + <p>読み込みに失敗しました。</p> + <button @click="resolveInitPromise">リトライ</button> + </div> + <transition-group name="mk-notes" class="transition"> <template v-for="(note, i) in _notes"> <mk-note :note="note" :key="note.id" @update:note="onNoteUpdated(i, $event)"/> @@ -11,22 +24,41 @@ </p> </template> </transition-group> - <footer> - <slot name="tail"></slot> + + <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 getNoteSummary from '../../../../../renderers/get-note-summary'; + +const displayLimit = 30; export default Vue.extend({ props: { - notes: { - type: Array, - default: () => [] + more: { + type: Function, + required: false } }, + + data() { + return { + requestInitPromise: null as () => Promise<any[]>, + notes: [], + queue: [], + unreadCount: 0, + fetching: true, + moreFetching: false + }; + }, + computed: { _notes(): any[] { return (this.notes as any).map(note => { @@ -38,9 +70,127 @@ export default Vue.extend({ }); } }, + + mounted() { + document.addEventListener('visibilitychange', this.onVisibilitychange, false); + window.addEventListener('scroll', this.onScroll); + }, + + beforeDestroy() { + document.removeEventListener('visibilitychange', this.onVisibilitychange); + window.removeEventListener('scroll', this.onScroll); + }, + methods: { + isScrollTop() { + return window.scrollY <= 8; + }, + 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 as any).os.i.id; + const isPureRenote = note.renoteId != null && note.text == null && note.mediaIds.length == 0 && note.poll == null; + + if ((this as any).os.i.clientSettings.showMyRenotes === false) { + if (isMyNote && isPureRenote) { + return; + } + } + + if ((this as any).os.i.clientSettings.showRenotedMyNotes === false) { + if (isPureRenote && (note.renote.userId == (this as any).os.i.id)) { + return; + } + } + //#endregion + + // 投稿が自分のものではないかつ、タブが非表示またはスクロール位置が最上部ではないならタイトルで通知 + if ((document.hidden || !this.isScrollTop()) && note.userId !== (this as any).os.i.id) { + this.unreadCount++; + document.title = `(${this.unreadCount}) ${getNoteSummary(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.unshift(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; + }, + + clearNotification() { + this.unreadCount = 0; + document.title = 'Misskey'; + }, + + onVisibilitychange() { + if (!document.hidden) { + this.clearNotification(); + } + }, + + onScroll() { + if (this.isScrollTop()) { + this.releaseQueue(); + this.clearNotification(); + } + + if ((this as any).os.i.clientSettings.fetchOnScroll !== false) { + const current = window.scrollY + window.innerHeight; + if (current > document.body.offsetHeight - 8) this.loadMore(); + } } } }); @@ -79,6 +229,13 @@ export default Vue.extend({ [data-fa] margin-right 8px + > .newer-indicator + position -webkit-sticky + position sticky + z-index 100 + height 3px + background $theme-color + > .init padding 64px 0 text-align center diff --git a/src/client/app/mobile/views/components/timeline.vue b/src/client/app/mobile/views/components/timeline.vue deleted file mode 100644 index f56667bed5..0000000000 --- a/src/client/app/mobile/views/components/timeline.vue +++ /dev/null @@ -1,190 +0,0 @@ -<template> -<div class="mk-timeline"> - <div class="newer-indicator" :style="{ top: $store.state.uiHeaderHeight + 'px' }" v-show="queue.length > 0"></div> - <mk-friends-maker v-if="alone"/> - <mk-notes :notes="notes"> - <div class="init" v-if="fetching"> - %fa:spinner .pulse%%i18n:common.loading% - </div> - <div class="empty" v-if="!fetching && notes.length == 0"> - %fa:R comments% - %i18n:@empty% - </div> - <button v-if="canFetchMore" @click="more" :disabled="moreFetching" slot="tail"> - <span v-if="!moreFetching">%i18n:@load-more%</span> - <span v-if="moreFetching">%i18n:common.loading%<mk-ellipsis/></span> - </button> - </mk-notes> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; - -const fetchLimit = 10; -const displayLimit = 30; - -export default Vue.extend({ - props: { - date: { - type: Date, - required: false, - default: null - } - }, - - data() { - return { - fetching: true, - moreFetching: false, - notes: [], - queue: [], - existMore: false, - connection: null, - connectionId: null - }; - }, - - computed: { - alone(): boolean { - return (this as any).os.i.followingCount == 0; - }, - - canFetchMore(): boolean { - return !this.moreFetching && !this.fetching && this.notes.length > 0 && this.existMore; - } - }, - - mounted() { - this.connection = (this as any).os.stream.getConnection(); - this.connectionId = (this as any).os.stream.use(); - - this.connection.on('note', this.onNote); - this.connection.on('follow', this.onChangeFollowing); - this.connection.on('unfollow', this.onChangeFollowing); - - window.addEventListener('scroll', this.onScroll); - - this.fetch(); - }, - - beforeDestroy() { - this.connection.off('note', this.onNote); - this.connection.off('follow', this.onChangeFollowing); - this.connection.off('unfollow', this.onChangeFollowing); - (this as any).os.stream.dispose(this.connectionId); - - window.removeEventListener('scroll', this.onScroll); - }, - - methods: { - isScrollTop() { - return window.scrollY <= 8; - }, - - fetch(cb?) { - this.queue = []; - this.fetching = true; - (this as any).api('notes/timeline', { - limit: fetchLimit + 1, - untilDate: this.date ? (this.date as any).getTime() : undefined, - includeMyRenotes: (this as any).os.i.clientSettings.showMyRenotes, - includeRenotedMyNotes: (this as any).os.i.clientSettings.showRenotedMyNotes - }).then(notes => { - if (notes.length == fetchLimit + 1) { - notes.pop(); - this.existMore = true; - } - this.notes = notes; - this.fetching = false; - this.$emit('loaded'); - if (cb) cb(); - }); - }, - - more() { - this.moreFetching = true; - (this as any).api('notes/timeline', { - limit: fetchLimit + 1, - untilId: this.notes[this.notes.length - 1].id, - includeMyRenotes: (this as any).os.i.clientSettings.showMyRenotes, - includeRenotedMyNotes: (this as any).os.i.clientSettings.showRenotedMyNotes - }).then(notes => { - if (notes.length == fetchLimit + 1) { - notes.pop(); - this.existMore = true; - } else { - this.existMore = false; - } - this.notes = this.notes.concat(notes); - this.moreFetching = false; - }); - }, - - prependNote(note) { - // Prepent a note - this.notes.unshift(note); - - // オーバーフローしたら古い投稿は捨てる - if (this.notes.length >= displayLimit) { - this.notes = this.notes.slice(0, displayLimit); - } - }, - - releaseQueue() { - this.queue.forEach(n => this.prependNote(n)); - this.queue = []; - }, - - onNote(note) { - //#region 弾く - const isMyNote = note.userId == (this as any).os.i.id; - const isPureRenote = note.renoteId != null && note.text == null && note.mediaIds.length == 0 && note.poll == null; - - if ((this as any).os.i.clientSettings.showMyRenotes === false) { - if (isMyNote && isPureRenote) { - return; - } - } - - if ((this as any).os.i.clientSettings.showRenotedMyNotes === false) { - if (isPureRenote && (note.renote.userId == (this as any).os.i.id)) { - return; - } - } - //#endregion - - if (this.isScrollTop()) { - this.prependNote(note); - } else { - this.queue.unshift(note); - } - }, - - onChangeFollowing() { - this.fetch(); - }, - - onScroll() { - if (this.isScrollTop()) { - this.releaseQueue(); - } - } - } -}); -</script> - -<style lang="stylus" scoped> -@import '~const.styl' - -.mk-timeline - > .newer-indicator - position -webkit-sticky - position sticky - z-index 100 - height 3px - background $theme-color - - > .mk-friends-maker - margin-bottom 8px -</style> diff --git a/src/client/app/mobile/views/components/user-list-timeline.vue b/src/client/app/mobile/views/components/user-list-timeline.vue new file mode 100644 index 0000000000..ee983a969c --- /dev/null +++ b/src/client/app/mobile/views/components/user-list-timeline.vue @@ -0,0 +1,93 @@ +<template> +<div> + <mk-notes ref="timeline" :more="existMore ? more : null"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { UserListStream } from '../../../common/scripts/streaming/user-list'; + +const fetchLimit = 10; + +export default Vue.extend({ + props: ['list'], + data() { + return { + fetching: true, + moreFetching: false, + existMore: false, + connection: null + }; + }, + watch: { + $route: 'init' + }, + mounted() { + this.init(); + }, + beforeDestroy() { + this.connection.close(); + }, + methods: { + init() { + if (this.connection) this.connection.close(); + this.connection = new UserListStream((this as any).os, (this as any).os.i, this.list.id); + this.connection.on('note', this.onNote); + this.connection.on('userAdded', this.onUserAdded); + this.connection.on('userRemoved', this.onUserRemoved); + + this.fetch(); + }, + fetch() { + this.fetching = true; + + (this.$refs.timeline as any).init(() => new Promise((res, rej) => { + (this as any).api('notes/user-list-timeline', { + listId: this.list.id, + limit: fetchLimit + 1, + includeMyRenotes: (this as any).os.i.clientSettings.showMyRenotes, + includeRenotedMyNotes: (this as any).os.i.clientSettings.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; + + (this as any).api('notes/list-timeline', { + listId: this.list.id, + limit: fetchLimit + 1, + untilId: (this.$refs.timeline as any).tail().id, + includeMyRenotes: (this as any).os.i.clientSettings.showMyRenotes, + includeRenotedMyNotes: (this as any).os.i.clientSettings.showRenotedMyNotes + }).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; + }); + }, + onNote(note) { + // Prepend a note + (this.$refs.timeline as any).prepend(note); + }, + onUserAdded() { + this.fetch(); + }, + onUserRemoved() { + this.fetch(); + } + } +}); +</script> diff --git a/src/client/app/mobile/views/components/user-timeline.vue b/src/client/app/mobile/views/components/user-timeline.vue index 40b3be035e..89ac4d2c66 100644 --- a/src/client/app/mobile/views/components/user-timeline.vue +++ b/src/client/app/mobile/views/components/user-timeline.vue @@ -1,17 +1,10 @@ <template> <div class="mk-user-timeline"> - <mk-notes :notes="notes"> - <div class="init" v-if="fetching"> - %fa:spinner .pulse%%i18n:common.loading% - </div> - <div class="empty" v-if="!fetching && notes.length == 0"> + <mk-notes ref="timeline" :more="existMore ? more : null"> + <div slot="empty"> %fa:R comments% {{ withMedia ? '%i18n:!@no-notes-with-media%' : '%i18n:!@no-notes%' }} </div> - <button v-if="!fetching && existMore" @click="more" :disabled="moreFetching" slot="tail"> - <span v-if="!moreFetching">%i18n:@load-more%</span> - <span v-if="moreFetching">%i18n:common.loading%<mk-ellipsis/></span> - </button> </mk-notes> </div> </template> @@ -19,49 +12,53 @@ <script lang="ts"> import Vue from 'vue'; -const limit = 10; +const fetchLimit = 10; export default Vue.extend({ props: ['user', 'withMedia'], data() { return { fetching: true, - notes: [], existMore: false, moreFetching: false }; }, mounted() { - (this as any).api('users/notes', { - userId: this.user.id, - withMedia: this.withMedia, - limit: limit + 1 - }).then(notes => { - if (notes.length == limit + 1) { - notes.pop(); - this.existMore = true; - } - this.notes = notes; - this.fetching = false; - this.$emit('loaded'); - }); + this.fetch(); }, methods: { + fetch() { + this.fetching = true; + (this.$refs.timeline as any).init(() => new Promise((res, rej) => { + (this as any).api('users/notes', { + userId: this.user.id, + withMedia: this.withMedia, + limit: fetchLimit + 1 + }).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; (this as any).api('users/notes', { userId: this.user.id, withMedia: this.withMedia, - limit: limit + 1, - untilId: this.notes[this.notes.length - 1].id + limit: fetchLimit + 1, + untilId: (this.$refs.timeline as any).tail().id }).then(notes => { - if (notes.length == limit + 1) { + if (notes.length == fetchLimit + 1) { notes.pop(); - this.existMore = true; } else { this.existMore = false; } - this.notes = this.notes.concat(notes); + notes.forEach(n => (this.$refs.timeline as any).append(n)); this.moreFetching = false; }); } diff --git a/src/client/app/mobile/views/components/users-list.vue b/src/client/app/mobile/views/components/users-list.vue index 8fa7a9cbe6..67a38a8955 100644 --- a/src/client/app/mobile/views/components/users-list.vue +++ b/src/client/app/mobile/views/components/users-list.vue @@ -1,8 +1,8 @@ <template> <div class="mk-users-list"> <nav> - <span :data-is-active="mode == 'all'" @click="mode = 'all'">%i18n:@all%<span>{{ count }}</span></span> - <span v-if="os.isSignedIn && youKnowCount" :data-is-active="mode == 'iknow'" @click="mode = 'iknow'">%i18n:@known%<span>{{ youKnowCount }}</span></span> + <span :data-active="mode == 'all'" @click="mode = 'all'">%i18n:@all%<span>{{ count }}</span></span> + <span v-if="os.isSignedIn && youKnowCount" :data-active="mode == 'iknow'" @click="mode = 'iknow'">%i18n:@known%<span>{{ youKnowCount }}</span></span> </nav> <div class="users" v-if="!fetching && users.length != 0"> <mk-user-preview v-for="u in users" :user="u" :key="u.id"/> @@ -85,7 +85,7 @@ export default Vue.extend({ color #657786 border-bottom solid 2px transparent - &[data-is-active] + &[data-active] font-weight bold color $theme-color border-color $theme-color diff --git a/src/client/app/mobile/views/pages/dashboard.vue b/src/client/app/mobile/views/pages/dashboard.vue new file mode 100644 index 0000000000..14779da650 --- /dev/null +++ b/src/client/app/mobile/views/pages/dashboard.vue @@ -0,0 +1,196 @@ +<template> +<mk-ui> + <span slot="header">%fa:home%ダッシュボード</span> + <template slot="func"> + <button @click="customizing = !customizing">%fa:cog%</button> + </template> + <main> + <template v-if="customizing"> + <header> + <select v-model="widgetAdderSelected"> + <option value="profile">プロフィール</option> + <option value="calendar">カレンダー</option> + <option value="activity">アクティビティ</option> + <option value="rss">RSSリーダー</option> + <option value="photo-stream">フォトストリーム</option> + <option value="slideshow">スライドショー</option> + <option value="version">バージョン</option> + <option value="access-log">アクセスログ</option> + <option value="server">サーバー情報</option> + <option value="donation">寄付のお願い</option> + <option value="nav">ナビゲーション</option> + <option value="tips">ヒント</option> + </select> + <button @click="addWidget">追加</button> + <p><a @click="hint">カスタマイズのヒント</a></p> + </header> + <x-draggable + :list="widgets" + :options="{ handle: '.handle', animation: 150 }" + @sort="onWidgetSort" + > + <div v-for="widget in widgets" class="customize-container" :key="widget.id"> + <header> + <span class="handle">%fa:bars%</span>{{ widget.name }}<button class="remove" @click="removeWidget(widget)">%fa:times%</button> + </header> + <div @click="widgetFunc(widget.id)"> + <component :is="`mkw-${widget.name}`" :widget="widget" :ref="widget.id" :is-customize-mode="true" :is-mobile="true"/> + </div> + </div> + </x-draggable> + </template> + <template v-else> + <component class="widget" v-for="widget in widgets" :is="`mkw-${widget.name}`" :key="widget.id" :ref="widget.id" :widget="widget" :is-mobile="true" @chosen="warp"/> + </template> + </main> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as XDraggable from 'vuedraggable'; +import * as uuid from 'uuid'; + +export default Vue.extend({ + components: { + XDraggable + }, + data() { + return { + showNav: false, + widgets: [], + customizing: false, + widgetAdderSelected: null + }; + }, + created() { + if ((this as any).os.i.clientSettings.mobileHome == null) { + Vue.set((this as any).os.i.clientSettings, 'mobileHome', [{ + name: 'calendar', + id: 'a', data: {} + }, { + name: 'activity', + id: 'b', data: {} + }, { + name: 'rss', + id: 'c', data: {} + }, { + name: 'photo-stream', + id: 'd', data: {} + }, { + name: 'donation', + id: 'e', data: {} + }, { + name: 'nav', + id: 'f', data: {} + }, { + name: 'version', + id: 'g', data: {} + }]); + this.widgets = (this as any).os.i.clientSettings.mobileHome; + this.saveHome(); + } else { + this.widgets = (this as any).os.i.clientSettings.mobileHome; + } + + this.$watch('os.i.clientSettings', i => { + this.widgets = (this as any).os.i.clientSettings.mobileHome; + }, { + deep: true + }); + }, + + mounted() { + document.title = 'Misskey'; + document.documentElement.style.background = '#313a42'; + }, + + methods: { + onHomeUpdated(data) { + if (data.home) { + (this as any).os.i.clientSettings.mobileHome = data.home; + this.widgets = data.home; + } else { + const w = (this as any).os.i.clientSettings.mobileHome.find(w => w.id == data.id); + if (w != null) { + w.data = data.data; + this.$refs[w.id][0].preventSave = true; + this.$refs[w.id][0].props = w.data; + this.widgets = (this as any).os.i.clientSettings.mobileHome; + } + } + }, + hint() { + alert('ウィジェットを追加/削除したり並べ替えたりできます。ウィジェットを移動するには「三」をドラッグします。ウィジェットを削除するには「x」をタップします。いくつかのウィジェットはタップすることで表示を変更できます。'); + }, + widgetFunc(id) { + const w = this.$refs[id][0]; + if (w.func) w.func(); + }, + onWidgetSort() { + this.saveHome(); + }, + addWidget() { + const widget = { + name: this.widgetAdderSelected, + id: uuid(), + data: {} + }; + + this.widgets.unshift(widget); + this.saveHome(); + }, + removeWidget(widget) { + this.widgets = this.widgets.filter(w => w.id != widget.id); + this.saveHome(); + }, + saveHome() { + (this as any).os.i.clientSettings.mobileHome = this.widgets; + (this as any).api('i/update_mobile_home', { + home: this.widgets + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +main + margin 0 auto + max-width 500px + + @media (min-width 500px) + padding 8px + + > header + padding 8px + background #fff + + .widget + margin 8px + + .customize-container + margin 8px + background #fff + + > header + line-height 32px + background #eee + + > .handle + padding 0 8px + + > .remove + position absolute + top 0 + right 0 + padding 0 8px + line-height 32px + + > div + padding 8px + + > * + pointer-events none + +</style> diff --git a/src/client/app/mobile/views/pages/home.timeline.vue b/src/client/app/mobile/views/pages/home.timeline.vue new file mode 100644 index 0000000000..5f4bd6dcd8 --- /dev/null +++ b/src/client/app/mobile/views/pages/home.timeline.vue @@ -0,0 +1,149 @@ +<template> +<div> + <mk-friends-maker v-if="src == 'home' && alone" style="margin-bottom:8px"/> + + <mk-notes ref="timeline" :more="existMore ? more : null"> + <div slot="empty"> + %fa:R comments% + %i18n:@empty% + </div> + </mk-notes> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +const fetchLimit = 10; + +export default Vue.extend({ + props: { + src: { + type: String, + required: true + } + }, + + data() { + return { + fetching: true, + moreFetching: false, + existMore: false, + connection: null, + connectionId: null, + unreadCount: 0, + date: null + }; + }, + + computed: { + alone(): boolean { + return (this as any).os.i.followingCount == 0; + }, + + 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'; + }, + + canFetchMore(): boolean { + return !this.moreFetching && !this.fetching && this.existMore; + } + }, + + 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: { + 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 as any).os.i.clientSettings.showMyRenotes, + includeRenotedMyNotes: (this as any).os.i.clientSettings.showRenotedMyNotes + }).then(notes => { + if (notes.length == fetchLimit + 1) { + notes.pop(); + this.existMore = true; + } + res(notes); + this.fetching = false; + this.$emit('loaded'); + }, rej); + })); + }, + + more() { + if (!this.canFetchMore) return; + + this.moreFetching = true; + + (this as any).api(this.endpoint, { + limit: fetchLimit + 1, + untilId: (this.$refs.timeline as any).tail().id, + includeMyRenotes: (this as any).os.i.clientSettings.showMyRenotes, + includeRenotedMyNotes: (this as any).os.i.clientSettings.showRenotedMyNotes + }).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; + }); + }, + + onNote(note) { + // Prepend a note + (this.$refs.timeline as any).prepend(note); + }, + + onChangeFollowing() { + this.fetch(); + }, + + focus() { + (this.$refs.timeline as any).focus(); + }, + + warp(date) { + this.date = date; + this.fetch(); + } + } +}); +</script> diff --git a/src/client/app/mobile/views/pages/home.vue b/src/client/app/mobile/views/pages/home.vue index 3d94dd7ce6..92d34fa83b 100644 --- a/src/client/app/mobile/views/pages/home.vue +++ b/src/client/app/mobile/views/pages/home.vue @@ -1,59 +1,42 @@ <template> <mk-ui> - <span slot="header" @click="showTl = !showTl"> - <template v-if="showTl">%fa:home%%i18n:@timeline%</template> - <template v-else>%fa:home%ウィジェット</template> + <span slot="header" @click="showNav = true"> + <span> + <span v-if="src == 'home'">%fa:home%ホーム</span> + <span v-if="src == 'local'">%fa:R comments%ローカル</span> + <span v-if="src == 'global'">%fa:globe%グローバル</span> + <span v-if="src.startsWith('list')">%fa:list%{{ list.title }}</span> + </span> <span style="margin-left:8px"> - <template v-if="showTl">%fa:angle-down%</template> + <template v-if="!showNav">%fa:angle-down%</template> <template v-else>%fa:angle-up%</template> </span> </span> + <template slot="func"> - <button @click="fn" v-if="showTl">%fa:pencil-alt%</button> - <button @click="customizing = !customizing" v-else>%fa:cog%</button> + <button @click="fn">%fa:pencil-alt%</button> </template> + <main> - <div class="tl"> - <mk-timeline @loaded="onLoaded" v-show="showTl"/> + <div class="nav" v-if="showNav"> + <div class="bg" @click="showNav = false"></div> + <div class="body"> + <div> + <span :data-active="src == 'home'" @click="src = 'home'">%fa:home% ホーム</span> + <span :data-active="src == 'local'" @click="src = 'local'">%fa:R comments% ローカル</span> + <span :data-active="src == 'global'" @click="src = 'global'">%fa:globe% グローバル</span> + <template v-if="lists"> + <span v-for="l in lists" :data-active="src == 'list:' + l.id" @click="src = 'list:' + l.id; list = l" :key="l.id">%fa:list% {{ l.title }}</span> + </template> + </div> + </div> </div> - <div class="widgets" v-show="!showTl"> - <template v-if="customizing"> - <header> - <select v-model="widgetAdderSelected"> - <option value="profile">プロフィール</option> - <option value="calendar">カレンダー</option> - <option value="activity">アクティビティ</option> - <option value="rss">RSSリーダー</option> - <option value="photo-stream">フォトストリーム</option> - <option value="slideshow">スライドショー</option> - <option value="version">バージョン</option> - <option value="access-log">アクセスログ</option> - <option value="server">サーバー情報</option> - <option value="donation">寄付のお願い</option> - <option value="nav">ナビゲーション</option> - <option value="tips">ヒント</option> - </select> - <button @click="addWidget">追加</button> - <p><a @click="hint">カスタマイズのヒント</a></p> - </header> - <x-draggable - :list="widgets" - :options="{ handle: '.handle', animation: 150 }" - @sort="onWidgetSort" - > - <div v-for="widget in widgets" class="customize-container" :key="widget.id"> - <header> - <span class="handle">%fa:bars%</span>{{ widget.name }}<button class="remove" @click="removeWidget(widget)">%fa:times%</button> - </header> - <div @click="widgetFunc(widget.id)"> - <component :is="`mkw-${widget.name}`" :widget="widget" :ref="widget.id" :is-customize-mode="true" :is-mobile="true"/> - </div> - </div> - </x-draggable> - </template> - <template v-else> - <component class="widget" v-for="widget in widgets" :is="`mkw-${widget.name}`" :key="widget.id" :ref="widget.id" :widget="widget" :is-mobile="true" @chosen="warp"/> - </template> + + <div class="tl"> + <x-tl v-if="src == 'home'" ref="tl" key="home" src="home" @loaded="onLoaded"/> + <x-tl v-if="src == 'local'" ref="tl" key="local" src="local"/> + <x-tl v-if="src == 'global'" ref="tl" key="global" src="global"/> + <mk-user-list-timeline v-if="src.startsWith('list:')" ref="tl" key="list" :list="list"/> </div> </main> </mk-ui> @@ -61,144 +44,53 @@ <script lang="ts"> import Vue from 'vue'; -import * as XDraggable from 'vuedraggable'; -import * as uuid from 'uuid'; import Progress from '../../../common/scripts/loading'; -import getNoteSummary from '../../../../../renderers/get-note-summary'; +import XTl from './home.timeline.vue'; export default Vue.extend({ components: { - XDraggable + XTl }, + data() { return { - connection: null, - connectionId: null, - unreadCount: 0, - showTl: true, - widgets: [], - customizing: false, - widgetAdderSelected: null + src: 'home', + list: null, + lists: null, + showNav: false }; }, - created() { - if ((this as any).os.i.clientSettings.mobileHome == null) { - Vue.set((this as any).os.i.clientSettings, 'mobileHome', [{ - name: 'calendar', - id: 'a', data: {} - }, { - name: 'activity', - id: 'b', data: {} - }, { - name: 'rss', - id: 'c', data: {} - }, { - name: 'photo-stream', - id: 'd', data: {} - }, { - name: 'donation', - id: 'e', data: {} - }, { - name: 'nav', - id: 'f', data: {} - }, { - name: 'version', - id: 'g', data: {} - }]); - this.widgets = (this as any).os.i.clientSettings.mobileHome; - this.saveHome(); - } else { - this.widgets = (this as any).os.i.clientSettings.mobileHome; - } - this.$watch('os.i.clientSettings', i => { - this.widgets = (this as any).os.i.clientSettings.mobileHome; - }, { - deep: true - }); + watch: { + src() { + this.showNav = false; + }, + + showNav(v) { + if (v && this.lists === null) { + (this as any).api('users/lists/list').then(lists => { + this.lists = lists; + }); + } + } }, + mounted() { document.title = 'Misskey'; document.documentElement.style.background = '#313a42'; - this.connection = (this as any).os.stream.getConnection(); - this.connectionId = (this as any).os.stream.use(); - - this.connection.on('note', this.onStreamNote); - this.connection.on('mobile_home_updated', this.onHomeUpdated); - document.addEventListener('visibilitychange', this.onVisibilitychange, false); - Progress.start(); }, - beforeDestroy() { - this.connection.off('note', this.onStreamNote); - this.connection.off('mobile_home_updated', this.onHomeUpdated); - (this as any).os.stream.dispose(this.connectionId); - document.removeEventListener('visibilitychange', this.onVisibilitychange); - }, + methods: { fn() { (this as any).apis.post(); }, + onLoaded() { Progress.done(); }, - onStreamNote(note) { - if (document.hidden && note.userId !== (this as any).os.i.id) { - this.unreadCount++; - document.title = `(${this.unreadCount}) ${getNoteSummary(note)}`; - } - }, - onVisibilitychange() { - if (!document.hidden) { - this.unreadCount = 0; - document.title = 'Misskey'; - } - }, - onHomeUpdated(data) { - if (data.home) { - (this as any).os.i.clientSettings.mobileHome = data.home; - this.widgets = data.home; - } else { - const w = (this as any).os.i.clientSettings.mobileHome.find(w => w.id == data.id); - if (w != null) { - w.data = data.data; - this.$refs[w.id][0].preventSave = true; - this.$refs[w.id][0].props = w.data; - this.widgets = (this as any).os.i.clientSettings.mobileHome; - } - } - }, - hint() { - alert('ウィジェットを追加/削除したり並べ替えたりできます。ウィジェットを移動するには「三」をドラッグします。ウィジェットを削除するには「x」をタップします。いくつかのウィジェットはタップすることで表示を変更できます。'); - }, - widgetFunc(id) { - const w = this.$refs[id][0]; - if (w.func) w.func(); - }, - onWidgetSort() { - this.saveHome(); - }, - addWidget() { - const widget = { - name: this.widgetAdderSelected, - id: uuid(), - data: {} - }; - this.widgets.unshift(widget); - this.saveHome(); - }, - removeWidget(widget) { - this.widgets = this.widgets.filter(w => w.id != widget.id); - this.saveHome(); - }, - saveHome() { - (this as any).os.i.clientSettings.mobileHome = this.widgets; - (this as any).api('i/update_mobile_home', { - home: this.widgets - }); - }, warp() { } @@ -207,53 +99,75 @@ export default Vue.extend({ </script> <style lang="stylus" scoped> +@import '~const.styl' + main + > .nav + > .bg + position fixed + z-index 10000 + top 0 + left 0 + width 100% + height 100% + background rgba(#000, 0.5) - > .tl - > .mk-timeline - max-width 600px + > .body + position fixed + z-index 10001 + top 56px + left 0 + right 0 + width 300px margin 0 auto - padding 8px - - @media (min-width 500px) - padding 16px - - > .widgets - margin 0 auto - max-width 500px + background #fff + border-radius 8px + box-shadow 0 0 16px rgba(0, 0, 0, 0.1) - @media (min-width 500px) - padding 8px + $balloon-size = 16px - > header - padding 8px - background #fff + &:before + content "" + display block + position absolute + top -($balloon-size * 2) + left s('calc(50% - %s)', $balloon-size) + border-top solid $balloon-size transparent + border-left solid $balloon-size transparent + border-right solid $balloon-size transparent + border-bottom solid $balloon-size $border-color - .widget - margin 8px + &:after + content "" + display block + position absolute + top -($balloon-size * 2) + 1.5px + left s('calc(50% - %s)', $balloon-size) + border-top solid $balloon-size transparent + border-left solid $balloon-size transparent + border-right solid $balloon-size transparent + border-bottom solid $balloon-size #fff - .customize-container - margin 8px - background #fff + > div + padding 8px 0 - > header - line-height 32px - background #eee + > * + display block + padding 8px 16px - > .handle - padding 0 8px + &[data-active] + color $theme-color-foreground + background $theme-color - > .remove - position absolute - top 0 - right 0 - padding 0 8px - line-height 32px + &:not([data-active]):hover + background #eee - > div - padding 8px + > .tl + max-width 600px + margin 0 auto + padding 8px - > * - pointer-events none + @media (min-width 500px) + padding 16px </style> diff --git a/src/client/app/mobile/views/pages/user.vue b/src/client/app/mobile/views/pages/user.vue index 3ff9057f73..73b8e24315 100644 --- a/src/client/app/mobile/views/pages/user.vue +++ b/src/client/app/mobile/views/pages/user.vue @@ -45,9 +45,9 @@ </header> <nav> <div class="nav-container"> - <a :data-is-active="page == 'home'" @click="page = 'home'">%i18n:@overview%</a> - <a :data-is-active="page == 'notes'" @click="page = 'notes'">%i18n:@timeline%</a> - <a :data-is-active="page == 'media'" @click="page = 'media'">%i18n:@media%</a> + <a :data-active="page == 'home'" @click="page = 'home'">%i18n:@overview%</a> + <a :data-active="page == 'notes'" @click="page = 'notes'">%i18n:@timeline%</a> + <a :data-active="page == 'media'" @click="page = 'media'">%i18n:@media%</a> </div> </nav> <div class="body"> @@ -256,7 +256,7 @@ main color #657786 border-bottom solid 2px transparent - &[data-is-active] + &[data-active] font-weight bold color $theme-color border-color $theme-color |