From 695e8672212badaaa15680af06f5f2ae9d6e0f22 Mon Sep 17 00:00:00 2001
From: Ian Macdonald <ian@caliban.org>
Date: Sat, 14 May 2022 18:23:51 +0200
Subject: [PATCH] Add setting to specify minimum age of open group messages to
 prune.

When an open group has more than 2000 messages, those older than the
specified number of months will be pruned on application start-up.

Fixes #2310.
---
 _locales/en/messages.json                     |  4 +
 .../settings/PruningSessionSlider.tsx         | 36 +++++++++
 .../settings/section/CategoryAppearance.tsx   |  2 +
 ts/mains/main_node.ts                         | 13 ++++
 ts/node/sql.ts                                | 78 ++++++++++---------
 ts/types/LocalizerKeys.ts                     |  4 +
 6 files changed, 100 insertions(+), 37 deletions(-)
 create mode 100644 ts/components/settings/PruningSessionSlider.tsx

diff --git a/_locales/en/messages.json b/_locales/en/messages.json
index af3f62d7d..6750a6e39 100644
--- a/_locales/en/messages.json
+++ b/_locales/en/messages.json
@@ -137,6 +137,10 @@
   "typingIndicatorsSettingDescription": "See and share when messages are being typed (applies to all sessions).",
   "typingIndicatorsSettingTitle": "Typing Indicators",
   "zoomFactorSettingTitle": "Zoom Factor",
+  "pruneSettingTitle": "Prune Old Open Group Messages",
+  "pruneSettingDescription": "When Session starts, prune messages older than this from groups with > 2000 messages (0 = no pruning)",
+  "pruneSettingUnit": "month",
+  "pruneSettingUnits": "months",
   "notificationSettingsDialog": "When messages arrive, display notifications that reveal...",
   "disableNotifications": "Mute notifications",
   "nameAndMessage": "Name and content",
diff --git a/ts/components/settings/PruningSessionSlider.tsx b/ts/components/settings/PruningSessionSlider.tsx
new file mode 100644
index 000000000..fad195771
--- /dev/null
+++ b/ts/components/settings/PruningSessionSlider.tsx
@@ -0,0 +1,36 @@
+import Slider from 'rc-slider';
+import React from 'react';
+// tslint:disable-next-line: no-submodule-imports
+import useUpdate from 'react-use/lib/useUpdate';
+import { SessionSettingsItemWrapper } from './SessionSettingListItem';
+import { ToastUtils } from '../../session/utils';
+
+export const PruningSessionSlider = (props: { onSliderChange?: (value: number) => void }) => {
+  const forceUpdate = useUpdate();
+  const handleSlider = (valueToForward: number) => {
+    props?.onSliderChange?.(valueToForward);
+    window.setSettingValue('prune-setting', valueToForward);
+    ToastUtils.pushRestartNeeded();
+    forceUpdate();
+  };
+  const currentValueFromSettings = window.getSettingValue('prune-setting') || 0;
+
+  return (
+    <SessionSettingsItemWrapper title={window.i18n('pruneSettingTitle')} description={window.i18n('pruneSettingDescription')} inline={false}>
+      <div className="slider-wrapper">
+        <Slider
+          dots={true}
+          step={1}
+          min={0}
+          max={12}
+          defaultValue={currentValueFromSettings}
+          onAfterChange={handleSlider}
+        />
+
+        <div className="slider-info">
+          <p>{currentValueFromSettings} {currentValueFromSettings === 1 ? window.i18n('pruneSettingUnit') : window.i18n('pruneSettingUnits')}</p>
+        </div>
+      </div>
+    </SessionSettingsItemWrapper>
+  );
+};
diff --git a/ts/components/settings/section/CategoryAppearance.tsx b/ts/components/settings/section/CategoryAppearance.tsx
index 39e1b237b..d14b21d6d 100644
--- a/ts/components/settings/section/CategoryAppearance.tsx
+++ b/ts/components/settings/section/CategoryAppearance.tsx
@@ -13,6 +13,7 @@ import { SessionButtonColor } from '../../basic/SessionButton';
 
 import { SessionSettingButtonItem, SessionToggleWithDescription } from '../SessionSettingListItem';
 import { ZoomingSessionSlider } from '../ZoomingSessionSlider';
