diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index a3b2bd88e..11dd76d0e 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -169,6 +169,7 @@ common: hashtag: "ハッシュタグ" global: "グローバル" mentions: "あなた宛て" + direct: "ダイレクト投稿" notifications: "通知" list: "リスト" swap-left: "左に移動" @@ -916,6 +917,7 @@ desktop/views/components/timeline.vue: hybrid: "ソーシャル" global: "グローバル" mentions: "あなた宛て" + messages: "メッセージ" list: "リスト" hashtag: "ハッシュタグ" add-tag-timeline: "ハッシュタグを追加" @@ -1322,6 +1324,7 @@ mobile/views/pages/home.vue: hybrid: "ソーシャル" global: "グローバル" mentions: "あなた宛て" + messages: "メッセージ" mobile/views/pages/tag.vue: no-posts-found: "ハッシュタグ「{}」が付けられた投稿は見つかりませんでした。" diff --git a/src/client/app/desktop/views/components/timeline.core.vue b/src/client/app/desktop/views/components/timeline.core.vue index d2176dee8..c8aa36f17 100644 --- a/src/client/app/desktop/views/components/timeline.core.vue +++ b/src/client/app/desktop/views/components/timeline.core.vue @@ -38,7 +38,14 @@ export default Vue.extend({ streamManager: null, connection: null, connectionId: null, - date: null + date: null, + baseQuery: { + includeMyRenotes: this.$store.state.settings.showMyRenotes, + includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, + includeLocalRenotes: this.$store.state.settings.showLocalRenotes + }, + query: {}, + endpoint: null }; }, @@ -47,53 +54,102 @@ export default Vue.extend({ return this.$store.state.i.followingCount == 0; }, - endpoint(): string { - switch (this.src) { - case 'home': return 'notes/timeline'; - case 'local': return 'notes/local-timeline'; - case 'hybrid': return 'notes/hybrid-timeline'; - case 'global': return 'notes/global-timeline'; - case 'mentions': return 'notes/mentions'; - case 'tag': return 'notes/search_by_tag'; - } - }, - canFetchMore(): boolean { return !this.moreFetching && !this.fetching && this.existMore; } }, mounted() { + const prepend = note => { + (this.$refs.timeline as any).prepend(note); + }; + if (this.src == 'tag') { + this.endpoint = 'notes/search_by_tag'; + this.query = { + query: this.tagTl.query + }; this.connection = new HashtagStream((this as any).os, this.$store.state.i, this.tagTl.query); - this.connection.on('note', this.onNote); + this.connection.on('note', prepend); + this.$once('beforeDestroy', () => { + this.connection.off('note', prepend); + this.connection.close(); + }); } else if (this.src == 'home') { + this.endpoint = 'notes/timeline'; + const onChangeFollowing = () => { + this.fetch(); + }; this.streamManager = (this as any).os.stream; this.connection = this.streamManager.getConnection(); this.connectionId = this.streamManager.use(); - this.connection.on('note', this.onNote); - this.connection.on('follow', this.onChangeFollowing); - this.connection.on('unfollow', this.onChangeFollowing); + this.connection.on('note', prepend); + this.connection.on('follow', onChangeFollowing); + this.connection.on('unfollow', onChangeFollowing); + this.$once('beforeDestroy', () => { + this.connection.off('note', prepend); + this.connection.off('follow', onChangeFollowing); + this.connection.off('unfollow', onChangeFollowing); + this.streamManager.dispose(this.connectionId); + }); } else if (this.src == 'local') { + this.endpoint = 'notes/local-timeline'; this.streamManager = (this as any).os.streams.localTimelineStream; this.connection = this.streamManager.getConnection(); this.connectionId = this.streamManager.use(); - this.connection.on('note', this.onNote); + this.connection.on('note', prepend); + this.$once('beforeDestroy', () => { + this.connection.off('note', prepend); + this.streamManager.dispose(this.connectionId); + }); } else if (this.src == 'hybrid') { + this.endpoint = 'notes/hybrid-timeline'; this.streamManager = (this as any).os.streams.hybridTimelineStream; this.connection = this.streamManager.getConnection(); this.connectionId = this.streamManager.use(); - this.connection.on('note', this.onNote); + this.connection.on('note', prepend); + this.$once('beforeDestroy', () => { + this.connection.off('note', prepend); + this.streamManager.dispose(this.connectionId); + }); } else if (this.src == 'global') { + this.endpoint = 'notes/global-timeline'; this.streamManager = (this as any).os.streams.globalTimelineStream; this.connection = this.streamManager.getConnection(); this.connectionId = this.streamManager.use(); - this.connection.on('note', this.onNote); + this.connection.on('note', prepend); + this.$once('beforeDestroy', () => { + this.connection.off('note', prepend); + this.streamManager.dispose(this.connectionId); + }); } else if (this.src == 'mentions') { + this.endpoint = 'notes/mentions'; this.streamManager = (this as any).os.stream; this.connection = this.streamManager.getConnection(); this.connectionId = this.streamManager.use(); - this.connection.on('mention', this.onNote); + this.connection.on('mention', prepend); + this.$once('beforeDestroy', () => { + this.connection.off('mention', prepend); + this.streamManager.dispose(this.connectionId); + }); + } else if (this.src == 'messages') { + this.endpoint = 'notes/mentions'; + this.query = { + visibility: 'specified' + }; + const onNote = note => { + if (note.visibility == 'specified') { + prepend(note); + } + }; + this.streamManager = (this as any).os.stream; + this.connection = this.streamManager.getConnection(); + this.connectionId = this.streamManager.use(); + this.connection.on('mention', onNote); + this.$once('beforeDestroy', () => { + this.connection.off('mention', onNote); + this.streamManager.dispose(this.connectionId); + }); } document.addEventListener('keydown', this.onKeydown); @@ -102,28 +158,7 @@ export default Vue.extend({ }, beforeDestroy() { - if (this.src == 'tag') { - this.connection.off('note', this.onNote); - this.connection.close(); - } else if (this.src == 'home') { - this.connection.off('note', this.onNote); - this.connection.off('follow', this.onChangeFollowing); - this.connection.off('unfollow', this.onChangeFollowing); - this.streamManager.dispose(this.connectionId); - } else if (this.src == 'local') { - this.connection.off('note', this.onNote); - this.streamManager.dispose(this.connectionId); - } else if (this.src == 'hybrid') { - this.connection.off('note', this.onNote); - this.streamManager.dispose(this.connectionId); - } else if (this.src == 'global') { - this.connection.off('note', this.onNote); - this.streamManager.dispose(this.connectionId); - } else if (this.src == 'mentions') { - this.connection.off('mention', this.onNote); - this.streamManager.dispose(this.connectionId); - } - + this.$emit('beforeDestroy'); document.removeEventListener('keydown', this.onKeydown); }, @@ -132,14 +167,10 @@ export default Vue.extend({ this.fetching = true; (this.$refs.timeline as any).init(() => new Promise((res, rej) => { - (this as any).api(this.endpoint, { + (this as any).api(this.endpoint, Object.assign({ limit: fetchLimit + 1, - untilDate: this.date ? this.date.getTime() : undefined, - includeMyRenotes: this.$store.state.settings.showMyRenotes, - includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, - includeLocalRenotes: this.$store.state.settings.showLocalRenotes, - query: this.tagTl ? this.tagTl.query : undefined - }).then(notes => { + untilDate: this.date ? this.date.getTime() : undefined + }, this.baseQuery, this.query)).then(notes => { if (notes.length == fetchLimit + 1) { notes.pop(); this.existMore = true; @@ -156,14 +187,10 @@ export default Vue.extend({ this.moreFetching = true; - const promise = (this as any).api(this.endpoint, { + const promise = (this as any).api(this.endpoint, Object.assign({ limit: fetchLimit + 1, - untilId: (this.$refs.timeline as any).tail().id, - includeMyRenotes: this.$store.state.settings.showMyRenotes, - includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, - includeLocalRenotes: this.$store.state.settings.showLocalRenotes, - query: this.tagTl ? this.tagTl.query : undefined - }); + untilId: (this.$refs.timeline as any).tail().id + }, this.baseQuery, this.query)); promise.then(notes => { if (notes.length == fetchLimit + 1) { @@ -178,15 +205,6 @@ export default Vue.extend({ return promise; }, - onNote(note) { - // Prepend a note - (this.$refs.timeline as any).prepend(note); - }, - - onChangeFollowing() { - this.fetch(); - }, - focus() { (this.$refs.timeline as any).focus(); }, diff --git a/src/client/app/desktop/views/components/timeline.vue b/src/client/app/desktop/views/components/timeline.vue index 2dc84004d..ccc35f95f 100644 --- a/src/client/app/desktop/views/components/timeline.vue +++ b/src/client/app/desktop/views/components/timeline.vue @@ -5,10 +5,11 @@ %fa:R comments% %i18n:@local% %fa:share-alt% %i18n:@hybrid% %fa:globe% %i18n:@global% - %fa:at% %i18n:@mentions% %fa:hashtag% {{ tagTl.title }} %fa:list% {{ list.title }}
+ +
@@ -18,6 +19,7 @@ + @@ -202,6 +204,20 @@ root(isDark) &:active color isDark ? #b2c1d5 : #999 + &[data-active] + color $theme-color + cursor default + + &:before + content "" + display block + position absolute + bottom 0 + left 0 + width 100% + height 2px + background $theme-color + > span display inline-block padding 0 10px diff --git a/src/client/app/desktop/views/pages/deck/deck.column-core.vue b/src/client/app/desktop/views/pages/deck/deck.column-core.vue index a320f697b..e1490cb0e 100644 --- a/src/client/app/desktop/views/pages/deck/deck.column-core.vue +++ b/src/client/app/desktop/views/pages/deck/deck.column-core.vue @@ -8,6 +8,7 @@ + diff --git a/src/client/app/desktop/views/pages/deck/deck.direct.vue b/src/client/app/desktop/views/pages/deck/deck.direct.vue new file mode 100644 index 000000000..ec9e6b9c3 --- /dev/null +++ b/src/client/app/desktop/views/pages/deck/deck.direct.vue @@ -0,0 +1,97 @@ + + + diff --git a/src/client/app/desktop/views/pages/deck/deck.vue b/src/client/app/desktop/views/pages/deck/deck.vue index aafe9a45d..e5aeba251 100644 --- a/src/client/app/desktop/views/pages/deck/deck.vue +++ b/src/client/app/desktop/views/pages/deck/deck.vue @@ -147,6 +147,15 @@ export default Vue.extend({ type: 'mentions' }); } + }, { + icon: '%fa:envelope R%', + text: '%i18n:common.deck.direct%', + action: () => { + this.$store.dispatch('settings/addDeckColumn', { + id: uuid(), + type: 'direct' + }); + } }, { icon: '%fa:list%', text: '%i18n:common.deck.list%', diff --git a/src/client/app/mobile/views/pages/home.timeline.vue b/src/client/app/mobile/views/pages/home.timeline.vue index fecb2384b..225abcff6 100644 --- a/src/client/app/mobile/views/pages/home.timeline.vue +++ b/src/client/app/mobile/views/pages/home.timeline.vue @@ -37,7 +37,14 @@ export default Vue.extend({ connection: null, connectionId: null, unreadCount: 0, - date: null + date: null, + baseQuery: { + includeMyRenotes: this.$store.state.settings.showMyRenotes, + includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, + includeLocalRenotes: this.$store.state.settings.showLocalRenotes + }, + query: {}, + endpoint: null }; }, @@ -46,80 +53,109 @@ export default Vue.extend({ return this.$store.state.i.followingCount == 0; }, - endpoint(): string { - switch (this.src) { - case 'home': return 'notes/timeline'; - case 'local': return 'notes/local-timeline'; - case 'hybrid': return 'notes/hybrid-timeline'; - case 'global': return 'notes/global-timeline'; - case 'mentions': return 'notes/mentions'; - case 'tag': return 'notes/search_by_tag'; - } - }, - canFetchMore(): boolean { return !this.moreFetching && !this.fetching && this.existMore; } }, mounted() { + const prepend = note => { + (this.$refs.timeline as any).prepend(note); + }; + if (this.src == 'tag') { + this.endpoint = 'notes/search_by_tag'; + this.query = { + query: this.tagTl.query + }; this.connection = new HashtagStream((this as any).os, this.$store.state.i, this.tagTl.query); - this.connection.on('note', this.onNote); + this.connection.on('note', prepend); + this.$once('beforeDestroy', () => { + this.connection.off('note', prepend); + this.connection.close(); + }); } else if (this.src == 'home') { + this.endpoint = 'notes/timeline'; + const onChangeFollowing = () => { + this.fetch(); + }; this.streamManager = (this as any).os.stream; this.connection = this.streamManager.getConnection(); this.connectionId = this.streamManager.use(); - this.connection.on('note', this.onNote); - this.connection.on('follow', this.onChangeFollowing); - this.connection.on('unfollow', this.onChangeFollowing); + this.connection.on('note', prepend); + this.connection.on('follow', onChangeFollowing); + this.connection.on('unfollow', onChangeFollowing); + this.$once('beforeDestroy', () => { + this.connection.off('note', prepend); + this.connection.off('follow', onChangeFollowing); + this.connection.off('unfollow', onChangeFollowing); + this.streamManager.dispose(this.connectionId); + }); } else if (this.src == 'local') { + this.endpoint = 'notes/local-timeline'; this.streamManager = (this as any).os.streams.localTimelineStream; this.connection = this.streamManager.getConnection(); this.connectionId = this.streamManager.use(); - this.connection.on('note', this.onNote); + this.connection.on('note', prepend); + this.$once('beforeDestroy', () => { + this.connection.off('note', prepend); + this.streamManager.dispose(this.connectionId); + }); } else if (this.src == 'hybrid') { + this.endpoint = 'notes/hybrid-timeline'; this.streamManager = (this as any).os.streams.hybridTimelineStream; this.connection = this.streamManager.getConnection(); this.connectionId = this.streamManager.use(); - this.connection.on('note', this.onNote); + this.connection.on('note', prepend); + this.$once('beforeDestroy', () => { + this.connection.off('note', prepend); + this.streamManager.dispose(this.connectionId); + }); } else if (this.src == 'global') { + this.endpoint = 'notes/global-timeline'; this.streamManager = (this as any).os.streams.globalTimelineStream; this.connection = this.streamManager.getConnection(); this.connectionId = this.streamManager.use(); - this.connection.on('note', this.onNote); + this.connection.on('note', prepend); + this.$once('beforeDestroy', () => { + this.connection.off('note', prepend); + this.streamManager.dispose(this.connectionId); + }); } else if (this.src == 'mentions') { + this.endpoint = 'notes/mentions'; this.streamManager = (this as any).os.stream; this.connection = this.streamManager.getConnection(); this.connectionId = this.streamManager.use(); - this.connection.on('mention', this.onNote); + this.connection.on('mention', prepend); + this.$once('beforeDestroy', () => { + this.connection.off('mention', prepend); + this.streamManager.dispose(this.connectionId); + }); + } else if (this.src == 'messages') { + this.endpoint = 'notes/mentions'; + this.query = { + visibility: 'specified' + }; + const onNote = note => { + if (note.visibility == 'specified') { + prepend(note); + } + }; + this.streamManager = (this as any).os.stream; + this.connection = this.streamManager.getConnection(); + this.connectionId = this.streamManager.use(); + this.connection.on('mention', onNote); + this.$once('beforeDestroy', () => { + this.connection.off('mention', onNote); + this.streamManager.dispose(this.connectionId); + }); } this.fetch(); }, beforeDestroy() { - if (this.src == 'tag') { - this.connection.off('note', this.onNote); - this.connection.close(); - } else if (this.src == 'home') { - this.connection.off('note', this.onNote); - this.connection.off('follow', this.onChangeFollowing); - this.connection.off('unfollow', this.onChangeFollowing); - this.streamManager.dispose(this.connectionId); - } else if (this.src == 'local') { - this.connection.off('note', this.onNote); - this.streamManager.dispose(this.connectionId); - } else if (this.src == 'hybrid') { - this.connection.off('note', this.onNote); - this.streamManager.dispose(this.connectionId); - } else if (this.src == 'global') { - this.connection.off('note', this.onNote); - this.streamManager.dispose(this.connectionId); - } else if (this.src == 'mentions') { - this.connection.off('mention', this.onNote); - this.streamManager.dispose(this.connectionId); - } + this.$emit('beforeDestroy'); }, methods: { @@ -127,14 +163,10 @@ export default Vue.extend({ this.fetching = true; (this.$refs.timeline as any).init(() => new Promise((res, rej) => { - (this as any).api(this.endpoint, { + (this as any).api(this.endpoint, Object.assign({ limit: fetchLimit + 1, - untilDate: this.date ? this.date.getTime() : undefined, - includeMyRenotes: this.$store.state.settings.showMyRenotes, - includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, - includeLocalRenotes: this.$store.state.settings.showLocalRenotes, - query: this.tagTl ? this.tagTl.query : undefined - }).then(notes => { + untilDate: this.date ? this.date.getTime() : undefined + }, this.baseQuery, this.query)).then(notes => { if (notes.length == fetchLimit + 1) { notes.pop(); this.existMore = true; @@ -151,14 +183,10 @@ export default Vue.extend({ this.moreFetching = true; - const promise = (this as any).api(this.endpoint, { + const promise = (this as any).api(this.endpoint, Object.assign({ limit: fetchLimit + 1, - untilId: (this.$refs.timeline as any).tail().id, - includeMyRenotes: this.$store.state.settings.showMyRenotes, - includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, - includeLocalRenotes: this.$store.state.settings.showLocalRenotes, - query: this.tagTl ? this.tagTl.query : undefined - }); + untilId: (this.$refs.timeline as any).tail().id + }, this.baseQuery, this.query)); promise.then(notes => { if (notes.length == fetchLimit + 1) { @@ -173,15 +201,6 @@ export default Vue.extend({ return promise; }, - onNote(note) { - // Prepend a note - (this.$refs.timeline as any).prepend(note); - }, - - onChangeFollowing() { - this.fetch(); - }, - focus() { (this.$refs.timeline as any).focus(); }, diff --git a/src/client/app/mobile/views/pages/home.vue b/src/client/app/mobile/views/pages/home.vue index 3ec2f16b7..e61916fe1 100644 --- a/src/client/app/mobile/views/pages/home.vue +++ b/src/client/app/mobile/views/pages/home.vue @@ -7,6 +7,7 @@ %fa:share-alt%%i18n:@hybrid% %fa:globe%%i18n:@global% %fa:at%%i18n:@mentions% + %fa:envelope R%%i18n:@messages% %fa:list%{{ list.title }} %fa:hashtag%{{ tagTl.title }} @@ -23,16 +24,21 @@
@@ -150,6 +157,26 @@ export default Vue.extend({ root(isDark) > .nav + > .pointer + position fixed + z-index 10002 + top 56px + left 0 + right 0 + + $size = 16px + + &:after + content "" + display block + position absolute + top -($size * 2) + left s('calc(50% - %s)', $size) + border-top solid $size transparent + border-left solid $size transparent + border-right solid $size transparent + border-bottom solid $size isDark ? #272f3a : #fff + > .bg position fixed z-index 10000 @@ -166,28 +193,22 @@ root(isDark) left 0 right 0 width 300px + max-height calc(100% - 70px) margin 0 auto + overflow auto + -webkit-overflow-scrolling touch background isDark ? #272f3a : #fff border-radius 8px box-shadow 0 0 16px rgba(#000, 0.1) - $balloon-size = 16px - - &: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 isDark ? #272f3a : #fff - > div padding 8px 0 - > * + > .hr + margin 8px 0 + border-top solid 1px isDark ? rgba(#000, 0.3) : rgba(#000, 0.1) + + > *:not(.hr) display block padding 8px 16px color isDark ? #cdd0d8 : #666 diff --git a/src/server/api/endpoints/notes/mentions.ts b/src/server/api/endpoints/notes/mentions.ts index 3b2e262e4..8675a9f56 100644 --- a/src/server/api/endpoints/notes/mentions.ts +++ b/src/server/api/endpoints/notes/mentions.ts @@ -27,6 +27,9 @@ export const meta = { untilId: $.type(ID).optional.note({ }), + + visibility: $.str.optional.note({ + }), } }; @@ -52,6 +55,10 @@ export default (params: any, user: ILocalUser) => new Promise(async (res, rej) = _id: -1 }; + if (ps.visibility) { + query.visibility = ps.visibility; + } + if (ps.following) { const followingIds = await getFriendIds(user._id); diff --git a/src/services/note/create.ts b/src/services/note/create.ts index 7daf83b29..7c1e71dcb 100644 --- a/src/services/note/create.ts +++ b/src/services/note/create.ts @@ -142,6 +142,14 @@ export default async (user: IUser, data: Option, silent = false) => new Promise< mentionedUsers.push(await User.findOne({ _id: data.reply.userId })); } + if (data.visibility == 'specified') { + data.visibleUsers.forEach(u => { + if (!mentionedUsers.some(x => x._id.equals(u._id))) { + mentionedUsers.push(u); + } + }); + } + const note = await insertNote(user, data, tags, mentionedUsers); res(note); @@ -188,7 +196,7 @@ export default async (user: IUser, data: Option, silent = false) => new Promise< const nm = new NotificationManager(user, note); const nmRelatedPromises = []; - createMentionedEvents(mentionedUsers, noteObj, nm); + createMentionedEvents(mentionedUsers, note, nm); const noteActivity = await renderActivity(data, note); @@ -318,7 +326,7 @@ async function publish(user: IUser, note: INote, noteObj: any, reply: INote, ren if (['public', 'home', 'followers'].includes(note.visibility)) { // フォロワーに配信 - publishToFollowers(note, noteObj, user, noteActivity); + publishToFollowers(note, user, noteActivity); } // リストに配信 @@ -456,7 +464,7 @@ async function publishToUserLists(note: INote, noteObj: any) { }); } -async function publishToFollowers(note: INote, noteObj: any, user: IUser, noteActivity: any) { +async function publishToFollowers(note: INote, user: IUser, noteActivity: any) { const detailPackedNote = await pack(note, null, { detail: true, skipHide: true @@ -505,9 +513,13 @@ function deliverNoteToMentionedRemoteUsers(mentionedUsers: IUser[], user: ILocal }); } -function createMentionedEvents(mentionedUsers: IUser[], noteObj: any, nm: NotificationManager) { +function createMentionedEvents(mentionedUsers: IUser[], note: INote, nm: NotificationManager) { mentionedUsers.filter(u => isLocalUser(u)).forEach(async (u) => { - publishUserStream(u._id, 'mention', noteObj); + const detailPackedNote = await pack(note, u, { + detail: true + }); + + publishUserStream(u._id, 'mention', detailPackedNote); // Create notification nm.push(u._id, 'mention');