diff --git a/src/client/app/common/scripts/streaming/home.ts b/src/client/app/common/scripts/streaming/home.ts
index dd18c70d7..26729507f 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 fdf41a52c..ac2c1ce97 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 9f421a68e..7d683236b 100644
--- a/src/client/app/desktop/views/components/timeline.vue
+++ b/src/client/app/desktop/views/components/timeline.vue
@@ -8,8 +8,8 @@
%fa:hashtag% {{ tagTl.title }}
%fa:list% {{ list.title }}
-
-
+
+
@@ -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 980fb0313..99274b0f4 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 8787b39a9..0ce72cab1 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 c9b3ab51a..15c8299c2 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 27ac95604..c0fc7b48d 100644
--- a/src/client/app/mobile/views/pages/drive.vue
+++ b/src/client/app/mobile/views/pages/drive.vue
@@ -1,9 +1,9 @@
- %fa:R folder-open%{{ folder.name }}
- {{ file.name }}
- %fa:cloud%%i18n:@drive%
+ %fa:R folder-open%{{ folder.name }}
+ {{ file.name }}
+ %fa:cloud%%i18n:@drive%
- %fa:star%%i18n:@title%
+ %fa:star%%i18n:@title%
diff --git a/src/client/app/mobile/views/pages/games/reversi.vue b/src/client/app/mobile/views/pages/games/reversi.vue
index bdadc88a4..f3bba586d 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 @@
- %fa:gamepad%%i18n:@reversi%
+ %fa:gamepad%%i18n:@reversi%
diff --git a/src/client/app/mobile/views/pages/home.vue b/src/client/app/mobile/views/pages/home.vue
index 43afc98b4..ca62d4e2f 100644
--- a/src/client/app/mobile/views/pages/home.vue
+++ b/src/client/app/mobile/views/pages/home.vue
@@ -1,7 +1,7 @@
-
+
%fa:home%%i18n:@home%
%fa:R comments%%i18n:@local%
%fa:share-alt%%i18n:@hybrid%
@@ -15,6 +15,7 @@
%fa:angle-down%
%fa:angle-up%
+ %fa:circle%
@@ -32,8 +33,8 @@
%fa:share-alt% %i18n:@hybrid%
%fa:globe% %i18n:@global%
- %fa:at% %i18n:@mentions%
- %fa:envelope R% %i18n:@messages%
+ %fa:at% %i18n:@mentions%%fa:circle%
+ %fa:envelope R% %i18n:@messages%%fa:circle%
%fa:list% {{ l.title }}
@@ -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)
+
+
diff --git a/src/client/app/mobile/views/pages/messaging-room.vue b/src/client/app/mobile/views/pages/messaging-room.vue
index 401397d85..750ba2629 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 @@
- %fa:R comments%{{ user | userName }}
+ %fa:R comments%{{ user | userName }}
diff --git a/src/client/app/mobile/views/pages/messaging.vue b/src/client/app/mobile/views/pages/messaging.vue
index 388350528..98ae79fe6 100644
--- a/src/client/app/mobile/views/pages/messaging.vue
+++ b/src/client/app/mobile/views/pages/messaging.vue
@@ -1,6 +1,6 @@
- %fa:R comments%%i18n:@messaging%
+ %fa:R comments%%i18n:@messaging%
diff --git a/src/client/app/mobile/views/pages/note.vue b/src/client/app/mobile/views/pages/note.vue
index fee60b350..d7307c79a 100644
--- a/src/client/app/mobile/views/pages/note.vue
+++ b/src/client/app/mobile/views/pages/note.vue
@@ -1,6 +1,6 @@
- %fa:R sticky-note%%i18n:@title%
+ %fa:R sticky-note%%i18n:@title%
diff --git a/src/client/app/mobile/views/pages/notifications.vue b/src/client/app/mobile/views/pages/notifications.vue
index 4d3c8ee53..bddcd457b 100644
--- a/src/client/app/mobile/views/pages/notifications.vue
+++ b/src/client/app/mobile/views/pages/notifications.vue
@@ -1,6 +1,6 @@
- %fa:R bell%%i18n:@notifications%
+ %fa:R bell%%i18n:@notifications%
diff --git a/src/client/app/mobile/views/pages/settings.vue b/src/client/app/mobile/views/pages/settings.vue
index f315c058d..9e9041637 100644
--- a/src/client/app/mobile/views/pages/settings.vue
+++ b/src/client/app/mobile/views/pages/settings.vue
@@ -1,6 +1,6 @@
- %fa:cog%%i18n:@settings%
+ %fa:cog%%i18n:@settings%
diff --git a/src/client/app/mobile/views/pages/tag.vue b/src/client/app/mobile/views/pages/tag.vue
index a545e2b83..3f963501e 100644
--- a/src/client/app/mobile/views/pages/tag.vue
+++ b/src/client/app/mobile/views/pages/tag.vue
@@ -1,6 +1,6 @@
- %fa:hashtag%{{ $route.params.tag }}
+ %fa:hashtag%{{ $route.params.tag }}
%fa:search% {{ '%i18n:no-posts-found%'.split('{}')[0] }}{{ q }}{{ '%i18n:no-posts-found%'.split('{}')[1] }}
diff --git a/src/client/app/mobile/views/pages/widgets.vue b/src/client/app/mobile/views/pages/widgets.vue
index a83103632..c649529c0 100644
--- a/src/client/app/mobile/views/pages/widgets.vue
+++ b/src/client/app/mobile/views/pages/widgets.vue
@@ -1,6 +1,6 @@
- %fa:home%%i18n:@dashboard%
+ %fa:home%%i18n:@dashboard%
diff --git a/src/models/note-unread.ts b/src/models/note-unread.ts
new file mode 100644
index 000000000..62408d23b
--- /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('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 ce307d061..ba88e9f48 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 b595fa8d7..bacae485a 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(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 1f99ef2d8..5aa207065 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 f1fced42d..5575d0d52 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 7c1e71dcb..b62b8c43b 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 000000000..46918bc38
--- /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(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 000000000..6e10c8b24
--- /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);
+}