+import { PruningSessionSlider } from '../PruningSessionSlider';
 
 async function toggleLinkPreviews() {
   const newValue = !window.getSettingValue('link-preview-setting');
@@ -119,6 +120,7 @@ export const SettingsCategoryAppearance = (props: { hasPassword: boolean | null
           description={window.i18n('audioMessageAutoplayDescription')}
           active={audioAutoPlay}
         />
+        <PruningSessionSlider />
         <ZoomingSessionSlider />
         <SessionSettingButtonItem
           title={window.i18n('surveyTitle')}
diff --git a/ts/mains/main_node.ts b/ts/mains/main_node.ts
index d311381cf..b2b2dda9b 100644
--- a/ts/mains/main_node.ts
+++ b/ts/mains/main_node.ts
@@ -98,6 +98,16 @@ async function getSpellCheckSetting() {
   return json.value;
 }
 
+async function getPruneSetting() {
+  const json = sqlNode.getItemById('prune-setting');
+  // Default to `6` if setting doesn't exist yet
+  if (!json) {
+    return 6;
+  }
+
+  return json.value;
+}
+
 function showWindow() {
   if (!mainWindow) {
     return;
@@ -750,9 +760,12 @@ async function showMainWindow(sqlKey: string, passwordAttempt = false) {
     messages: locale.messages,
     passwordAttempt,
   });
+  const pruneSetting = await getPruneSetting();
   appStartInitialSpellcheckSetting = await getSpellCheckSetting();
   sqlChannels.initializeSqlChannel();
 
+  sqlNode.cleanUpOldOpengroups(pruneSetting);
+
   await initAttachmentsChannel({
     userDataPath,
   });
diff --git a/ts/node/sql.ts b/ts/node/sql.ts
index 7ca85ba7e..a37c8805a 100644
--- a/ts/node/sql.ts
+++ b/ts/node/sql.ts
@@ -1550,7 +1550,6 @@ async function initializeSql({
 
     console.info('total message count before cleaning: ', getMessageCount());
     console.info('total conversation count before cleaning: ', getConversationCount());
-    cleanUpOldOpengroups();
     cleanUpUnusedNodeForKeyEntries();
     printDbStats();
 
@@ -3405,7 +3404,7 @@ function cleanUpMessagesJson() {
   console.info(`cleanUpMessagesJson took ${Date.now() - start}ms`);
 }
 
-function cleanUpOldOpengroups() {
+function cleanUpOldOpengroups(pruneSetting: number) {
   const ourNumber = getItemById('number_id');
   if (!ourNumber || !ourNumber.value) {
     console.info('cleanUpOldOpengroups: ourNumber is not set');
@@ -3429,42 +3428,46 @@ function cleanUpOldOpengroups() {
 
   db.transaction(() => {
     dropFtsAndTriggers(db);
-    v2Convos.forEach(convo => {
-      const convoId = convo.id;
-      const messagesInConvoBefore = getMessagesCountByConversation(convoId);
-
-      if (messagesInConvoBefore >= maxMessagePerOpengroupConvo) {
-        const minute = 1000 * 60;
-        const sixMonths = minute * 60 * 24 * 30 * 6;
-        const messagesTimestampToRemove = Date.now() - sixMonths;
-        const countToRemove = assertGlobalInstance()
-          .prepare(
-            `SELECT count(*) from ${MESSAGES_TABLE} WHERE serverTimestamp <= $serverTimestamp AND conversationId = $conversationId;`
-          )
-          .get({ conversationId: convoId, serverTimestamp: Date.now() - sixMonths })['count(*)'];
-        const start = Date.now();
-
-        assertGlobalInstance()
-          .prepare(
-            `
-      DELETE FROM ${MESSAGES_TABLE} WHERE serverTimestamp <= $serverTimestamp AND conversationId = $conversationId`
-          )
-          .run({ conversationId: convoId, serverTimestamp: messagesTimestampToRemove }); // delete messages older than sixMonths
-        const messagesInConvoAfter = getMessagesCountByConversation(convoId);
-
-        console.info(
-          `Cleaning ${countToRemove} messages older than 6 months in public convo: ${convoId} took ${Date.now() -
-            start}ms. Old message count: ${messagesInConvoBefore}, new message count: ${messagesInConvoAfter}`
-        );
-
-        const unreadCount = getUnreadCountByConversation(convoId);
-        const convoProps = getConversationById(convoId);
-        if (convoProps) {
-          convoProps.unreadCount = unreadCount;
-          updateConversation(convoProps);
+    if (pruneSetting !== 0) {
+      v2Convos.forEach(convo => {
+        const convoId = convo.id;
+        const messagesInConvoBefore = getMessagesCountByConversation(convoId);
+
+        if (messagesInConvoBefore >= maxMessagePerOpengroupConvo) {
+          const minute = 1000 * 60;
+          const userDefinedMonths = minute * 60 * 24 * 30 * pruneSetting;
+          const messagesTimestampToRemove = Date.now() - userDefinedMonths;
+          const countToRemove = assertGlobalInstance()
+            .prepare(
+              `SELECT count(*) from ${MESSAGES_TABLE} WHERE serverTimestamp <= $serverTimestamp AND conversationId = $conversationId;`
+            )
+            .get({ conversationId: convoId, serverTimestamp: Date.now() - userDefinedMonths })['count(*)'];
+          const start = Date.now();
+
+          assertGlobalInstance()
+            .prepare(
+              `
+        DELETE FROM ${MESSAGES_TABLE} WHERE serverTimestamp <= $serverTimestamp AND conversationId = $conversationId`
+            )
+            .run({ conversationId: convoId, serverTimestamp: messagesTimestampToRemove }); // delete messages older than the user-specified age.
+          const messagesInConvoAfter = getMessagesCountByConversation(convoId);
+
+          console.info(
+            `Cleaning ${countToRemove} messages older than ${pruneSetting} months in public convo: ${convoId} took ${Date.now() -
+              start}ms. Old message count: ${messagesInConvoBefore}, new message count: ${messagesInConvoAfter}`
+          );
+
+          const unreadCount = getUnreadCountByConversation(convoId);
+          const convoProps = getConversationById(convoId);
+          if (convoProps) {
+            convoProps.unreadCount = unreadCount;
+            updateConversation(convoProps);
+          }
         }
-      }
-    });
+      });
+    } else {
+      console.info('Skipping cleaning messages in public convos.');
+    };
 
     // now, we might have a bunch of private conversation, without any interaction and no messages
     // those are the conversation of the old members in the opengroups we just cleaned.
@@ -3680,6 +3683,7 @@ export const sqlNode = {
   getPubkeysInPublicConversation,
   getAllGroupsInvolvingId,
   removeAllConversations,
+  cleanUpOldOpengroups,
 
   searchConversations,
   searchMessages,
diff --git a/ts/types/LocalizerKeys.ts b/ts/types/LocalizerKeys.ts
index 412e6c60d..ebbf75f96 100644
--- a/ts/types/LocalizerKeys.ts
+++ b/ts/types/LocalizerKeys.ts
@@ -162,6 +162,10 @@ export type LocalizerKeys =
   | 'copySessionID'
   | 'timerOption_0_seconds'
   | 'zoomFactorSettingTitle'
+  | 'pruneSettingTitle'
+  | 'pruneSettingDescription'
+  | 'pruneSettingUnit'
+  | 'pruneSettingUnits'
   | 'unableToCall'
   | 'callMissedTitle'
   | 'done'