From fc7dfbddf3c19e1e6a2ad960fa36548d942f00f3 Mon Sep 17 00:00:00 2001
From: syuilo <Syuilotan@yahoo.co.jp>
Date: Sat, 17 Apr 2021 15:30:26 +0900
Subject: [PATCH] Implement user online status

Resolve #7422
Fix #7424
---
 locales/ja-JP.yml                             |  6 ++++++
 .../1618637372000-user-last-active-date.ts    | 16 ++++++++++++++++
 .../1618639857000-user-hide-online-status.ts  | 14 ++++++++++++++
 src/client/pages/settings/privacy.vue         |  7 +++++++
 src/const.ts                                  |  2 ++
 src/models/entities/user.ts                   | 11 +++++++++++
 src/models/repositories/user.ts               | 14 ++++++++++++++
 .../api/endpoints/get-online-users-count.ts   | 19 ++++++++++---------
 src/server/api/endpoints/i/update.ts          |  5 +++++
 src/server/api/streaming.ts                   |  7 +++++++
 10 files changed, 92 insertions(+), 9 deletions(-)
 create mode 100644 migration/1618637372000-user-last-active-date.ts
 create mode 100644 migration/1618639857000-user-hide-online-status.ts
 create mode 100644 src/const.ts

diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 86deff675..989edcda2 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -722,6 +722,12 @@ notSpecifiedMentionWarning: "宛先に含まれていないメンションがあ
 info: "情報"
 userInfo: "ユーザー情報"
 unknown: "不明"
+onlineStatus: "オンライン状態"
+hideOnlineStatus: "オンライン状態を隠す"
+hideOnlineStatusDescription: "オンライン状態を隠すと、検索などの一部機能において利便性が低下することがあります。"
+online: "オンライン"
+active: "アクティブ"
+offline: "オフライン"
 
 _email:
   _follow:
diff --git a/migration/1618637372000-user-last-active-date.ts b/migration/1618637372000-user-last-active-date.ts
new file mode 100644
index 000000000..a66c433a3
--- /dev/null
+++ b/migration/1618637372000-user-last-active-date.ts
@@ -0,0 +1,16 @@
+import {MigrationInterface, QueryRunner} from "typeorm";
+
+export class userLastActiveDate1618637372000 implements MigrationInterface {
+    name = 'userLastActiveDate1618637372000'
+
+    public async up(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`ALTER TABLE "user" ADD "lastActiveDate" TIMESTAMP WITH TIME ZONE DEFAULT NULL`);
+        await queryRunner.query(`CREATE INDEX "IDX_seoignmeoprigmkpodgrjmkpormg" ON "user" ("lastActiveDate") `);
+    }
+
+    public async down(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`DROP INDEX "IDX_seoignmeoprigmkpodgrjmkpormg"`);
+        await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "lastActiveDate"`);
+    }
+
+}
diff --git a/migration/1618639857000-user-hide-online-status.ts b/migration/1618639857000-user-hide-online-status.ts
new file mode 100644
index 000000000..d5d77f983
--- /dev/null
+++ b/migration/1618639857000-user-hide-online-status.ts
@@ -0,0 +1,14 @@
+import {MigrationInterface, QueryRunner} from "typeorm";
+
+export class userHideOnlineStatus1618639857000 implements MigrationInterface {
+    name = 'userHideOnlineStatus1618639857000'
+
+    public async up(queryRunner: QueryRunner): Promise<void> {
+			await queryRunner.query(`ALTER TABLE "user" ADD "hideOnlineStatus" boolean NOT NULL DEFAULT false`);
+    }
+
+    public async down(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "hideOnlineStatus"`);
+    }
+
+}
diff --git a/src/client/pages/settings/privacy.vue b/src/client/pages/settings/privacy.vue
index 0542c527f..c8df37841 100644
--- a/src/client/pages/settings/privacy.vue
+++ b/src/client/pages/settings/privacy.vue
@@ -5,6 +5,10 @@
 		<FormSwitch v-model:value="autoAcceptFollowed" :disabled="!isLocked" @update:value="save()">{{ $ts.autoAcceptFollowed }}</FormSwitch>
 		<template #caption>{{ $ts.lockedAccountInfo }}</template>
 	</FormGroup>
