diff --git a/src/client/components/notification.vue b/src/client/components/notification.vue
index de233d14ac9b4fd2cfda96f43d70abcfb8df6fb2..9e4806f05f0da25b57c39ee3d439fd82d0968735 100644
--- a/src/client/components/notification.vue
+++ b/src/client/components/notification.vue
@@ -90,9 +90,36 @@ export default Vue.extend({
 			getNoteSummary: (text: string) => noteSummary(text, this.$root.i18n.messages[this.$root.i18n.locale]),
 			followRequestDone: false,
 			groupInviteDone: false,
+			connection: null,
+			readObserver: null,
 			faIdCardAlt, faPlus, faQuoteLeft, faQuoteRight, faRetweet, faReply, faAt, faClock, faCheck, faPollH
 		};
 	},
+
+	mounted() {
+		if (!this.notification.isRead) {
+			this.readObserver = new IntersectionObserver((entries, observer) => {
+				if (!entries.some(entry => entry.isIntersecting)) return;
+				this.$root.stream.send('readNotification', {
+					id: this.notification.id
+				});
+				entries.map(({ target }) => observer.unobserve(target));
+			});
+
+			this.readObserver.observe(this.$el);
+
+			this.connection = this.$root.stream.useSharedConnection('main');
+			this.connection.on('readAllNotifications', () => this.readObserver.unobserve(this.$el));
+		}
+	},
+
+	beforeDestroy() {
+		if (!this.notification.isRead) {
+			this.readObserver.unobserve(this.$el);
+			this.connection.dispose();
+		}
+	},
+
 	methods: {
 		acceptFollowRequest() {
 			this.followRequestDone = true;
diff --git a/src/client/components/notifications.vue b/src/client/components/notifications.vue
index 36464a3096efc1ff716111174723e0396304ba5d..5c5b5fb810ff7727c59a009ddb857ab4be94f6a0 100644
--- a/src/client/components/notifications.vue
+++ b/src/client/components/notifications.vue
@@ -71,10 +71,13 @@ export default Vue.extend({
 
 	methods: {
 		onNotification(notification) {
-			// TODO: ユーザーが画面を見てないと思われるとき(ブラウザやタブがアクティブじゃないなど)は送信しない
-			this.$root.stream.send('readNotification', {
-				id: notification.id
-			});
+			if (document.visibilityState === 'visible') {
+				this.$root.stream.send('readNotification', {
+					id: notification.id
+				});
+
+				notification.isRead = true;
+			}
 
 			this.prepend(notification);
 		},
diff --git a/src/client/components/post-form.vue b/src/client/components/post-form.vue
index 07f6f0b5d6248b0c5fe1250e19d78469c0054179..444bf229e021cfa611dc8820b933face6f1e41db 100644
--- a/src/client/components/post-form.vue
+++ b/src/client/components/post-form.vue
@@ -67,6 +67,7 @@ import extractMentions from '../../misc/extract-mentions';
 import getAcct from '../../misc/acct/render';
 import { formatTimeString } from '../../misc/format-time-string';
 import { selectDriveFile } from '../scripts/select-drive-file';
+import { noteVisibilities } from '../../types';
 
 export default Vue.extend({
 	components: {
@@ -407,7 +408,7 @@ export default Vue.extend({
 		},
 
 		applyVisibility(v: string) {
-			this.visibility = ['public', 'home', 'followers', 'specified'].includes(v) ? v : 'public'; // v11互換性のため
+			this.visibility = (noteVisibilities as unknown as string[]).includes(v) ? v : 'public'; // v11互換性のため
 		},
 
 		addVisibleUser() {
diff --git a/src/models/entities/note.ts b/src/models/entities/note.ts
index 79b6b5ab7db8bfea532193c425e473aedefe5e4b..196be1e350e8826ecbede9aeeac26be57aed5bc1 100644
--- a/src/models/entities/note.ts
+++ b/src/models/entities/note.ts
@@ -2,6 +2,8 @@ import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typ
 import { User } from './user';
 import { DriveFile } from './drive-file';
 import { id } from '../id';
+import { noteVisibilities } from '../../types';
+
 
 @Entity()
 @Index('IDX_NOTE_TAGS', { synchronize: false })
@@ -102,8 +104,8 @@ export class Note {
 	 * followers ... フォロワーのみ
 	 * specified ... visibleUserIds で指定したユーザーのみ
 	 */
-	@Column('enum', { enum: ['public', 'home', 'followers', 'specified'] })
-	public visibility: 'public' | 'home' | 'followers' | 'specified';
+	@Column('enum', { enum: noteVisibilities })
+	public visibility: typeof noteVisibilities[number];
 
 	@Index({ unique: true })
 	@Column('varchar', {
diff --git a/src/models/entities/notification.ts b/src/models/entities/notification.ts
index 565645a5d63ce93af95fbe75912cad3fa3d86770..988fdb341ffcea5ce63e8258a16de4b806bf0130 100644
--- a/src/models/entities/notification.ts
+++ b/src/models/entities/notification.ts
@@ -5,6 +5,7 @@ import { Note } from './note';
 import { FollowRequest } from './follow-request';
 import { UserGroupInvitation } from './user-group-invitation';
 import { AccessToken } from './access-token';
+import { notificationTypes } from '../../types';
 
 @Entity()
 export class Notification {
@@ -66,10 +67,10 @@ export class Notification {
 	 */
 	@Index()
 	@Column('enum', {
-		enum: ['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app'],
+		enum: notificationTypes,
 		comment: 'The type of the Notification.'
 	})
-	public type: 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollVote' | 'receiveFollowRequest' | 'followRequestAccepted' | 'groupInvited' | 'app';
+	public type: typeof notificationTypes[number];
 
 	/**
 	 * 通知が読まれたかどうか
diff --git a/src/models/entities/poll.ts b/src/models/entities/poll.ts
index 6bb67163a2b1901ad6746b0e4f6b048d72630bc3..e3bbb1c3f2700036d37aee6352de1401a732effa 100644
--- a/src/models/entities/poll.ts
+++ b/src/models/entities/poll.ts
@@ -2,6 +2,7 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, OneToOne } from 'type
 import { id } from '../id';
 import { Note } from './note';
 import { User } from './user';
+import { noteVisibilities } from '../../types';
 
 @Entity()
 export class Poll {
@@ -34,10 +35,10 @@ export class Poll {
 
 	//#region Denormalized fields
 	@Column('enum', {
-		enum: ['public', 'home', 'followers', 'specified'],
+		enum: noteVisibilities,
 		comment: '[Denormalized]'
 	})
-	public noteVisibility: 'public' | 'home' | 'followers' | 'specified';
+	public noteVisibility: typeof noteVisibilities[number];
 
 	@Index()
 	@Column({
diff --git a/src/models/repositories/notification.ts b/src/models/repositories/notification.ts
index b484c43c5e2b1da6b0ebcd6ebec5eef318ec675a..40f43d6c15d7d8c832d64bbf23d0c472275fb931 100644
--- a/src/models/repositories/notification.ts
+++ b/src/models/repositories/notification.ts
@@ -19,6 +19,7 @@ export class NotificationRepository extends Repository<Notification> {
 			id: notification.id,
 			createdAt: notification.createdAt.toISOString(),
 			type: notification.type,
+			isRead: notification.isRead,
 			userId: notification.notifierId,
 			user: notification.notifierId ? Users.pack(notification.notifier || notification.notifierId) : null,
 			...(notification.type === 'mention' ? {
diff --git a/src/server/api/endpoints/i/notifications.ts b/src/server/api/endpoints/i/notifications.ts
index 9a2e17a717f778dded5a0414a24268e7e4b8ef91..db6772beb35094549c24ca5535309f424a249c0a 100644
--- a/src/server/api/endpoints/i/notifications.ts
+++ b/src/server/api/endpoints/i/notifications.ts
@@ -4,6 +4,7 @@ import { readNotification } from '../../common/read-notification';
 import define from '../../define';
 import { makePaginationQuery } from '../../common/make-pagination-query';
 import { Notifications, Followings, Mutings, Users } from '../../../../models';
+import { notificationTypes } from '../../../../types';
 
 export const meta = {
 	desc: {
@@ -42,12 +43,12 @@ export const meta = {
 		},
 
 		includeTypes: {
-			validator: $.optional.arr($.str.or(['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'receiveFollowRequest', 'followRequestAccepted'])),
+			validator: $.optional.arr($.str.or(notificationTypes as unknown as string[])),
 			default: [] as string[]
 		},
 
 		excludeTypes: {
-			validator: $.optional.arr($.str.or(['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'receiveFollowRequest', 'followRequestAccepted'])),
+			validator: $.optional.arr($.str.or(notificationTypes as unknown as string[])),
 			default: [] as string[]
 		}
 	},
diff --git a/src/server/api/endpoints/notes/create.ts b/src/server/api/endpoints/notes/create.ts
index cccf138add2ec47e51573d2427f6b36fc403f00e..5076dad82a22d1c2a0b3ad72684f6803ed996cec 100644
--- a/src/server/api/endpoints/notes/create.ts
+++ b/src/server/api/endpoints/notes/create.ts
@@ -11,6 +11,7 @@ import { Users, DriveFiles, Notes } from '../../../../models';
 import { DriveFile } from '../../../../models/entities/drive-file';
 import { Note } from '../../../../models/entities/note';
 import { DB_MAX_NOTE_TEXT_LENGTH } from '../../../../misc/hard-limits';
+import { noteVisibilities } from '../../../../types';
 
 let maxNoteTextLength = 500;
 
@@ -38,7 +39,7 @@ export const meta = {
 
 	params: {
 		visibility: {
-			validator: $.optional.str.or(['public', 'home', 'followers', 'specified']),
+			validator: $.optional.str.or(noteVisibilities as unknown as string[]),
 			default: 'public',
 			desc: {
 				'ja-JP': '投稿の公開範囲'
diff --git a/src/types.ts b/src/types.ts
new file mode 100644
index 0000000000000000000000000000000000000000..30a62412a8ca4bb2587e88cffba09eec4a5ff122
--- /dev/null
+++ b/src/types.ts
@@ -0,0 +1,3 @@
+export const notificationTypes = ['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app'] as const;
+
+export const noteVisibilities = ['public', 'home', 'followers', 'specified'] as const;