summaryrefslogtreecommitdiff
path: root/src/client/app
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2018-04-26 16:10:25 +0900
committerGitHub <noreply@github.com>2018-04-26 16:10:25 +0900
commit5d4b884528e0533e32b9c827ae8ccf64df0085dc (patch)
tree1f6a3238dfbf1f77da78d96e993f6d76cad73089 /src/client/app
parentRefactor (diff)
parentwip (diff)
downloadmisskey-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')
-rw-r--r--src/client/app/common/mios.ts1
-rw-r--r--src/client/app/common/scripts/streaming/user-list.ts17
-rw-r--r--src/client/app/desktop/api/update-banner.ts2
-rw-r--r--src/client/app/desktop/script.ts2
-rw-r--r--src/client/app/desktop/views/components/follow-button.vue6
-rw-r--r--src/client/app/desktop/views/components/index.ts2
-rw-r--r--src/client/app/desktop/views/components/mentions.vue6
-rw-r--r--src/client/app/desktop/views/components/notes.vue190
-rw-r--r--src/client/app/desktop/views/components/timeline.core.vue167
-rw-r--r--src/client/app/desktop/views/components/timeline.vue43
-rw-r--r--src/client/app/desktop/views/components/ui.header.account.vue11
-rw-r--r--src/client/app/desktop/views/components/user-list-timeline.vue93
-rw-r--r--src/client/app/desktop/views/components/user-lists-window.vue69
-rw-r--r--src/client/app/desktop/views/components/users-list.vue6
-rw-r--r--src/client/app/desktop/views/components/widget-container.vue2
-rw-r--r--src/client/app/desktop/views/pages/user-list.users.vue131
-rw-r--r--src/client/app/desktop/views/pages/user-list.vue71
-rw-r--r--src/client/app/desktop/views/pages/user/user.profile.vue51
-rw-r--r--src/client/app/desktop/views/pages/user/user.timeline.vue73
-rw-r--r--src/client/app/mobile/views/components/index.ts4
-rw-r--r--src/client/app/mobile/views/components/notes.vue169
-rw-r--r--src/client/app/mobile/views/components/timeline.vue190
-rw-r--r--src/client/app/mobile/views/components/user-list-timeline.vue93
-rw-r--r--src/client/app/mobile/views/components/user-timeline.vue55
-rw-r--r--src/client/app/mobile/views/components/users-list.vue6
-rw-r--r--src/client/app/mobile/views/pages/dashboard.vue196
-rw-r--r--src/client/app/mobile/views/pages/home.timeline.vue149
-rw-r--r--src/client/app/mobile/views/pages/home.vue304
-rw-r--r--src/client/app/mobile/views/pages/user.vue8
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