+	<FormSwitch v-model:value="hideOnlineStatus" @update:value="save()">
+		{{ $ts.hideOnlineStatus }}
+		<template #desc>{{ $ts.hideOnlineStatusDescription }}</template>
+	</FormSwitch>
 	<FormSwitch v-model:value="noCrawle" @update:value="save()">
 		{{ $ts.noCrawle }}
 		<template #desc>{{ $ts.noCrawleDescription }}</template>
@@ -58,6 +62,7 @@ export default defineComponent({
 			autoAcceptFollowed: false,
 			noCrawle: false,
 			isExplorable: false,
+			hideOnlineStatus: false,
 		}
 	},
 
@@ -72,6 +77,7 @@ export default defineComponent({
 		this.autoAcceptFollowed = this.$i.autoAcceptFollowed;
 		this.noCrawle = this.$i.noCrawle;
 		this.isExplorable = this.$i.isExplorable;
+		this.hideOnlineStatus = this.$i.hideOnlineStatus;
 	},
 
 	mounted() {
@@ -85,6 +91,7 @@ export default defineComponent({
 				autoAcceptFollowed: !!this.autoAcceptFollowed,
 				noCrawle: !!this.noCrawle,
 				isExplorable: !!this.isExplorable,
+				hideOnlineStatus: !!this.hideOnlineStatus,
 			});
 		}
 	}
diff --git a/src/const.ts b/src/const.ts
new file mode 100644
index 000000000..43f59f1e4
--- /dev/null
+++ b/src/const.ts
@@ -0,0 +1,2 @@
+export const USER_ONLINE_THRESHOLD = 1000 * 60 * 10; // 10min
+export const USER_ACTIVE_THRESHOLD = 1000 * 60 * 60 * 24 * 3; // 3days
diff --git a/src/models/entities/user.ts b/src/models/entities/user.ts
index 91fbe35d9..060ec06b9 100644
--- a/src/models/entities/user.ts
+++ b/src/models/entities/user.ts
@@ -26,6 +26,17 @@ export class User {
 	})
 	public lastFetchedAt: Date | null;
 
