diff --git a/.eslintrc b/.eslintrc index 7a74d6ef9b..0943cb4b64 100644 --- a/.eslintrc +++ b/.eslintrc @@ -14,6 +14,7 @@ "vue/no-unused-vars": false, "vue/attributes-order": false, "vue/require-prop-types": false, + "vue/require-default-prop": false, "no-console": 0, "no-unused-vars": 0, "no-empty": 0 diff --git a/locales/en.yml b/locales/en.yml index 0a05393227..4ef12432be 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -370,6 +370,7 @@ desktop/views/components/ui.header.account.vue: profile: "Your profile" drive: "Drive" favorites: "Favorites" + lists: "Lists" customize: "Customize" settings: "Settings" signout: "Sign out" diff --git a/locales/fr.yml b/locales/fr.yml index e640c4883c..80b1ed13da 100644 --- a/locales/fr.yml +++ b/locales/fr.yml @@ -370,6 +370,7 @@ desktop/views/components/ui.header.account.vue: profile: "Votre profil" drive: "Drive" favorites: "Favorites" + lists: "リスト" customize: "Modifications" settings: "Réglages" signout: "Déconnexion" diff --git a/locales/ja.yml b/locales/ja.yml index 3d023281cd..e13348c407 100644 --- a/locales/ja.yml +++ b/locales/ja.yml @@ -370,6 +370,7 @@ desktop/views/components/ui.header.account.vue: profile: "プロフィール" drive: "ドライブ" favorites: "お気に入り" + lists: "リスト" customize: "カスタマイズ" settings: "設定" signout: "サインアウト" diff --git a/package.json b/package.json index 0a3026e17e..d37bbc040d 100644 --- a/package.json +++ b/package.json @@ -88,7 +88,7 @@ "autwh": "0.1.0", "bcryptjs": "2.4.3", "bootstrap-vue": "2.0.0-rc.6", - "cafy": "3.2.1", + "cafy": "6.0.0", "chai": "4.1.2", "chai-http": "4.0.0", "chalk": "2.4.0", @@ -144,6 +144,7 @@ "koa-multer": "1.0.2", "koa-router": "7.4.0", "koa-send": "4.1.3", + "koa-slow": "^2.1.0", "kue": "0.11.6", "license-checker": "18.0.0", "loader-utils": "1.1.0", diff --git a/src/cafy-id.ts b/src/cafy-id.ts new file mode 100644 index 0000000000..3faf5cd996 --- /dev/null +++ b/src/cafy-id.ts @@ -0,0 +1,29 @@ +import * as mongo from 'mongodb'; +import { Query } from 'cafy'; + +export const isAnId = x => mongo.ObjectID.isValid(x); +export const isNotAnId = x => !isAnId(x); + +/** + * ID + */ +export default class ID extends Query<mongo.ObjectID> { + constructor(...args) { + super(...args); + + this.transform = v => { + if (isAnId(v) && !mongo.ObjectID.prototype.isPrototypeOf(v)) { + return new mongo.ObjectID(v); + } else { + return v; + } + }; + + this.pushValidator(v => { + if (!mongo.ObjectID.prototype.isPrototypeOf(v) && isNotAnId(v)) { + return new Error('must-be-an-id'); + } + return true; + }); + } +} 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 - - @media (min-width 500px) - padding 8px - - > header - padding 8px background #fff + border-radius 8px + box-shadow 0 0 16px rgba(0, 0, 0, 0.1) - .widget - margin 8px + $balloon-size = 16px - .customize-container - margin 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 - > header - line-height 32px - background #eee - - > .handle - padding 0 8px - - > .remove - position absolute - top 0 - right 0 - padding 0 8px - line-height 32px + &: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 > div - padding 8px + padding 8px 0 > * - pointer-events none + display block + padding 8px 16px + + &[data-active] + color $theme-color-foreground + background $theme-color + + &:not([data-active]):hover + background #eee + + > .tl + max-width 600px + margin 0 auto + padding 8px + + @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 diff --git a/src/models/note-reaction.ts b/src/models/note-reaction.ts index 7891ebdf17..f78b0d9d01 100644 --- a/src/models/note-reaction.ts +++ b/src/models/note-reaction.ts @@ -1,5 +1,5 @@ import * as mongo from 'mongodb'; -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import deepcopy = require('deepcopy'); import db from '../db/mongodb'; import Reaction from './note-reaction'; diff --git a/src/models/sw-subscription.ts b/src/models/sw-subscription.ts index 621ac8a9b6..a38edd3a50 100644 --- a/src/models/sw-subscription.ts +++ b/src/models/sw-subscription.ts @@ -38,4 +38,3 @@ export async function deleteSwSubscription(swSubscription: string | mongo.Object _id: s._id }); } - diff --git a/src/models/user-list.ts b/src/models/user-list.ts new file mode 100644 index 0000000000..7100fced7e --- /dev/null +++ b/src/models/user-list.ts @@ -0,0 +1,67 @@ +import * as mongo from 'mongodb'; +import deepcopy = require('deepcopy'); +import db from '../db/mongodb'; + +const UserList = db.get<IUserList>('userList'); +export default UserList; + +export interface IUserList { + _id: mongo.ObjectID; + createdAt: Date; + title: string; + userId: mongo.ObjectID; + userIds: mongo.ObjectID[]; +} + +/** + * UserListを物理削除します + */ +export async function deleteUserList(userList: string | mongo.ObjectID | IUserList) { + let u: IUserList; + + // Populate + if (mongo.ObjectID.prototype.isPrototypeOf(userList)) { + u = await UserList.findOne({ + _id: userList + }); + } else if (typeof userList === 'string') { + u = await UserList.findOne({ + _id: new mongo.ObjectID(userList) + }); + } else { + u = userList as IUserList; + } + + if (u == null) return; + + // このUserListを削除 + await UserList.remove({ + _id: u._id + }); +} + +export const pack = ( + userList: string | mongo.ObjectID | IUserList +) => new Promise<any>(async (resolve, reject) => { + let _userList: any; + + if (mongo.ObjectID.prototype.isPrototypeOf(userList)) { + _userList = await UserList.findOne({ + _id: userList + }); + } else if (typeof userList === 'string') { + _userList = await UserList.findOne({ + _id: new mongo.ObjectID(userList) + }); + } else { + _userList = deepcopy(userList); + } + + if (!_userList) throw `invalid userList arg ${userList}`; + + // Rename _id to id + _userList.id = _userList._id; + delete _userList._id; + + resolve(_userList); +}); diff --git a/src/models/user.ts b/src/models/user.ts index ca1ca28937..0621b6e736 100644 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -1,5 +1,6 @@ import * as mongo from 'mongodb'; import deepcopy = require('deepcopy'); +import sequential = require('promise-sequential'); import rap from '@prezzemolo/rap'; import db from '../db/mongodb'; import Note, { pack as packNote, deleteNote } from './note'; @@ -20,6 +21,7 @@ import FollowingLog, { deleteFollowingLog } from './following-log'; import FollowedLog, { deleteFollowedLog } from './followed-log'; import SwSubscription, { deleteSwSubscription } from './sw-subscription'; import Notification, { deleteNotification } from './notification'; +import UserList, { deleteUserList } from './user-list'; const User = db.get<IUser>('users'); @@ -166,9 +168,9 @@ export async function deleteUser(user: string | mongo.ObjectID | IUser) { ).map(x => deleteAccessToken(x))); // このユーザーのNoteをすべて削除 - await Promise.all(( - await Note.find({ userId: u._id }) - ).map(x => deleteNote(x))); + //await sequential(( + // await Note.find({ userId: u._id }) + //).map(x => () => deleteNote(x))); // このユーザーのNoteReactionをすべて削除 await Promise.all(( @@ -260,6 +262,20 @@ export async function deleteUser(user: string | mongo.ObjectID | IUser) { await Notification.find({ notifierId: u._id }) ).map(x => deleteNotification(x))); + // このユーザーのUserListをすべて削除 + await Promise.all(( + await UserList.find({ userId: u._id }) + ).map(x => deleteUserList(x))); + + // このユーザーが入っているすべてのUserListからこのユーザーを削除 + await Promise.all(( + await UserList.find({ userIds: u._id }) + ).map(x => + UserList.update({ _id: x._id }, { + $pull: { userIds: u._id } + }) + )); + // このユーザーを削除 await User.remove({ _id: u._id diff --git a/src/publishers/stream.ts b/src/publishers/stream.ts index 2ecbfa0dd8..dcc03e39f1 100644 --- a/src/publishers/stream.ts +++ b/src/publishers/stream.ts @@ -25,6 +25,10 @@ class MisskeyEvent { this.publish(`note-stream:${noteId}`, type, typeof value === 'undefined' ? null : value); } + public publishUserListStream(listId: ID, type: string, value?: any): void { + this.publish(`user-list-stream:${listId}`, type, typeof value === 'undefined' ? null : value); + } + public publishMessagingStream(userId: ID, otherpartyId: ID, type: string, value?: any): void { this.publish(`messaging-stream:${userId}-${otherpartyId}`, type, typeof value === 'undefined' ? null : value); } @@ -69,6 +73,7 @@ export default ev.publishUserStream.bind(ev); export const publishLocalTimelineStream = ev.publishLocalTimelineStream.bind(ev); export const publishGlobalTimelineStream = ev.publishGlobalTimelineStream.bind(ev); export const publishDriveStream = ev.publishDriveStream.bind(ev); +export const publishUserListStream = ev.publishUserListStream.bind(ev); export const publishNoteStream = ev.publishNoteStream.bind(ev); export const publishMessagingStream = ev.publishMessagingStream.bind(ev); export const publishMessagingIndexStream = ev.publishMessagingIndexStream.bind(ev); diff --git a/src/server/api/endpoints.ts b/src/server/api/endpoints.ts index 3686918147..734b8273f1 100644 --- a/src/server/api/endpoints.ts +++ b/src/server/api/endpoints.ts @@ -414,6 +414,27 @@ const endpoints: Endpoint[] = [ name: 'users/get_frequently_replied_users' }, + { + name: 'users/lists/show', + withCredential: true, + kind: 'account-read' + }, + { + name: 'users/lists/create', + withCredential: true, + kind: 'account-write' + }, + { + name: 'users/lists/push', + withCredential: true, + kind: 'account-write' + }, + { + name: 'users/lists/list', + withCredential: true, + kind: 'account-read' + }, + { name: 'following/create', withCredential: true, @@ -503,6 +524,14 @@ const endpoints: Endpoint[] = [ max: 100 } }, + { + name: 'notes/user-list-timeline', + withCredential: true, + limit: { + duration: ms('10minutes'), + max: 100 + } + }, { name: 'notes/mentions', withCredential: true, diff --git a/src/server/api/endpoints/aggregation/posts.ts b/src/server/api/endpoints/aggregation/posts.ts index cc2a48b53d..17bead2808 100644 --- a/src/server/api/endpoints/aggregation/posts.ts +++ b/src/server/api/endpoints/aggregation/posts.ts @@ -6,9 +6,6 @@ import Note from '../../../../models/note'; /** * Aggregate notes - * - * @param {any} params - * @return {Promise<any>} */ module.exports = params => new Promise(async (res, rej) => { // Get 'limit' parameter diff --git a/src/server/api/endpoints/aggregation/users.ts b/src/server/api/endpoints/aggregation/users.ts index 19776ed297..b0a7632f24 100644 --- a/src/server/api/endpoints/aggregation/users.ts +++ b/src/server/api/endpoints/aggregation/users.ts @@ -6,9 +6,6 @@ import User from '../../../../models/user'; /** * Aggregate users - * - * @param {any} params - * @return {Promise<any>} */ module.exports = params => new Promise(async (res, rej) => { // Get 'limit' parameter diff --git a/src/server/api/endpoints/aggregation/users/activity.ts b/src/server/api/endpoints/aggregation/users/activity.ts index 318cce77a5..d36e07a441 100644 --- a/src/server/api/endpoints/aggregation/users/activity.ts +++ b/src/server/api/endpoints/aggregation/users/activity.ts @@ -1,7 +1,7 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../../cafy-id'; import User from '../../../../../models/user'; import Note from '../../../../../models/note'; @@ -9,9 +9,6 @@ import Note from '../../../../../models/note'; /** * Aggregate activity of a user - * - * @param {any} params - * @return {Promise<any>} */ module.exports = (params) => new Promise(async (res, rej) => { // Get 'limit' parameter @@ -19,7 +16,7 @@ module.exports = (params) => new Promise(async (res, rej) => { if (limitErr) return rej('invalid limit param'); // Get 'userId' parameter - const [userId, userIdErr] = $(params.userId).id().$; + const [userId, userIdErr] = $(params.userId).type(ID).$; if (userIdErr) return rej('invalid userId param'); // Lookup user diff --git a/src/server/api/endpoints/aggregation/users/followers.ts b/src/server/api/endpoints/aggregation/users/followers.ts index 7ccb2a3066..a6dd29e735 100644 --- a/src/server/api/endpoints/aggregation/users/followers.ts +++ b/src/server/api/endpoints/aggregation/users/followers.ts @@ -1,19 +1,16 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../../cafy-id'; import User from '../../../../../models/user'; import FollowedLog from '../../../../../models/followed-log'; /** * Aggregate followers of a user - * - * @param {any} params - * @return {Promise<any>} */ module.exports = (params) => new Promise(async (res, rej) => { // Get 'userId' parameter - const [userId, userIdErr] = $(params.userId).id().$; + const [userId, userIdErr] = $(params.userId).type(ID).$; if (userIdErr) return rej('invalid userId param'); // Lookup user diff --git a/src/server/api/endpoints/aggregation/users/following.ts b/src/server/api/endpoints/aggregation/users/following.ts index 45e246495b..7336f392fe 100644 --- a/src/server/api/endpoints/aggregation/users/following.ts +++ b/src/server/api/endpoints/aggregation/users/following.ts @@ -1,19 +1,16 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../../cafy-id'; import User from '../../../../../models/user'; import FollowingLog from '../../../../../models/following-log'; /** * Aggregate following of a user - * - * @param {any} params - * @return {Promise<any>} */ module.exports = (params) => new Promise(async (res, rej) => { // Get 'userId' parameter - const [userId, userIdErr] = $(params.userId).id().$; + const [userId, userIdErr] = $(params.userId).type(ID).$; if (userIdErr) return rej('invalid userId param'); // Lookup user diff --git a/src/server/api/endpoints/aggregation/users/post.ts b/src/server/api/endpoints/aggregation/users/post.ts index e6170d83e2..c5a5e5ffca 100644 --- a/src/server/api/endpoints/aggregation/users/post.ts +++ b/src/server/api/endpoints/aggregation/users/post.ts @@ -1,19 +1,16 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../../cafy-id'; import User from '../../../../../models/user'; import Note from '../../../../../models/note'; /** * Aggregate note of a user - * - * @param {any} params - * @return {Promise<any>} */ module.exports = (params) => new Promise(async (res, rej) => { // Get 'userId' parameter - const [userId, userIdErr] = $(params.userId).id().$; + const [userId, userIdErr] = $(params.userId).type(ID).$; if (userIdErr) return rej('invalid userId param'); // Lookup user diff --git a/src/server/api/endpoints/aggregation/users/reaction.ts b/src/server/api/endpoints/aggregation/users/reaction.ts index 881c7ea693..f1664823cd 100644 --- a/src/server/api/endpoints/aggregation/users/reaction.ts +++ b/src/server/api/endpoints/aggregation/users/reaction.ts @@ -1,7 +1,7 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../../cafy-id'; import User from '../../../../../models/user'; import Reaction from '../../../../../models/note-reaction'; @@ -13,7 +13,7 @@ import Reaction from '../../../../../models/note-reaction'; */ module.exports = (params) => new Promise(async (res, rej) => { // Get 'userId' parameter - const [userId, userIdErr] = $(params.userId).id().$; + const [userId, userIdErr] = $(params.userId).type(ID).$; if (userIdErr) return rej('invalid userId param'); // Lookup user diff --git a/src/server/api/endpoints/app/create.ts b/src/server/api/endpoints/app/create.ts index 4a55d33f2d..f403429261 100644 --- a/src/server/api/endpoints/app/create.ts +++ b/src/server/api/endpoints/app/create.ts @@ -79,7 +79,7 @@ module.exports = async (params, user) => new Promise(async (res, rej) => { if (descriptionErr) return rej('invalid description param'); // Get 'permission' parameter - const [permission, permissionErr] = $(params.permission).array('string').unique().$; + const [permission, permissionErr] = $(params.permission).array($().string()).unique().$; if (permissionErr) return rej('invalid permission param'); // Get 'callbackUrl' parameter diff --git a/src/server/api/endpoints/app/show.ts b/src/server/api/endpoints/app/show.ts index 99a2093b68..92a03b9838 100644 --- a/src/server/api/endpoints/app/show.ts +++ b/src/server/api/endpoints/app/show.ts @@ -1,7 +1,7 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import App, { pack } from '../../../../models/app'; /** @@ -41,7 +41,7 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => { const isSecure = user != null && app == null; // Get 'appId' parameter - const [appId, appIdErr] = $(params.appId).optional.id().$; + const [appId, appIdErr] = $(params.appId).optional.type(ID).$; if (appIdErr) return rej('invalid appId param'); // Get 'nameId' parameter diff --git a/src/server/api/endpoints/channels.ts b/src/server/api/endpoints/channels.ts index 582e6ba43b..b68107ed7d 100644 --- a/src/server/api/endpoints/channels.ts +++ b/src/server/api/endpoints/channels.ts @@ -1,7 +1,7 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../cafy-id'; import Channel, { pack } from '../../../models/channel'; /** @@ -17,11 +17,11 @@ module.exports = (params, me) => new Promise(async (res, rej) => { if (limitErr) return rej('invalid limit param'); // Get 'sinceId' parameter - const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$; + const [sinceId, sinceIdErr] = $(params.sinceId).optional.type(ID).$; if (sinceIdErr) return rej('invalid sinceId param'); // Get 'untilId' parameter - const [untilId, untilIdErr] = $(params.untilId).optional.id().$; + const [untilId, untilIdErr] = $(params.untilId).optional.type(ID).$; if (untilIdErr) return rej('invalid untilId param'); // Check if both of sinceId and untilId is specified diff --git a/src/server/api/endpoints/channels/create.ts b/src/server/api/endpoints/channels/create.ts index 0f0f558c8a..a737fcb152 100644 --- a/src/server/api/endpoints/channels/create.ts +++ b/src/server/api/endpoints/channels/create.ts @@ -8,10 +8,6 @@ import { pack } from '../../../../models/channel'; /** * Create a channel - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = async (params, user) => new Promise(async (res, rej) => { // Get 'title' parameter diff --git a/src/server/api/endpoints/channels/notes.ts b/src/server/api/endpoints/channels/notes.ts index d636aa0d10..73a69c6d2a 100644 --- a/src/server/api/endpoints/channels/notes.ts +++ b/src/server/api/endpoints/channels/notes.ts @@ -1,16 +1,12 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import { default as Channel, IChannel } from '../../../../models/channel'; import Note, { pack } from '../../../../models/note'; /** * Show a notes of a channel - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'limit' parameter @@ -18,11 +14,11 @@ module.exports = (params, user) => new Promise(async (res, rej) => { if (limitErr) return rej('invalid limit param'); // Get 'sinceId' parameter - const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$; + const [sinceId, sinceIdErr] = $(params.sinceId).optional.type(ID).$; if (sinceIdErr) return rej('invalid sinceId param'); // Get 'untilId' parameter - const [untilId, untilIdErr] = $(params.untilId).optional.id().$; + const [untilId, untilIdErr] = $(params.untilId).optional.type(ID).$; if (untilIdErr) return rej('invalid untilId param'); // Check if both of sinceId and untilId is specified @@ -31,7 +27,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => { } // Get 'channelId' parameter - const [channelId, channelIdErr] = $(params.channelId).id().$; + const [channelId, channelIdErr] = $(params.channelId).type(ID).$; if (channelIdErr) return rej('invalid channelId param'); // Fetch channel diff --git a/src/server/api/endpoints/channels/show.ts b/src/server/api/endpoints/channels/show.ts index 3ce9ce4745..3f468937ed 100644 --- a/src/server/api/endpoints/channels/show.ts +++ b/src/server/api/endpoints/channels/show.ts @@ -1,19 +1,15 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import Channel, { IChannel, pack } from '../../../../models/channel'; /** * Show a channel - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'channelId' parameter - const [channelId, channelIdErr] = $(params.channelId).id().$; + const [channelId, channelIdErr] = $(params.channelId).type(ID).$; if (channelIdErr) return rej('invalid channelId param'); // Fetch channel diff --git a/src/server/api/endpoints/channels/unwatch.ts b/src/server/api/endpoints/channels/unwatch.ts index 8220b90b68..6ada3c9e1b 100644 --- a/src/server/api/endpoints/channels/unwatch.ts +++ b/src/server/api/endpoints/channels/unwatch.ts @@ -1,20 +1,16 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import Channel from '../../../../models/channel'; import Watching from '../../../../models/channel-watching'; /** * Unwatch a channel - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'channelId' parameter - const [channelId, channelIdErr] = $(params.channelId).id().$; + const [channelId, channelIdErr] = $(params.channelId).type(ID).$; if (channelIdErr) return rej('invalid channelId param'); //#region Fetch channel diff --git a/src/server/api/endpoints/channels/watch.ts b/src/server/api/endpoints/channels/watch.ts index 6906282a54..7880c34652 100644 --- a/src/server/api/endpoints/channels/watch.ts +++ b/src/server/api/endpoints/channels/watch.ts @@ -1,20 +1,16 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import Channel from '../../../../models/channel'; import Watching from '../../../../models/channel-watching'; /** * Watch a channel - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'channelId' parameter - const [channelId, channelIdErr] = $(params.channelId).id().$; + const [channelId, channelIdErr] = $(params.channelId).type(ID).$; if (channelIdErr) return rej('invalid channelId param'); //#region Fetch channel diff --git a/src/server/api/endpoints/drive/files.ts b/src/server/api/endpoints/drive/files.ts index 63d69d145a..7f78ef9daa 100644 --- a/src/server/api/endpoints/drive/files.ts +++ b/src/server/api/endpoints/drive/files.ts @@ -1,16 +1,11 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import DriveFile, { pack } from '../../../../models/drive-file'; /** * Get drive files - * - * @param {any} params - * @param {any} user - * @param {any} app - * @return {Promise<any>} */ module.exports = async (params, user, app) => { // Get 'limit' parameter @@ -18,11 +13,11 @@ module.exports = async (params, user, app) => { if (limitErr) throw 'invalid limit param'; // Get 'sinceId' parameter - const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$; + const [sinceId, sinceIdErr] = $(params.sinceId).optional.type(ID).$; if (sinceIdErr) throw 'invalid sinceId param'; // Get 'untilId' parameter - const [untilId, untilIdErr] = $(params.untilId).optional.id().$; + const [untilId, untilIdErr] = $(params.untilId).optional.type(ID).$; if (untilIdErr) throw 'invalid untilId param'; // Check if both of sinceId and untilId is specified @@ -31,7 +26,7 @@ module.exports = async (params, user, app) => { } // Get 'folderId' parameter - const [folderId = null, folderIdErr] = $(params.folderId).optional.nullable.id().$; + const [folderId = null, folderIdErr] = $(params.folderId).optional.nullable.type(ID).$; if (folderIdErr) throw 'invalid folderId param'; // Get 'type' parameter diff --git a/src/server/api/endpoints/drive/files/create.ts b/src/server/api/endpoints/drive/files/create.ts index df0bd0a0d3..3d5048732d 100644 --- a/src/server/api/endpoints/drive/files/create.ts +++ b/src/server/api/endpoints/drive/files/create.ts @@ -1,17 +1,12 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../../cafy-id'; import { validateFileName, pack } from '../../../../../models/drive-file'; import create from '../../../../../services/drive/add-file'; /** * Create a file - * - * @param {any} file - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = async (file, params, user): Promise<any> => { if (file == null) { @@ -34,7 +29,7 @@ module.exports = async (file, params, user): Promise<any> => { } // Get 'folderId' parameter - const [folderId = null, folderIdErr] = $(params.folderId).optional.nullable.id().$; + const [folderId = null, folderIdErr] = $(params.folderId).optional.nullable.type(ID).$; if (folderIdErr) throw 'invalid folderId param'; try { diff --git a/src/server/api/endpoints/drive/files/find.ts b/src/server/api/endpoints/drive/files/find.ts index 0ab6e5d3e3..5d49577983 100644 --- a/src/server/api/endpoints/drive/files/find.ts +++ b/src/server/api/endpoints/drive/files/find.ts @@ -1,15 +1,11 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../../cafy-id'; import DriveFile, { pack } from '../../../../../models/drive-file'; /** * Find a file(s) - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'name' parameter @@ -17,7 +13,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => { if (nameErr) return rej('invalid name param'); // Get 'folderId' parameter - const [folderId = null, folderIdErr] = $(params.folderId).optional.nullable.id().$; + const [folderId = null, folderIdErr] = $(params.folderId).optional.nullable.type(ID).$; if (folderIdErr) return rej('invalid folderId param'); // Issue query diff --git a/src/server/api/endpoints/drive/files/show.ts b/src/server/api/endpoints/drive/files/show.ts index 3398f24541..93c3a63031 100644 --- a/src/server/api/endpoints/drive/files/show.ts +++ b/src/server/api/endpoints/drive/files/show.ts @@ -1,19 +1,15 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../../cafy-id'; import DriveFile, { pack } from '../../../../../models/drive-file'; /** * Show a file - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = async (params, user) => { // Get 'fileId' parameter - const [fileId, fileIdErr] = $(params.fileId).id().$; + const [fileId, fileIdErr] = $(params.fileId).type(ID).$; if (fileIdErr) throw 'invalid fileId param'; // Fetch file diff --git a/src/server/api/endpoints/drive/files/update.ts b/src/server/api/endpoints/drive/files/update.ts index c783ad8b3b..3ac157b530 100644 --- a/src/server/api/endpoints/drive/files/update.ts +++ b/src/server/api/endpoints/drive/files/update.ts @@ -1,21 +1,17 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../../cafy-id'; import DriveFolder from '../../../../../models/drive-folder'; import DriveFile, { validateFileName, pack } from '../../../../../models/drive-file'; import { publishDriveStream } from '../../../../../publishers/stream'; /** * Update a file - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'fileId' parameter - const [fileId, fileIdErr] = $(params.fileId).id().$; + const [fileId, fileIdErr] = $(params.fileId).type(ID).$; if (fileIdErr) return rej('invalid fileId param'); // Fetch file @@ -35,7 +31,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => { if (name) file.filename = name; // Get 'folderId' parameter - const [folderId, folderIdErr] = $(params.folderId).optional.nullable.id().$; + const [folderId, folderIdErr] = $(params.folderId).optional.nullable.type(ID).$; if (folderIdErr) return rej('invalid folderId param'); if (folderId !== undefined) { diff --git a/src/server/api/endpoints/drive/files/upload_from_url.ts b/src/server/api/endpoints/drive/files/upload_from_url.ts index 8a426c0efc..cfae1ae192 100644 --- a/src/server/api/endpoints/drive/files/upload_from_url.ts +++ b/src/server/api/endpoints/drive/files/upload_from_url.ts @@ -1,7 +1,7 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../../cafy-id'; import { pack } from '../../../../../models/drive-file'; import uploadFromUrl from '../../../../../services/drive/upload-from-url'; @@ -15,7 +15,7 @@ module.exports = async (params, user): Promise<any> => { if (urlErr) throw 'invalid url param'; // Get 'folderId' parameter - const [folderId = null, folderIdErr] = $(params.folderId).optional.nullable.id().$; + const [folderId = null, folderIdErr] = $(params.folderId).optional.nullable.type(ID).$; if (folderIdErr) throw 'invalid folderId param'; return pack(await uploadFromUrl(url, user, folderId)); diff --git a/src/server/api/endpoints/drive/folders.ts b/src/server/api/endpoints/drive/folders.ts index 489e47912e..cba33c4286 100644 --- a/src/server/api/endpoints/drive/folders.ts +++ b/src/server/api/endpoints/drive/folders.ts @@ -1,16 +1,11 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import DriveFolder, { pack } from '../../../../models/drive-folder'; /** * Get drive folders - * - * @param {any} params - * @param {any} user - * @param {any} app - * @return {Promise<any>} */ module.exports = (params, user, app) => new Promise(async (res, rej) => { // Get 'limit' parameter @@ -18,11 +13,11 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => { if (limitErr) return rej('invalid limit param'); // Get 'sinceId' parameter - const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$; + const [sinceId, sinceIdErr] = $(params.sinceId).optional.type(ID).$; if (sinceIdErr) return rej('invalid sinceId param'); // Get 'untilId' parameter - const [untilId, untilIdErr] = $(params.untilId).optional.id().$; + const [untilId, untilIdErr] = $(params.untilId).optional.type(ID).$; if (untilIdErr) return rej('invalid untilId param'); // Check if both of sinceId and untilId is specified @@ -31,7 +26,7 @@ module.exports = (params, user, app) => new Promise(async (res, rej) => { } // Get 'folderId' parameter - const [folderId = null, folderIdErr] = $(params.folderId).optional.nullable.id().$; + const [folderId = null, folderIdErr] = $(params.folderId).optional.nullable.type(ID).$; if (folderIdErr) return rej('invalid folderId param'); // Construct query diff --git a/src/server/api/endpoints/drive/folders/create.ts b/src/server/api/endpoints/drive/folders/create.ts index f34d0019d7..65425537a2 100644 --- a/src/server/api/endpoints/drive/folders/create.ts +++ b/src/server/api/endpoints/drive/folders/create.ts @@ -1,16 +1,12 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../../cafy-id'; import DriveFolder, { isValidFolderName, pack } from '../../../../../models/drive-folder'; import { publishDriveStream } from '../../../../../publishers/stream'; /** * Create drive folder - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'name' parameter @@ -18,7 +14,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => { if (nameErr) return rej('invalid name param'); // Get 'parentId' parameter - const [parentId = null, parentIdErr] = $(params.parentId).optional.nullable.id().$; + const [parentId = null, parentIdErr] = $(params.parentId).optional.nullable.type(ID).$; if (parentIdErr) return rej('invalid parentId param'); // If the parent folder is specified diff --git a/src/server/api/endpoints/drive/folders/find.ts b/src/server/api/endpoints/drive/folders/find.ts index 04dc38f87f..d6277f1978 100644 --- a/src/server/api/endpoints/drive/folders/find.ts +++ b/src/server/api/endpoints/drive/folders/find.ts @@ -1,15 +1,11 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../../cafy-id'; import DriveFolder, { pack } from '../../../../../models/drive-folder'; /** * Find a folder(s) - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'name' parameter @@ -17,7 +13,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => { if (nameErr) return rej('invalid name param'); // Get 'parentId' parameter - const [parentId = null, parentIdErr] = $(params.parentId).optional.nullable.id().$; + const [parentId = null, parentIdErr] = $(params.parentId).optional.nullable.type(ID).$; if (parentIdErr) return rej('invalid parentId param'); // Issue query diff --git a/src/server/api/endpoints/drive/folders/show.ts b/src/server/api/endpoints/drive/folders/show.ts index b432f5a50a..c703209fef 100644 --- a/src/server/api/endpoints/drive/folders/show.ts +++ b/src/server/api/endpoints/drive/folders/show.ts @@ -1,19 +1,15 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../../cafy-id'; import DriveFolder, { pack } from '../../../../../models/drive-folder'; /** * Show a folder - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'folderId' parameter - const [folderId, folderIdErr] = $(params.folderId).id().$; + const [folderId, folderIdErr] = $(params.folderId).type(ID).$; if (folderIdErr) return rej('invalid folderId param'); // Get folder diff --git a/src/server/api/endpoints/drive/folders/update.ts b/src/server/api/endpoints/drive/folders/update.ts index dd7e8f5c86..d8da67fac8 100644 --- a/src/server/api/endpoints/drive/folders/update.ts +++ b/src/server/api/endpoints/drive/folders/update.ts @@ -1,20 +1,16 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../../cafy-id'; import DriveFolder, { isValidFolderName, pack } from '../../../../../models/drive-folder'; import { publishDriveStream } from '../../../../../publishers/stream'; /** * Update a folder - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'folderId' parameter - const [folderId, folderIdErr] = $(params.folderId).id().$; + const [folderId, folderIdErr] = $(params.folderId).type(ID).$; if (folderIdErr) return rej('invalid folderId param'); // Fetch folder @@ -34,7 +30,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => { if (name) folder.name = name; // Get 'parentId' parameter - const [parentId, parentIdErr] = $(params.parentId).optional.nullable.id().$; + const [parentId, parentIdErr] = $(params.parentId).optional.nullable.type(ID).$; if (parentIdErr) return rej('invalid parentId param'); if (parentId !== undefined) { if (parentId === null) { diff --git a/src/server/api/endpoints/drive/stream.ts b/src/server/api/endpoints/drive/stream.ts index 02313aa37b..00d89582b6 100644 --- a/src/server/api/endpoints/drive/stream.ts +++ b/src/server/api/endpoints/drive/stream.ts @@ -1,15 +1,11 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import DriveFile, { pack } from '../../../../models/drive-file'; /** * Get drive stream - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'limit' parameter @@ -17,11 +13,11 @@ module.exports = (params, user) => new Promise(async (res, rej) => { if (limitErr) return rej('invalid limit param'); // Get 'sinceId' parameter - const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$; + const [sinceId, sinceIdErr] = $(params.sinceId).optional.type(ID).$; if (sinceIdErr) return rej('invalid sinceId param'); // Get 'untilId' parameter - const [untilId, untilIdErr] = $(params.untilId).optional.id().$; + const [untilId, untilIdErr] = $(params.untilId).optional.type(ID).$; if (untilIdErr) return rej('invalid untilId param'); // Check if both of sinceId and untilId is specified diff --git a/src/server/api/endpoints/following/create.ts b/src/server/api/endpoints/following/create.ts index 27e5eb31db..43f902852e 100644 --- a/src/server/api/endpoints/following/create.ts +++ b/src/server/api/endpoints/following/create.ts @@ -1,7 +1,7 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import User from '../../../../models/user'; import Following from '../../../../models/following'; import create from '../../../../services/following/create'; @@ -13,7 +13,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => { const follower = user; // Get 'userId' parameter - const [userId, userIdErr] = $(params.userId).id().$; + const [userId, userIdErr] = $(params.userId).type(ID).$; if (userIdErr) return rej('invalid userId param'); // 自分自身 diff --git a/src/server/api/endpoints/following/delete.ts b/src/server/api/endpoints/following/delete.ts index ca0703ca22..99722ccf91 100644 --- a/src/server/api/endpoints/following/delete.ts +++ b/src/server/api/endpoints/following/delete.ts @@ -1,7 +1,7 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import User from '../../../../models/user'; import Following from '../../../../models/following'; import deleteFollowing from '../../../../services/following/delete'; @@ -13,7 +13,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => { const follower = user; // Get 'userId' parameter - const [userId, userIdErr] = $(params.userId).id().$; + const [userId, userIdErr] = $(params.userId).type(ID).$; if (userIdErr) return rej('invalid userId param'); // Check if the followee is yourself diff --git a/src/server/api/endpoints/following/stalk.ts b/src/server/api/endpoints/following/stalk.ts index fc8be4924d..1dfbc4df98 100644 --- a/src/server/api/endpoints/following/stalk.ts +++ b/src/server/api/endpoints/following/stalk.ts @@ -1,6 +1,5 @@ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import Following from '../../../../models/following'; -import { isLocalUser } from '../../../../models/user'; /** * Stalk a user @@ -9,7 +8,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => { const follower = user; // Get 'userId' parameter - const [userId, userIdErr] = $(params.userId).id().$; + const [userId, userIdErr] = $(params.userId).type(ID).$; if (userIdErr) return rej('invalid userId param'); // Fetch following diff --git a/src/server/api/endpoints/following/unstalk.ts b/src/server/api/endpoints/following/unstalk.ts index d7593bcd00..0d91ffeac8 100644 --- a/src/server/api/endpoints/following/unstalk.ts +++ b/src/server/api/endpoints/following/unstalk.ts @@ -1,4 +1,4 @@ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import Following from '../../../../models/following'; /** @@ -8,7 +8,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => { const follower = user; // Get 'userId' parameter - const [userId, userIdErr] = $(params.userId).id().$; + const [userId, userIdErr] = $(params.userId).type(ID).$; if (userIdErr) return rej('invalid userId param'); // Fetch following diff --git a/src/server/api/endpoints/i/authorized_apps.ts b/src/server/api/endpoints/i/authorized_apps.ts index 82fd2d2516..fd12b3dec0 100644 --- a/src/server/api/endpoints/i/authorized_apps.ts +++ b/src/server/api/endpoints/i/authorized_apps.ts @@ -7,10 +7,6 @@ import { pack } from '../../../../models/app'; /** * Get authorized apps of my account - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'limit' parameter diff --git a/src/server/api/endpoints/i/change_password.ts b/src/server/api/endpoints/i/change_password.ts index 57415083f1..a24e9f0be1 100644 --- a/src/server/api/endpoints/i/change_password.ts +++ b/src/server/api/endpoints/i/change_password.ts @@ -7,10 +7,6 @@ import User from '../../../../models/user'; /** * Change password - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = async (params, user) => new Promise(async (res, rej) => { // Get 'currentPasword' parameter diff --git a/src/server/api/endpoints/i/favorites.ts b/src/server/api/endpoints/i/favorites.ts index f390ef9ec7..a2c472ad17 100644 --- a/src/server/api/endpoints/i/favorites.ts +++ b/src/server/api/endpoints/i/favorites.ts @@ -1,7 +1,7 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import Favorite, { pack } from '../../../../models/favorite'; /** @@ -13,11 +13,11 @@ module.exports = (params, user) => new Promise(async (res, rej) => { if (limitErr) return rej('invalid limit param'); // Get 'sinceId' parameter - const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$; + const [sinceId, sinceIdErr] = $(params.sinceId).optional.type(ID).$; if (sinceIdErr) return rej('invalid sinceId param'); // Get 'untilId' parameter - const [untilId, untilIdErr] = $(params.untilId).optional.id().$; + const [untilId, untilIdErr] = $(params.untilId).optional.type(ID).$; if (untilIdErr) return rej('invalid untilId param'); // Check if both of sinceId and untilId is specified diff --git a/src/server/api/endpoints/i/notifications.ts b/src/server/api/endpoints/i/notifications.ts index 69a8910898..14ade7b023 100644 --- a/src/server/api/endpoints/i/notifications.ts +++ b/src/server/api/endpoints/i/notifications.ts @@ -1,7 +1,7 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import Notification from '../../../../models/notification'; import Mute from '../../../../models/mute'; import { pack } from '../../../../models/notification'; @@ -22,7 +22,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => { if (markAsReadErr) return rej('invalid markAsRead param'); // Get 'type' parameter - const [type, typeErr] = $(params.type).optional.array('string').unique().$; + const [type, typeErr] = $(params.type).optional.array($().string()).unique().$; if (typeErr) return rej('invalid type param'); // Get 'limit' parameter @@ -30,11 +30,11 @@ module.exports = (params, user) => new Promise(async (res, rej) => { if (limitErr) return rej('invalid limit param'); // Get 'sinceId' parameter - const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$; + const [sinceId, sinceIdErr] = $(params.sinceId).optional.type(ID).$; if (sinceIdErr) return rej('invalid sinceId param'); // Get 'untilId' parameter - const [untilId, untilIdErr] = $(params.untilId).optional.id().$; + const [untilId, untilIdErr] = $(params.untilId).optional.type(ID).$; if (untilIdErr) return rej('invalid untilId param'); // Check if both of sinceId and untilId is specified diff --git a/src/server/api/endpoints/i/pin.ts b/src/server/api/endpoints/i/pin.ts index 909a6fdbde..761e41bbea 100644 --- a/src/server/api/endpoints/i/pin.ts +++ b/src/server/api/endpoints/i/pin.ts @@ -1,21 +1,17 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import User from '../../../../models/user'; import Note from '../../../../models/note'; import { pack } from '../../../../models/user'; /** * Pin note - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = async (params, user) => new Promise(async (res, rej) => { // Get 'noteId' parameter - const [noteId, noteIdErr] = $(params.noteId).id().$; + const [noteId, noteIdErr] = $(params.noteId).type(ID).$; if (noteIdErr) return rej('invalid noteId param'); // Fetch pinee diff --git a/src/server/api/endpoints/i/regenerate_token.ts b/src/server/api/endpoints/i/regenerate_token.ts index f9e92c1797..945ddbdee4 100644 --- a/src/server/api/endpoints/i/regenerate_token.ts +++ b/src/server/api/endpoints/i/regenerate_token.ts @@ -9,10 +9,6 @@ import generateUserToken from '../../common/generate-native-user-token'; /** * Regenerate native token - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = async (params, user) => new Promise(async (res, rej) => { // Get 'password' parameter diff --git a/src/server/api/endpoints/i/signin_history.ts b/src/server/api/endpoints/i/signin_history.ts index 931b9e2252..77beca9fd6 100644 --- a/src/server/api/endpoints/i/signin_history.ts +++ b/src/server/api/endpoints/i/signin_history.ts @@ -1,15 +1,11 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import Signin, { pack } from '../../../../models/signin'; /** * Get signin history of my account - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'limit' parameter @@ -17,11 +13,11 @@ module.exports = (params, user) => new Promise(async (res, rej) => { if (limitErr) return rej('invalid limit param'); // Get 'sinceId' parameter - const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$; + const [sinceId, sinceIdErr] = $(params.sinceId).optional.type(ID).$; if (sinceIdErr) return rej('invalid sinceId param'); // Get 'untilId' parameter - const [untilId, untilIdErr] = $(params.untilId).optional.id().$; + const [untilId, untilIdErr] = $(params.untilId).optional.type(ID).$; if (untilIdErr) return rej('invalid untilId param'); // Check if both of sinceId and untilId is specified diff --git a/src/server/api/endpoints/i/update.ts b/src/server/api/endpoints/i/update.ts index f3c9d777b5..7505e73387 100644 --- a/src/server/api/endpoints/i/update.ts +++ b/src/server/api/endpoints/i/update.ts @@ -1,7 +1,7 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import User, { isValidName, isValidDescription, isValidLocation, isValidBirthday, pack } from '../../../../models/user'; import event from '../../../../publishers/stream'; @@ -32,12 +32,12 @@ module.exports = async (params, user, app) => new Promise(async (res, rej) => { if (birthday !== undefined) user.profile.birthday = birthday; // Get 'avatarId' parameter - const [avatarId, avatarIdErr] = $(params.avatarId).optional.id().$; + const [avatarId, avatarIdErr] = $(params.avatarId).optional.type(ID).$; if (avatarIdErr) return rej('invalid avatarId param'); if (avatarId) user.avatarId = avatarId; // Get 'bannerId' parameter - const [bannerId, bannerIdErr] = $(params.bannerId).optional.id().$; + const [bannerId, bannerIdErr] = $(params.bannerId).optional.type(ID).$; if (bannerIdErr) return rej('invalid bannerId param'); if (bannerId) user.bannerId = bannerId; diff --git a/src/server/api/endpoints/i/update_client_setting.ts b/src/server/api/endpoints/i/update_client_setting.ts index b0d5db5ec2..f753c8bcc4 100644 --- a/src/server/api/endpoints/i/update_client_setting.ts +++ b/src/server/api/endpoints/i/update_client_setting.ts @@ -7,10 +7,6 @@ import event from '../../../../publishers/stream'; /** * Update myself - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = async (params, user) => new Promise(async (res, rej) => { // Get 'name' parameter diff --git a/src/server/api/endpoints/i/update_home.ts b/src/server/api/endpoints/i/update_home.ts index ce7661ede0..4b8ba25069 100644 --- a/src/server/api/endpoints/i/update_home.ts +++ b/src/server/api/endpoints/i/update_home.ts @@ -8,7 +8,7 @@ import event from '../../../../publishers/stream'; module.exports = async (params, user) => new Promise(async (res, rej) => { // Get 'home' parameter const [home, homeErr] = $(params.home).optional.array().each( - $().strict.object() + $().object(true) .have('name', $().string()) .have('id', $().string()) .have('place', $().string()) diff --git a/src/server/api/endpoints/i/update_mobile_home.ts b/src/server/api/endpoints/i/update_mobile_home.ts index b710e2f330..c3ecea7178 100644 --- a/src/server/api/endpoints/i/update_mobile_home.ts +++ b/src/server/api/endpoints/i/update_mobile_home.ts @@ -8,7 +8,7 @@ import event from '../../../../publishers/stream'; module.exports = async (params, user) => new Promise(async (res, rej) => { // Get 'home' parameter const [home, homeErr] = $(params.home).optional.array().each( - $().strict.object() + $().object(true) .have('name', $().string()) .have('id', $().string()) .have('data', $().object())).$; diff --git a/src/server/api/endpoints/messaging/history.ts b/src/server/api/endpoints/messaging/history.ts index e42d34f21a..654bf5c198 100644 --- a/src/server/api/endpoints/messaging/history.ts +++ b/src/server/api/endpoints/messaging/history.ts @@ -8,10 +8,6 @@ import { pack } from '../../../../models/messaging-message'; /** * Show messaging history - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'limit' parameter diff --git a/src/server/api/endpoints/messaging/messages.ts b/src/server/api/endpoints/messaging/messages.ts index 092eab0562..f28699cb88 100644 --- a/src/server/api/endpoints/messaging/messages.ts +++ b/src/server/api/endpoints/messaging/messages.ts @@ -1,7 +1,7 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import Message from '../../../../models/messaging-message'; import User from '../../../../models/user'; import { pack } from '../../../../models/messaging-message'; @@ -16,7 +16,7 @@ import read from '../../common/read-messaging-message'; */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'userId' parameter - const [recipientId, recipientIdErr] = $(params.userId).id().$; + const [recipientId, recipientIdErr] = $(params.userId).type(ID).$; if (recipientIdErr) return rej('invalid userId param'); // Fetch recipient @@ -41,11 +41,11 @@ module.exports = (params, user) => new Promise(async (res, rej) => { if (limitErr) return rej('invalid limit param'); // Get 'sinceId' parameter - const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$; + const [sinceId, sinceIdErr] = $(params.sinceId).optional.type(ID).$; if (sinceIdErr) return rej('invalid sinceId param'); // Get 'untilId' parameter - const [untilId, untilIdErr] = $(params.untilId).optional.id().$; + const [untilId, untilIdErr] = $(params.untilId).optional.type(ID).$; if (untilIdErr) return rej('invalid untilId param'); // Check if both of sinceId and untilId is specified diff --git a/src/server/api/endpoints/messaging/messages/create.ts b/src/server/api/endpoints/messaging/messages/create.ts index 0483b602b2..cce326be6e 100644 --- a/src/server/api/endpoints/messaging/messages/create.ts +++ b/src/server/api/endpoints/messaging/messages/create.ts @@ -1,7 +1,7 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../../cafy-id'; import Message from '../../../../../models/messaging-message'; import { isValidText } from '../../../../../models/messaging-message'; import History from '../../../../../models/messaging-history'; @@ -16,14 +16,10 @@ import config from '../../../../../config'; /** * Create a message - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'userId' parameter - const [recipientId, recipientIdErr] = $(params.userId).id().$; + const [recipientId, recipientIdErr] = $(params.userId).type(ID).$; if (recipientIdErr) return rej('invalid userId param'); // Myself @@ -49,7 +45,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => { if (textErr) return rej('invalid text'); // Get 'fileId' parameter - const [fileId, fileIdErr] = $(params.fileId).optional.id().$; + const [fileId, fileIdErr] = $(params.fileId).optional.type(ID).$; if (fileIdErr) return rej('invalid fileId param'); let file = null; diff --git a/src/server/api/endpoints/messaging/unread.ts b/src/server/api/endpoints/messaging/unread.ts index 30d59dd8bd..1d83af501d 100644 --- a/src/server/api/endpoints/messaging/unread.ts +++ b/src/server/api/endpoints/messaging/unread.ts @@ -6,10 +6,6 @@ import Mute from '../../../../models/mute'; /** * Get count of unread messages - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = (params, user) => new Promise(async (res, rej) => { const mute = await Mute.find({ diff --git a/src/server/api/endpoints/mute/create.ts b/src/server/api/endpoints/mute/create.ts index 26ae612cab..0d59ecc118 100644 --- a/src/server/api/endpoints/mute/create.ts +++ b/src/server/api/endpoints/mute/create.ts @@ -1,22 +1,18 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import User from '../../../../models/user'; import Mute from '../../../../models/mute'; /** * Mute a user - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = (params, user) => new Promise(async (res, rej) => { const muter = user; // Get 'userId' parameter - const [userId, userIdErr] = $(params.userId).id().$; + const [userId, userIdErr] = $(params.userId).type(ID).$; if (userIdErr) return rej('invalid userId param'); // 自分自身 diff --git a/src/server/api/endpoints/mute/delete.ts b/src/server/api/endpoints/mute/delete.ts index 6f617416c8..3a37de9a21 100644 --- a/src/server/api/endpoints/mute/delete.ts +++ b/src/server/api/endpoints/mute/delete.ts @@ -1,7 +1,7 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import User from '../../../../models/user'; import Mute from '../../../../models/mute'; @@ -12,7 +12,7 @@ module.exports = (params, user) => new Promise(async (res, rej) => { const muter = user; // Get 'userId' parameter - const [userId, userIdErr] = $(params.userId).id().$; + const [userId, userIdErr] = $(params.userId).type(ID).$; if (userIdErr) return rej('invalid userId param'); // Check if the mutee is yourself diff --git a/src/server/api/endpoints/mute/list.ts b/src/server/api/endpoints/mute/list.ts index 0b8262d6c5..f35bf7d168 100644 --- a/src/server/api/endpoints/mute/list.ts +++ b/src/server/api/endpoints/mute/list.ts @@ -1,17 +1,13 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import Mute from '../../../../models/mute'; import { pack } from '../../../../models/user'; import { getFriendIds } from '../../common/get-friends'; /** * Get muted users of a user - * - * @param {any} params - * @param {any} me - * @return {Promise<any>} */ module.exports = (params, me) => new Promise(async (res, rej) => { // Get 'iknow' parameter @@ -23,7 +19,7 @@ module.exports = (params, me) => new Promise(async (res, rej) => { if (limitErr) return rej('invalid limit param'); // Get 'cursor' parameter - const [cursor = null, cursorErr] = $(params.cursor).optional.id().$; + const [cursor = null, cursorErr] = $(params.cursor).optional.type(ID).$; if (cursorErr) return rej('invalid cursor param'); // Construct query diff --git a/src/server/api/endpoints/my/apps.ts b/src/server/api/endpoints/my/apps.ts index 2a3f8bcd7a..eb7ece70e9 100644 --- a/src/server/api/endpoints/my/apps.ts +++ b/src/server/api/endpoints/my/apps.ts @@ -6,10 +6,6 @@ import App, { pack } from '../../../../models/app'; /** * Get my apps - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'limit' parameter diff --git a/src/server/api/endpoints/notes.ts b/src/server/api/endpoints/notes.ts index a70ac0588f..bf4d5bc66f 100644 --- a/src/server/api/endpoints/notes.ts +++ b/src/server/api/endpoints/notes.ts @@ -1,7 +1,7 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../cafy-id'; import Note, { pack } from '../../../models/note'; /** @@ -33,11 +33,11 @@ module.exports = (params) => new Promise(async (res, rej) => { if (limitErr) return rej('invalid limit param'); // Get 'sinceId' parameter - const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$; + const [sinceId, sinceIdErr] = $(params.sinceId).optional.type(ID).$; if (sinceIdErr) return rej('invalid sinceId param'); // Get 'untilId' parameter - const [untilId, untilIdErr] = $(params.untilId).optional.id().$; + const [untilId, untilIdErr] = $(params.untilId).optional.type(ID).$; if (untilIdErr) return rej('invalid untilId param'); // Check if both of sinceId and untilId is specified diff --git a/src/server/api/endpoints/notes/context.ts b/src/server/api/endpoints/notes/context.ts index 2caf742d26..309fc26447 100644 --- a/src/server/api/endpoints/notes/context.ts +++ b/src/server/api/endpoints/notes/context.ts @@ -1,7 +1,7 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import Note, { pack } from '../../../../models/note'; /** @@ -13,7 +13,7 @@ import Note, { pack } from '../../../../models/note'; */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'noteId' parameter - const [noteId, noteIdErr] = $(params.noteId).id().$; + const [noteId, noteIdErr] = $(params.noteId).type(ID).$; if (noteIdErr) return rej('invalid noteId param'); // Get 'limit' parameter diff --git a/src/server/api/endpoints/notes/create.ts b/src/server/api/endpoints/notes/create.ts index ea1f41aae2..1824a16c24 100644 --- a/src/server/api/endpoints/notes/create.ts +++ b/src/server/api/endpoints/notes/create.ts @@ -1,7 +1,7 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import Note, { INote, isValidText, isValidCw, pack } from '../../../../models/note'; import { ILocalUser } from '../../../../models/user'; import Channel, { IChannel } from '../../../../models/channel'; @@ -11,11 +11,6 @@ import { IApp } from '../../../../models/app'; /** * Create a note - * - * @param {any} params - * @param {any} user - * @param {any} app - * @return {Promise<any>} */ module.exports = (params, user: ILocalUser, app: IApp) => new Promise(async (res, rej) => { // Get 'visibility' parameter @@ -35,11 +30,11 @@ module.exports = (params, user: ILocalUser, app: IApp) => new Promise(async (res if (viaMobileErr) return rej('invalid viaMobile'); // Get 'tags' parameter - const [tags = [], tagsErr] = $(params.tags).optional.array('string').unique().eachQ(t => t.range(1, 32)).$; + const [tags = [], tagsErr] = $(params.tags).optional.array($().string().range(1, 32)).unique().$; if (tagsErr) return rej('invalid tags'); // Get 'geo' parameter - const [geo, geoErr] = $(params.geo).optional.nullable.strict.object() + const [geo, geoErr] = $(params.geo).optional.nullable.object(true) .have('coordinates', $().array().length(2) .item(0, $().number().range(-180, 180)) .item(1, $().number().range(-90, 90))) @@ -52,7 +47,7 @@ module.exports = (params, user: ILocalUser, app: IApp) => new Promise(async (res if (geoErr) return rej('invalid geo'); // Get 'mediaIds' parameter - const [mediaIds, mediaIdsErr] = $(params.mediaIds).optional.array('id').unique().range(1, 4).$; + const [mediaIds, mediaIdsErr] = $(params.mediaIds).optional.array($().type(ID)).unique().range(1, 4).$; if (mediaIdsErr) return rej('invalid mediaIds'); let files = []; @@ -79,7 +74,7 @@ module.exports = (params, user: ILocalUser, app: IApp) => new Promise(async (res } // Get 'renoteId' parameter - const [renoteId, renoteIdErr] = $(params.renoteId).optional.id().$; + const [renoteId, renoteIdErr] = $(params.renoteId).optional.type(ID).$; if (renoteIdErr) return rej('invalid renoteId'); let renote: INote = null; @@ -100,7 +95,7 @@ module.exports = (params, user: ILocalUser, app: IApp) => new Promise(async (res } // Get 'replyId' parameter - const [replyId, replyIdErr] = $(params.replyId).optional.id().$; + const [replyId, replyIdErr] = $(params.replyId).optional.type(ID).$; if (replyIdErr) return rej('invalid replyId'); let reply: INote = null; @@ -121,7 +116,7 @@ module.exports = (params, user: ILocalUser, app: IApp) => new Promise(async (res } // Get 'channelId' parameter - const [channelId, channelIdErr] = $(params.channelId).optional.id().$; + const [channelId, channelIdErr] = $(params.channelId).optional.type(ID).$; if (channelIdErr) return rej('invalid channelId'); let channel: IChannel = null; @@ -162,8 +157,8 @@ module.exports = (params, user: ILocalUser, app: IApp) => new Promise(async (res } // Get 'poll' parameter - const [poll, pollErr] = $(params.poll).optional.strict.object() - .have('choices', $().array('string') + const [poll, pollErr] = $(params.poll).optional.object(true) + .have('choices', $().array($().string()) .unique() .range(2, 10) .each(c => c.length > 0 && c.length < 50)) diff --git a/src/server/api/endpoints/notes/favorites/create.ts b/src/server/api/endpoints/notes/favorites/create.ts index c8e7f52426..e4c4adb9bb 100644 --- a/src/server/api/endpoints/notes/favorites/create.ts +++ b/src/server/api/endpoints/notes/favorites/create.ts @@ -1,20 +1,16 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../../cafy-id'; import Favorite from '../../../../../models/favorite'; import Note from '../../../../../models/note'; /** * Favorite a note - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'noteId' parameter - const [noteId, noteIdErr] = $(params.noteId).id().$; + const [noteId, noteIdErr] = $(params.noteId).type(ID).$; if (noteIdErr) return rej('invalid noteId param'); // Get favoritee diff --git a/src/server/api/endpoints/notes/favorites/delete.ts b/src/server/api/endpoints/notes/favorites/delete.ts index 92aceb343b..3c4d9a1111 100644 --- a/src/server/api/endpoints/notes/favorites/delete.ts +++ b/src/server/api/endpoints/notes/favorites/delete.ts @@ -1,20 +1,16 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../../cafy-id'; import Favorite from '../../../../../models/favorite'; import Note from '../../../../../models/note'; /** * Unfavorite a note - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'noteId' parameter - const [noteId, noteIdErr] = $(params.noteId).id().$; + const [noteId, noteIdErr] = $(params.noteId).type(ID).$; if (noteIdErr) return rej('invalid noteId param'); // Get favoritee diff --git a/src/server/api/endpoints/notes/global-timeline.ts b/src/server/api/endpoints/notes/global-timeline.ts index 07e138ec54..e2a94d8a3e 100644 --- a/src/server/api/endpoints/notes/global-timeline.ts +++ b/src/server/api/endpoints/notes/global-timeline.ts @@ -1,7 +1,7 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import Note from '../../../../models/note'; import Mute from '../../../../models/mute'; import { pack } from '../../../../models/note'; @@ -15,11 +15,11 @@ module.exports = async (params, user, app) => { if (limitErr) throw 'invalid limit param'; // Get 'sinceId' parameter - const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$; + const [sinceId, sinceIdErr] = $(params.sinceId).optional.type(ID).$; if (sinceIdErr) throw 'invalid sinceId param'; // Get 'untilId' parameter - const [untilId, untilIdErr] = $(params.untilId).optional.id().$; + const [untilId, untilIdErr] = $(params.untilId).optional.type(ID).$; if (untilIdErr) throw 'invalid untilId param'; // Get 'sinceDate' parameter diff --git a/src/server/api/endpoints/notes/local-timeline.ts b/src/server/api/endpoints/notes/local-timeline.ts index d63528c3cd..dda83311ac 100644 --- a/src/server/api/endpoints/notes/local-timeline.ts +++ b/src/server/api/endpoints/notes/local-timeline.ts @@ -1,7 +1,7 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import Note from '../../../../models/note'; import Mute from '../../../../models/mute'; import { pack } from '../../../../models/note'; @@ -15,11 +15,11 @@ module.exports = async (params, user, app) => { if (limitErr) throw 'invalid limit param'; // Get 'sinceId' parameter - const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$; + const [sinceId, sinceIdErr] = $(params.sinceId).optional.type(ID).$; if (sinceIdErr) throw 'invalid sinceId param'; // Get 'untilId' parameter - const [untilId, untilIdErr] = $(params.untilId).optional.id().$; + const [untilId, untilIdErr] = $(params.untilId).optional.type(ID).$; if (untilIdErr) throw 'invalid untilId param'; // Get 'sinceDate' parameter diff --git a/src/server/api/endpoints/notes/mentions.ts b/src/server/api/endpoints/notes/mentions.ts index 2d95606b3f..815cf271a2 100644 --- a/src/server/api/endpoints/notes/mentions.ts +++ b/src/server/api/endpoints/notes/mentions.ts @@ -1,7 +1,7 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import Note from '../../../../models/note'; import { getFriendIds } from '../../common/get-friends'; import { pack } from '../../../../models/note'; @@ -24,11 +24,11 @@ module.exports = (params, user) => new Promise(async (res, rej) => { if (limitErr) return rej('invalid limit param'); // Get 'sinceId' parameter - const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$; + const [sinceId, sinceIdErr] = $(params.sinceId).optional.type(ID).$; if (sinceIdErr) return rej('invalid sinceId param'); // Get 'untilId' parameter - const [untilId, untilIdErr] = $(params.untilId).optional.id().$; + const [untilId, untilIdErr] = $(params.untilId).optional.type(ID).$; if (untilIdErr) return rej('invalid untilId param'); // Check if both of sinceId and untilId is specified diff --git a/src/server/api/endpoints/notes/polls/recommendation.ts b/src/server/api/endpoints/notes/polls/recommendation.ts index cb530ea2cf..24b0a4c803 100644 --- a/src/server/api/endpoints/notes/polls/recommendation.ts +++ b/src/server/api/endpoints/notes/polls/recommendation.ts @@ -7,10 +7,6 @@ import Note, { pack } from '../../../../../models/note'; /** * Get recommended polls - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'limit' parameter diff --git a/src/server/api/endpoints/notes/polls/vote.ts b/src/server/api/endpoints/notes/polls/vote.ts index 03d94da60d..2669c39085 100644 --- a/src/server/api/endpoints/notes/polls/vote.ts +++ b/src/server/api/endpoints/notes/polls/vote.ts @@ -1,7 +1,7 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../../cafy-id'; import Vote from '../../../../../models/poll-vote'; import Note from '../../../../../models/note'; import Watching from '../../../../../models/note-watching'; @@ -11,14 +11,10 @@ import notify from '../../../../../publishers/notify'; /** * Vote poll of a note - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'noteId' parameter - const [noteId, noteIdErr] = $(params.noteId).id().$; + const [noteId, noteIdErr] = $(params.noteId).type(ID).$; if (noteIdErr) return rej('invalid noteId param'); // Get votee diff --git a/src/server/api/endpoints/notes/reactions.ts b/src/server/api/endpoints/notes/reactions.ts index bbff97bb0a..68ffbacd46 100644 --- a/src/server/api/endpoints/notes/reactions.ts +++ b/src/server/api/endpoints/notes/reactions.ts @@ -1,7 +1,7 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import Note from '../../../../models/note'; import Reaction, { pack } from '../../../../models/note-reaction'; @@ -14,7 +14,7 @@ import Reaction, { pack } from '../../../../models/note-reaction'; */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'noteId' parameter - const [noteId, noteIdErr] = $(params.noteId).id().$; + const [noteId, noteIdErr] = $(params.noteId).type(ID).$; if (noteIdErr) return rej('invalid noteId param'); // Get 'limit' parameter diff --git a/src/server/api/endpoints/notes/reactions/create.ts b/src/server/api/endpoints/notes/reactions/create.ts index 9e217cc3e0..1c21252604 100644 --- a/src/server/api/endpoints/notes/reactions/create.ts +++ b/src/server/api/endpoints/notes/reactions/create.ts @@ -1,7 +1,7 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../../cafy-id'; import Note from '../../../../../models/note'; import create from '../../../../../services/note/reaction/create'; import { validateReaction } from '../../../../../models/note-reaction'; @@ -11,7 +11,7 @@ import { validateReaction } from '../../../../../models/note-reaction'; */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'noteId' parameter - const [noteId, noteIdErr] = $(params.noteId).id().$; + const [noteId, noteIdErr] = $(params.noteId).type(ID).$; if (noteIdErr) return rej('invalid noteId param'); // Get 'reaction' parameter diff --git a/src/server/api/endpoints/notes/reactions/delete.ts b/src/server/api/endpoints/notes/reactions/delete.ts index b5d738b8ff..be3c1b214d 100644 --- a/src/server/api/endpoints/notes/reactions/delete.ts +++ b/src/server/api/endpoints/notes/reactions/delete.ts @@ -1,21 +1,16 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../../cafy-id'; import Reaction from '../../../../../models/note-reaction'; import Note from '../../../../../models/note'; -// import event from '../../../publishers/stream'; /** * Unreact to a note - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'noteId' parameter - const [noteId, noteIdErr] = $(params.noteId).id().$; + const [noteId, noteIdErr] = $(params.noteId).type(ID).$; if (noteIdErr) return rej('invalid noteId param'); // Fetch unreactee diff --git a/src/server/api/endpoints/notes/replies.ts b/src/server/api/endpoints/notes/replies.ts index 88d9ff329a..31f1bb941a 100644 --- a/src/server/api/endpoints/notes/replies.ts +++ b/src/server/api/endpoints/notes/replies.ts @@ -1,7 +1,7 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import Note, { pack } from '../../../../models/note'; /** @@ -13,7 +13,7 @@ import Note, { pack } from '../../../../models/note'; */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'noteId' parameter - const [noteId, noteIdErr] = $(params.noteId).id().$; + const [noteId, noteIdErr] = $(params.noteId).type(ID).$; if (noteIdErr) return rej('invalid noteId param'); // Get 'limit' parameter diff --git a/src/server/api/endpoints/notes/reposts.ts b/src/server/api/endpoints/notes/reposts.ts index 9dfc2c3cb5..fe98931380 100644 --- a/src/server/api/endpoints/notes/reposts.ts +++ b/src/server/api/endpoints/notes/reposts.ts @@ -1,7 +1,7 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import Note, { pack } from '../../../../models/note'; /** @@ -13,7 +13,7 @@ import Note, { pack } from '../../../../models/note'; */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'noteId' parameter - const [noteId, noteIdErr] = $(params.noteId).id().$; + const [noteId, noteIdErr] = $(params.noteId).type(ID).$; if (noteIdErr) return rej('invalid noteId param'); // Get 'limit' parameter @@ -21,11 +21,11 @@ module.exports = (params, user) => new Promise(async (res, rej) => { if (limitErr) return rej('invalid limit param'); // Get 'sinceId' parameter - const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$; + const [sinceId, sinceIdErr] = $(params.sinceId).optional.type(ID).$; if (sinceIdErr) return rej('invalid sinceId param'); // Get 'untilId' parameter - const [untilId, untilIdErr] = $(params.untilId).optional.id().$; + const [untilId, untilIdErr] = $(params.untilId).optional.type(ID).$; if (untilIdErr) return rej('invalid untilId param'); // Check if both of sinceId and untilId is specified diff --git a/src/server/api/endpoints/notes/search.ts b/src/server/api/endpoints/notes/search.ts index 3ff3fbbafa..021f620aa2 100644 --- a/src/server/api/endpoints/notes/search.ts +++ b/src/server/api/endpoints/notes/search.ts @@ -1,7 +1,7 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; const escapeRegexp = require('escape-regexp'); import Note from '../../../../models/note'; import User from '../../../../models/user'; @@ -22,19 +22,19 @@ module.exports = (params, me) => new Promise(async (res, rej) => { if (textError) return rej('invalid text param'); // Get 'includeUserIds' parameter - const [includeUserIds = [], includeUserIdsErr] = $(params.includeUserIds).optional.array('id').$; + const [includeUserIds = [], includeUserIdsErr] = $(params.includeUserIds).optional.array($().type(ID)).$; if (includeUserIdsErr) return rej('invalid includeUserIds param'); // Get 'excludeUserIds' parameter - const [excludeUserIds = [], excludeUserIdsErr] = $(params.excludeUserIds).optional.array('id').$; + const [excludeUserIds = [], excludeUserIdsErr] = $(params.excludeUserIds).optional.array($().type(ID)).$; if (excludeUserIdsErr) return rej('invalid excludeUserIds param'); // Get 'includeUserUsernames' parameter - const [includeUserUsernames = [], includeUserUsernamesErr] = $(params.includeUserUsernames).optional.array('string').$; + const [includeUserUsernames = [], includeUserUsernamesErr] = $(params.includeUserUsernames).optional.array($().string()).$; if (includeUserUsernamesErr) return rej('invalid includeUserUsernames param'); // Get 'excludeUserUsernames' parameter - const [excludeUserUsernames = [], excludeUserUsernamesErr] = $(params.excludeUserUsernames).optional.array('string').$; + const [excludeUserUsernames = [], excludeUserUsernamesErr] = $(params.excludeUserUsernames).optional.array($().string()).$; if (excludeUserUsernamesErr) return rej('invalid excludeUserUsernames param'); // Get 'following' parameter diff --git a/src/server/api/endpoints/notes/show.ts b/src/server/api/endpoints/notes/show.ts index 67cdc3038b..266e0687e9 100644 --- a/src/server/api/endpoints/notes/show.ts +++ b/src/server/api/endpoints/notes/show.ts @@ -1,7 +1,7 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import Note, { pack } from '../../../../models/note'; /** @@ -13,7 +13,7 @@ import Note, { pack } from '../../../../models/note'; */ module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'noteId' parameter - const [noteId, noteIdErr] = $(params.noteId).id().$; + const [noteId, noteIdErr] = $(params.noteId).type(ID).$; if (noteIdErr) return rej('invalid noteId param'); // Get note diff --git a/src/server/api/endpoints/notes/timeline.ts b/src/server/api/endpoints/notes/timeline.ts index de30afea57..476d64158c 100644 --- a/src/server/api/endpoints/notes/timeline.ts +++ b/src/server/api/endpoints/notes/timeline.ts @@ -1,7 +1,7 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import Note from '../../../../models/note'; import Mute from '../../../../models/mute'; import ChannelWatching from '../../../../models/channel-watching'; @@ -17,11 +17,11 @@ module.exports = async (params, user, app) => { if (limitErr) throw 'invalid limit param'; // Get 'sinceId' parameter - const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$; + const [sinceId, sinceIdErr] = $(params.sinceId).optional.type(ID).$; if (sinceIdErr) throw 'invalid sinceId param'; // Get 'untilId' parameter - const [untilId, untilIdErr] = $(params.untilId).optional.id().$; + const [untilId, untilIdErr] = $(params.untilId).optional.type(ID).$; if (untilIdErr) throw 'invalid untilId param'; // Get 'sinceDate' parameter diff --git a/src/server/api/endpoints/notes/user-list-timeline.ts b/src/server/api/endpoints/notes/user-list-timeline.ts new file mode 100644 index 0000000000..bb94fa0ab9 --- /dev/null +++ b/src/server/api/endpoints/notes/user-list-timeline.ts @@ -0,0 +1,179 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; import ID from '../../../../cafy-id'; +import Note from '../../../../models/note'; +import Mute from '../../../../models/mute'; +import { pack } from '../../../../models/note'; +import UserList from '../../../../models/user-list'; + +/** + * Get timeline of a user list + */ +module.exports = async (params, user, app) => { + // Get 'limit' parameter + const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 100).$; + if (limitErr) throw 'invalid limit param'; + + // Get 'sinceId' parameter + const [sinceId, sinceIdErr] = $(params.sinceId).optional.type(ID).$; + if (sinceIdErr) throw 'invalid sinceId param'; + + // Get 'untilId' parameter + const [untilId, untilIdErr] = $(params.untilId).optional.type(ID).$; + if (untilIdErr) throw 'invalid untilId param'; + + // Get 'sinceDate' parameter + const [sinceDate, sinceDateErr] = $(params.sinceDate).optional.number().$; + if (sinceDateErr) throw 'invalid sinceDate param'; + + // Get 'untilDate' parameter + const [untilDate, untilDateErr] = $(params.untilDate).optional.number().$; + if (untilDateErr) throw 'invalid untilDate param'; + + // Check if only one of sinceId, untilId, sinceDate, untilDate specified + if ([sinceId, untilId, sinceDate, untilDate].filter(x => x != null).length > 1) { + throw 'only one of sinceId, untilId, sinceDate, untilDate can be specified'; + } + + // Get 'includeMyRenotes' parameter + const [includeMyRenotes = true, includeMyRenotesErr] = $(params.includeMyRenotes).optional.boolean().$; + if (includeMyRenotesErr) throw 'invalid includeMyRenotes param'; + + // Get 'includeRenotedMyNotes' parameter + const [includeRenotedMyNotes = true, includeRenotedMyNotesErr] = $(params.includeRenotedMyNotes).optional.boolean().$; + if (includeRenotedMyNotesErr) throw 'invalid includeRenotedMyNotes param'; + + // Get 'listId' parameter + const [listId, listIdErr] = $(params.listId).type(ID).$; + if (listIdErr) throw 'invalid listId param'; + + const [list, mutedUserIds] = await Promise.all([ + // リストを取得 + // Fetch the list + UserList.findOne({ + _id: listId, + userId: user._id + }), + + // ミュートしているユーザーを取得 + Mute.find({ + muterId: user._id + }).then(ms => ms.map(m => m.muteeId)) + ]); + + if (list.userIds.length == 0) { + return []; + } + + //#region Construct query + const sort = { + _id: -1 + }; + + const listQuery = list.userIds.map(u => ({ + userId: u, + + // リプライは含めない(ただし投稿者自身の投稿へのリプライ、自分の投稿へのリプライ、自分のリプライは含める) + $or: [{ + // リプライでない + replyId: null + }, { // または + // リプライだが返信先が投稿者自身の投稿 + $expr: { + $eq: ['$_reply.userId', '$userId'] + } + }, { // または + // リプライだが返信先が自分(フォロワー)の投稿 + '_reply.userId': user._id + }, { // または + // 自分(フォロワー)が送信したリプライ + userId: user._id + }] + })); + + const query = { + $and: [{ + // リストに入っている人のタイムラインへの投稿 + $or: listQuery, + + // mute + userId: { + $nin: mutedUserIds + }, + '_reply.userId': { + $nin: mutedUserIds + }, + '_renote.userId': { + $nin: mutedUserIds + }, + }] + } as any; + + // MongoDBではトップレベルで否定ができないため、De Morganの法則を利用してクエリします。 + // つまり、「『自分の投稿かつRenote』ではない」を「『自分の投稿ではない』または『Renoteではない』」と表現します。 + // for details: https://en.wikipedia.org/wiki/De_Morgan%27s_laws + + if (includeMyRenotes === false) { + query.$and.push({ + $or: [{ + userId: { $ne: user._id } + }, { + renoteId: null + }, { + text: { $ne: null } + }, { + mediaIds: { $ne: [] } + }, { + poll: { $ne: null } + }] + }); + } + + if (includeRenotedMyNotes === false) { + query.$and.push({ + $or: [{ + '_renote.userId': { $ne: user._id } + }, { + renoteId: null + }, { + text: { $ne: null } + }, { + mediaIds: { $ne: [] } + }, { + poll: { $ne: null } + }] + }); + } + + if (sinceId) { + sort._id = 1; + query._id = { + $gt: sinceId + }; + } else if (untilId) { + query._id = { + $lt: untilId + }; + } else if (sinceDate) { + sort._id = 1; + query.createdAt = { + $gt: new Date(sinceDate) + }; + } else if (untilDate) { + query.createdAt = { + $lt: new Date(untilDate) + }; + } + //#endregion + + // Issue query + const timeline = await Note + .find(query, { + limit: limit, + sort: sort + }); + + // Serialize + return await Promise.all(timeline.map(note => pack(note, user))); +}; diff --git a/src/server/api/endpoints/notifications/get_unread_count.ts b/src/server/api/endpoints/notifications/get_unread_count.ts index 283ecd63b1..600a80d194 100644 --- a/src/server/api/endpoints/notifications/get_unread_count.ts +++ b/src/server/api/endpoints/notifications/get_unread_count.ts @@ -6,10 +6,6 @@ import Mute from '../../../../models/mute'; /** * Get count of unread notifications - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = (params, user) => new Promise(async (res, rej) => { const mute = await Mute.find({ diff --git a/src/server/api/endpoints/notifications/mark_as_read_all.ts b/src/server/api/endpoints/notifications/mark_as_read_all.ts index 01c9145837..dce3cb4663 100644 --- a/src/server/api/endpoints/notifications/mark_as_read_all.ts +++ b/src/server/api/endpoints/notifications/mark_as_read_all.ts @@ -6,10 +6,6 @@ import event from '../../../../publishers/stream'; /** * Mark as read all notifications - * - * @param {any} params - * @param {any} user - * @return {Promise<any>} */ module.exports = (params, user) => new Promise(async (res, rej) => { // Update documents diff --git a/src/server/api/endpoints/othello/games.ts b/src/server/api/endpoints/othello/games.ts index d05c1c2585..3b23b60637 100644 --- a/src/server/api/endpoints/othello/games.ts +++ b/src/server/api/endpoints/othello/games.ts @@ -1,4 +1,4 @@ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import OthelloGame, { pack } from '../../../../models/othello-game'; module.exports = (params, user) => new Promise(async (res, rej) => { @@ -11,11 +11,11 @@ module.exports = (params, user) => new Promise(async (res, rej) => { if (limitErr) return rej('invalid limit param'); // Get 'sinceId' parameter - const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$; + const [sinceId, sinceIdErr] = $(params.sinceId).optional.type(ID).$; if (sinceIdErr) return rej('invalid sinceId param'); // Get 'untilId' parameter - const [untilId, untilIdErr] = $(params.untilId).optional.id().$; + const [untilId, untilIdErr] = $(params.untilId).optional.type(ID).$; if (untilIdErr) return rej('invalid untilId param'); // Check if both of sinceId and untilId is specified diff --git a/src/server/api/endpoints/othello/games/show.ts b/src/server/api/endpoints/othello/games/show.ts index dd886936d4..d76c6556a2 100644 --- a/src/server/api/endpoints/othello/games/show.ts +++ b/src/server/api/endpoints/othello/games/show.ts @@ -1,10 +1,10 @@ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../../cafy-id'; import OthelloGame, { pack } from '../../../../../models/othello-game'; import Othello from '../../../../../othello/core'; module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'gameId' parameter - const [gameId, gameIdErr] = $(params.gameId).id().$; + const [gameId, gameIdErr] = $(params.gameId).type(ID).$; if (gameIdErr) return rej('invalid gameId param'); const game = await OthelloGame.findOne({ _id: gameId }); diff --git a/src/server/api/endpoints/othello/match.ts b/src/server/api/endpoints/othello/match.ts index d9274f8f9c..b73b64437b 100644 --- a/src/server/api/endpoints/othello/match.ts +++ b/src/server/api/endpoints/othello/match.ts @@ -1,4 +1,4 @@ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import Matching, { pack as packMatching } from '../../../../models/othello-matching'; import OthelloGame, { pack as packGame } from '../../../../models/othello-game'; import User from '../../../../models/user'; @@ -7,7 +7,7 @@ import { eighteight } from '../../../../othello/maps'; module.exports = (params, user) => new Promise(async (res, rej) => { // Get 'userId' parameter - const [childId, childIdErr] = $(params.userId).id().$; + const [childId, childIdErr] = $(params.userId).type(ID).$; if (childIdErr) return rej('invalid userId param'); // Myself diff --git a/src/server/api/endpoints/users.ts b/src/server/api/endpoints/users.ts index ae33e8af0c..5b389d452f 100644 --- a/src/server/api/endpoints/users.ts +++ b/src/server/api/endpoints/users.ts @@ -6,10 +6,6 @@ import User, { pack } from '../../../models/user'; /** * Lists all users - * - * @param {any} params - * @param {any} me - * @return {Promise<any>} */ module.exports = (params, me) => new Promise(async (res, rej) => { // Get 'limit' parameter diff --git a/src/server/api/endpoints/users/followers.ts b/src/server/api/endpoints/users/followers.ts index 5f03326be8..940b5ed9bc 100644 --- a/src/server/api/endpoints/users/followers.ts +++ b/src/server/api/endpoints/users/followers.ts @@ -1,7 +1,7 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import User from '../../../../models/user'; import Following from '../../../../models/following'; import { pack } from '../../../../models/user'; @@ -9,14 +9,10 @@ import { getFriendIds } from '../../common/get-friends'; /** * Get followers of a user - * - * @param {any} params - * @param {any} me - * @return {Promise<any>} */ module.exports = (params, me) => new Promise(async (res, rej) => { // Get 'userId' parameter - const [userId, userIdErr] = $(params.userId).id().$; + const [userId, userIdErr] = $(params.userId).type(ID).$; if (userIdErr) return rej('invalid userId param'); // Get 'iknow' parameter @@ -28,7 +24,7 @@ module.exports = (params, me) => new Promise(async (res, rej) => { if (limitErr) return rej('invalid limit param'); // Get 'cursor' parameter - const [cursor = null, cursorErr] = $(params.cursor).optional.id().$; + const [cursor = null, cursorErr] = $(params.cursor).optional.type(ID).$; if (cursorErr) return rej('invalid cursor param'); // Lookup user diff --git a/src/server/api/endpoints/users/following.ts b/src/server/api/endpoints/users/following.ts index 9fb135b24d..63a73a2e27 100644 --- a/src/server/api/endpoints/users/following.ts +++ b/src/server/api/endpoints/users/following.ts @@ -1,7 +1,7 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import User from '../../../../models/user'; import Following from '../../../../models/following'; import { pack } from '../../../../models/user'; @@ -16,7 +16,7 @@ import { getFriendIds } from '../../common/get-friends'; */ module.exports = (params, me) => new Promise(async (res, rej) => { // Get 'userId' parameter - const [userId, userIdErr] = $(params.userId).id().$; + const [userId, userIdErr] = $(params.userId).type(ID).$; if (userIdErr) return rej('invalid userId param'); // Get 'iknow' parameter @@ -28,7 +28,7 @@ module.exports = (params, me) => new Promise(async (res, rej) => { if (limitErr) return rej('invalid limit param'); // Get 'cursor' parameter - const [cursor = null, cursorErr] = $(params.cursor).optional.id().$; + const [cursor = null, cursorErr] = $(params.cursor).optional.type(ID).$; if (cursorErr) return rej('invalid cursor param'); // Lookup user diff --git a/src/server/api/endpoints/users/get_frequently_replied_users.ts b/src/server/api/endpoints/users/get_frequently_replied_users.ts index 7a98f44e98..4c00620a52 100644 --- a/src/server/api/endpoints/users/get_frequently_replied_users.ts +++ b/src/server/api/endpoints/users/get_frequently_replied_users.ts @@ -1,13 +1,13 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import Note from '../../../../models/note'; import User, { pack } from '../../../../models/user'; module.exports = (params, me) => new Promise(async (res, rej) => { // Get 'userId' parameter - const [userId, userIdErr] = $(params.userId).id().$; + const [userId, userIdErr] = $(params.userId).type(ID).$; if (userIdErr) return rej('invalid userId param'); // Get 'limit' parameter diff --git a/src/server/api/endpoints/users/lists/create.ts b/src/server/api/endpoints/users/lists/create.ts new file mode 100644 index 0000000000..6ae510f52b --- /dev/null +++ b/src/server/api/endpoints/users/lists/create.ts @@ -0,0 +1,25 @@ +/** + * Module dependencies + */ +import $ from 'cafy'; +import UserList, { pack } from '../../../../../models/user-list'; + +/** + * Create a user list + */ +module.exports = async (params, user) => new Promise(async (res, rej) => { + // Get 'title' parameter + const [title, titleErr] = $(params.title).string().range(1, 100).$; + if (titleErr) return rej('invalid title param'); + + // insert + const userList = await UserList.insert({ + createdAt: new Date(), + userId: user._id, + title: title, + userIds: [] + }); + + // Response + res(await pack(userList)); +}); diff --git a/src/server/api/endpoints/users/lists/list.ts b/src/server/api/endpoints/users/lists/list.ts new file mode 100644 index 0000000000..d19339a1f5 --- /dev/null +++ b/src/server/api/endpoints/users/lists/list.ts @@ -0,0 +1,13 @@ +import UserList, { pack } from '../../../../../models/user-list'; + +/** + * Add a user to a user list + */ +module.exports = async (params, me) => new Promise(async (res, rej) => { + // Fetch lists + const userLists = await UserList.find({ + userId: me._id, + }); + + res(await Promise.all(userLists.map(x => pack(x)))); +}); diff --git a/src/server/api/endpoints/users/lists/push.ts b/src/server/api/endpoints/users/lists/push.ts new file mode 100644 index 0000000000..467c08efd4 --- /dev/null +++ b/src/server/api/endpoints/users/lists/push.ts @@ -0,0 +1,51 @@ +import $ from 'cafy'; import ID from '../../../../../cafy-id'; +import UserList from '../../../../../models/user-list'; +import User, { pack as packUser } from '../../../../../models/user'; +import { publishUserListStream } from '../../../../../publishers/stream'; + +/** + * Add a user to a user list + */ +module.exports = async (params, me) => new Promise(async (res, rej) => { + // Get 'listId' parameter + const [listId, listIdErr] = $(params.listId).type(ID).$; + if (listIdErr) return rej('invalid listId param'); + + // Fetch the list + const userList = await UserList.findOne({ + _id: listId, + userId: me._id, + }); + + if (userList == null) { + return rej('list not found'); + } + + // Get 'userId' parameter + const [userId, userIdErr] = $(params.userId).type(ID).$; + if (userIdErr) return rej('invalid userId param'); + + // Fetch the user + const user = await User.findOne({ + _id: userId + }); + + if (user == null) { + return rej('user not found'); + } + + if (userList.userIds.map(id => id.toHexString()).includes(user._id.toHexString())) { + return rej('the user already added'); + } + + // Push the user + await UserList.update({ _id: userList._id }, { + $push: { + userIds: user._id + } + }); + + res(); + + publishUserListStream(userList._id, 'userAdded', await packUser(user)); +}); diff --git a/src/server/api/endpoints/users/lists/show.ts b/src/server/api/endpoints/users/lists/show.ts new file mode 100644 index 0000000000..61e0f0463f --- /dev/null +++ b/src/server/api/endpoints/users/lists/show.ts @@ -0,0 +1,23 @@ +import $ from 'cafy'; import ID from '../../../../../cafy-id'; +import UserList, { pack } from '../../../../../models/user-list'; + +/** + * Show a user list + */ +module.exports = async (params, me) => new Promise(async (res, rej) => { + // Get 'listId' parameter + const [listId, listIdErr] = $(params.listId).type(ID).$; + if (listIdErr) return rej('invalid listId param'); + + // Fetch the list + const userList = await UserList.findOne({ + _id: listId, + userId: me._id, + }); + + if (userList == null) { + return rej('list not found'); + } + + res(await pack(userList)); +}); diff --git a/src/server/api/endpoints/users/notes.ts b/src/server/api/endpoints/users/notes.ts index bd4247c79c..dafa18bcc9 100644 --- a/src/server/api/endpoints/users/notes.ts +++ b/src/server/api/endpoints/users/notes.ts @@ -1,7 +1,7 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import getHostLower from '../../common/get-host-lower'; import Note, { pack } from '../../../../models/note'; import User from '../../../../models/user'; @@ -11,7 +11,7 @@ import User from '../../../../models/user'; */ module.exports = (params, me) => new Promise(async (res, rej) => { // Get 'userId' parameter - const [userId, userIdErr] = $(params.userId).optional.id().$; + const [userId, userIdErr] = $(params.userId).optional.type(ID).$; if (userIdErr) return rej('invalid userId param'); // Get 'username' parameter @@ -43,11 +43,11 @@ module.exports = (params, me) => new Promise(async (res, rej) => { if (limitErr) return rej('invalid limit param'); // Get 'sinceId' parameter - const [sinceId, sinceIdErr] = $(params.sinceId).optional.id().$; + const [sinceId, sinceIdErr] = $(params.sinceId).optional.type(ID).$; if (sinceIdErr) return rej('invalid sinceId param'); // Get 'untilId' parameter - const [untilId, untilIdErr] = $(params.untilId).optional.id().$; + const [untilId, untilIdErr] = $(params.untilId).optional.type(ID).$; if (untilIdErr) return rej('invalid untilId param'); // Get 'sinceDate' parameter diff --git a/src/server/api/endpoints/users/search_by_username.ts b/src/server/api/endpoints/users/search_by_username.ts index 5f6ececff9..91d9ad1f3a 100644 --- a/src/server/api/endpoints/users/search_by_username.ts +++ b/src/server/api/endpoints/users/search_by_username.ts @@ -6,10 +6,6 @@ import User, { pack } from '../../../../models/user'; /** * Search a user by username - * - * @param {any} params - * @param {any} me - * @return {Promise<any>} */ module.exports = (params, me) => new Promise(async (res, rej) => { // Get 'query' parameter diff --git a/src/server/api/endpoints/users/show.ts b/src/server/api/endpoints/users/show.ts index 7e7f5dc488..141565ece6 100644 --- a/src/server/api/endpoints/users/show.ts +++ b/src/server/api/endpoints/users/show.ts @@ -1,22 +1,26 @@ /** * Module dependencies */ -import $ from 'cafy'; +import $ from 'cafy'; import ID from '../../../../cafy-id'; import User, { pack } from '../../../../models/user'; import resolveRemoteUser from '../../../../remote/resolve-user'; const cursorOption = { fields: { data: false } }; /** - * Show a user + * Show user(s) */ module.exports = (params, me) => new Promise(async (res, rej) => { let user; // Get 'userId' parameter - const [userId, userIdErr] = $(params.userId).optional.id().$; + const [userId, userIdErr] = $(params.userId).optional.type(ID).$; if (userIdErr) return rej('invalid userId param'); + // Get 'userIds' parameter + const [userIds, userIdsErr] = $(params.userIds).optional.array($().type(ID)).$; + if (userIdsErr) return rej('invalid userIds param'); + // Get 'username' parameter const [username, usernameErr] = $(params.username).optional.string().$; if (usernameErr) return rej('invalid username param'); @@ -25,32 +29,40 @@ module.exports = (params, me) => new Promise(async (res, rej) => { const [host, hostErr] = $(params.host).nullable.optional.string().$; if (hostErr) return rej('invalid host param'); - if (userId === undefined && typeof username !== 'string') { - return rej('userId or pair of username and host is required'); - } + if (userIds) { + const users = await User.find({ + _id: { + $in: userIds + } + }); - // Lookup user - if (typeof host === 'string') { - try { - user = await resolveRemoteUser(username, host, cursorOption); - } catch (e) { - console.warn(`failed to resolve remote user: ${e}`); - return rej('failed to resolve remote user'); - } + res(await Promise.all(users.map(u => pack(u, me, { + detail: true + })))); } else { - const q = userId !== undefined - ? { _id: userId } - : { usernameLower: username.toLowerCase(), host: null }; + // Lookup user + if (typeof host === 'string') { + try { + user = await resolveRemoteUser(username, host, cursorOption); + } catch (e) { + console.warn(`failed to resolve remote user: ${e}`); + return rej('failed to resolve remote user'); + } + } else { + const q = userId !== undefined + ? { _id: userId } + : { usernameLower: username.toLowerCase(), host: null }; - user = await User.findOne(q, cursorOption); + user = await User.findOne(q, cursorOption); - if (user === null) { - return rej('user not found'); + if (user === null) { + return rej('user not found'); + } } - } - // Send response - res(await pack(user, me, { - detail: true - })); + // Send response + res(await pack(user, me, { + detail: true + })); + } }); diff --git a/src/server/api/stream/user-list.ts b/src/server/api/stream/user-list.ts new file mode 100644 index 0000000000..ba03b97860 --- /dev/null +++ b/src/server/api/stream/user-list.ts @@ -0,0 +1,14 @@ +import * as websocket from 'websocket'; +import * as redis from 'redis'; +import { ParsedUrlQuery } from 'querystring'; + +export default function(request: websocket.request, connection: websocket.connection, subscriber: redis.RedisClient, user: any): void { + const q = request.resourceURL.query as ParsedUrlQuery; + const listId = q.listId as string; + + // Subscribe stream + subscriber.subscribe(`misskey:user-list-stream:${listId}`); + subscriber.on('message', (_, data) => { + connection.send(data); + }); +} diff --git a/src/server/api/streaming.ts b/src/server/api/streaming.ts index ce13253649..e4884ed7c4 100644 --- a/src/server/api/streaming.ts +++ b/src/server/api/streaming.ts @@ -6,6 +6,7 @@ import config from '../../config'; import homeStream from './stream/home'; import localTimelineStream from './stream/local-timeline'; import globalTimelineStream from './stream/global-timeline'; +import userListStream from './stream/user-list'; import driveStream from './stream/drive'; import messagingStream from './stream/messaging'; import messagingIndexStream from './stream/messaging-index'; @@ -70,6 +71,7 @@ module.exports = (server: http.Server) => { request.resourceURL.pathname === '/' ? homeStream : request.resourceURL.pathname === '/local-timeline' ? localTimelineStream : request.resourceURL.pathname === '/global-timeline' ? globalTimelineStream : + request.resourceURL.pathname === '/user-list' ? userListStream : request.resourceURL.pathname === '/drive' ? driveStream : request.resourceURL.pathname === '/messaging' ? messagingStream : request.resourceURL.pathname === '/messaging-index' ? messagingIndexStream : diff --git a/src/server/index.ts b/src/server/index.ts index 2b5a910507..594f40c22f 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -11,6 +11,7 @@ import * as Router from 'koa-router'; import * as mount from 'koa-mount'; import * as compress from 'koa-compress'; import * as logger from 'koa-logger'; +const slow = require('koa-slow'); import activityPub from './activitypub'; import webFinger from './webfinger'; @@ -23,6 +24,11 @@ app.proxy = true; if (process.env.NODE_ENV != 'production') { // Logger app.use(logger()); + + // Delay + app.use(slow({ + delay: 1000 + })); } // Compress response diff --git a/src/services/note/create.ts b/src/services/note/create.ts index e5ad96898f..4808edfda4 100644 --- a/src/services/note/create.ts +++ b/src/services/note/create.ts @@ -1,6 +1,6 @@ import Note, { pack, INote } from '../../models/note'; import User, { isLocalUser, IUser, isRemoteUser } from '../../models/user'; -import stream, { publishLocalTimelineStream, publishGlobalTimelineStream } from '../../publishers/stream'; +import stream, { publishLocalTimelineStream, publishGlobalTimelineStream, publishUserListStream } from '../../publishers/stream'; import Following from '../../models/following'; import { deliver } from '../../queue'; import renderNote from '../../remote/activitypub/renderer/note'; @@ -16,6 +16,7 @@ import pushSw from '../../publishers/push-sw'; import event from '../../publishers/stream'; import parse from '../../text/parse'; import { IApp } from '../../models/app'; +import UserList from '../../models/user-list'; export default async (user: IUser, data: { createdAt?: Date; @@ -110,60 +111,73 @@ export default async (user: IUser, data: { // タイムラインへの投稿 if (note.channelId == null) { - if (isLocalUser(user)) { - // Publish event to myself's stream - stream(note.userId, 'note', noteObj); - - // Publish note to local timeline stream - publishLocalTimelineStream(noteObj); - } - - // Publish note to global timeline stream - publishGlobalTimelineStream(noteObj); - - // Fetch all followers - const followers = await Following.find({ - followeeId: note.userId - }); - if (!silent) { - const render = async () => { - const content = data.renote && data.text == null - ? renderAnnounce(data.renote.uri ? data.renote.uri : await renderNote(data.renote)) - : renderCreate(await renderNote(note)); - return packAp(content); - }; + if (isLocalUser(user)) { + // Publish event to myself's stream + stream(note.userId, 'note', noteObj); - // 投稿がリプライかつ投稿者がローカルユーザーかつリプライ先の投稿の投稿者がリモートユーザーなら配送 - if (data.reply && isLocalUser(user) && isRemoteUser(data.reply._user)) { - deliver(user, await render(), data.reply._user.inbox); + // Publish note to local timeline stream + publishLocalTimelineStream(noteObj); } - // 投稿がRenoteかつ投稿者がローカルユーザーかつRenote元の投稿の投稿者がリモートユーザーなら配送 - if (data.renote && isLocalUser(user) && isRemoteUser(data.renote._user)) { - deliver(user, await render(), data.renote._user.inbox); - } + // Publish note to global timeline stream + publishGlobalTimelineStream(noteObj); - Promise.all(followers.map(async following => { - const follower = following._follower; + // フォロワーに配信 + Following.find({ + followeeId: note.userId + }).then(followers => { + followers.map(async following => { + const follower = following._follower; - if (isLocalUser(follower)) { - // ストーキングしていない場合 - if (!following.stalk) { - // この投稿が返信ならスキップ - if (note.replyId && !note._reply.userId.equals(following.followerId) && !note._reply.userId.equals(note.userId)) return; + if (isLocalUser(follower)) { + // ストーキングしていない場合 + if (!following.stalk) { + // この投稿が返信ならスキップ + if (note.replyId && !note._reply.userId.equals(following.followerId) && !note._reply.userId.equals(note.userId)) return; + } + + // Publish event to followers stream + stream(following.followerId, 'note', noteObj); + } else { + //#region AP配送 + // フォロワーがリモートユーザーかつ投稿者がローカルユーザーなら投稿を配信 + if (isLocalUser(user)) { + deliver(user, await render(), follower.inbox); + } + //#endergion } + }); + }); - // Publish event to followers stream - stream(following.followerId, 'note', noteObj); - } else { - // フォロワーがリモートユーザーかつ投稿者がローカルユーザーなら投稿を配信 - if (isLocalUser(user)) { - deliver(user, await render(), follower.inbox); - } - } - })); + // リストに配信 + UserList.find({ + userIds: note.userId + }).then(lists => { + lists.forEach(list => { + publishUserListStream(list._id, 'note', noteObj); + }); + }); } + + //#region AP配送 + const render = async () => { + const content = data.renote && data.text == null + ? renderAnnounce(data.renote.uri ? data.renote.uri : await renderNote(data.renote)) + : renderCreate(await renderNote(note)); + return packAp(content); + }; + + // 投稿がリプライかつ投稿者がローカルユーザーかつリプライ先の投稿の投稿者がリモートユーザーなら配送 + if (data.reply && isLocalUser(user) && isRemoteUser(data.reply._user)) { + deliver(user, await render(), data.reply._user.inbox); + } + + // 投稿がRenoteかつ投稿者がローカルユーザーかつRenote元の投稿の投稿者がリモートユーザーなら配送 + if (data.renote && isLocalUser(user) && isRemoteUser(data.renote._user)) { + deliver(user, await render(), data.renote._user.inbox); + } + //#endergion } // チャンネルへの投稿 diff --git a/test/api.ts b/test/api.ts index 87bbb8ee16..d8c163e920 100644 --- a/test/api.ts +++ b/test/api.ts @@ -15,11 +15,10 @@ process.on('unhandledRejection', console.dir); const fs = require('fs'); const _chai = require('chai'); const chaiHttp = require('chai-http'); -const should = _chai.should(); _chai.use(chaiHttp); -const server = require('../built/server/api'); +const server = require('../built/server/api').callback(); const db = require('../built/db/mongodb').default; const async = fn => (done) => { @@ -56,16 +55,6 @@ describe('API', () => { db.get('authSessions').drop() ])); - it('greet server', done => { - _chai.request(server) - .get('/') - .end((err, res) => { - res.should.have.status(200); - res.text.should.be.equal('YEE HAW'); - done(); - }); - }); - describe('signup', () => { it('不正なユーザー名でアカウントが作成できない', async(async () => { const res = await request('/signup', { diff --git a/tslint.json b/tslint.json index d3f96000b9..ae0df46b96 100644 --- a/tslint.json +++ b/tslint.json @@ -5,6 +5,7 @@ ], "jsRules": {}, "rules": { + "align": false, "indent": ["tab"], "quotemark": ["single"], "no-var-requires": false,