diff --git a/_locales/en/messages.json b/_locales/en/messages.json
index 56cd35866..e10d612da 100644
--- a/_locales/en/messages.json
+++ b/_locales/en/messages.json
@@ -197,6 +197,11 @@
   "timerModeRead": "read",
   "timerModeSent": "sent",
   "confirm": "Confirm",
+  "messageHash": "Message Hash",
+  "serverId": "Server ID",
+  "expirationType": "Expiration Type",
+  "expirationDuration": "Expiration Duration",
+  "disappears": "Disappears",
   "followSetting": "Follow Setting",
   "followSettingDisabled": "Messages you send will no longer disappear. Are you sure you want to turn off disappearing messages?",
   "followSettingTimeAndType": "Set your messages to disappear <b>$time$</b> after they have been <b>$type$</b>?",
diff --git a/package.json b/package.json
index d5b695c23..3d9b25ed6 100644
--- a/package.json
+++ b/package.json
@@ -85,6 +85,7 @@
     "config": "1.28.1",
     "country-code-lookup": "^0.0.19",
     "curve25519-js": "https://github.com/oxen-io/curve25519-js",
+    "date-fns": "^3.3.1",
     "dompurify": "^2.0.7",
     "electron-localshortcut": "^3.2.1",
     "electron-updater": "^4.2.2",
diff --git a/preload.js b/preload.js
index 643e7938f..13712388d 100644
--- a/preload.js
+++ b/preload.js
@@ -2,6 +2,8 @@
 const { clipboard, ipcRenderer, webFrame } = require('electron/main');
 const { Storage } = require('./ts/util/storage');
 
+const { isTestNet, isTestIntegration } = require('./ts/shared/env_vars');
+
 const url = require('url');
 
 const _ = require('lodash');
@@ -22,19 +24,14 @@ window.getTitle = () => title;
 window.getEnvironment = () => configAny.environment;
 window.getAppInstance = () => configAny.appInstance;
 window.getVersion = () => configAny.version;