+	@Index()
+	@Column('timestamp with time zone', {
+		nullable: true
+	})
+	public lastActiveDate: Date | null;
+
+	@Column('boolean', {
+		default: false,
+	})
+	public hideOnlineStatus: boolean;
+
 	@Column('varchar', {
 		length: 128,
 		comment: 'The username of the User.'
diff --git a/src/models/repositories/user.ts b/src/models/repositories/user.ts
index a3b4c69f4..0d59ed254 100644
--- a/src/models/repositories/user.ts
+++ b/src/models/repositories/user.ts
@@ -7,6 +7,7 @@ import { SchemaType } from '@/misc/schema';
 import { awaitAll } from '../../prelude/await-all';
 import { populateEmojis } from '@/misc/populate-emojis';
 import { getAntennas } from '@/misc/antenna-cache';
+import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD } from '@/const';
 
 export type PackedUser = SchemaType<typeof packedUserSchema>;
 
@@ -145,6 +146,17 @@ export class UserRepository extends Repository<User> {
 		return count > 0;
 	}
 
+	public getOnlineStatus(user: User): string {
+		if (user.hideOnlineStatus == null) return 'unknown';
+		if (user.lastActiveDate == null) return 'unknown';
+		const elapsed = Date.now() - user.lastActiveDate.getTime();
+		return (
+			elapsed < USER_ONLINE_THRESHOLD ? 'online' :
+			elapsed < USER_ACTIVE_THRESHOLD ? 'active' :
+			'offline'
+		);
+	}
+
 	public async pack(
 		src: User['id'] | User,
 		me?: { id: User['id'] } | null | undefined,
@@ -192,6 +204,7 @@ export class UserRepository extends Repository<User> {
 				themeColor: instance.themeColor,
 			} : undefined) : undefined,
 			emojis: populateEmojis(user.emojis, user.host),
+			onlineStatus: this.getOnlineStatus(user),
 
 			...(opts.detail ? {
 				url: profile!.url,
@@ -239,6 +252,7 @@ export class UserRepository extends Repository<User> {
 				autoAcceptFollowed: profile!.autoAcceptFollowed,
 				noCrawle: profile!.noCrawle,
 				isExplorable: user.isExplorable,
+				hideOnlineStatus: user.hideOnlineStatus,
 				hasUnreadSpecifiedNotes: NoteUnreads.count({
 					where: { userId: user.id, isSpecified: true },
 					take: 1
diff --git a/src/server/api/endpoints/get-online-users-count.ts b/src/server/api/endpoints/get-online-users-count.ts
index 150ac9e36..a13363055 100644
--- a/src/server/api/endpoints/get-online-users-count.ts
+++ b/src/server/api/endpoints/get-online-users-count.ts
@@ -1,6 +1,7 @@
+import { USER_ONLINE_THRESHOLD } from '@/const';
+import { Users } from '@/models';
+import { MoreThan } from 'typeorm';
 import define from '../define';
-import { redisClient } from '../../../db/redis';
-import config from '@/config';
 
 export const meta = {
 	tags: ['meta'],
@@ -11,12 +12,12 @@ export const meta = {
 	}
 };
 
-export default define(meta, (ps, user) => {
-	return new Promise((res, rej) => {
-		redisClient.pubsub('numsub', config.host, (_, x) => {
-			res({
-				count: x[1]
-			});
-		});
+export default define(meta, async () => {
+	const count = await Users.count({
+		lastActiveDate: MoreThan(new Date(Date.now() - USER_ONLINE_THRESHOLD))
 	});
+
+	return {
+		count
+	};
 });
diff --git a/src/server/api/endpoints/i/update.ts b/src/server/api/endpoints/i/update.ts
index c0ffd75e2..032dccd91 100644
--- a/src/server/api/endpoints/i/update.ts
+++ b/src/server/api/endpoints/i/update.ts
@@ -96,6 +96,10 @@ export const meta = {
 			validator: $.optional.bool,
 		},
 
+		hideOnlineStatus: {
+			validator: $.optional.bool,
+		},
+
 		carefulBot: {
 			validator: $.optional.bool,
 			desc: {
@@ -228,6 +232,7 @@ export default define(meta, async (ps, _user, token) => {
 	if (ps.mutingNotificationTypes !== undefined) profileUpdates.mutingNotificationTypes = ps.mutingNotificationTypes as typeof notificationTypes[number][];
 	if (typeof ps.isLocked === 'boolean') updates.isLocked = ps.isLocked;
 	if (typeof ps.isExplorable === 'boolean') updates.isExplorable = ps.isExplorable;
+	if (typeof ps.hideOnlineStatus === 'boolean') updates.hideOnlineStatus = ps.hideOnlineStatus;
 	if (typeof ps.isBot === 'boolean') updates.isBot = ps.isBot;
 	if (typeof ps.carefulBot === 'boolean') profileUpdates.carefulBot = ps.carefulBot;
 	if (typeof ps.autoAcceptFollowed === 'boolean') profileUpdates.autoAcceptFollowed = ps.autoAcceptFollowed;
diff --git a/src/server/api/streaming.ts b/src/server/api/streaming.ts
index 81b83edcf..7224c2357 100644
--- a/src/server/api/streaming.ts
+++ b/src/server/api/streaming.ts
@@ -6,6 +6,7 @@ import { ParsedUrlQuery } from 'querystring';
 import authenticate from './authenticate';
 import { EventEmitter } from 'events';
 import { subsdcriber as redisClient } from '../../db/redis';
+import { Users } from '@/models';
 
 module.exports = (server: http.Server) => {
 	// Init websocket server
@@ -45,5 +46,11 @@ module.exports = (server: http.Server) => {
 				connection.send('pong');
 			}
 		});
+
+		if (user) {
+			Users.update(user.id, {
+				lastActiveDate: new Date(),
+			});
+		}
 	});
 };