From d9f0e158a35eec183da77e84a3b038fab645bf62 Mon Sep 17 00:00:00 2001 From: syuilo <syuilotan@yahoo.co.jp> Date: Wed, 19 Sep 2018 14:18:34 +0900 Subject: [PATCH] Implement #2736 --- .../app/common/scripts/streaming/home.ts | 24 +++++++ .../desktop/views/components/notes.note.vue | 8 ++- .../app/desktop/views/components/timeline.vue | 11 +++- .../desktop/views/pages/deck/deck.note.vue | 8 ++- .../app/mobile/views/components/note.vue | 8 ++- .../app/mobile/views/components/ui.header.vue | 3 - src/client/app/mobile/views/pages/drive.vue | 6 +- .../app/mobile/views/pages/favorites.vue | 2 +- .../app/mobile/views/pages/games/reversi.vue | 2 +- src/client/app/mobile/views/pages/home.vue | 27 +++++++- .../app/mobile/views/pages/messaging-room.vue | 2 +- .../app/mobile/views/pages/messaging.vue | 2 +- src/client/app/mobile/views/pages/note.vue | 2 +- .../app/mobile/views/pages/notifications.vue | 2 +- .../app/mobile/views/pages/settings.vue | 2 +- src/client/app/mobile/views/pages/tag.vue | 2 +- src/client/app/mobile/views/pages/widgets.vue | 2 +- src/models/note-unread.ts | 17 +++++ src/models/note.ts | 4 +- src/models/user.ts | 8 ++- src/server/api/endpoints/i.ts | 1 + src/server/api/stream/home.ts | 4 ++ src/services/note/create.ts | 22 ++++--- src/services/note/read.ts | 62 +++++++++++++++++++ src/services/note/unread.ts | 47 ++++++++++++++ 25 files changed, 239 insertions(+), 39 deletions(-) create mode 100644 src/models/note-unread.ts create mode 100644 src/services/note/read.ts create mode 100644 src/services/note/unread.ts diff --git a/src/client/app/common/scripts/streaming/home.ts b/src/client/app/common/scripts/streaming/home.ts index dd18c70d70..26729507fb 100644 --- a/src/client/app/common/scripts/streaming/home.ts +++ b/src/client/app/common/scripts/streaming/home.ts @@ -50,6 +50,30 @@ export class HomeStream extends Stream { }); }); + this.on('unreadMention', () => { + os.store.dispatch('mergeMe', { + hasUnreadMentions: true + }); + }); + + this.on('readAllUnreadMentions', () => { + os.store.dispatch('mergeMe', { + hasUnreadMentions: false + }); + }); + + this.on('unreadSpecifiedNote', () => { + os.store.dispatch('mergeMe', { + hasUnreadSpecifiedNotes: true + }); + }); + + this.on('readAllUnreadSpecifiedNotes', () => { + os.store.dispatch('mergeMe', { + hasUnreadSpecifiedNotes: false + }); + }); + this.on('clientSettingUpdated', x => { os.store.commit('settings/set', { key: x.key, diff --git a/src/client/app/desktop/views/components/notes.note.vue b/src/client/app/desktop/views/components/notes.note.vue index fdf41a52c9..ac2c1ce97f 100644 --- a/src/client/app/desktop/views/components/notes.note.vue +++ b/src/client/app/desktop/views/components/notes.note.vue @@ -213,10 +213,14 @@ export default Vue.extend({ methods: { capture(withHandler = false) { if (this.$store.getters.isSignedIn) { - this.connection.send({ + const data = { type: 'capture', id: this.p.id - }); + } as any; + if ((this.p.visibleUserIds || []).includes(this.$store.state.i.id) || (this.p.mentions || []).includes(this.$store.state.i.id)) { + data.read = true; + } + this.connection.send(data); if (withHandler) this.connection.on('note-updated', this.onStreamNoteUpdated); } }, diff --git a/src/client/app/desktop/views/components/timeline.vue b/src/client/app/desktop/views/components/timeline.vue index 9f421a68ed..7d683236bb 100644 --- a/src/client/app/desktop/views/components/timeline.vue +++ b/src/client/app/desktop/views/components/timeline.vue @@ -8,8 +8,8 @@ <span :data-active="src == 'tag'" @click="src = 'tag'" v-if="tagTl">%fa:hashtag% {{ tagTl.title }}</span> <span :data-active="src == 'list'" @click="src = 'list'" v-if="list">%fa:list% {{ list.title }}</span> <div class="buttons"> - <button :data-active="src == 'mentions'" @click="src = 'mentions'" title="%i18n:@mentions%">%fa:at%</button> - <button :data-active="src == 'messages'" @click="src = 'messages'" title="%i18n:@messages%">%fa:envelope R%</button> + <button :data-active="src == 'mentions'" @click="src = 'mentions'" title="%i18n:@mentions%">%fa:at%<i class="badge" v-if="$store.state.i.hasUnreadMentions">%fa:circle%</i></button> + <button :data-active="src == 'messages'" @click="src = 'messages'" title="%i18n:@messages%">%fa:envelope R%<i class="badge" v-if="$store.state.i.hasUnreadSpecifiedNotes">%fa:circle%</i></button> <button @click="chooseTag" title="%i18n:@hashtag%" ref="tagButton">%fa:hashtag%</button> <button @click="chooseList" title="%i18n:@list%" ref="listButton">%fa:list%</button> </div> @@ -202,6 +202,13 @@ root(isDark) line-height 42px color isDark ? #9baec8 : #ccc + > .badge + position absolute + top -4px + right 4px + font-size 10px + color $theme-color + &:hover color isDark ? #b2c1d5 : #aaa diff --git a/src/client/app/desktop/views/pages/deck/deck.note.vue b/src/client/app/desktop/views/pages/deck/deck.note.vue index 980fb03136..99274b0f41 100644 --- a/src/client/app/desktop/views/pages/deck/deck.note.vue +++ b/src/client/app/desktop/views/pages/deck/deck.note.vue @@ -147,10 +147,14 @@ export default Vue.extend({ methods: { capture(withHandler = false) { if (this.$store.getters.isSignedIn) { - this.connection.send({ + const data = { type: 'capture', id: this.p.id - }); + } as any; + if ((this.p.visibleUserIds || []).includes(this.$store.state.i.id) || (this.p.mentions || []).includes(this.$store.state.i.id)) { + data.read = true; + } + this.connection.send(data); if (withHandler) this.connection.on('note-updated', this.onStreamNoteUpdated); } }, diff --git a/src/client/app/mobile/views/components/note.vue b/src/client/app/mobile/views/components/note.vue index 8787b39a93..0ce72cab11 100644 --- a/src/client/app/mobile/views/components/note.vue +++ b/src/client/app/mobile/views/components/note.vue @@ -160,10 +160,14 @@ export default Vue.extend({ methods: { capture(withHandler = false) { if (this.$store.getters.isSignedIn) { - this.connection.send({ + const data = { type: 'capture', id: this.p.id - }); + } as any; + if ((this.p.visibleUserIds || []).includes(this.$store.state.i.id) || (this.p.mentions || []).includes(this.$store.state.i.id)) { + data.read = true; + } + this.connection.send(data); if (withHandler) this.connection.on('note-updated', this.onStreamNoteUpdated); } }, diff --git a/src/client/app/mobile/views/components/ui.header.vue b/src/client/app/mobile/views/components/ui.header.vue index c9b3ab51ae..15c8299c2e 100644 --- a/src/client/app/mobile/views/components/ui.header.vue +++ b/src/client/app/mobile/views/components/ui.header.vue @@ -188,9 +188,6 @@ root(isDark) overflow hidden text-overflow ellipsis - [data-fa], [data-icon] - margin-right 4px - > img display inline-block vertical-align bottom diff --git a/src/client/app/mobile/views/pages/drive.vue b/src/client/app/mobile/views/pages/drive.vue index 27ac956043..c0fc7b48dc 100644 --- a/src/client/app/mobile/views/pages/drive.vue +++ b/src/client/app/mobile/views/pages/drive.vue @@ -1,9 +1,9 @@ <template> <mk-ui> <span slot="header"> - <template v-if="folder">%fa:R folder-open%{{ folder.name }}</template> - <template v-if="file"><mk-file-type-icon data-icon :type="file.type"/>{{ file.name }}</template> - <template v-if="!folder && !file">%fa:cloud%%i18n:@drive%</template> + <template v-if="folder"><span style="margin-right:4px;">%fa:R folder-open%</span>{{ folder.name }}</template> + <template v-if="file"><mk-file-type-icon data-icon :type="file.type" style="margin-right:4px;"/>{{ file.name }}</template> + <template v-if="!folder && !file"><span style="margin-right:4px;">%fa:cloud%</span>%i18n:@drive%</template> </span> <template slot="func"><button @click="fn">%fa:ellipsis-h%</button></template> <mk-drive diff --git a/src/client/app/mobile/views/pages/favorites.vue b/src/client/app/mobile/views/pages/favorites.vue index 6b9aec6a0c..53512e804c 100644 --- a/src/client/app/mobile/views/pages/favorites.vue +++ b/src/client/app/mobile/views/pages/favorites.vue @@ -1,6 +1,6 @@ <template> <mk-ui> - <span slot="header">%fa:star%%i18n:@title%</span> + <span slot="header"><span style="margin-right:4px;">%fa:star%</span>%i18n:@title%</span> <main> <template v-for="favorite in favorites"> diff --git a/src/client/app/mobile/views/pages/games/reversi.vue b/src/client/app/mobile/views/pages/games/reversi.vue index bdadc88a43..f3bba586de 100644 --- a/src/client/app/mobile/views/pages/games/reversi.vue +++ b/src/client/app/mobile/views/pages/games/reversi.vue @@ -1,6 +1,6 @@ <template> <mk-ui> - <span slot="header">%fa:gamepad%%i18n:@reversi%</span> + <span slot="header"><span style="margin-right:4px;">%fa:gamepad%</span>%i18n:@reversi%</span> <mk-reversi :game-id="$route.params.game" @nav="nav" :self-nav="false"/> </mk-ui> </template> diff --git a/src/client/app/mobile/views/pages/home.vue b/src/client/app/mobile/views/pages/home.vue index 43afc98b45..ca62d4e2f8 100644 --- a/src/client/app/mobile/views/pages/home.vue +++ b/src/client/app/mobile/views/pages/home.vue @@ -1,7 +1,7 @@ <template> <mk-ui> <span slot="header" @click="showNav = true"> - <span> + <span :class="$style.title"> <span v-if="src == 'home'">%fa:home%%i18n:@home%</span> <span v-if="src == 'local'">%fa:R comments%%i18n:@local%</span> <span v-if="src == 'hybrid'">%fa:share-alt%%i18n:@hybrid%</span> @@ -15,6 +15,7 @@ <template v-if="!showNav">%fa:angle-down%</template> <template v-else>%fa:angle-up%</template> </span> + <i :class="$style.badge" v-if="$store.state.i.hasUnreadMentions || $store.state.i.hasUnreadSpecifiedNotes">%fa:circle%</i> </span> <template slot="func"> @@ -32,8 +33,8 @@ <span :data-active="src == 'hybrid'" @click="src = 'hybrid'" v-if="enableLocalTimeline">%fa:share-alt% %i18n:@hybrid%</span> <span :data-active="src == 'global'" @click="src = 'global'">%fa:globe% %i18n:@global%</span> <div class="hr"></div> - <span :data-active="src == 'mentions'" @click="src = 'mentions'">%fa:at% %i18n:@mentions%</span> - <span :data-active="src == 'messages'" @click="src = 'messages'">%fa:envelope R% %i18n:@messages%</span> + <span :data-active="src == 'mentions'" @click="src = 'mentions'">%fa:at% %i18n:@mentions%<i class="badge" v-if="$store.state.i.hasUnreadMentions">%fa:circle%</i></span> + <span :data-active="src == 'messages'" @click="src = 'messages'">%fa:envelope R% %i18n:@messages%<i class="badge" v-if="$store.state.i.hasUnreadSpecifiedNotes">%fa:circle%</i></span> <template v-if="lists"> <div class="hr" v-if="lists.length > 0"></div> <span v-for="l in lists" :data-active="src == 'list' && list == l" @click="src = 'list'; list = l" :key="l.id">%fa:list% {{ l.title }}</span> @@ -220,6 +221,11 @@ root(isDark) &:not([data-active]):hover background isDark ? #353e4a : #eee + > .badge + margin-left 6px + font-size 10px + color $theme-color + > .tl max-width 680px margin 0 auto @@ -238,3 +244,18 @@ main:not([data-darkmode]) root(false) </style> + +<style lang="stylus" module> +@import '~const.styl' + +.title + i + margin-right 4px + +.badge + margin-left 6px + font-size 10px + color $theme-color + vertical-align middle + +</style> diff --git a/src/client/app/mobile/views/pages/messaging-room.vue b/src/client/app/mobile/views/pages/messaging-room.vue index 401397d856..750ba26294 100644 --- a/src/client/app/mobile/views/pages/messaging-room.vue +++ b/src/client/app/mobile/views/pages/messaging-room.vue @@ -1,7 +1,7 @@ <template> <mk-ui> <span slot="header"> - <template v-if="user">%fa:R comments%{{ user | userName }}</template> + <template v-if="user"><span style="margin-right:4px;">%fa:R comments%</span>{{ user | userName }}</template> <template v-else><mk-ellipsis/></template> </span> <mk-messaging-room v-if="!fetching" :user="user" :is-naked="true"/> diff --git a/src/client/app/mobile/views/pages/messaging.vue b/src/client/app/mobile/views/pages/messaging.vue index 3883505281..98ae79fe6c 100644 --- a/src/client/app/mobile/views/pages/messaging.vue +++ b/src/client/app/mobile/views/pages/messaging.vue @@ -1,6 +1,6 @@ <template> <mk-ui> - <span slot="header">%fa:R comments%%i18n:@messaging%</span> + <span slot="header"><span style="margin-right:4px;">%fa:R comments%</span>%i18n:@messaging%</span> <mk-messaging @navigate="navigate" :header-top="48"/> </mk-ui> </template> diff --git a/src/client/app/mobile/views/pages/note.vue b/src/client/app/mobile/views/pages/note.vue index fee60b350e..d7307c79a8 100644 --- a/src/client/app/mobile/views/pages/note.vue +++ b/src/client/app/mobile/views/pages/note.vue @@ -1,6 +1,6 @@ <template> <mk-ui> - <span slot="header">%fa:R sticky-note%%i18n:@title%</span> + <span slot="header"><span style="margin-right:4px;">%fa:R sticky-note%</span>%i18n:@title%</span> <main v-if="!fetching"> <div> <mk-note-detail :note="note"/> diff --git a/src/client/app/mobile/views/pages/notifications.vue b/src/client/app/mobile/views/pages/notifications.vue index 4d3c8ee534..bddcd457bb 100644 --- a/src/client/app/mobile/views/pages/notifications.vue +++ b/src/client/app/mobile/views/pages/notifications.vue @@ -1,6 +1,6 @@ <template> <mk-ui> - <span slot="header">%fa:R bell%%i18n:@notifications%</span> + <span slot="header"><span style="margin-right:4px;">%fa:R bell%</span>%i18n:@notifications%</span> <template slot="func"><button @click="fn">%fa:check%</button></template> <main> diff --git a/src/client/app/mobile/views/pages/settings.vue b/src/client/app/mobile/views/pages/settings.vue index f315c058df..9e90416370 100644 --- a/src/client/app/mobile/views/pages/settings.vue +++ b/src/client/app/mobile/views/pages/settings.vue @@ -1,6 +1,6 @@ <template> <mk-ui> - <span slot="header">%fa:cog%%i18n:@settings%</span> + <span slot="header"><span style="margin-right:4px;">%fa:cog%</span>%i18n:@settings%</span> <main :data-darkmode="$store.state.device.darkmode"> <div class="signin-as" v-html="'%i18n:@signed-in-as%'.replace('{}', `<b>${name}</b>`)"></div> diff --git a/src/client/app/mobile/views/pages/tag.vue b/src/client/app/mobile/views/pages/tag.vue index a545e2b839..3f963501e0 100644 --- a/src/client/app/mobile/views/pages/tag.vue +++ b/src/client/app/mobile/views/pages/tag.vue @@ -1,6 +1,6 @@ <template> <mk-ui> - <span slot="header">%fa:hashtag%{{ $route.params.tag }}</span> + <span slot="header"><span style="margin-right:4px;">%fa:hashtag%</span>{{ $route.params.tag }}</span> <main> <p v-if="!fetching && empty">%fa:search% {{ '%i18n:no-posts-found%'.split('{}')[0] }}{{ q }}{{ '%i18n:no-posts-found%'.split('{}')[1] }}</p> diff --git a/src/client/app/mobile/views/pages/widgets.vue b/src/client/app/mobile/views/pages/widgets.vue index a83103632e..c649529c0e 100644 --- a/src/client/app/mobile/views/pages/widgets.vue +++ b/src/client/app/mobile/views/pages/widgets.vue @@ -1,6 +1,6 @@ <template> <mk-ui> - <span slot="header">%fa:home%%i18n:@dashboard%</span> + <span slot="header"><span style="margin-right:4px;">%fa:home%</span>%i18n:@dashboard%</span> <template slot="func"> <button @click="customizing = !customizing">%fa:cog%</button> </template> diff --git a/src/models/note-unread.ts b/src/models/note-unread.ts new file mode 100644 index 0000000000..62408d23b6 --- /dev/null +++ b/src/models/note-unread.ts @@ -0,0 +1,17 @@ +import * as mongo from 'mongodb'; +import db from '../db/mongodb'; + +const NoteUnread = db.get<INoteUnread>('noteUnreads'); +NoteUnread.createIndex(['userId', 'noteId'], { unique: true }); +export default NoteUnread; + +export interface INoteUnread { + _id: mongo.ObjectID; + noteId: mongo.ObjectID; + userId: mongo.ObjectID; + isSpecified: boolean; + + _note: { + userId: mongo.ObjectID; + }; +} diff --git a/src/models/note.ts b/src/models/note.ts index ce307d061a..ba88e9f481 100644 --- a/src/models/note.ts +++ b/src/models/note.ts @@ -295,8 +295,8 @@ export const pack = async ( delete _note._user; delete _note._reply; - delete _note.repost; - delete _note.mentions; + delete _note._renote; + delete _note._files; if (_note.geo) delete _note.geo.type; // Populate user diff --git a/src/models/user.ts b/src/models/user.ts index b595fa8d71..bacae485a8 100644 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -348,7 +348,8 @@ export const pack = ( me?: string | mongo.ObjectID | IUser, options?: { detail?: boolean, - includeSecrets?: boolean + includeSecrets?: boolean, + includeHasUnreadNotes?: boolean } ) => new Promise<any>(async (resolve, reject) => { @@ -510,6 +511,11 @@ export const pack = ( } } + if (!opts.includeHasUnreadNotes) { + delete _user.hasUnreadSpecifiedNotes; + delete _user.hasUnreadMentions; + } + // resolve promises in _user object _user = await rap(_user); diff --git a/src/server/api/endpoints/i.ts b/src/server/api/endpoints/i.ts index 1f99ef2d8d..5aa2070650 100644 --- a/src/server/api/endpoints/i.ts +++ b/src/server/api/endpoints/i.ts @@ -22,6 +22,7 @@ export default (params: any, user: ILocalUser, app: IApp) => new Promise(async ( // Serialize res(await pack(user, user, { detail: true, + includeHasUnreadNotes: true, includeSecrets: isSecure })); diff --git a/src/server/api/stream/home.ts b/src/server/api/stream/home.ts index f1fced42d7..5575d0d523 100644 --- a/src/server/api/stream/home.ts +++ b/src/server/api/stream/home.ts @@ -9,6 +9,7 @@ import readNotification from '../common/read-notification'; import call from '../call'; import { IApp } from '../../../models/app'; import shouldMuteThisNote from '../../../misc/should-mute-this-note'; +import readNote from '../../../services/note/read'; const log = debug('misskey'); @@ -94,6 +95,9 @@ export default async function( if (!msg.id) return; log(`CAPTURE: ${msg.id} by @${user.username}`); subscriber.on(`note-stream:${msg.id}`, onNoteStream); + if (msg.read) { + readNote(user._id, msg.id); + } break; case 'decapture': diff --git a/src/services/note/create.ts b/src/services/note/create.ts index 7c1e71dcb3..b62b8c43ba 100644 --- a/src/services/note/create.ts +++ b/src/services/note/create.ts @@ -25,6 +25,7 @@ import { TextElementMention } from '../../mfm/parse/elements/mention'; import { TextElementHashtag } from '../../mfm/parse/elements/hashtag'; import { updateNoteStats } from '../update-chart'; import { erase, unique } from '../../prelude/array'; +import insertNoteUnread from './unread'; type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; @@ -170,6 +171,17 @@ export default async (user: IUser, data: Option, silent = false) => new Promise< // Increment notes count (user) incNotesCountOfUser(user); + // 未読通知を作成 + if (data.visibility == 'specified') { + data.visibleUsers.forEach(u => { + insertNoteUnread(u, note, true); + }); + } else { + mentionedUsers.forEach(u => { + insertNoteUnread(u, note, false); + }); + } + if (data.reply) { saveReply(data.reply, note); } @@ -314,16 +326,6 @@ async function publish(user: IUser, note: INote, noteObj: any, reply: INote, ren publishGlobalTimelineStream(noteObj); } - if (note.visibility == 'specified') { - visibleUsers.forEach(async (u) => { - const n = await pack(note, u, { - detail: true - }); - publishUserStream(u._id, 'note', n); - publishHybridTimelineStream(u._id, n); - }); - } - if (['public', 'home', 'followers'].includes(note.visibility)) { // フォロワーに配信 publishToFollowers(note, user, noteActivity); diff --git a/src/services/note/read.ts b/src/services/note/read.ts new file mode 100644 index 0000000000..46918bc38c --- /dev/null +++ b/src/services/note/read.ts @@ -0,0 +1,62 @@ +import * as mongo from 'mongodb'; +import { publishUserStream } from '../../stream'; +import User from '../../models/user'; +import NoteUnread from '../../models/note-unread'; + +/** + * Mark a note as read + */ +export default ( + user: string | mongo.ObjectID, + note: string | mongo.ObjectID +) => new Promise<any>(async (resolve, reject) => { + + const userId: mongo.ObjectID = mongo.ObjectID.prototype.isPrototypeOf(user) + ? user as mongo.ObjectID + : new mongo.ObjectID(user); + + const noteId: mongo.ObjectID = mongo.ObjectID.prototype.isPrototypeOf(note) + ? note as mongo.ObjectID + : new mongo.ObjectID(note); + + // Remove document + await NoteUnread.remove({ + userId: userId, + noteId: noteId + }); + + const count1 = await NoteUnread + .count({ + userId: userId, + isSpecified: false + }, { + limit: 1 + }); + + const count2 = await NoteUnread + .count({ + userId: userId, + isSpecified: true + }, { + limit: 1 + }); + + if (count1 == 0 || count2 == 0) { + User.update({ _id: userId }, { + $set: { + hasUnreadMentions: count1 != 0 || count2 != 0, + hasUnreadSpecifiedNotes: count2 != 0 + } + }); + } + + if (count1 == 0) { + // 全て既読になったイベントを発行 + publishUserStream(userId, 'readAllUnreadMentions'); + } + + if (count2 == 0) { + // 全て既読になったイベントを発行 + publishUserStream(userId, 'readAllUnreadSpecifiedNotes'); + } +}); diff --git a/src/services/note/unread.ts b/src/services/note/unread.ts new file mode 100644 index 0000000000..6e10c8b248 --- /dev/null +++ b/src/services/note/unread.ts @@ -0,0 +1,47 @@ +import NoteUnread from '../../models/note-unread'; +import User, { IUser } from '../../models/user'; +import { INote } from '../../models/note'; +import Mute from '../../models/mute'; +import { publishUserStream } from '../../stream'; + +export default async function(user: IUser, note: INote, isSpecified = false) { + const unread = await NoteUnread.insert({ + noteId: note._id, + userId: user._id, + isSpecified, + _note: { + userId: note.userId + } + }); + + // 3秒経っても既読にならなかったら「未読の投稿がありますよ」イベントを発行する + setTimeout(async () => { + const exist = await NoteUnread.findOne({ _id: unread._id }); + if (exist == null) return; + + //#region ただしミュートされているなら発行しない + const mute = await Mute.find({ + muterId: user._id + }); + const mutedUserIds = mute.map(m => m.muteeId.toString()); + if (mutedUserIds.includes(note.userId.toString())) return; + //#endregion + + User.update({ + _id: user._id + }, { + $set: isSpecified ? { + hasUnreadSpecifiedNotes: true, + hasUnreadMentions: true + } : { + hasUnreadMentions: true + } + }); + + publishUserStream(user._id, 'unreadMention', note._id); + + if (isSpecified) { + publishUserStream(user._id, 'unreadSpecifiedNote', note._id); + } + }, 3000); +}