-window.isDev = () => config.environment === 'development';
 window.getCommitHash = () => configAny.commitHash;
 window.getNodeVersion = () => configAny.node_version;
 
 window.sessionFeatureFlags = {
   useOnionRequests: true,
-  useTestNet: Boolean(
-    process.env.NODE_APP_INSTANCE && process.env.NODE_APP_INSTANCE.includes('testnet')
-  ),
-  integrationTestEnv: Boolean(
-    process.env.NODE_APP_INSTANCE && process.env.NODE_APP_INSTANCE.includes('test-integration')
-  ),
-  useClosedGroupV3: false || process.env.USE_CLOSED_GROUP_V3,
+  useTestNet: isTestNet(),
+  integrationTestEnv: isTestIntegration(),
+  useClosedGroupV3: false,
   debug: {
     debugLogging: !_.isEmpty(process.env.SESSION_DEBUG),
     debugLibsessionDumps: !_.isEmpty(process.env.SESSION_DEBUG_LIBSESSION_DUMPS),
diff --git a/ts/components/conversation/message/message-content/MessageReactBar.tsx b/ts/components/conversation/message/message-content/MessageReactBar.tsx
index 9052ddf14..dbd5a2bd6 100644
--- a/ts/components/conversation/message/message-content/MessageReactBar.tsx
+++ b/ts/components/conversation/message/message-content/MessageReactBar.tsx
@@ -3,7 +3,10 @@ import styled from 'styled-components';
 
 import { isEmpty } from 'lodash';
 import moment from 'moment';
+import useBoolean from 'react-use/lib/useBoolean';
+import useInterval from 'react-use/lib/useInterval';
 import { useMessageExpirationPropsById } from '../../../../hooks/useParamSelector';
+import { DURATION } from '../../../../session/constants';
 import { nativeEmojiData } from '../../../../util/emoji';
 import { getRecentReactions } from '../../../../util/storage';
 import { SpacerSM } from '../../../basic/Text';
@@ -91,37 +94,36 @@ function useIsRenderedExpiresInItem(messageId: string) {
   return expiryDetails.expirationTimestamp;
 }
 
-function formatExpiry({ expirationTimestamp }: { expirationTimestamp: number }) {
-  const diffMs = expirationTimestamp - Date.now();
-  const diff = moment(diffMs).utc();
+function formatTimeLeft({ timeLeftMs }: { timeLeftMs: number }) {
+  const timeLeft = moment(timeLeftMs).utc();
 
-  if (diffMs <= 0) {
+  if (timeLeftMs <= 0) {
     return `0s`;
   }
 
   const prefix = 'Message will expire in';
 
-  if (diff.isBefore(moment.utc(0).add(1, 'minute'))) {
-    return `${prefix} ${diff.seconds()}s`;
+  if (timeLeft.isBefore(moment.utc(0).add(1, 'minute'))) {
+    return `${prefix} ${timeLeft.seconds()}s`;
   }
 
-  if (diff.isBefore(moment.utc(0).add(1, 'hour'))) {
-    const extraUnit = diff.seconds() ? ` ${diff.seconds()}s` : '';
-    return `${prefix} ${diff.minutes()}m${extraUnit}`;
+  if (timeLeft.isBefore(moment.utc(0).add(1, 'hour'))) {
+    const extraUnit = timeLeft.seconds() ? ` ${timeLeft.seconds()}s` : '';
+    return `${prefix} ${timeLeft.minutes()}m${extraUnit}`;
   }
 
-  if (diff.isBefore(moment.utc(0).add(1, 'day'))) {
-    const extraUnit = diff.minutes() ? ` ${diff.minutes()}m` : '';
-    return `${prefix} ${diff.hours()}h${extraUnit}`;
+  if (timeLeft.isBefore(moment.utc(0).add(1, 'day'))) {
+    const extraUnit = timeLeft.minutes() ? ` ${timeLeft.minutes()}m` : '';
+    return `${prefix} ${timeLeft.hours()}h${extraUnit}`;
   }
 
-  if (diff.isBefore(moment.utc(0).add(7, 'day'))) {
-    const extraUnit = diff.hours() ? ` ${diff.hours()}h` : '';
-    return `${prefix} ${diff.dayOfYear() - 1}d${extraUnit}`;
+  if (timeLeft.isBefore(moment.utc(0).add(7, 'day'))) {
+    const extraUnit = timeLeft.hours() ? ` ${timeLeft.hours()}h` : '';
+    return `${prefix} ${timeLeft.dayOfYear() - 1}d${extraUnit}`;
   }
 
-  if (diff.isBefore(moment.utc(0).add(31, 'day'))) {
-    const days = diff.dayOfYear() - 1;
+  if (timeLeft.isBefore(moment.utc(0).add(31, 'day'))) {
+    const days = timeLeft.dayOfYear() - 1;
     const weeks = Math.floor(days / 7);
     const daysLeft = days % 7;
     const extraUnit = daysLeft ? ` ${daysLeft}d` : '';
@@ -132,7 +134,20 @@ function formatExpiry({ expirationTimestamp }: { expirationTimestamp: number })
 }
 
 const ExpiresInItem = ({ expirationTimestamp }: { expirationTimestamp?: number | null }) => {
-  if (!expirationTimestamp) {
+  // this boolean is just used to forceRefresh the state when we get to display seconds in the contextmenu
+  const [refresh, setRefresh] = useBoolean(false);
+  const timeLeftMs = (expirationTimestamp || 0) - Date.now();
+
+  useInterval(
+    () => {
+      setRefresh(!refresh);
+    },
+    // We want to force refresh this component a lot more if the message has more than 2 minutes before disappearing,
+    // because when that's the case we also display the seconds left (i.e. 1min 23s) and we want that 23s to be dynamic.
+    // Also, we use a refresh interval of 500 rather than 1s so that the counter is a bit smoother
+    timeLeftMs > 0 && timeLeftMs <= 2 * DURATION.MINUTES ? 500 : null
+  );
+  if (!expirationTimestamp || timeLeftMs < 0) {
     return null;
   }
 
@@ -140,7 +155,7 @@ const ExpiresInItem = ({ expirationTimestamp }: { expirationTimestamp?: number |
     <StyledExpiresIn>
       <SessionIcon iconSize={'small'} iconType="stopwatch" />
       <SpacerSM />
-      <span>{formatExpiry({ expirationTimestamp })}</span>
+      <span>{formatTimeLeft({ timeLeftMs })}</span>
     </StyledExpiresIn>
   );
 };
diff --git a/ts/components/conversation/right-panel/overlay/disappearing-messages/OverlayDisappearingMessages.tsx b/ts/components/conversation/right-panel/overlay/disappearing-messages/OverlayDisappearingMessages.tsx
index 44e63aab0..d68f7d544 100644
--- a/ts/components/conversation/right-panel/overlay/disappearing-messages/OverlayDisappearingMessages.tsx
+++ b/ts/components/conversation/right-panel/overlay/disappearing-messages/OverlayDisappearingMessages.tsx
@@ -18,17 +18,22 @@ import {
 import { ReleasedFeatures } from '../../../../../util/releaseFeature';
 import { Flex } from '../../../../basic/Flex';
 import { SessionButton } from '../../../../basic/SessionButton';
-import { SpacerLG, SpacerXL } from '../../../../basic/Text';
+import { SpacerLG } from '../../../../basic/Text';
 import { Header, HeaderSubtitle, HeaderTitle, StyledScrollContainer } from '../components';
 import { DisappearingModes } from './DisappearingModes';
 import { TimeOptions } from './TimeOptions';
 
-const StyledContainer = styled(Flex)`
+const StyledButtonContainer = styled.div`
+  background: linear-gradient(0deg, black, transparent);
+  position: sticky;
+  width: 100%;
+  bottom: 0px;
+
   .session-button {
     font-weight: 500;
     min-width: 90px;
     width: fit-content;
-    margin: 35px auto 0;
+    margin: 35px auto 10px;
   }
 `;
 
@@ -160,7 +165,7 @@ export const OverlayDisappearingMessages = () => {
 
   return (
     <StyledScrollContainer>
-      <StyledContainer container={true} flexDirection={'column'} alignItems={'center'}>
+      <Flex container={true} flexDirection={'column'} alignItems={'center'}>
         <Header>
           <HeaderTitle>{window.i18n('disappearingMessages')}</HeaderTitle>
           <HeaderSubtitle>
@@ -205,22 +210,22 @@ export const OverlayDisappearingMessages = () => {
             </StyledNonAdminDescription>
           </>
         )}
-        <SessionButton
-          onClick={handleSetMode}
-          disabled={
-            singleMode
-              ? disappearingModeOptions[singleMode]
-              : modeSelected
-              ? disappearingModeOptions[modeSelected]
-              : undefined
-          }
-          dataTestId={'disappear-set-button'}
-        >
-          {window.i18n('set')}
-        </SessionButton>
-        <SpacerLG />
-        <SpacerXL />
-      </StyledContainer>
+        <StyledButtonContainer>
+          <SessionButton
+            onClick={handleSetMode}
+            disabled={
+              singleMode
+                ? disappearingModeOptions[singleMode]
+                : modeSelected
+                ? disappearingModeOptions[modeSelected]
+                : undefined
+            }
+            dataTestId={'disappear-set-button'}
+          >
+            {window.i18n('set')}
+          </SessionButton>
+        </StyledButtonContainer>
+      </Flex>
     </StyledScrollContainer>
   );
 };
diff --git a/ts/components/conversation/right-panel/overlay/message-info/components/MessageInfo.tsx b/ts/components/conversation/right-panel/overlay/message-info/components/MessageInfo.tsx
index 624032ab3..1875298f0 100644
--- a/ts/components/conversation/right-panel/overlay/message-info/components/MessageInfo.tsx
+++ b/ts/components/conversation/right-panel/overlay/message-info/components/MessageInfo.tsx
@@ -1,17 +1,25 @@
+import { format, formatDistanceStrict } from 'date-fns';
 import { ipcRenderer } from 'electron';
 import { isEmpty } from 'lodash';
 import moment from 'moment';
+
 import React from 'react';
 import styled from 'styled-components';
 import { MessageFrom } from '.';
 import {
   useMessageDirection,
+  useMessageExpirationDurationMs,
+  useMessageExpirationTimestamp,
+  useMessageExpirationType,
+  useMessageHash,
   useMessageReceivedAt,
   useMessageSender,
+  useMessageServerId,
   useMessageServerTimestamp,
   useMessageTimestamp,
 } from '../../../../../../state/selectors';
 
+import { isDevProd } from '../../../../../../shared/env_vars';
 import { Flex } from '../../../../../basic/Flex';
 import { SpacerSM } from '../../../../../basic/Text';
 
@@ -57,6 +65,46 @@ const showDebugLog = () => {
   ipcRenderer.send('show-debug-log');
 };
 
+const DebugMessageInfo = ({ messageId }: { messageId: string }) => {
+  const messageHash = useMessageHash(messageId);
+  const serverId = useMessageServerId(messageId);
+  const expirationType = useMessageExpirationType(messageId);
+  const expirationDurationMs = useMessageExpirationDurationMs(messageId);
+  const expirationTimestamp = useMessageExpirationTimestamp(messageId);
+
+  if (!isDevProd()) {
+    return null;
+  }
+
+  return (
+    <>
+      {messageHash ? (
+        <LabelWithInfo label={`${window.i18n('messageHash')}:`} info={messageHash} />
+      ) : null}
+      {serverId ? (
+        <LabelWithInfo label={`${window.i18n('serverId')}:`} info={`${serverId}`} />
+      ) : null}
+      {expirationType ? (
+        <LabelWithInfo label={`${window.i18n('expirationType')}:`} info={expirationType} />
+      ) : null}
+      {expirationDurationMs ? (
+        <LabelWithInfo
+          label={`${window.i18n('expirationDuration')}:`}
+          // formatDistanceStrict (date-fns) is not localized yet
+          info={`${formatDistanceStrict(0, Math.floor(expirationDurationMs / 1000))}`}
+        />
+      ) : null}
+      {expirationTimestamp ? (
+        <LabelWithInfo
+          label={`${window.i18n('disappears')}:`}
+          // format (date-fns) is not localized yet
+          info={`${format(expirationTimestamp, 'PPpp')}`}
+        />
+      ) : null}
+    </>
+  );
+};
+
 export const MessageInfo = ({ messageId, errors }: { messageId: string; errors: Array<Error> }) => {
   const sender = useMessageSender(messageId);
   const direction = useMessageDirection(messageId);
@@ -83,6 +131,8 @@ export const MessageInfo = ({ messageId, errors }: { messageId: string; errors:
   return (
     <Flex container={true} flexDirection="column">
       <LabelWithInfo label={`${window.i18n('sent')}:`} info={sentAtStr} />
+      <DebugMessageInfo messageId={messageId} />
+
       {direction === 'incoming' ? (
         <LabelWithInfo label={`${window.i18n('received')}:`} info={receivedAtStr} />
       ) : null}
diff --git a/ts/mains/main_node.ts b/ts/mains/main_node.ts
index cf4f34122..807046738 100644
--- a/ts/mains/main_node.ts
+++ b/ts/mains/main_node.ts
@@ -87,10 +87,9 @@ import { windowMarkShouldQuit, windowShouldQuit } from '../node/window_state'; /
 
 let appStartInitialSpellcheckSetting = true;
 
-const isTestIntegration = Boolean(
-  process.env.NODE_APP_INSTANCE && process.env.NODE_APP_INSTANCE.includes('test-integration')
-);
-const openDevToolsTestIntegration = isTestIntegration && !isEmpty(process.env.TEST_OPEN_DEV_TOOLS);
+function openDevToolsTestIntegration() {
+  return isTestIntegration() && !isEmpty(process.env.TEST_OPEN_DEV_TOOLS);
+}
 
 async function getSpellCheckSetting() {
   const json = sqlNode.getItemById('spell-check');
@@ -159,6 +158,7 @@ if (windowFromUserConfig) {
 import { getAppRootPath } from '../node/getRootPath';
 import { setLastestRelease } from '../node/latest_desktop_release';
 import { load as loadLocale, LocaleMessagesWithNameType } from '../node/locale';
+import { isDevProd, isTestIntegration } from '../shared/env_vars';
 import { classicDark } from '../themes';
 
 // Both of these will be set after app fires the 'ready' event
@@ -215,7 +215,7 @@ function captureClicks(window: BrowserWindow) {
 function getDefaultWindowSize() {
   return {
     defaultWidth: 880,
-    defaultHeight: openDevToolsTestIntegration ? 1000 : 820, // the dev tools open at the bottom hide some stuff which should be visible
+    defaultHeight: openDevToolsTestIntegration() ? 1000 : 820, // the dev tools open at the bottom hide some stuff which should be visible
     minWidth: 880,
     minHeight: 600,
   };
@@ -274,7 +274,7 @@ async function createWindow() {
     y: (windowConfig as any).y,
   };
 
-  if (isTestIntegration) {
+  if (isTestIntegration()) {
     const screenWidth =
       screen.getPrimaryDisplay().workAreaSize.width - getDefaultWindowSize().defaultWidth;
     const screenHeight =
@@ -416,7 +416,7 @@ async function createWindow() {
   const urlToLoad = prepareURL([getAppRootPath(), 'background.html']);
 
   await mainWindow.loadURL(urlToLoad);
-  if (openDevToolsTestIntegration) {
+  if (openDevToolsTestIntegration()) {
     setTimeout(() => {
       if (mainWindow && mainWindow.webContents) {
         mainWindow.webContents.openDevTools({
@@ -427,7 +427,7 @@ async function createWindow() {
     }, 5000);
   }
 
-  if ((process.env.NODE_APP_INSTANCE || '').startsWith('devprod')) {
+  if (isDevProd()) {
     // Open the DevTools.
     mainWindow.webContents.openDevTools({
       mode: 'bottom',
diff --git a/ts/models/conversation.ts b/ts/models/conversation.ts
index ce7445fca..e6d84d3f9 100644
--- a/ts/models/conversation.ts
+++ b/ts/models/conversation.ts
@@ -1144,9 +1144,7 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
     };
 
     // if the message is trying to be added unread, make sure that it shouldn't be already read from our other devices
-
     markAttributesAsReadIfNeeded(toBeAddedAttributes);
-
     return this.addSingleMessage(toBeAddedAttributes);
   }
 
@@ -2616,7 +2614,7 @@ async function cleanUpExpireHistoryFromConvo(conversationId: string, isPrivate:
     conversationId,
     isPrivate
   );
-  window.inboxStore.dispatch(
+  window?.inboxStore?.dispatch(
     messagesDeleted(updateIdsRemoved.map(m => ({ conversationKey: conversationId, messageId: m })))
   );
 }
diff --git a/ts/models/messageFactory.ts b/ts/models/messageFactory.ts
index f5efedc07..c56a3d63e 100644
--- a/ts/models/messageFactory.ts
+++ b/ts/models/messageFactory.ts
@@ -117,6 +117,7 @@ export function markAttributesAsReadIfNeeded(messageAttributes: MessageAttribute
       latestUnreadForThisConvo?.lastRead &&
       sentAt <= latestUnreadForThisConvo.lastRead
     ) {
+      // The message was sent before our last read timestamp for that conversation.
       // eslint-disable-next-line no-param-reassign
       messageAttributes.unread = READ_MESSAGE_STATE.read;
     }
diff --git a/ts/node/migration/sessionMigrations.ts b/ts/node/migration/sessionMigrations.ts
index 1c949d7b0..e000ed1b8 100644
--- a/ts/node/migration/sessionMigrations.ts
+++ b/ts/node/migration/sessionMigrations.ts
@@ -104,6 +104,7 @@ const LOKI_SCHEMA_VERSIONS = [
   updateToSessionSchemaVersion33,
   updateToSessionSchemaVersion34,
   updateToSessionSchemaVersion35,
+  updateToSessionSchemaVersion36,
 ];
 
 function updateToSessionSchemaVersion1(currentVersion: number, db: BetterSqlite3.Database) {
@@ -1928,6 +1929,26 @@ function updateToSessionSchemaVersion35(currentVersion: number, db: BetterSqlite
   console.log(`updateToSessionSchemaVersion${targetVersion}: success!`);
 }
 
+function updateToSessionSchemaVersion36(currentVersion: number, db: BetterSqlite3.Database) {
+  const targetVersion = 36;
+  if (currentVersion >= targetVersion) {
+    return;
+  }
+
+  console.log(`updateToSessionSchemaVersion${targetVersion}: starting...`);
+
+  db.transaction(() => {
+    db.exec(`CREATE INDEX messages_DaR_unread_sent_at ON ${MESSAGES_TABLE} (
+      expirationType,
+      unread,
+      sent_at
+    );`);
+    writeSessionSchemaVersion(targetVersion, db);
+  })();
+
+  console.log(`updateToSessionSchemaVersion${targetVersion}: success!`);
+}
+
 export function printTableColumns(table: string, db: BetterSqlite3.Database) {
   console.info(db.pragma(`table_info('${table}');`));
 }
diff --git a/ts/node/sql.ts b/ts/node/sql.ts
index 3f0cc08c3..52a12d6e0 100644
--- a/ts/node/sql.ts
+++ b/ts/node/sql.ts
@@ -64,6 +64,7 @@ import { KNOWN_BLINDED_KEYS_ITEM, SettingsKey } from '../data/settings-key';
 import { MessageAttributes } from '../models/messageType';
 import { SignalService } from '../protobuf';
 import { Quote } from '../receiver/types';
+import { DURATION } from '../session/constants';
 import {
   getSQLCipherIntegrityCheck,
   openAndMigrateDatabase,
@@ -192,6 +193,7 @@ async function initializeSql({
     console.info('total conversation count before cleaning: ', getConversationCount());
     cleanUpOldOpengroupsOnStart();
     cleanUpUnusedNodeForKeyEntriesOnStart();
+    cleanUpUnreadExpiredDaRMessages();
     printDbStats();
 
     console.info('total message count after cleaning: ', getMessageCount());
@@ -1614,6 +1616,28 @@ function getExpiredMessages() {
   return map(rows, row => jsonToObject(row.json));
 }
 
+function cleanUpUnreadExpiredDaRMessages() {
+  // we cannot rely on network offset here, so we need to trust the user clock
+  const t14daysEarlier = Date.now() - 14 * DURATION.DAYS;
+  const start = Date.now();
+  const deleted = assertGlobalInstance()
+    .prepare(
+      `DELETE FROM ${MESSAGES_TABLE} WHERE
+      expirationType = 'deleteAfterRead' AND
+      unread = $unread AND
+      sent_at <= $t14daysEarlier;`
+    )
+    .run({
+      unread: toSqliteBoolean(true),
+      t14daysEarlier,
+    });
+  console.info(
+    `cleanUpUnreadExpiredDaRMessages: deleted ${
+      deleted.changes
+    } message(s) which were DaR and sent before ${t14daysEarlier} in ${Date.now() - start}ms`
+  );
+}
+
 function getOutgoingWithoutExpiresAt() {
   const rows = assertGlobalInstance()
     .prepare(
diff --git a/ts/session/disappearing_messages/index.ts b/ts/session/disappearing_messages/index.ts
index 16c53475e..ad6454a02 100644
--- a/ts/session/disappearing_messages/index.ts
+++ b/ts/session/disappearing_messages/index.ts
@@ -12,6 +12,7 @@ import { ExpiringDetails, expireMessagesOnSnode } from '../apis/snode_api/expire
 import { GetNetworkTime } from '../apis/snode_api/getNetworkTime';
 import { getConversationController } from '../conversations';
 import { isValidUnixTimestamp } from '../utils/Timestamps';
+import { UpdateMsgExpirySwarm } from '../utils/job_runners/jobs/UpdateMsgExpirySwarmJob';
 import {
   checkIsLegacyDisappearingDataMessage,
   couldBeLegacyDisappearingMessageContent,
@@ -543,18 +544,40 @@ function getMessageReadyToDisappear(
     messageExpirationFromRetrieve &&
     messageExpirationFromRetrieve > 0
   ) {
-    const expirationStartTimestamp = messageExpirationFromRetrieve - expireTimer * 1000;
-    const expires_at = messageExpirationFromRetrieve;
-    // TODO a message might be added even when it expired, but the period cleaning of expired message will pick it up and remove it soon enough
-    window.log.debug(
-      `incoming DaR message already read by another device, forcing readAt ${(Date.now() -
-        expirationStartTimestamp) /
-        1000}s ago, so with ${(expires_at - Date.now()) / 1000}s left`
-    );
-    messageModel.set({
-      expirationStartTimestamp,
-      expires_at,
-    });
+    /**
+     * Edge case: when we send a message before we poll for a message sent earlier, our convo volatile update will
+     * mark that incoming message as read right away (because it was sent earlier than our latest convolatile lastRead).
+     * To take care of this case, we need to check if an incoming DaR message is in a read state but its expiration has not been updated yet.
+     * The way we do it, is by checking that the swarm expiration is before (now + expireTimer).
+     * If it looks like this expiration was not updated yet, we need to trigger a UpdateExpiryJob for that message.
+     */
+    const now = GetNetworkTime.getNowWithNetworkOffset();
+    const expirationNowPlusTimer = now + expireTimer * 1000;
+    const msgExpirationWasAlreadyUpdated = messageExpirationFromRetrieve <= expirationNowPlusTimer;
+    // Note: a message might be added even when it expired, but the periodic cleaning of expired message will pick it up and remove it soon enough
+
+    if (msgExpirationWasAlreadyUpdated) {
+      const expirationStartTimestamp = messageExpirationFromRetrieve - expireTimer * 1000;
+      window.log.debug(
+        `incoming DaR message already read by another device, forcing readAt ${(Date.now() -
+          expirationStartTimestamp) /
+          1000}s ago, so with ${(messageExpirationFromRetrieve - Date.now()) / 1000}s left`
+      );
+      messageModel.set({
+        expirationStartTimestamp,
+        expires_at: messageExpirationFromRetrieve,
+      });
+    } else {
+      window.log.debug(
+        `incoming DaR message already read by another device but swarmExpiration seems NOT updated, forcing readAt NOW and triggering UpdateExpiryJob with ${expireTimer}s left`
+      );
+      messageModel.set({
+        expirationStartTimestamp: now,
+        expires_at: expirationNowPlusTimer,
+      });
+      // Ideally we would batch call those UpdateExpiry, but we can't currently and disappear v2 is already too complex as it is.
+      void UpdateMsgExpirySwarm.queueNewJobIfNeeded([messageModel.id]);
+    }
   } else if (
     expirationType === 'deleteAfterSend' &&
     expireTimer > 0 &&
diff --git a/ts/session/disappearing_messages/timerOptions.ts b/ts/session/disappearing_messages/timerOptions.ts
index ef1ea5281..1dc1267f3 100644
--- a/ts/session/disappearing_messages/timerOptions.ts
+++ b/ts/session/disappearing_messages/timerOptions.ts
@@ -1,5 +1,5 @@
-import { isEmpty } from 'lodash';
 import moment from 'moment';
+import { isDevProd } from '../../shared/env_vars';
 import { LocalizerKeys } from '../../types/LocalizerKeys';
 
 type TimerOptionsEntry = { name: string; value: number };
@@ -67,12 +67,7 @@ const VALUES: Array<number> = timerOptionsDurations.map(t => {
 });
 
 const filterOutDebugValues = (option: number) => {
-  // process.env.NODE_APP_INSTANCE is empty when the app is packaged, and not empty when starting from start-prod or start-dev
-  const isPackaged = isEmpty(process.env.NODE_APP_INSTANCE);
-  if (isPackaged) {
-    return option > 60; // when packaged, filter out options with less than 60s
-  }
-  return true;
+  return isDevProd() || option > 60; // when not a dev build, filter out options with less than 60s
 };
 
 const DELETE_AFTER_READ = VALUES.filter(option => {
diff --git a/ts/shared/env_vars.ts b/ts/shared/env_vars.ts
new file mode 100644
index 000000000..349277c42
--- /dev/null
+++ b/ts/shared/env_vars.ts
@@ -0,0 +1,16 @@
+function envAppInstanceIncludes(prefix: string) {
+  if (!process.env.NODE_APP_INSTANCE) {
+    return false;
+  }
+  return !!process.env.NODE_APP_INSTANCE.includes(prefix);
+}
+
+export function isDevProd() {
+  return envAppInstanceIncludes('devprod');
+}
+export function isTestNet() {
+  return envAppInstanceIncludes('testnet');
+}
+export function isTestIntegration() {
+  return envAppInstanceIncludes('test-integration');
+}
diff --git a/ts/state/selectors/messages.ts b/ts/state/selectors/messages.ts
index 8ae182966..b998bf7f7 100644
--- a/ts/state/selectors/messages.ts
+++ b/ts/state/selectors/messages.ts
@@ -136,6 +136,26 @@ export const useMessageQuote = (messageId: string | undefined): PropsForQuote |
   return useMessagePropsByMessageId(messageId)?.propsForMessage.quote;
 };
 
+export const useMessageHash = (messageId: string | undefined) => {
+  return useMessagePropsByMessageId(messageId)?.propsForMessage.messageHash;
+};
+
+export const useMessageExpirationType = (messageId: string | undefined) => {
+  return useMessagePropsByMessageId(messageId)?.propsForMessage.expirationType;
+};
+
+export const useMessageExpirationDurationMs = (messageId: string | undefined) => {
+  return useMessagePropsByMessageId(messageId)?.propsForMessage.expirationDurationMs;
+};
+
+export const useMessageExpirationTimestamp = (messageId: string | undefined) => {
+  return useMessagePropsByMessageId(messageId)?.propsForMessage.expirationTimestamp;
+};
+
+export const useMessageServerId = (messageId: string | undefined) => {
+  return useMessagePropsByMessageId(messageId)?.propsForMessage.serverId;
+};
+
 export const useMessageText = (messageId: string | undefined): string | undefined => {
   return useMessagePropsByMessageId(messageId)?.propsForMessage.text;
 };
diff --git a/ts/types/LocalizerKeys.ts b/ts/types/LocalizerKeys.ts
index 87a80c1ac..abbdadb23 100644
--- a/ts/types/LocalizerKeys.ts
+++ b/ts/types/LocalizerKeys.ts
@@ -159,6 +159,7 @@ export type LocalizerKeys =
   | 'disappearingMessagesModeLegacySubtitle'
   | 'disappearingMessagesModeOff'
   | 'disappearingMessagesModeOutdated'
+  | 'disappears'
   | 'displayName'
   | 'displayNameEmpty'
   | 'displayNameTooLong'
@@ -198,6 +199,8 @@ export type LocalizerKeys =
   | 'error'
   | 'establishingConnection'
   | 'expandedReactionsText'
+  | 'expirationDuration'
+  | 'expirationType'
   | 'failed'
   | 'failedResolveOns'
   | 'failedToAddAsModerator'
@@ -296,6 +299,7 @@ export type LocalizerKeys =
   | 'messageBodyMissing'
   | 'messageDeletedPlaceholder'
   | 'messageDeletionForbidden'
+  | 'messageHash'
   | 'messageInfo'
   | 'messageRequestAccepted'
   | 'messageRequestAcceptedOurs'
@@ -444,6 +448,7 @@ export type LocalizerKeys =
   | 'sendRecoveryPhraseTitle'
   | 'sending'
   | 'sent'
+  | 'serverId'
   | 'sessionMessenger'
   | 'set'
   | 'setAccountPasswordDescription'
diff --git a/ts/util/privacy.ts b/ts/util/privacy.ts
index 387876f0b..148aef54b 100644
--- a/ts/util/privacy.ts
+++ b/ts/util/privacy.ts
@@ -3,6 +3,7 @@
 import { escapeRegExp, isEmpty, isRegExp, isString } from 'lodash';
 import { compose } from 'lodash/fp';
 import { getAppRootPath } from '../node/getRootPath';
+import { isDevProd } from '../shared/env_vars';
 
 const APP_ROOT_PATH = getAppRootPath();
 const SESSION_ID_PATTERN = /\b((05)?[0-9a-f]{64})\b/gi;
@@ -103,7 +104,7 @@ function shouldNotRedactLogs() {
     return true;
   }
   // otherwise we don't want to redact logs when running on the devprod env
-  return (process.env.NODE_APP_INSTANCE || '').startsWith('devprod');
+  return isDevProd();
 }
 
 //      redactAll :: String -> String
diff --git a/ts/window.d.ts b/ts/window.d.ts
index 2e5da8494..b90a6b03b 100644
--- a/ts/window.d.ts
+++ b/ts/window.d.ts
@@ -17,14 +17,11 @@ If you import anything in global.d.ts, the type system won't work correctly.
 
 declare global {
   interface Window {
-    CONSTANTS: any;
     Events: any;
-    Lodash: any;
     Session: any;
     Whisper: any;
-    clearLocalData: any;
+    clearLocalData: () => Promise<void>;
     clipboard: any;
-    dcodeIO: any;
     getSettingValue: (id: string, comparisonValue?: any) => any;
     setSettingValue: (id: string, value: any) => Promise<void>;
 
@@ -43,22 +40,20 @@ declare global {
         debugOnionRequests: boolean;
       };
     };
-    SessionSnodeAPI: SessionSnodeAPI;
     onLogin: (pw: string) => Promise<void>;
     persistStore?: Persistor;
-    restart: any;
+    restart: () => void;
     getSeedNodeList: () => Array<string> | undefined;
-    setPassword: any;
+    setPassword: (newPassword: string | null, oldPassword: string | null) => Promise<void>;
     isOnline: boolean;
     toggleMediaPermissions: () => Promise<void>;
     toggleCallMediaPermissionsTo: (enabled: boolean) => Promise<void>;
     getCallMediaPermissions: () => boolean;
     toggleMenuBar: () => void;
-    toggleSpellCheck: any;
+    toggleSpellCheck: () => void;
     primaryColor: PrimaryColorStateType;
     theme: ThemeStateType;
     setTheme: (newTheme: string) => Promise<void>;
-    isDev?: () => boolean;
     userConfig: any;
     versionInfo: any;
     getConversations: () => ConversationCollection;
diff --git a/yarn.lock b/yarn.lock
index 232727930..fe3253dd7 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2641,6 +2641,11 @@ data-urls@^4.0.0:
     whatwg-mimetype "^3.0.0"
     whatwg-url "^12.0.0"
 
+date-fns@^3.3.1:
+  version "3.3.1"
+  resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-3.3.1.tgz#7581daca0892d139736697717a168afbb908cfed"
+  integrity sha512-y8e109LYGgoQDveiEBD3DYXKba1jWf5BA8YU1FL5Tvm0BTdEfy54WLCwnuYWZNnzzvALy/QQ4Hov+Q9RVRv+Zw==
+
 debug@4, debug@4.3.4, debug@^4, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4:
   version "4.3.4"
   resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865"