Init Chat Folders UI
This commit is contained in:
		
					parent
					
						
							
								726234a27b
							
						
					
				
			
			
				commit
				
					
						ec7d07269d
					
				
			
		
					 88 changed files with 4082 additions and 1306 deletions
				
			
		
							
								
								
									
										18
									
								
								.eslintrc.js
									
										
									
									
									
								
							
							
						
						
									
										18
									
								
								.eslintrc.js
									
										
									
									
									
								
							| 
						 | 
				
			
			@ -436,6 +436,24 @@ module.exports = {
 | 
			
		|||
        ],
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      files: ['ts/axo/**/*.tsx'],
 | 
			
		||||
      rules: {
 | 
			
		||||
        '@typescript-eslint/no-namespace': 'off',
 | 
			
		||||
        '@typescript-eslint/no-redeclare': [
 | 
			
		||||
          'error',
 | 
			
		||||
          {
 | 
			
		||||
            ignoreDeclarationMerge: true,
 | 
			
		||||
          },
 | 
			
		||||
        ],
 | 
			
		||||
        '@typescript-eslint/explicit-module-boundary-types': [
 | 
			
		||||
          'error',
 | 
			
		||||
          {
 | 
			
		||||
            allowHigherOrderFunctions: false,
 | 
			
		||||
          },
 | 
			
		||||
        ],
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  ],
 | 
			
		||||
 | 
			
		||||
  rules: {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -379,6 +379,38 @@
 | 
			
		|||
    "messageformat": "Enter a username followed by a dot and its set of numbers.",
 | 
			
		||||
    "description": "Description displayed under search input in left pane when looking up someone by username"
 | 
			
		||||
  },
 | 
			
		||||
  "icu:LeftPaneChatFolders__ItemLabel--All--Short": {
 | 
			
		||||
    "messageformat": "All",
 | 
			
		||||
    "description": "Left Pane > Inbox > Chat List > Chat Folders > Item Label > All chats (needs to fit in very small space)"
 | 
			
		||||
  },
 | 
			
		||||
  "icu:LeftPaneChatFolders__ItemLabel--All": {
 | 
			
		||||
    "messageformat": "All chats",
 | 
			
		||||
    "description": "Left Pane > Inbox > Chat List > Chat Folders > Item Label > All chats"
 | 
			
		||||
  },
 | 
			
		||||
  "icu:LeftPaneChatFolders__ItemUnreadBadge__MaxCount": {
 | 
			
		||||
    "messageformat": "{maxCount, number}+",
 | 
			
		||||
    "description": "Left Pane > Inbox > Chat List > Chat Folders > Item > Badge Count > When over the max count (Example: 1000 or more would be 999+)"
 | 
			
		||||
  },
 | 
			
		||||
  "icu:LeftPaneChatFolders__Item__ContextMenu__MarkAllRead": {
 | 
			
		||||
    "messageformat": "Mark all read",
 | 
			
		||||
    "description": "Left Pane > Inbox > Chat List > Chat Folders > Item > Right-Click Context Menu > Mark all unread chats in chat folder as read"
 | 
			
		||||
  },
 | 
			
		||||
  "icu:LeftPaneChatFolders__Item__ContextMenu__MuteNotifications": {
 | 
			
		||||
    "messageformat": "Mute notifications",
 | 
			
		||||
    "description": "Left Pane > Inbox > Chat List > Chat Folders > Item > Right-Click Context Menu > Mute Notifications"
 | 
			
		||||
  },
 | 
			
		||||
  "icu:LeftPaneChatFolders__Item__ContextMenu__MuteNotifications__UnmuteAll": {
 | 
			
		||||
    "messageformat": "Unmute all",
 | 
			
		||||
    "description": "Left Pane > Inbox > Chat List > Chat Folders > Item > Right-Click Context Menu > Mute Notifications > Sub-Menu > Unmute all"
 | 
			
		||||
  },
 | 
			
		||||
  "icu:LeftPaneChatFolders__Item__ContextMenu__UnmuteAll": {
 | 
			
		||||
    "messageformat": "Unmute all",
 | 
			
		||||
    "description": "Left Pane > Inbox > Chat List > Chat Folders > Item > Right-Click Context Menu > Unmute all chats in chat folder"
 | 
			
		||||
  },
 | 
			
		||||
  "icu:LeftPaneChatFolders__Item__ContextMenu__EditFolder": {
 | 
			
		||||
    "messageformat": "Edit folder",
 | 
			
		||||
    "description": "Left Pane > Inbox > Chat List > Chat Folders > Item > Right-Click Context Menu > Open settings for current chat folder"
 | 
			
		||||
  },
 | 
			
		||||
  "icu:CountryCodeSelect__placeholder": {
 | 
			
		||||
    "messageformat": "Country code",
 | 
			
		||||
    "description": "Placeholder displayed as default value of country code select element"
 | 
			
		||||
| 
						 | 
				
			
			@ -447,6 +479,10 @@
 | 
			
		|||
    "messageformat": "Mark as unread",
 | 
			
		||||
    "description": "Shown in menu for conversation, and marks conversation as unread"
 | 
			
		||||
  },
 | 
			
		||||
  "icu:markRead": {
 | 
			
		||||
    "messageformat": "Mark read",
 | 
			
		||||
    "description": "Shown in menu for conversation, and marks conversation read"
 | 
			
		||||
  },
 | 
			
		||||
  "icu:ConversationHeader__menu__selectMessages": {
 | 
			
		||||
    "messageformat": "Select messages",
 | 
			
		||||
    "description": "Shown in menu for conversation, allows the user to start selecting multiple messages in the conversation"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1374,6 +1374,10 @@ $secondary-text-color: light-dark(
 | 
			
		|||
  padding-block: 8px;
 | 
			
		||||
  padding-inline: 24px;
 | 
			
		||||
  border-radius: 1px;
 | 
			
		||||
 | 
			
		||||
  &[data-dragging='true'] {
 | 
			
		||||
    opacity: 50%;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.Preferences__ChatFolders__ChatSelection__ItemAvatar {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -410,3 +410,9 @@
 | 
			
		|||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@property --axo-select-trigger-mask-start {
 | 
			
		||||
  syntax: '<color>';
 | 
			
		||||
  inherits: false;
 | 
			
		||||
  initial-value: transparent;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -368,6 +368,8 @@ export class ConversationController {
 | 
			
		|||
          // because `conversation.format()` can return cached props by the
 | 
			
		||||
          // time this runs
 | 
			
		||||
          return {
 | 
			
		||||
            id: conversation.get('id'),
 | 
			
		||||
            type: conversation.get('type') === 'private' ? 'direct' : 'group',
 | 
			
		||||
            activeAt: conversation.get('active_at') ?? undefined,
 | 
			
		||||
            isArchived: conversation.get('isArchived'),
 | 
			
		||||
            markedUnread: conversation.get('markedUnread'),
 | 
			
		||||
| 
						 | 
				
			
			@ -383,15 +385,16 @@ export class ConversationController {
 | 
			
		|||
    drop(window.storage.put('unreadCount', unreadStats.unreadCount));
 | 
			
		||||
 | 
			
		||||
    if (unreadStats.unreadCount > 0) {
 | 
			
		||||
      window.IPC.setBadge(unreadStats.unreadCount);
 | 
			
		||||
      window.IPC.updateTrayIcon(unreadStats.unreadCount);
 | 
			
		||||
      window.document.title = `${window.getTitle()} (${
 | 
			
		||||
        unreadStats.unreadCount
 | 
			
		||||
      })`;
 | 
			
		||||
    } else if (unreadStats.markedUnread) {
 | 
			
		||||
      window.IPC.setBadge('marked-unread');
 | 
			
		||||
      window.IPC.updateTrayIcon(1);
 | 
			
		||||
      window.document.title = `${window.getTitle()} (1)`;
 | 
			
		||||
      const total =
 | 
			
		||||
        unreadStats.unreadCount + unreadStats.readChatsMarkedUnreadCount;
 | 
			
		||||
      window.IPC.setBadge(total);
 | 
			
		||||
      window.IPC.updateTrayIcon(total);
 | 
			
		||||
      window.document.title = `${window.getTitle()} (${total})`;
 | 
			
		||||
    } else if (unreadStats.readChatsMarkedUnreadCount > 0) {
 | 
			
		||||
      const total = unreadStats.readChatsMarkedUnreadCount;
 | 
			
		||||
      window.IPC.setBadge(total);
 | 
			
		||||
      window.IPC.updateTrayIcon(total);
 | 
			
		||||
      window.document.title = `${window.getTitle()} (${total})`;
 | 
			
		||||
    } else {
 | 
			
		||||
      window.IPC.setBadge(0);
 | 
			
		||||
      window.IPC.updateTrayIcon(0);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -78,9 +78,13 @@ function CardButton(props: {
 | 
			
		|||
}) {
 | 
			
		||||
  return (
 | 
			
		||||
    <AriaClickable.SubWidget>
 | 
			
		||||
      <AxoButton variant={props.variant} size="medium" onClick={props.onClick}>
 | 
			
		||||
      <AxoButton.Root
 | 
			
		||||
        variant={props.variant}
 | 
			
		||||
        size="medium"
 | 
			
		||||
        onClick={props.onClick}
 | 
			
		||||
      >
 | 
			
		||||
        {props.children}
 | 
			
		||||
      </AxoButton>
 | 
			
		||||
      </AxoButton.Root>
 | 
			
		||||
    </AriaClickable.SubWidget>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -27,7 +27,7 @@ const Namespace = 'AriaClickable';
 | 
			
		|||
 *       <AriaClickable.HiddenTrigger aria-labelledby="see-more-1"/>
 | 
			
		||||
 *     </p>
 | 
			
		||||
 *     <AriaClickable.SubWidget>
 | 
			
		||||
 *       <AxoButton>Delete</AxoButton>
 | 
			
		||||
 *       <AxoButton.Root>Delete</AxoButton.Root>
 | 
			
		||||
 *     </AriaClickable.SubWidget>
 | 
			
		||||
 *     <AriaClickable.SubWidget>
 | 
			
		||||
 *       <AxoLink>Edit</AxoLink>
 | 
			
		||||
| 
						 | 
				
			
			@ -36,7 +36,6 @@ const Namespace = 'AriaClickable';
 | 
			
		|||
 * );
 | 
			
		||||
 * ```
 | 
			
		||||
 */
 | 
			
		||||
// eslint-disable-next-line @typescript-eslint/no-namespace
 | 
			
		||||
export namespace AriaClickable {
 | 
			
		||||
  type TriggerState = Readonly<{
 | 
			
		||||
    hovered: boolean;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										57
									
								
								ts/axo/AxoBadge.stories.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								ts/axo/AxoBadge.stories.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,57 @@
 | 
			
		|||
// Copyright 2025 Signal Messenger, LLC
 | 
			
		||||
// SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		||||
import type { Meta } from '@storybook/react';
 | 
			
		||||
import React from 'react';
 | 
			
		||||
import { ExperimentalAxoBadge } from './AxoBadge.js';
 | 
			
		||||
import { tw } from './tw.js';
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
  title: 'Axo/AriaBadge (Experimental)',
 | 
			
		||||
} satisfies Meta;
 | 
			
		||||
 | 
			
		||||
export function All(): JSX.Element {
 | 
			
		||||
  const values: ReadonlyArray<ExperimentalAxoBadge.BadgeValue> = [
 | 
			
		||||
    -1,
 | 
			
		||||
    0,
 | 
			
		||||
    1,
 | 
			
		||||
    10,
 | 
			
		||||
    123,
 | 
			
		||||
    1234,
 | 
			
		||||
    12345,
 | 
			
		||||
    'mention',
 | 
			
		||||
    'unread',
 | 
			
		||||
  ];
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <table className={tw('border-separate border-spacing-2 text-center')}>
 | 
			
		||||
      <thead>
 | 
			
		||||
        <th>size</th>
 | 
			
		||||
        {values.map(value => {
 | 
			
		||||
          return <th key={value}>{value}</th>;
 | 
			
		||||
        })}
 | 
			
		||||
      </thead>
 | 
			
		||||
      <tbody>
 | 
			
		||||
        {ExperimentalAxoBadge._getAllBadgeSizes().map(size => {
 | 
			
		||||
          return (
 | 
			
		||||
            <tr key={size}>
 | 
			
		||||
              <th>{size}</th>
 | 
			
		||||
              {values.map(value => {
 | 
			
		||||
                return (
 | 
			
		||||
                  <td key={value} className={tw('')}>
 | 
			
		||||
                    <ExperimentalAxoBadge.Root
 | 
			
		||||
                      size={size}
 | 
			
		||||
                      value={value}
 | 
			
		||||
                      max={99}
 | 
			
		||||
                      maxDisplay="99+"
 | 
			
		||||
                      aria-label={null}
 | 
			
		||||
                    />
 | 
			
		||||
                  </td>
 | 
			
		||||
                );
 | 
			
		||||
              })}
 | 
			
		||||
            </tr>
 | 
			
		||||
          );
 | 
			
		||||
        })}
 | 
			
		||||
      </tbody>
 | 
			
		||||
    </table>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										125
									
								
								ts/axo/AxoBadge.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										125
									
								
								ts/axo/AxoBadge.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,125 @@
 | 
			
		|||
// Copyright 2025 Signal Messenger, LLC
 | 
			
		||||
// SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		||||
import type { FC } from 'react';
 | 
			
		||||
import React, { memo, useMemo } from 'react';
 | 
			
		||||
import { AxoSymbol } from './AxoSymbol.js';
 | 
			
		||||
import type { TailwindStyles } from './tw.js';
 | 
			
		||||
import { tw } from './tw.js';
 | 
			
		||||
import { unreachable } from './_internal/assert.js';
 | 
			
		||||
 | 
			
		||||
const Namespace = 'AxoBadge';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * @example Anatomy
 | 
			
		||||
 * ```tsx
 | 
			
		||||
 * <AxoBadge.Root aria-label="42 unread messages">
 | 
			
		||||
 *   <AxoBadge.Count value={42} max={999}/>
 | 
			
		||||
 * </AxoBadge.Root>
 | 
			
		||||
 *
 | 
			
		||||
 * <AxoBadge.Root aria-label="Marked unread"/>
 | 
			
		||||
 *
 | 
			
		||||
 * <AxoBadge.Root aria-label="You were mentioned">
 | 
			
		||||
 *   <AxoBadge.Icon symbol="at" />
 | 
			
		||||
 * </AxoBadge.Root>
 | 
			
		||||
 * ````
 | 
			
		||||
 */
 | 
			
		||||
export namespace ExperimentalAxoBadge {
 | 
			
		||||
  export type BadgeSize = 'sm' | 'md' | 'lg';
 | 
			
		||||
  export type BadgeValue = number | 'mention' | 'unread';
 | 
			
		||||
 | 
			
		||||
  const baseStyles = tw(
 | 
			
		||||
    'flex size-fit items-center justify-center-safe overflow-clip',
 | 
			
		||||
    'rounded-full font-semibold',
 | 
			
		||||
    'bg-color-fill-primary text-label-primary-on-color',
 | 
			
		||||
    'select-none'
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  type BadgeConfig = Readonly<{
 | 
			
		||||
    rootStyles: TailwindStyles;
 | 
			
		||||
    countStyles: TailwindStyles;
 | 
			
		||||
  }>;
 | 
			
		||||
 | 
			
		||||
  const BadgeSizes: Record<BadgeSize, BadgeConfig> = {
 | 
			
		||||
    sm: {
 | 
			
		||||
      rootStyles: tw(baseStyles, 'min-h-3.5 min-w-3.5 text-[8px] leading-3.5'),
 | 
			
		||||
      countStyles: tw('px-[3px]'),
 | 
			
		||||
    },
 | 
			
		||||
    md: {
 | 
			
		||||
      rootStyles: tw(baseStyles, 'min-h-4 min-w-4 text-[11px] leading-4'),
 | 
			
		||||
      countStyles: tw('px-[4px]'),
 | 
			
		||||
    },
 | 
			
		||||
    lg: {
 | 
			
		||||
      rootStyles: tw(baseStyles, 'min-h-4.5 min-w-4.5 text-[11px] leading-4.5'),
 | 
			
		||||
      countStyles: tw('px-[5px]'),
 | 
			
		||||
    },
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  export function _getAllBadgeSizes(): ReadonlyArray<BadgeSize> {
 | 
			
		||||
    return Object.keys(BadgeSizes) as Array<BadgeSize>;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  let cachedNumberFormat: Intl.NumberFormat;
 | 
			
		||||
 | 
			
		||||
  // eslint-disable-next-line no-inner-declarations
 | 
			
		||||
  function formatBadgeCount(
 | 
			
		||||
    value: number,
 | 
			
		||||
    max: number,
 | 
			
		||||
    maxDisplay: string
 | 
			
		||||
  ): string {
 | 
			
		||||
    if (value > max) {
 | 
			
		||||
      return maxDisplay;
 | 
			
		||||
    }
 | 
			
		||||
    cachedNumberFormat ??= new Intl.NumberFormat();
 | 
			
		||||
    return cachedNumberFormat.format(value);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Component: <AxoBadge.Root>
 | 
			
		||||
   * --------------------------
 | 
			
		||||
   */
 | 
			
		||||
 | 
			
		||||
  export type RootProps = Readonly<{
 | 
			
		||||
    size: BadgeSize;
 | 
			
		||||
    value: BadgeValue;
 | 
			
		||||
    max: number;
 | 
			
		||||
    maxDisplay: string;
 | 
			
		||||
    'aria-label': string | null;
 | 
			
		||||
  }>;
 | 
			
		||||
 | 
			
		||||
  export const Root: FC<RootProps> = memo(props => {
 | 
			
		||||
    const { value, max, maxDisplay } = props;
 | 
			
		||||
    const config = BadgeSizes[props.size];
 | 
			
		||||
 | 
			
		||||
    const children = useMemo(() => {
 | 
			
		||||
      if (value === 'unread') {
 | 
			
		||||
        return null;
 | 
			
		||||
      }
 | 
			
		||||
      if (value === 'mention') {
 | 
			
		||||
        return (
 | 
			
		||||
          <span aria-hidden className={tw('leading-none')}>
 | 
			
		||||
            <AxoSymbol.InlineGlyph symbol="at" label={null} />
 | 
			
		||||
          </span>
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
      if (typeof value === 'number') {
 | 
			
		||||
        return (
 | 
			
		||||
          <span aria-hidden className={config.countStyles}>
 | 
			
		||||
            {formatBadgeCount(value, max, maxDisplay)}
 | 
			
		||||
          </span>
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
      unreachable(value);
 | 
			
		||||
    }, [value, max, maxDisplay, config]);
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <span
 | 
			
		||||
        aria-label={props['aria-label'] ?? undefined}
 | 
			
		||||
        className={config.rootStyles}
 | 
			
		||||
      >
 | 
			
		||||
        {children}
 | 
			
		||||
      </span>
 | 
			
		||||
    );
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  Root.displayName = `${Namespace}.Root`;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -26,33 +26,33 @@ export function Basic(): JSX.Element {
 | 
			
		|||
            {variants.map(variant => {
 | 
			
		||||
              return (
 | 
			
		||||
                <div key={variant} className={tw('flex gap-1')}>
 | 
			
		||||
                  <AxoButton
 | 
			
		||||
                  <AxoButton.Root
 | 
			
		||||
                    variant={variant}
 | 
			
		||||
                    size={size}
 | 
			
		||||
                    onClick={action('click')}
 | 
			
		||||
                  >
 | 
			
		||||
                    {variant}
 | 
			
		||||
                  </AxoButton>
 | 
			
		||||
                  </AxoButton.Root>
 | 
			
		||||
 | 
			
		||||
                  <AxoButton
 | 
			
		||||
                  <AxoButton.Root
 | 
			
		||||
                    variant={variant}
 | 
			
		||||
                    size={size}
 | 
			
		||||
                    onClick={action('click')}
 | 
			
		||||
                    disabled
 | 
			
		||||
                  >
 | 
			
		||||
                    Disabled
 | 
			
		||||
                  </AxoButton>
 | 
			
		||||
                  </AxoButton.Root>
 | 
			
		||||
 | 
			
		||||
                  <AxoButton
 | 
			
		||||
                  <AxoButton.Root
 | 
			
		||||
                    symbol="info"
 | 
			
		||||
                    variant={variant}
 | 
			
		||||
                    size={size}
 | 
			
		||||
                    onClick={action('click')}
 | 
			
		||||
                  >
 | 
			
		||||
                    Icon
 | 
			
		||||
                  </AxoButton>
 | 
			
		||||
                  </AxoButton.Root>
 | 
			
		||||
 | 
			
		||||
                  <AxoButton
 | 
			
		||||
                  <AxoButton.Root
 | 
			
		||||
                    symbol="info"
 | 
			
		||||
                    variant={variant}
 | 
			
		||||
                    size={size}
 | 
			
		||||
| 
						 | 
				
			
			@ -60,18 +60,18 @@ export function Basic(): JSX.Element {
 | 
			
		|||
                    disabled
 | 
			
		||||
                  >
 | 
			
		||||
                    Disabled
 | 
			
		||||
                  </AxoButton>
 | 
			
		||||
                  </AxoButton.Root>
 | 
			
		||||
 | 
			
		||||
                  <AxoButton
 | 
			
		||||
                  <AxoButton.Root
 | 
			
		||||
                    arrow
 | 
			
		||||
                    variant={variant}
 | 
			
		||||
                    size={size}
 | 
			
		||||
                    onClick={action('click')}
 | 
			
		||||
                  >
 | 
			
		||||
                    Arrow
 | 
			
		||||
                  </AxoButton>
 | 
			
		||||
                  </AxoButton.Root>
 | 
			
		||||
 | 
			
		||||
                  <AxoButton
 | 
			
		||||
                  <AxoButton.Root
 | 
			
		||||
                    arrow
 | 
			
		||||
                    variant={variant}
 | 
			
		||||
                    size={size}
 | 
			
		||||
| 
						 | 
				
			
			@ -79,7 +79,7 @@ export function Basic(): JSX.Element {
 | 
			
		|||
                    disabled
 | 
			
		||||
                  >
 | 
			
		||||
                    Disabled
 | 
			
		||||
                  </AxoButton>
 | 
			
		||||
                  </AxoButton.Root>
 | 
			
		||||
                </div>
 | 
			
		||||
              );
 | 
			
		||||
            })}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -139,15 +139,6 @@ type BaseButtonAttrs = Omit<
 | 
			
		|||
type AxoButtonVariant = keyof typeof AxoButtonVariants;
 | 
			
		||||
type AxoButtonSize = keyof typeof AxoButtonSizes;
 | 
			
		||||
 | 
			
		||||
type AxoButtonProps = BaseButtonAttrs &
 | 
			
		||||
  Readonly<{
 | 
			
		||||
    variant: AxoButtonVariant;
 | 
			
		||||
    size: AxoButtonSize;
 | 
			
		||||
    symbol?: AxoSymbol.InlineGlyphName;
 | 
			
		||||
    arrow?: boolean;
 | 
			
		||||
    children: ReactNode;
 | 
			
		||||
  }>;
 | 
			
		||||
 | 
			
		||||
export function _getAllAxoButtonVariants(): ReadonlyArray<AxoButtonVariant> {
 | 
			
		||||
  return Object.keys(AxoButtonVariants) as Array<AxoButtonVariant>;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -156,8 +147,19 @@ export function _getAllAxoButtonSizes(): ReadonlyArray<AxoButtonSize> {
 | 
			
		|||
  return Object.keys(AxoButtonSizes) as Array<AxoButtonSize>;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// eslint-disable-next-line import/export
 | 
			
		||||
export const AxoButton: FC<AxoButtonProps> = memo(
 | 
			
		||||
export namespace AxoButton {
 | 
			
		||||
  export type Variant = AxoButtonVariant;
 | 
			
		||||
  export type Size = AxoButtonSize;
 | 
			
		||||
  export type RootProps = BaseButtonAttrs &
 | 
			
		||||
    Readonly<{
 | 
			
		||||
      variant: AxoButtonVariant;
 | 
			
		||||
      size: AxoButtonSize;
 | 
			
		||||
      symbol?: AxoSymbol.InlineGlyphName;
 | 
			
		||||
      arrow?: boolean;
 | 
			
		||||
      children: ReactNode;
 | 
			
		||||
    }>;
 | 
			
		||||
 | 
			
		||||
  export const Root: FC<RootProps> = memo(
 | 
			
		||||
    forwardRef((props, ref: ForwardedRef<HTMLButtonElement>) => {
 | 
			
		||||
      const { variant, size, symbol, arrow, children, ...rest } = props;
 | 
			
		||||
      const variantStyles = assert(
 | 
			
		||||
| 
						 | 
				
			
			@ -179,18 +181,13 @@ export const AxoButton: FC<AxoButtonProps> = memo(
 | 
			
		|||
            <AxoSymbol.InlineGlyph symbol={symbol} label={null} />
 | 
			
		||||
          )}
 | 
			
		||||
          {children}
 | 
			
		||||
        {arrow && <AxoSymbol.InlineGlyph symbol="chevron-[end]" label={null} />}
 | 
			
		||||
          {arrow && (
 | 
			
		||||
            <AxoSymbol.InlineGlyph symbol="chevron-[end]" label={null} />
 | 
			
		||||
          )}
 | 
			
		||||
        </button>
 | 
			
		||||
      );
 | 
			
		||||
    })
 | 
			
		||||
);
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
AxoButton.displayName = `${Namespace}`;
 | 
			
		||||
 | 
			
		||||
// eslint-disable-next-line max-len
 | 
			
		||||
// eslint-disable-next-line @typescript-eslint/no-namespace, @typescript-eslint/no-redeclare, import/export
 | 
			
		||||
export namespace AxoButton {
 | 
			
		||||
  export type Variant = AxoButtonVariant;
 | 
			
		||||
  export type Size = AxoButtonSize;
 | 
			
		||||
  export type Props = AxoButtonProps;
 | 
			
		||||
  Root.displayName = `${Namespace}.Root`;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -17,7 +17,7 @@ function Template(props: {
 | 
			
		|||
  const [checked, setChecked] = useState(props.defaultChecked);
 | 
			
		||||
  return (
 | 
			
		||||
    <label className={tw('my-2 flex items-center gap-2')}>
 | 
			
		||||
      <AxoCheckbox
 | 
			
		||||
      <AxoCheckbox.Root
 | 
			
		||||
        checked={checked}
 | 
			
		||||
        onCheckedChange={setChecked}
 | 
			
		||||
        disabled={props.disabled}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -7,16 +7,16 @@ import { tw } from './tw.js';
 | 
			
		|||
 | 
			
		||||
const Namespace = 'AxoCheckbox';
 | 
			
		||||
 | 
			
		||||
type AxoCheckboxProps = Readonly<{
 | 
			
		||||
export namespace AxoCheckbox {
 | 
			
		||||
  export type RootProps = Readonly<{
 | 
			
		||||
    id?: string;
 | 
			
		||||
    checked: boolean;
 | 
			
		||||
    onCheckedChange: (nextChecked: boolean) => void;
 | 
			
		||||
    disabled?: boolean;
 | 
			
		||||
    required?: boolean;
 | 
			
		||||
}>;
 | 
			
		||||
  }>;
 | 
			
		||||
 | 
			
		||||
// eslint-disable-next-line import/export
 | 
			
		||||
export const AxoCheckbox = memo((props: AxoCheckboxProps) => {
 | 
			
		||||
  export const Root = memo((props: RootProps) => {
 | 
			
		||||
    return (
 | 
			
		||||
      <Checkbox.Root
 | 
			
		||||
        id={props.id}
 | 
			
		||||
| 
						 | 
				
			
			@ -46,12 +46,7 @@ export const AxoCheckbox = memo((props: AxoCheckboxProps) => {
 | 
			
		|||
        </Checkbox.Indicator>
 | 
			
		||||
      </Checkbox.Root>
 | 
			
		||||
    );
 | 
			
		||||
});
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
AxoCheckbox.displayName = `${Namespace}`;
 | 
			
		||||
 | 
			
		||||
// eslint-disable-next-line max-len
 | 
			
		||||
// eslint-disable-next-line @typescript-eslint/no-namespace, @typescript-eslint/no-redeclare, import/export
 | 
			
		||||
export namespace AxoCheckbox {
 | 
			
		||||
  export type Props = AxoCheckboxProps;
 | 
			
		||||
  Root.displayName = `${Namespace}.Root`;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -48,7 +48,6 @@ const Namespace = 'AxoContextMenu';
 | 
			
		|||
 * )
 | 
			
		||||
 * ```
 | 
			
		||||
 */
 | 
			
		||||
// eslint-disable-next-line @typescript-eslint/no-namespace
 | 
			
		||||
export namespace AxoContextMenu {
 | 
			
		||||
  /**
 | 
			
		||||
   * Component: <AxoContextMenu.Root>
 | 
			
		||||
| 
						 | 
				
			
			@ -71,7 +70,7 @@ export namespace AxoContextMenu {
 | 
			
		|||
  export type TriggerProps = AxoBaseMenu.MenuTriggerProps;
 | 
			
		||||
 | 
			
		||||
  export const Trigger: FC<TriggerProps> = memo(props => {
 | 
			
		||||
    return <ContextMenu.Trigger>{props.children}</ContextMenu.Trigger>;
 | 
			
		||||
    return <ContextMenu.Trigger asChild>{props.children}</ContextMenu.Trigger>;
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  Trigger.displayName = `${Namespace}.Trigger`;
 | 
			
		||||
| 
						 | 
				
			
			@ -247,7 +246,7 @@ export namespace AxoContextMenu {
 | 
			
		|||
  export const RadioGroup: FC<RadioGroupProps> = memo(props => {
 | 
			
		||||
    return (
 | 
			
		||||
      <ContextMenu.RadioGroup
 | 
			
		||||
        value={props.value}
 | 
			
		||||
        value={props.value ?? undefined}
 | 
			
		||||
        onValueChange={props.onValueChange}
 | 
			
		||||
        className={AxoBaseMenu.menuRadioGroupStyles}
 | 
			
		||||
      >
 | 
			
		||||
| 
						 | 
				
			
			@ -283,7 +282,11 @@ export namespace AxoContextMenu {
 | 
			
		|||
          </AxoBaseMenu.ItemCheckPlaceholder>
 | 
			
		||||
        </AxoBaseMenu.ItemLeadingSlot>
 | 
			
		||||
        <AxoBaseMenu.ItemContentSlot>
 | 
			
		||||
          {props.symbol && <AxoBaseMenu.ItemSymbol symbol={props.symbol} />}
 | 
			
		||||
          {props.symbol && (
 | 
			
		||||
            <span className={tw('me-2')}>
 | 
			
		||||
              <AxoBaseMenu.ItemSymbol symbol={props.symbol} />
 | 
			
		||||
            </span>
 | 
			
		||||
          )}
 | 
			
		||||
          <AxoBaseMenu.ItemText>{props.children}</AxoBaseMenu.ItemText>
 | 
			
		||||
          {props.keyboardShortcut && (
 | 
			
		||||
            <AxoBaseMenu.ItemKeyboardShortcut
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -19,9 +19,9 @@ export function Basic(): JSX.Element {
 | 
			
		|||
    <div className={tw('flex h-96 w-full items-center justify-center')}>
 | 
			
		||||
      <AxoDropdownMenu.Root>
 | 
			
		||||
        <AxoDropdownMenu.Trigger>
 | 
			
		||||
          <AxoButton variant="secondary" size="medium">
 | 
			
		||||
          <AxoButton.Root variant="secondary" size="medium">
 | 
			
		||||
            Open Dropdown Menu
 | 
			
		||||
          </AxoButton>
 | 
			
		||||
          </AxoButton.Root>
 | 
			
		||||
        </AxoDropdownMenu.Trigger>
 | 
			
		||||
        <AxoDropdownMenu.Content>
 | 
			
		||||
          <AxoDropdownMenu.Item
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -51,7 +51,6 @@ const Namespace = 'AxoDropdownMenu';
 | 
			
		|||
 * )
 | 
			
		||||
 * ```
 | 
			
		||||
 */
 | 
			
		||||
// eslint-disable-next-line @typescript-eslint/no-namespace
 | 
			
		||||
export namespace AxoDropdownMenu {
 | 
			
		||||
  /**
 | 
			
		||||
   * Component: <AxoDropdownMenu.Root>
 | 
			
		||||
| 
						 | 
				
			
			@ -261,7 +260,7 @@ export namespace AxoDropdownMenu {
 | 
			
		|||
  export const RadioGroup: FC<RadioGroupProps> = memo(props => {
 | 
			
		||||
    return (
 | 
			
		||||
      <DropdownMenu.RadioGroup
 | 
			
		||||
        value={props.value}
 | 
			
		||||
        value={props.value ?? undefined}
 | 
			
		||||
        onValueChange={props.onValueChange}
 | 
			
		||||
        className={AxoBaseMenu.menuRadioGroupStyles}
 | 
			
		||||
      >
 | 
			
		||||
| 
						 | 
				
			
			@ -297,7 +296,11 @@ export namespace AxoDropdownMenu {
 | 
			
		|||
          </AxoBaseMenu.ItemCheckPlaceholder>
 | 
			
		||||
        </AxoBaseMenu.ItemLeadingSlot>
 | 
			
		||||
        <AxoBaseMenu.ItemContentSlot>
 | 
			
		||||
          {props.symbol && <AxoBaseMenu.ItemSymbol symbol={props.symbol} />}
 | 
			
		||||
          {props.symbol && (
 | 
			
		||||
            <span className={tw('me-2')}>
 | 
			
		||||
              <AxoBaseMenu.ItemSymbol symbol={props.symbol} />
 | 
			
		||||
            </span>
 | 
			
		||||
          )}
 | 
			
		||||
          <AxoBaseMenu.ItemText>{props.children}</AxoBaseMenu.ItemText>
 | 
			
		||||
          {props.keyboardShortcut && (
 | 
			
		||||
            <AxoBaseMenu.ItemKeyboardShortcut
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										120
									
								
								ts/axo/AxoSegmentedControl.stories.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										120
									
								
								ts/axo/AxoSegmentedControl.stories.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,120 @@
 | 
			
		|||
// Copyright 2025 Signal Messenger, LLC
 | 
			
		||||
// SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		||||
import type { Meta } from '@storybook/react';
 | 
			
		||||
import React, { useState } from 'react';
 | 
			
		||||
import { ExperimentalAxoSegmentedControl } from './AxoSegmentedControl.js';
 | 
			
		||||
import { tw } from './tw.js';
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
  title: 'Axo/AxoSegmentedControl (Experimental)',
 | 
			
		||||
} satisfies Meta;
 | 
			
		||||
 | 
			
		||||
function Template(props: {
 | 
			
		||||
  variant: ExperimentalAxoSegmentedControl.Variant;
 | 
			
		||||
  width: ExperimentalAxoSegmentedControl.RootWidth;
 | 
			
		||||
  itemWidth: ExperimentalAxoSegmentedControl.ItemWidth;
 | 
			
		||||
  longNames?: boolean;
 | 
			
		||||
  includeBadges?: boolean;
 | 
			
		||||
}) {
 | 
			
		||||
  const [value, setValue] = useState('inbox');
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <h2 className={tw('font-mono type-title-medium')}>
 | 
			
		||||
        {`variant=${props.variant}, `}
 | 
			
		||||
        {`width=${props.width}, `}
 | 
			
		||||
        {`itemWidth=${props.itemWidth}`}
 | 
			
		||||
      </h2>
 | 
			
		||||
      <ExperimentalAxoSegmentedControl.Root
 | 
			
		||||
        variant={props.variant}
 | 
			
		||||
        width={props.width}
 | 
			
		||||
        itemWidth={props.itemWidth}
 | 
			
		||||
        value={value}
 | 
			
		||||
        onValueChange={newValue => {
 | 
			
		||||
          if (newValue != null) {
 | 
			
		||||
            setValue(newValue);
 | 
			
		||||
          }
 | 
			
		||||
        }}
 | 
			
		||||
      >
 | 
			
		||||
        <ExperimentalAxoSegmentedControl.Item value="inbox">
 | 
			
		||||
          <ExperimentalAxoSegmentedControl.ItemText>
 | 
			
		||||
            {props.longNames && 'Really Really Long Name For '}
 | 
			
		||||
            Inbox
 | 
			
		||||
          </ExperimentalAxoSegmentedControl.ItemText>
 | 
			
		||||
          {props.includeBadges && (
 | 
			
		||||
            <ExperimentalAxoSegmentedControl.ExperimentalItemBadge
 | 
			
		||||
              value={42}
 | 
			
		||||
              max={99}
 | 
			
		||||
              maxDisplay="99+"
 | 
			
		||||
              aria-label={null}
 | 
			
		||||
            />
 | 
			
		||||
          )}
 | 
			
		||||
        </ExperimentalAxoSegmentedControl.Item>
 | 
			
		||||
        <ExperimentalAxoSegmentedControl.Item value="drafts">
 | 
			
		||||
          <ExperimentalAxoSegmentedControl.ItemText>
 | 
			
		||||
            {props.longNames && 'Really Really Long Name For '}
 | 
			
		||||
            Drafts
 | 
			
		||||
          </ExperimentalAxoSegmentedControl.ItemText>
 | 
			
		||||
          {props.includeBadges && (
 | 
			
		||||
            <ExperimentalAxoSegmentedControl.ExperimentalItemBadge
 | 
			
		||||
              value="mention"
 | 
			
		||||
              max={99}
 | 
			
		||||
              maxDisplay="99+"
 | 
			
		||||
              aria-label={null}
 | 
			
		||||
            />
 | 
			
		||||
          )}
 | 
			
		||||
        </ExperimentalAxoSegmentedControl.Item>
 | 
			
		||||
        <ExperimentalAxoSegmentedControl.Item value="sent">
 | 
			
		||||
          <ExperimentalAxoSegmentedControl.ItemText>
 | 
			
		||||
            Sent
 | 
			
		||||
          </ExperimentalAxoSegmentedControl.ItemText>
 | 
			
		||||
          {props.includeBadges && (
 | 
			
		||||
            <ExperimentalAxoSegmentedControl.ExperimentalItemBadge
 | 
			
		||||
              value="unread"
 | 
			
		||||
              max={99}
 | 
			
		||||
              maxDisplay="99+"
 | 
			
		||||
              aria-label={null}
 | 
			
		||||
            />
 | 
			
		||||
          )}
 | 
			
		||||
        </ExperimentalAxoSegmentedControl.Item>
 | 
			
		||||
      </ExperimentalAxoSegmentedControl.Root>
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function TemplateVariants(props: {
 | 
			
		||||
  longNames?: boolean;
 | 
			
		||||
  includeBadges?: boolean;
 | 
			
		||||
}) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div className={tw('grid gap-4')}>
 | 
			
		||||
      <Template variant="track" width="full" itemWidth="fit" {...props} />
 | 
			
		||||
      <Template variant="no-track" width="full" itemWidth="fit" {...props} />
 | 
			
		||||
 | 
			
		||||
      <Template variant="track" width="full" itemWidth="equal" {...props} />
 | 
			
		||||
      <Template variant="no-track" width="full" itemWidth="equal" {...props} />
 | 
			
		||||
 | 
			
		||||
      <Template variant="track" width="fit" itemWidth="fit" {...props} />
 | 
			
		||||
      <Template variant="no-track" width="fit" itemWidth="fit" {...props} />
 | 
			
		||||
 | 
			
		||||
      <Template variant="track" width="fit" itemWidth="equal" {...props} />
 | 
			
		||||
      <Template variant="no-track" width="fit" itemWidth="equal" {...props} />
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function Basic(): JSX.Element {
 | 
			
		||||
  return <TemplateVariants />;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function LongNames(): JSX.Element {
 | 
			
		||||
  return <TemplateVariants longNames />;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function WithBadges(): JSX.Element {
 | 
			
		||||
  return <TemplateVariants includeBadges />;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function LongNamesWithBadges(): JSX.Element {
 | 
			
		||||
  return <TemplateVariants longNames includeBadges />;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										130
									
								
								ts/axo/AxoSegmentedControl.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										130
									
								
								ts/axo/AxoSegmentedControl.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,130 @@
 | 
			
		|||
// Copyright 2025 Signal Messenger, LLC
 | 
			
		||||
// SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		||||
import type { ButtonHTMLAttributes, FC, ForwardedRef, ReactNode } from 'react';
 | 
			
		||||
import React, { forwardRef, memo, useCallback } from 'react';
 | 
			
		||||
import { ToggleGroup } from 'radix-ui';
 | 
			
		||||
import { ExperimentalAxoBaseSegmentedControl } from './_internal/AxoBaseSegmentedControl.js';
 | 
			
		||||
 | 
			
		||||
const Namespace = 'AxoSegmentedControl';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * @example Anatomy
 | 
			
		||||
 * ```tsx
 | 
			
		||||
 * <AxoSegmentedControl.Root>
 | 
			
		||||
 *   <AxoSegmentedControl.Item>
 | 
			
		||||
 *     <AxoSegmentedControl.ItemText/>
 | 
			
		||||
 *     <AxoSegmentedControl.ItemBadge/>
 | 
			
		||||
 *   </AxoSegmentedControl.Item>
 | 
			
		||||
 * </AxoSegmentedControl.Root>
 | 
			
		||||
 * ```
 | 
			
		||||
 */
 | 
			
		||||
export namespace ExperimentalAxoSegmentedControl {
 | 
			
		||||
  export type Variant = ExperimentalAxoBaseSegmentedControl.Variant;
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Component: <AxoSegmentedControl.Root>
 | 
			
		||||
   * -------------------------------------
 | 
			
		||||
   */
 | 
			
		||||
 | 
			
		||||
  export type RootWidth = ExperimentalAxoBaseSegmentedControl.RootWidth;
 | 
			
		||||
  export type ItemWidth = ExperimentalAxoBaseSegmentedControl.ItemWidth;
 | 
			
		||||
 | 
			
		||||
  export type RootProps = Readonly<{
 | 
			
		||||
    width: RootWidth;
 | 
			
		||||
    itemWidth: ItemWidth;
 | 
			
		||||
    variant: Variant;
 | 
			
		||||
    value: string | null;
 | 
			
		||||
    onValueChange: (newValue: string | null) => void;
 | 
			
		||||
    children: ReactNode;
 | 
			
		||||
  }>;
 | 
			
		||||
 | 
			
		||||
  export const Root = memo((props: RootProps) => {
 | 
			
		||||
    const { onValueChange } = props;
 | 
			
		||||
 | 
			
		||||
    const handleValueChange = useCallback(
 | 
			
		||||
      (newValue: string) => {
 | 
			
		||||
        onValueChange(newValue === '' ? null : newValue);
 | 
			
		||||
      },
 | 
			
		||||
      [onValueChange]
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <ToggleGroup.Root
 | 
			
		||||
        type="single"
 | 
			
		||||
        value={props.value ?? undefined}
 | 
			
		||||
        onValueChange={handleValueChange}
 | 
			
		||||
        orientation="horizontal"
 | 
			
		||||
        loop
 | 
			
		||||
        rovingFocus
 | 
			
		||||
        asChild
 | 
			
		||||
      >
 | 
			
		||||
        <ExperimentalAxoBaseSegmentedControl.Root
 | 
			
		||||
          value={props.value}
 | 
			
		||||
          variant={props.variant}
 | 
			
		||||
          width={props.width}
 | 
			
		||||
          itemWidth={props.itemWidth}
 | 
			
		||||
        >
 | 
			
		||||
          {props.children}
 | 
			
		||||
        </ExperimentalAxoBaseSegmentedControl.Root>
 | 
			
		||||
      </ToggleGroup.Root>
 | 
			
		||||
    );
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  Root.displayName = `${Namespace}.Root`;
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Component: <AxoSegmentedControl.Item>
 | 
			
		||||
   * -------------------------------------
 | 
			
		||||
   */
 | 
			
		||||
 | 
			
		||||
  export type ItemProps = ButtonHTMLAttributes<HTMLButtonElement> &
 | 
			
		||||
    Readonly<{
 | 
			
		||||
      value: string;
 | 
			
		||||
      children: ReactNode;
 | 
			
		||||
    }>;
 | 
			
		||||
 | 
			
		||||
  export const Item: FC<ItemProps> = memo(
 | 
			
		||||
    forwardRef((props: ItemProps, ref: ForwardedRef<HTMLButtonElement>) => {
 | 
			
		||||
      const { value, children, ...rest } = props;
 | 
			
		||||
      return (
 | 
			
		||||
        <ToggleGroup.Item {...rest} ref={ref} value={value} asChild>
 | 
			
		||||
          <ExperimentalAxoBaseSegmentedControl.Item value={value}>
 | 
			
		||||
            {children}
 | 
			
		||||
          </ExperimentalAxoBaseSegmentedControl.Item>
 | 
			
		||||
        </ToggleGroup.Item>
 | 
			
		||||
      );
 | 
			
		||||
    })
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  Item.displayName = `${Namespace}.Item`;
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Component: <AxoSegmentedControl.ItemText>
 | 
			
		||||
   * -----------------------------------------
 | 
			
		||||
   */
 | 
			
		||||
 | 
			
		||||
  export type ItemTextProps = Readonly<{
 | 
			
		||||
    maxWidth?: ExperimentalAxoBaseSegmentedControl.ItemMaxWidth;
 | 
			
		||||
    children: ReactNode;
 | 
			
		||||
  }>;
 | 
			
		||||
 | 
			
		||||
  export const ItemText: FC<ItemTextProps> = memo((props: ItemTextProps) => {
 | 
			
		||||
    return (
 | 
			
		||||
      <ExperimentalAxoBaseSegmentedControl.ItemText maxWidth={props.maxWidth}>
 | 
			
		||||
        {props.children}
 | 
			
		||||
      </ExperimentalAxoBaseSegmentedControl.ItemText>
 | 
			
		||||
    );
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  ItemText.displayName = `${Namespace}.ItemText`;
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Component: <AxoSegmentedControl.ItemBadge>
 | 
			
		||||
   * ------------------------------------------
 | 
			
		||||
   */
 | 
			
		||||
 | 
			
		||||
  export type ExperimentalItemBadgeProps =
 | 
			
		||||
    ExperimentalAxoBaseSegmentedControl.ExperimentalItemBadgeProps;
 | 
			
		||||
 | 
			
		||||
  export const { ExperimentalItemBadge } = ExperimentalAxoBaseSegmentedControl;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,5 +1,6 @@
 | 
			
		|||
// Copyright 2025 Signal Messenger, LLC
 | 
			
		||||
// SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		||||
import type { ReactNode } from 'react';
 | 
			
		||||
import React, { useState } from 'react';
 | 
			
		||||
import type { Meta } from '@storybook/react';
 | 
			
		||||
import { AxoSelect } from './AxoSelect.js';
 | 
			
		||||
| 
						 | 
				
			
			@ -9,6 +10,18 @@ export default {
 | 
			
		|||
  title: 'Axo/AxoSelect',
 | 
			
		||||
} satisfies Meta;
 | 
			
		||||
 | 
			
		||||
function TemplateItem(props: {
 | 
			
		||||
  value: string;
 | 
			
		||||
  disabled?: boolean;
 | 
			
		||||
  children: ReactNode;
 | 
			
		||||
}): JSX.Element {
 | 
			
		||||
  return (
 | 
			
		||||
    <AxoSelect.Item value={props.value} disabled={props.disabled}>
 | 
			
		||||
      <AxoSelect.ItemText>{props.children}</AxoSelect.ItemText>
 | 
			
		||||
    </AxoSelect.Item>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function Template(props: {
 | 
			
		||||
  disabled?: boolean;
 | 
			
		||||
  triggerWidth?: AxoSelect.TriggerWidth;
 | 
			
		||||
| 
						 | 
				
			
			@ -29,29 +42,29 @@ function Template(props: {
 | 
			
		|||
      <AxoSelect.Content>
 | 
			
		||||
        <AxoSelect.Group>
 | 
			
		||||
          <AxoSelect.Label>Fruits</AxoSelect.Label>
 | 
			
		||||
          <AxoSelect.Item value="apple">Apple</AxoSelect.Item>
 | 
			
		||||
          <AxoSelect.Item value="banana">Banana</AxoSelect.Item>
 | 
			
		||||
          <AxoSelect.Item value="blueberry">Blueberry</AxoSelect.Item>
 | 
			
		||||
          <AxoSelect.Item value="grapes">Grapes</AxoSelect.Item>
 | 
			
		||||
          <AxoSelect.Item value="pineapple">Pineapple</AxoSelect.Item>
 | 
			
		||||
          <TemplateItem value="apple">Apple</TemplateItem>
 | 
			
		||||
          <TemplateItem value="banana">Banana</TemplateItem>
 | 
			
		||||
          <TemplateItem value="blueberry">Blueberry</TemplateItem>
 | 
			
		||||
          <TemplateItem value="grapes">Grapes</TemplateItem>
 | 
			
		||||
          <TemplateItem value="pineapple">Pineapple</TemplateItem>
 | 
			
		||||
        </AxoSelect.Group>
 | 
			
		||||
        <AxoSelect.Separator />
 | 
			
		||||
        <AxoSelect.Group>
 | 
			
		||||
          <AxoSelect.Label>Vegetables</AxoSelect.Label>
 | 
			
		||||
          <AxoSelect.Item value="aubergine">Aubergine</AxoSelect.Item>
 | 
			
		||||
          <AxoSelect.Item value="broccoli">Broccoli</AxoSelect.Item>
 | 
			
		||||
          <AxoSelect.Item value="carrot" disabled>
 | 
			
		||||
          <TemplateItem value="aubergine">Aubergine</TemplateItem>
 | 
			
		||||
          <TemplateItem value="broccoli">Broccoli</TemplateItem>
 | 
			
		||||
          <TemplateItem value="carrot" disabled>
 | 
			
		||||
            Carrot
 | 
			
		||||
          </AxoSelect.Item>
 | 
			
		||||
          <AxoSelect.Item value="leek">Leek</AxoSelect.Item>
 | 
			
		||||
          </TemplateItem>
 | 
			
		||||
          <TemplateItem value="leek">Leek</TemplateItem>
 | 
			
		||||
        </AxoSelect.Group>
 | 
			
		||||
        <AxoSelect.Separator />
 | 
			
		||||
        <AxoSelect.Group>
 | 
			
		||||
          <AxoSelect.Label>Meat</AxoSelect.Label>
 | 
			
		||||
          <AxoSelect.Item value="beef">Beef</AxoSelect.Item>
 | 
			
		||||
          <AxoSelect.Item value="chicken">Chicken</AxoSelect.Item>
 | 
			
		||||
          <AxoSelect.Item value="lamb">Lamb</AxoSelect.Item>
 | 
			
		||||
          <AxoSelect.Item value="pork">Pork</AxoSelect.Item>
 | 
			
		||||
          <TemplateItem value="beef">Beef</TemplateItem>
 | 
			
		||||
          <TemplateItem value="chicken">Chicken</TemplateItem>
 | 
			
		||||
          <TemplateItem value="lamb">Lamb</TemplateItem>
 | 
			
		||||
          <TemplateItem value="pork">Pork</TemplateItem>
 | 
			
		||||
        </AxoSelect.Group>
 | 
			
		||||
      </AxoSelect.Content>
 | 
			
		||||
    </AxoSelect.Root>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -7,6 +7,7 @@ import { AxoBaseMenu } from './_internal/AxoBaseMenu.js';
 | 
			
		|||
import { AxoSymbol } from './AxoSymbol.js';
 | 
			
		||||
import type { TailwindStyles } from './tw.js';
 | 
			
		||||
import { tw } from './tw.js';
 | 
			
		||||
import { ExperimentalAxoBadge } from './AxoBadge.js';
 | 
			
		||||
 | 
			
		||||
const Namespace = 'AxoSelect';
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -19,18 +20,22 @@ const Namespace = 'AxoSelect';
 | 
			
		|||
 *   <AxoSelect.Root>
 | 
			
		||||
 *     <AxoSelect.Trigger/>
 | 
			
		||||
 *     <AxoSelect.Content>
 | 
			
		||||
 *       <AxoSelect.Item/>
 | 
			
		||||
 *       <AxoSelect.Item>
 | 
			
		||||
 *         <AxoSelect.ItemText/>
 | 
			
		||||
 *         <AxoSelect.ItemBadge/>
 | 
			
		||||
 *       </AxoSelect.Item>
 | 
			
		||||
 *       <AxoSelect.Separator/>
 | 
			
		||||
 *       <AxoSelect.Group>
 | 
			
		||||
 *         <AxoSelect.Label/>
 | 
			
		||||
 *         <AxoSelect.Item/>
 | 
			
		||||
 *         <AxoSelect.Item>
 | 
			
		||||
 *           <AxoSelect.ItemText/>
 | 
			
		||||
 *         </AxoSelect.Item>
 | 
			
		||||
 *       </AxoSelect.Group>
 | 
			
		||||
 *     </AxoSelect.Content>
 | 
			
		||||
 *   </AxoSelect.Root>
 | 
			
		||||
 * );
 | 
			
		||||
 * ```
 | 
			
		||||
 */
 | 
			
		||||
// eslint-disable-next-line @typescript-eslint/no-namespace
 | 
			
		||||
export namespace AxoSelect {
 | 
			
		||||
  /**
 | 
			
		||||
   * Component: <AxoSelect.Root>
 | 
			
		||||
| 
						 | 
				
			
			@ -78,15 +83,19 @@ export namespace AxoSelect {
 | 
			
		|||
   * ---------------------------
 | 
			
		||||
   */
 | 
			
		||||
 | 
			
		||||
  export type TriggerVariant = 'default' | 'floating' | 'borderless';
 | 
			
		||||
  export type TriggerWidth = 'hug' | 'full';
 | 
			
		||||
  export type TriggerChevron = 'always' | 'on-hover';
 | 
			
		||||
 | 
			
		||||
  const baseTriggerStyles = tw(
 | 
			
		||||
    'flex',
 | 
			
		||||
    'rounded-full py-[5px] ps-3 pe-2.5 type-body-medium text-label-primary',
 | 
			
		||||
    'group relative flex items-center',
 | 
			
		||||
    'rounded-full text-start type-body-medium text-label-primary',
 | 
			
		||||
    'disabled:text-label-disabled',
 | 
			
		||||
    'outline-0 outline-border-focused focused:outline-[2.5px]',
 | 
			
		||||
    'forced-colors:border'
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const TriggerVariants = {
 | 
			
		||||
  const TriggerVariants: Record<TriggerVariant, TailwindStyles> = {
 | 
			
		||||
    default: tw(
 | 
			
		||||
      baseTriggerStyles,
 | 
			
		||||
      'bg-fill-secondary',
 | 
			
		||||
| 
						 | 
				
			
			@ -104,19 +113,52 @@ export namespace AxoSelect {
 | 
			
		|||
      'hovered:bg-fill-secondary',
 | 
			
		||||
      'pressed:bg-fill-secondary-pressed'
 | 
			
		||||
    ),
 | 
			
		||||
  } as const satisfies Record<string, TailwindStyles>;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const TriggerWidths = {
 | 
			
		||||
  const TriggerWidths: Record<TriggerWidth, TailwindStyles> = {
 | 
			
		||||
    hug: tw(),
 | 
			
		||||
    full: tw('w-full'),
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  export type TriggerVariant = keyof typeof TriggerVariants;
 | 
			
		||||
  export type TriggerWidth = keyof typeof TriggerWidths;
 | 
			
		||||
  type TriggerChevronConfig = {
 | 
			
		||||
    chevronStyles: TailwindStyles;
 | 
			
		||||
    contentStyles: TailwindStyles;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const baseContentStyles = tw('flex min-w-0 flex-1');
 | 
			
		||||
 | 
			
		||||
  const TriggerChevrons: Record<TriggerChevron, TriggerChevronConfig> = {
 | 
			
		||||
    always: {
 | 
			
		||||
      chevronStyles: tw('ps-2 pe-2.5'),
 | 
			
		||||
      contentStyles: tw(baseContentStyles, 'py-[5px] ps-3'),
 | 
			
		||||
    },
 | 
			
		||||
    'on-hover': {
 | 
			
		||||
      chevronStyles: tw(
 | 
			
		||||
        'absolute inset-y-0 end-0 w-9.5',
 | 
			
		||||
        'flex items-center justify-end pe-2',
 | 
			
		||||
        'opacity-0 group-focus:opacity-100 group-data-[state=open]:opacity-100 group-hovered:opacity-100',
 | 
			
		||||
        'transition-opacity duration-150'
 | 
			
		||||
      ),
 | 
			
		||||
      contentStyles: tw(
 | 
			
		||||
        baseContentStyles,
 | 
			
		||||
        'px-3 py-[5px]',
 | 
			
		||||
        '[--axo-select-trigger-mask-start:black]',
 | 
			
		||||
        'group-hovered:[--axo-select-trigger-mask-start:transparent]',
 | 
			
		||||
        'group-focus:[--axo-select-trigger-mask-start:transparent]',
 | 
			
		||||
        'group-data-[state=open]:[--axo-select-trigger-mask-start:transparent]',
 | 
			
		||||
        '[mask-image:linear-gradient(to_left,var(--axo-select-trigger-mask-start)_19px,black_38px)]',
 | 
			
		||||
        'rtl:[mask-image:linear-gradient(to_right,var(--axo-select-trigger-mask-start)_19px,black_38px)]',
 | 
			
		||||
        '[mask-repeat:no-repeat]',
 | 
			
		||||
        '[mask-position:right] rtl:[mask-position:left]',
 | 
			
		||||
        '[transition-property:--axo-select-trigger-mask-start] duration-150'
 | 
			
		||||
      ),
 | 
			
		||||
    },
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  export type TriggerProps = Readonly<{
 | 
			
		||||
    variant?: TriggerVariant;
 | 
			
		||||
    width?: TriggerWidth;
 | 
			
		||||
    chevron?: TriggerChevron;
 | 
			
		||||
    placeholder: string;
 | 
			
		||||
    children?: ReactNode;
 | 
			
		||||
  }>;
 | 
			
		||||
| 
						 | 
				
			
			@ -129,16 +171,20 @@ export namespace AxoSelect {
 | 
			
		|||
  export const Trigger: FC<TriggerProps> = memo(props => {
 | 
			
		||||
    const variant = props.variant ?? 'default';
 | 
			
		||||
    const width = props.width ?? 'hug';
 | 
			
		||||
    const chevron = props.chevron ?? 'always';
 | 
			
		||||
    const variantStyles = TriggerVariants[variant];
 | 
			
		||||
    const widthStyles = TriggerWidths[width];
 | 
			
		||||
    const chevronConfig = TriggerChevrons[chevron];
 | 
			
		||||
    return (
 | 
			
		||||
      <Select.Trigger className={tw(variantStyles, widthStyles)}>
 | 
			
		||||
        <div className={chevronConfig.contentStyles}>
 | 
			
		||||
          <AxoBaseMenu.ItemText>
 | 
			
		||||
            <Select.Value placeholder={props.placeholder}>
 | 
			
		||||
              {props.children}
 | 
			
		||||
            </Select.Value>
 | 
			
		||||
          </AxoBaseMenu.ItemText>
 | 
			
		||||
        <Select.Icon className={tw('ms-2')}>
 | 
			
		||||
        </div>
 | 
			
		||||
        <Select.Icon className={chevronConfig.chevronStyles}>
 | 
			
		||||
          <AxoSymbol.Icon symbol="chevron-down" size={14} label={null} />
 | 
			
		||||
        </Select.Icon>
 | 
			
		||||
      </Select.Trigger>
 | 
			
		||||
| 
						 | 
				
			
			@ -152,7 +198,29 @@ export namespace AxoSelect {
 | 
			
		|||
   * ------------------------------
 | 
			
		||||
   */
 | 
			
		||||
 | 
			
		||||
  export type ContentPosition = 'item-aligned' | 'dropdown';
 | 
			
		||||
 | 
			
		||||
  type ContentPositionConfig = {
 | 
			
		||||
    position: Select.SelectContentProps['position'];
 | 
			
		||||
    alignOffset?: Select.SelectContentProps['alignOffset'];
 | 
			
		||||
    collisionPadding?: Select.SelectContentProps['collisionPadding'];
 | 
			
		||||
    sideOffset?: Select.SelectContentProps['sideOffset'];
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const ContentPositions: Record<ContentPosition, ContentPositionConfig> = {
 | 
			
		||||
    'item-aligned': {
 | 
			
		||||
      position: 'item-aligned',
 | 
			
		||||
    },
 | 
			
		||||
    dropdown: {
 | 
			
		||||
      position: 'popper',
 | 
			
		||||
      alignOffset: 0,
 | 
			
		||||
      collisionPadding: 6,
 | 
			
		||||
      sideOffset: 8,
 | 
			
		||||
    },
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  export type ContentProps = Readonly<{
 | 
			
		||||
    position?: ContentPosition;
 | 
			
		||||
    children: ReactNode;
 | 
			
		||||
  }>;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -161,9 +229,17 @@ export namespace AxoSelect {
 | 
			
		|||
   * Uses a portal to render the content part into the `body`.
 | 
			
		||||
   */
 | 
			
		||||
  export const Content: FC<ContentProps> = memo(props => {
 | 
			
		||||
    const position = props.position ?? 'item-aligned';
 | 
			
		||||
    const positionConfig = ContentPositions[position];
 | 
			
		||||
    return (
 | 
			
		||||
      <Select.Portal>
 | 
			
		||||
        <Select.Content className={AxoBaseMenu.selectContentStyles}>
 | 
			
		||||
        <Select.Content
 | 
			
		||||
          className={AxoBaseMenu.selectContentStyles}
 | 
			
		||||
          position={positionConfig.position}
 | 
			
		||||
          alignOffset={positionConfig.alignOffset}
 | 
			
		||||
          collisionPadding={positionConfig.collisionPadding}
 | 
			
		||||
          sideOffset={positionConfig.sideOffset}
 | 
			
		||||
        >
 | 
			
		||||
          <Select.ScrollUpButton
 | 
			
		||||
            className={tw(
 | 
			
		||||
              'flex items-center justify-center p-1 text-label-primary'
 | 
			
		||||
| 
						 | 
				
			
			@ -197,6 +273,7 @@ export namespace AxoSelect {
 | 
			
		|||
    value: string;
 | 
			
		||||
    disabled?: boolean;
 | 
			
		||||
    textValue?: string;
 | 
			
		||||
    symbol?: AxoSymbol.IconName;
 | 
			
		||||
    children: ReactNode;
 | 
			
		||||
  }>;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -219,9 +296,12 @@ export namespace AxoSelect {
 | 
			
		|||
          </AxoBaseMenu.ItemCheckPlaceholder>
 | 
			
		||||
        </AxoBaseMenu.ItemLeadingSlot>
 | 
			
		||||
        <AxoBaseMenu.ItemContentSlot>
 | 
			
		||||
          <AxoBaseMenu.ItemText>
 | 
			
		||||
            <Select.ItemText>{props.children}</Select.ItemText>
 | 
			
		||||
          </AxoBaseMenu.ItemText>
 | 
			
		||||
          {props.symbol && (
 | 
			
		||||
            <span className={tw('me-2')}>
 | 
			
		||||
              <AxoBaseMenu.ItemSymbol symbol={props.symbol} />
 | 
			
		||||
            </span>
 | 
			
		||||
          )}
 | 
			
		||||
          {props.children}
 | 
			
		||||
        </AxoBaseMenu.ItemContentSlot>
 | 
			
		||||
      </Select.Item>
 | 
			
		||||
    );
 | 
			
		||||
| 
						 | 
				
			
			@ -229,9 +309,55 @@ export namespace AxoSelect {
 | 
			
		|||
 | 
			
		||||
  Item.displayName = `${Namespace}.Content`;
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Component: <AxoSelect.ItemText>
 | 
			
		||||
   */
 | 
			
		||||
 | 
			
		||||
  export type ItemTextProps = Readonly<{
 | 
			
		||||
    children: ReactNode;
 | 
			
		||||
  }>;
 | 
			
		||||
 | 
			
		||||
  export const ItemText: FC<ItemTextProps> = memo(props => {
 | 
			
		||||
    return (
 | 
			
		||||
      <AxoBaseMenu.ItemText>
 | 
			
		||||
        <Select.ItemText>{props.children}</Select.ItemText>
 | 
			
		||||
      </AxoBaseMenu.ItemText>
 | 
			
		||||
    );
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  ItemText.displayName = `${Namespace}.ItemText`;
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Component: <AxoSelect.ItemBadge>
 | 
			
		||||
   * --------------------------------
 | 
			
		||||
   */
 | 
			
		||||
 | 
			
		||||
  export type ExperimentalItemBadgeProps = Omit<
 | 
			
		||||
    ExperimentalAxoBadge.RootProps,
 | 
			
		||||
    'size'
 | 
			
		||||
  >;
 | 
			
		||||
 | 
			
		||||
  export const ExperimentalItemBadge = memo(
 | 
			
		||||
    (props: ExperimentalItemBadgeProps) => {
 | 
			
		||||
      return (
 | 
			
		||||
        <span className={tw('ms-[5px]')}>
 | 
			
		||||
          <ExperimentalAxoBadge.Root
 | 
			
		||||
            size="sm"
 | 
			
		||||
            value={props.value}
 | 
			
		||||
            max={props.max}
 | 
			
		||||
            maxDisplay={props.maxDisplay}
 | 
			
		||||
            aria-label={props['aria-label']}
 | 
			
		||||
          />
 | 
			
		||||
        </span>
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  ExperimentalItemBadge.displayName = `${Namespace}.ItemBadge`;
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Component: <AxoSelect.Group>
 | 
			
		||||
   * ---------------------------
 | 
			
		||||
   * ----------------------------
 | 
			
		||||
   */
 | 
			
		||||
 | 
			
		||||
  export type GroupProps = Readonly<{
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -17,7 +17,7 @@ function Template(props: {
 | 
			
		|||
  const [checked, setChecked] = useState(props.defaultChecked);
 | 
			
		||||
  return (
 | 
			
		||||
    <label className={tw('my-2 flex items-center gap-2')}>
 | 
			
		||||
      <AxoSwitch
 | 
			
		||||
      <AxoSwitch.Root
 | 
			
		||||
        checked={checked}
 | 
			
		||||
        onCheckedChange={setChecked}
 | 
			
		||||
        disabled={props.disabled}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -7,15 +7,15 @@ import { AxoSymbol } from './AxoSymbol.js';
 | 
			
		|||
 | 
			
		||||
const Namespace = 'AxoSwitch';
 | 
			
		||||
 | 
			
		||||
type AxoSwitchProps = Readonly<{
 | 
			
		||||
export namespace AxoSwitch {
 | 
			
		||||
  export type RootProps = Readonly<{
 | 
			
		||||
    checked: boolean;
 | 
			
		||||
    onCheckedChange: (nextChecked: boolean) => void;
 | 
			
		||||
    disabled?: boolean;
 | 
			
		||||
    required?: boolean;
 | 
			
		||||
}>;
 | 
			
		||||
  }>;
 | 
			
		||||
 | 
			
		||||
// eslint-disable-next-line import/export
 | 
			
		||||
export const AxoSwitch = memo((props: AxoSwitchProps) => {
 | 
			
		||||
  export const Root = memo((props: RootProps) => {
 | 
			
		||||
    return (
 | 
			
		||||
      <Switch.Root
 | 
			
		||||
        checked={props.checked}
 | 
			
		||||
| 
						 | 
				
			
			@ -72,12 +72,7 @@ export const AxoSwitch = memo((props: AxoSwitchProps) => {
 | 
			
		|||
        />
 | 
			
		||||
      </Switch.Root>
 | 
			
		||||
    );
 | 
			
		||||
});
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
AxoSwitch.displayName = `${Namespace}`;
 | 
			
		||||
 | 
			
		||||
// eslint-disable-next-line max-len
 | 
			
		||||
// eslint-disable-next-line @typescript-eslint/no-namespace, @typescript-eslint/no-redeclare, import/export
 | 
			
		||||
export namespace AxoSwitch {
 | 
			
		||||
  export type Props = AxoSwitchProps;
 | 
			
		||||
  Root.displayName = `${Namespace}.Root`;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -18,7 +18,6 @@ const { useDirection } = Direction;
 | 
			
		|||
 | 
			
		||||
const Namespace = 'AxoSymbol';
 | 
			
		||||
 | 
			
		||||
// eslint-disable-next-line @typescript-eslint/no-namespace
 | 
			
		||||
export namespace AxoSymbol {
 | 
			
		||||
  const symbolStyles = tw('font-symbols select-none');
 | 
			
		||||
  const labelStyles = tw('select-none');
 | 
			
		||||
| 
						 | 
				
			
			@ -83,7 +82,7 @@ export namespace AxoSymbol {
 | 
			
		|||
 | 
			
		||||
  export type IconProps = Readonly<{
 | 
			
		||||
    size: IconSize;
 | 
			
		||||
    symbol: AxoSymbolIconName;
 | 
			
		||||
    symbol: IconName;
 | 
			
		||||
    label: string | null;
 | 
			
		||||
  }>;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -93,7 +92,6 @@ export namespace AxoSymbol {
 | 
			
		|||
 | 
			
		||||
  export const Icon: FC<IconProps> = memo(props => {
 | 
			
		||||
    const config = IconSizes[props.size];
 | 
			
		||||
 | 
			
		||||
    const direction = useDirection();
 | 
			
		||||
    const glyph = getAxoSymbolIcon(props.symbol, direction);
 | 
			
		||||
    const content = useRenderSymbol(glyph, props.label);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -5,7 +5,6 @@ import type { ReactNode } from 'react';
 | 
			
		|||
import { tw } from '../tw.js';
 | 
			
		||||
import { AxoSymbol } from '../AxoSymbol.js';
 | 
			
		||||
 | 
			
		||||
// eslint-disable-next-line @typescript-eslint/no-namespace
 | 
			
		||||
export namespace AxoBaseMenu {
 | 
			
		||||
  // <Content/SubContent>
 | 
			
		||||
  const baseContentStyles = tw(
 | 
			
		||||
| 
						 | 
				
			
			@ -114,7 +113,7 @@ export namespace AxoBaseMenu {
 | 
			
		|||
   * -----------------------
 | 
			
		||||
   */
 | 
			
		||||
 | 
			
		||||
  export const itemTextStyles = tw('flex-1 truncate text-start');
 | 
			
		||||
  export const itemTextStyles = tw('flex-auto grow-0 truncate text-start');
 | 
			
		||||
 | 
			
		||||
  export type ItemTextProps = Readonly<{
 | 
			
		||||
    children: ReactNode;
 | 
			
		||||
| 
						 | 
				
			
			@ -283,7 +282,7 @@ export namespace AxoBaseMenu {
 | 
			
		|||
    /**
 | 
			
		||||
     * The value of the selected item in the group.
 | 
			
		||||
     */
 | 
			
		||||
    value: string;
 | 
			
		||||
    value: string | null;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Event handler called when the value changes.
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										269
									
								
								ts/axo/_internal/AxoBaseSegmentedControl.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										269
									
								
								ts/axo/_internal/AxoBaseSegmentedControl.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,269 @@
 | 
			
		|||
// Copyright 2025 Signal Messenger, LLC
 | 
			
		||||
// SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		||||
import type {
 | 
			
		||||
  ButtonHTMLAttributes,
 | 
			
		||||
  CSSProperties,
 | 
			
		||||
  FC,
 | 
			
		||||
  ForwardedRef,
 | 
			
		||||
  HTMLAttributes,
 | 
			
		||||
  ReactNode,
 | 
			
		||||
} from 'react';
 | 
			
		||||
import React, {
 | 
			
		||||
  createContext,
 | 
			
		||||
  forwardRef,
 | 
			
		||||
  memo,
 | 
			
		||||
  useContext,
 | 
			
		||||
  useId,
 | 
			
		||||
  useMemo,
 | 
			
		||||
} from 'react';
 | 
			
		||||
import type { Transition } from 'framer-motion';
 | 
			
		||||
import { motion } from 'framer-motion';
 | 
			
		||||
import type { TailwindStyles } from '../tw.js';
 | 
			
		||||
import { tw } from '../tw.js';
 | 
			
		||||
import { ExperimentalAxoBadge } from '../AxoBadge.js';
 | 
			
		||||
 | 
			
		||||
const Namespace = 'AxoBaseSegmentedControl';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Used to share styles/animations for SegmentedControls, Toolbar ToggleGroups,
 | 
			
		||||
 * and Tabs.
 | 
			
		||||
 *
 | 
			
		||||
 * @example Anatomy
 | 
			
		||||
 * ```tsx
 | 
			
		||||
 * <ToggleGroup.Root asChild>
 | 
			
		||||
 *   <AxoBaseSegmentedControl.Root>
 | 
			
		||||
 *     <ToggleGroup.Item asChild>
 | 
			
		||||
 *       <AxoBaseSegmentedControl.Item/>
 | 
			
		||||
 *     </ToggleGroup.Item>
 | 
			
		||||
 *   </AxoBaseSegmentedControl.Root>
 | 
			
		||||
 * </ToggleGroup.Root>
 | 
			
		||||
 * ```
 | 
			
		||||
 */
 | 
			
		||||
export namespace ExperimentalAxoBaseSegmentedControl {
 | 
			
		||||
  export type Variant = 'track' | 'no-track';
 | 
			
		||||
  export type RootWidth = 'fit' | 'full';
 | 
			
		||||
  export type ItemWidth = 'fit' | 'equal';
 | 
			
		||||
 | 
			
		||||
  export type RootValue = string | ReadonlyArray<string> | null;
 | 
			
		||||
 | 
			
		||||
  type RootContextType = Readonly<{
 | 
			
		||||
    id: string;
 | 
			
		||||
    value: RootValue;
 | 
			
		||||
    variant: Variant;
 | 
			
		||||
    rootWidth: RootWidth;
 | 
			
		||||
    itemWidth: ItemWidth;
 | 
			
		||||
  }>;
 | 
			
		||||
 | 
			
		||||
  const RootContext = createContext<RootContextType | null>(null);
 | 
			
		||||
 | 
			
		||||
  // eslint-disable-next-line no-inner-declarations
 | 
			
		||||
  function useRootContext(componentName: string): RootContextType {
 | 
			
		||||
    const context = useContext(RootContext);
 | 
			
		||||
    if (context == null) {
 | 
			
		||||
      throw new Error(
 | 
			
		||||
        `<${Namespace}.${componentName}> must be wrapped with <${Namespace}.Root>`
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
    return context;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  type VariantConfig = {
 | 
			
		||||
    rootStyles: TailwindStyles;
 | 
			
		||||
    indicatorStyles: TailwindStyles;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const base: VariantConfig = {
 | 
			
		||||
    rootStyles: tw(
 | 
			
		||||
      'flex min-w-min flex-row items-center justify-items-stretch',
 | 
			
		||||
      'rounded-full',
 | 
			
		||||
      'forced-colors:border',
 | 
			
		||||
      'forced-colors:border-[ButtonBorder]'
 | 
			
		||||
    ),
 | 
			
		||||
    indicatorStyles: tw(
 | 
			
		||||
      'pointer-events-none absolute inset-0 z-10 rounded-full',
 | 
			
		||||
      'forced-colors:bg-[Highlight]'
 | 
			
		||||
    ),
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const Variants: Record<Variant, VariantConfig> = {
 | 
			
		||||
    track: {
 | 
			
		||||
      rootStyles: tw(base.rootStyles, 'bg-fill-secondary'),
 | 
			
		||||
      indicatorStyles: tw(
 | 
			
		||||
        base.indicatorStyles,
 | 
			
		||||
        'bg-fill-primary',
 | 
			
		||||
        'shadow-elevation-1'
 | 
			
		||||
      ),
 | 
			
		||||
    },
 | 
			
		||||
    'no-track': {
 | 
			
		||||
      rootStyles: tw(base.rootStyles),
 | 
			
		||||
      indicatorStyles: tw(base.indicatorStyles, 'bg-fill-selected'),
 | 
			
		||||
    },
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const IndicatorTransition: Transition = {
 | 
			
		||||
    type: 'spring',
 | 
			
		||||
    stiffness: 422,
 | 
			
		||||
    damping: 37.3,
 | 
			
		||||
    mass: 1,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Component: <AxoBaseSegmentedControl.Root>
 | 
			
		||||
   * -----------------------------------------
 | 
			
		||||
   */
 | 
			
		||||
 | 
			
		||||
  const RootWidths: Record<RootWidth, TailwindStyles> = {
 | 
			
		||||
    fit: tw('w-fit'),
 | 
			
		||||
    full: tw('w-full'),
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  export type RootProps = HTMLAttributes<HTMLDivElement> &
 | 
			
		||||
    Readonly<{
 | 
			
		||||
      value: RootValue;
 | 
			
		||||
      variant: Variant;
 | 
			
		||||
      width: RootWidth;
 | 
			
		||||
      itemWidth: ItemWidth;
 | 
			
		||||
    }>;
 | 
			
		||||
 | 
			
		||||
  export const Root: FC<RootProps> = memo(
 | 
			
		||||
    forwardRef((props, ref: ForwardedRef<HTMLDivElement>) => {
 | 
			
		||||
      const { value, variant, width, itemWidth, ...rest } = props;
 | 
			
		||||
      const id = useId();
 | 
			
		||||
      const config = Variants[variant];
 | 
			
		||||
      const widthStyles = RootWidths[width];
 | 
			
		||||
      const context = useMemo(() => {
 | 
			
		||||
        return { id, value, variant, rootWidth: width, itemWidth };
 | 
			
		||||
      }, [id, value, variant, width, itemWidth]);
 | 
			
		||||
      return (
 | 
			
		||||
        <RootContext.Provider value={context}>
 | 
			
		||||
          <div
 | 
			
		||||
            ref={ref}
 | 
			
		||||
            {...rest}
 | 
			
		||||
            className={tw(config.rootStyles, widthStyles)}
 | 
			
		||||
          />
 | 
			
		||||
        </RootContext.Provider>
 | 
			
		||||
      );
 | 
			
		||||
    })
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  Root.displayName = `${Namespace}.Root`;
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Component: <AxoBaseSegmentedControl.Item>
 | 
			
		||||
   * -----------------------------------------
 | 
			
		||||
   */
 | 
			
		||||
 | 
			
		||||
  const ItemWidths: Record<ItemWidth, TailwindStyles> = {
 | 
			
		||||
    fit: tw('min-w-0 shrink grow basis-auto'),
 | 
			
		||||
    equal: tw('flex-1'),
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  export type ItemProps = ButtonHTMLAttributes<HTMLButtonElement> &
 | 
			
		||||
    Readonly<{
 | 
			
		||||
      value: string;
 | 
			
		||||
    }>;
 | 
			
		||||
 | 
			
		||||
  export const Item: FC<ItemProps> = memo(
 | 
			
		||||
    forwardRef((props, ref: ForwardedRef<HTMLButtonElement>) => {
 | 
			
		||||
      const { value, ...rest } = props;
 | 
			
		||||
 | 
			
		||||
      const context = useRootContext('Item');
 | 
			
		||||
      const config = Variants[context.variant];
 | 
			
		||||
      const itemWidthStyles = ItemWidths[context.itemWidth];
 | 
			
		||||
 | 
			
		||||
      const isSelected = useMemo(() => {
 | 
			
		||||
        if (context.value == null) {
 | 
			
		||||
          return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (Array.isArray(context.value)) {
 | 
			
		||||
          return context.value.includes(value);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return context.value === value;
 | 
			
		||||
      }, [value, context.value]);
 | 
			
		||||
 | 
			
		||||
      return (
 | 
			
		||||
        <button
 | 
			
		||||
          ref={ref}
 | 
			
		||||
          type="button"
 | 
			
		||||
          {...rest}
 | 
			
		||||
          className={tw(
 | 
			
		||||
            'group relative flex min-w-0 items-center justify-center px-3 py-[5px]',
 | 
			
		||||
            'cursor-pointer rounded-full type-body-medium font-medium text-label-primary',
 | 
			
		||||
            'outline-border-focused not-forced-colors:outline-0 not-forced-colors:focused:outline-[2.5px]',
 | 
			
		||||
            'forced-colors:bg-[ButtonFace] forced-colors:text-[ButtonText]',
 | 
			
		||||
            itemWidthStyles,
 | 
			
		||||
            isSelected && tw('forced-colors:text-[HighlightText]')
 | 
			
		||||
          )}
 | 
			
		||||
        >
 | 
			
		||||
          {props.children}
 | 
			
		||||
          {isSelected && (
 | 
			
		||||
            <motion.span
 | 
			
		||||
              layoutId={`${context.id}.Indicator`}
 | 
			
		||||
              layoutDependency={isSelected}
 | 
			
		||||
              className={config.indicatorStyles}
 | 
			
		||||
              transition={IndicatorTransition}
 | 
			
		||||
              style={{ borderRadius: 14 }}
 | 
			
		||||
            />
 | 
			
		||||
          )}
 | 
			
		||||
        </button>
 | 
			
		||||
      );
 | 
			
		||||
    })
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  Item.displayName = `${Namespace}.Item`;
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Component: <AxoBaseSegmentedControl.ItemText>
 | 
			
		||||
   * ---------------------------------------------
 | 
			
		||||
   */
 | 
			
		||||
 | 
			
		||||
  export type ItemMaxWidth = CSSProperties['maxWidth'];
 | 
			
		||||
 | 
			
		||||
  export type ItemTextProps = Readonly<{
 | 
			
		||||
    maxWidth?: ItemMaxWidth;
 | 
			
		||||
    children: ReactNode;
 | 
			
		||||
  }>;
 | 
			
		||||
 | 
			
		||||
  export const ItemText: FC<ItemTextProps> = memo(props => {
 | 
			
		||||
    return (
 | 
			
		||||
      <span
 | 
			
		||||
        className={tw('relative z-20 block truncate forced-color-adjust-none')}
 | 
			
		||||
        style={{ maxWidth: props.maxWidth }}
 | 
			
		||||
      >
 | 
			
		||||
        {props.children}
 | 
			
		||||
      </span>
 | 
			
		||||
    );
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  ItemText.displayName = `${Namespace}.ItemText`;
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Component: <AxoBaseSegmentedControl.ItemBadge>
 | 
			
		||||
   * ----------------------------------------------
 | 
			
		||||
   */
 | 
			
		||||
 | 
			
		||||
  export type ExperimentalItemBadgeProps = Omit<
 | 
			
		||||
    ExperimentalAxoBadge.RootProps,
 | 
			
		||||
    'size'
 | 
			
		||||
  >;
 | 
			
		||||
 | 
			
		||||
  export const ExperimentalItemBadge = memo(
 | 
			
		||||
    (props: ExperimentalItemBadgeProps) => {
 | 
			
		||||
      return (
 | 
			
		||||
        <span className={tw('relative z-20 ms-[5px]')}>
 | 
			
		||||
          <ExperimentalAxoBadge.Root
 | 
			
		||||
            size="md"
 | 
			
		||||
            value={props.value}
 | 
			
		||||
            max={props.max}
 | 
			
		||||
            maxDisplay={props.maxDisplay}
 | 
			
		||||
            aria-label={props['aria-label']}
 | 
			
		||||
          />
 | 
			
		||||
        </span>
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  ExperimentalItemBadge.displayName = `${Namespace}.ItemBadge`;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -15,3 +15,9 @@ export function assert<T>(input: T, message?: string): NonNullable<T> {
 | 
			
		|||
  }
 | 
			
		||||
  return input;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function unreachable(_value: never): never {
 | 
			
		||||
  // eslint-disable-next-line no-debugger
 | 
			
		||||
  debugger;
 | 
			
		||||
  throw new AssertionError('unreachable');
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -20,7 +20,7 @@ export default {
 | 
			
		|||
    otherTabsUnreadStats: {
 | 
			
		||||
      unreadCount: 0,
 | 
			
		||||
      unreadMentionsCount: 0,
 | 
			
		||||
      markedUnread: false,
 | 
			
		||||
      readChatsMarkedUnreadCount: 0,
 | 
			
		||||
    },
 | 
			
		||||
    isStaging: false,
 | 
			
		||||
    hasPendingUpdate: false,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -373,7 +373,8 @@ export const ConversationMessageRequest = (): JSX.Element =>
 | 
			
		|||
export function ConversationsUnreadCount(): JSX.Element {
 | 
			
		||||
  return (
 | 
			
		||||
    <Wrapper
 | 
			
		||||
      rows={[4, 10, 34, 250, 2048].map(unreadCount => ({
 | 
			
		||||
      rows={[4, 10, 34, 250, 2048, Number.MAX_SAFE_INTEGER].map(
 | 
			
		||||
        unreadCount => ({
 | 
			
		||||
          type: RowType.Conversation,
 | 
			
		||||
          conversation: createConversation({
 | 
			
		||||
            lastMessage: {
 | 
			
		||||
| 
						 | 
				
			
			@ -383,7 +384,8 @@ export function ConversationsUnreadCount(): JSX.Element {
 | 
			
		|||
            },
 | 
			
		||||
            unreadCount,
 | 
			
		||||
          }),
 | 
			
		||||
      }))}
 | 
			
		||||
        })
 | 
			
		||||
      )}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -38,6 +38,7 @@ import { GroupListItem } from './conversationList/GroupListItem.js';
 | 
			
		|||
import { ListView } from './ListView.js';
 | 
			
		||||
import { Button, ButtonVariant } from './Button.js';
 | 
			
		||||
import { ListTile } from './ListTile.js';
 | 
			
		||||
import type { RenderConversationListItemContextMenuProps } from './conversationList/BaseConversationListItem.js';
 | 
			
		||||
 | 
			
		||||
const { get, pick } = lodash;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -238,6 +239,9 @@ export type PropsType = {
 | 
			
		|||
  onOutgoingVideoCallInConversation: (conversationId: string) => void;
 | 
			
		||||
  removeConversation: (conversationId: string) => void;
 | 
			
		||||
  renderMessageSearchResult?: (id: string) => JSX.Element;
 | 
			
		||||
  renderConversationListItemContextMenu?: (
 | 
			
		||||
    props: RenderConversationListItemContextMenuProps
 | 
			
		||||
  ) => JSX.Element;
 | 
			
		||||
  showChooseGroupMembers: () => void;
 | 
			
		||||
  showFindByUsername: () => void;
 | 
			
		||||
  showFindByPhoneNumber: () => void;
 | 
			
		||||
| 
						 | 
				
			
			@ -264,6 +268,7 @@ export function ConversationList({
 | 
			
		|||
  onOutgoingVideoCallInConversation,
 | 
			
		||||
  removeConversation,
 | 
			
		||||
  renderMessageSearchResult,
 | 
			
		||||
  renderConversationListItemContextMenu,
 | 
			
		||||
  rowCount,
 | 
			
		||||
  scrollBehavior = ScrollBehavior.Default,
 | 
			
		||||
  scrollToRowIndex,
 | 
			
		||||
| 
						 | 
				
			
			@ -514,6 +519,9 @@ export function ConversationList({
 | 
			
		|||
              onClick={onSelectConversation}
 | 
			
		||||
              i18n={i18n}
 | 
			
		||||
              theme={theme}
 | 
			
		||||
              renderConversationListItemContextMenu={
 | 
			
		||||
                renderConversationListItemContextMenu
 | 
			
		||||
              }
 | 
			
		||||
            />
 | 
			
		||||
          );
 | 
			
		||||
          break;
 | 
			
		||||
| 
						 | 
				
			
			@ -650,6 +658,7 @@ export function ConversationList({
 | 
			
		|||
      onSelectConversation,
 | 
			
		||||
      removeConversation,
 | 
			
		||||
      renderMessageSearchResult,
 | 
			
		||||
      renderConversationListItemContextMenu,
 | 
			
		||||
      setIsFetchingUUID,
 | 
			
		||||
      showChooseGroupMembers,
 | 
			
		||||
      showFindByUsername,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										53
									
								
								ts/components/DeleteMessagesConfirmationDialog.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								ts/components/DeleteMessagesConfirmationDialog.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,53 @@
 | 
			
		|||
// Copyright 2025 Signal Messenger, LLC
 | 
			
		||||
// SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		||||
import React from 'react';
 | 
			
		||||
import type { LocalizerType } from '../types/I18N.js';
 | 
			
		||||
import { ConfirmationDialog } from './ConfirmationDialog.js';
 | 
			
		||||
import { LocalDeleteWarningModal } from './LocalDeleteWarningModal.js';
 | 
			
		||||
 | 
			
		||||
export function DeleteMessagesConfirmationDialog({
 | 
			
		||||
  i18n,
 | 
			
		||||
  localDeleteWarningShown,
 | 
			
		||||
  onDestroyMessages,
 | 
			
		||||
  onClose,
 | 
			
		||||
  setLocalDeleteWarningShown,
 | 
			
		||||
}: {
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
  localDeleteWarningShown: boolean;
 | 
			
		||||
  onDestroyMessages: () => void;
 | 
			
		||||
  onClose: () => void;
 | 
			
		||||
  setLocalDeleteWarningShown: () => void;
 | 
			
		||||
}): JSX.Element {
 | 
			
		||||
  if (!localDeleteWarningShown) {
 | 
			
		||||
    return (
 | 
			
		||||
      <LocalDeleteWarningModal
 | 
			
		||||
        i18n={i18n}
 | 
			
		||||
        onClose={setLocalDeleteWarningShown}
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const dialogBody = i18n(
 | 
			
		||||
    'icu:ConversationHeader__DeleteConversationConfirmation__description-with-sync'
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <ConfirmationDialog
 | 
			
		||||
      dialogName="ConversationHeader.destroyMessages"
 | 
			
		||||
      title={i18n(
 | 
			
		||||
        'icu:ConversationHeader__DeleteConversationConfirmation__title'
 | 
			
		||||
      )}
 | 
			
		||||
      actions={[
 | 
			
		||||
        {
 | 
			
		||||
          action: onDestroyMessages,
 | 
			
		||||
          style: 'negative',
 | 
			
		||||
          text: i18n('icu:delete'),
 | 
			
		||||
        },
 | 
			
		||||
      ]}
 | 
			
		||||
      i18n={i18n}
 | 
			
		||||
      onClose={onClose}
 | 
			
		||||
    >
 | 
			
		||||
      {dialogBody}
 | 
			
		||||
    </ConfirmationDialog>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -107,7 +107,7 @@ export function DisappearingTimerSelect(props: Props): JSX.Element {
 | 
			
		|||
          {expirationTimerOptions.map(option => {
 | 
			
		||||
            return (
 | 
			
		||||
              <AxoSelect.Item key={option.value} value={String(option.value)}>
 | 
			
		||||
                {option.text}
 | 
			
		||||
                <AxoSelect.ItemText>{option.text}</AxoSelect.ItemText>
 | 
			
		||||
              </AxoSelect.Item>
 | 
			
		||||
            );
 | 
			
		||||
          })}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -34,6 +34,8 @@ import {
 | 
			
		|||
} from '../test-helpers/fakeLookupConversationWithoutServiceId.js';
 | 
			
		||||
import type { GroupListItemConversationType } from './conversationList/GroupListItem.js';
 | 
			
		||||
import { ServerAlert } from '../util/handleServerAlerts.js';
 | 
			
		||||
import { LeftPaneChatFolders } from './leftPane/LeftPaneChatFolders.js';
 | 
			
		||||
import { LeftPaneConversationListItemContextMenu } from './leftPane/LeftPaneConversationListItemContextMenu.js';
 | 
			
		||||
 | 
			
		||||
const { i18n } = window.SignalContext;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -144,7 +146,7 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
 | 
			
		|||
    otherTabsUnreadStats: {
 | 
			
		||||
      unreadCount: 0,
 | 
			
		||||
      unreadMentionsCount: 0,
 | 
			
		||||
      markedUnread: false,
 | 
			
		||||
      readChatsMarkedUnreadCount: 0,
 | 
			
		||||
    },
 | 
			
		||||
    backupMediaDownloadProgress: {
 | 
			
		||||
      isBackupMediaEnabled: true,
 | 
			
		||||
| 
						 | 
				
			
			@ -298,6 +300,38 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
 | 
			
		|||
        isInFullScreenCall={false}
 | 
			
		||||
      />
 | 
			
		||||
    ),
 | 
			
		||||
    renderLeftPaneChatFolders: () => (
 | 
			
		||||
      <LeftPaneChatFolders
 | 
			
		||||
        i18n={i18n}
 | 
			
		||||
        navSidebarWidthBreakpoint={null}
 | 
			
		||||
        sortedChatFolders={[]}
 | 
			
		||||
        allChatFoldersUnreadStats={new Map()}
 | 
			
		||||
        allChatFoldersMutedStats={new Map()}
 | 
			
		||||
        selectedChatFolder={null}
 | 
			
		||||
        onSelectedChatFolderIdChange={action('onSelectedChatFolderIdChange')}
 | 
			
		||||
        onChatFolderMarkRead={action('onChatFolderMarkRead')}
 | 
			
		||||
        onChatFolderUpdateMute={action('onChatFolderUpdateMute')}
 | 
			
		||||
        onChatFolderOpenSettings={action('onChatFolderOpenSettings')}
 | 
			
		||||
      />
 | 
			
		||||
    ),
 | 
			
		||||
    renderConversationListItemContextMenu: props => (
 | 
			
		||||
      <LeftPaneConversationListItemContextMenu
 | 
			
		||||
        i18n={i18n}
 | 
			
		||||
        conversation={getDefaultConversation()}
 | 
			
		||||
        onMarkUnread={action('onMarkUnread')}
 | 
			
		||||
        onMarkRead={action('onMarkRead')}
 | 
			
		||||
        onPin={action('onPin')}
 | 
			
		||||
        onUnpin={action('onUnpin')}
 | 
			
		||||
        onUpdateMute={action('onUpdateMute')}
 | 
			
		||||
        onArchive={action('onArchive')}
 | 
			
		||||
        onUnarchive={action('onUnarchive')}
 | 
			
		||||
        onDelete={action('onDelete')}
 | 
			
		||||
        localDeleteWarningShown={false}
 | 
			
		||||
        setLocalDeleteWarningShown={action('setLocalDeleteWarningShown')}
 | 
			
		||||
      >
 | 
			
		||||
        {props.children}
 | 
			
		||||
      </LeftPaneConversationListItemContextMenu>
 | 
			
		||||
    ),
 | 
			
		||||
    selectedConversationId: undefined,
 | 
			
		||||
    targetedMessageId: undefined,
 | 
			
		||||
    openUsernameReservationModal: action('openUsernameReservationModal'),
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -60,6 +60,7 @@ import type { ServerAlertsType } from '../util/handleServerAlerts.js';
 | 
			
		|||
import { getServerAlertDialog } from './ServerAlerts.js';
 | 
			
		||||
import { NavTab, SettingsPage, ProfileEditorPage } from '../types/Nav.js';
 | 
			
		||||
import type { Location } from '../types/Nav.js';
 | 
			
		||||
import type { RenderConversationListItemContextMenuProps } from './conversationList/BaseConversationListItem.js';
 | 
			
		||||
 | 
			
		||||
const { isNumber } = lodash;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -173,6 +174,9 @@ export type PropsType = {
 | 
			
		|||
 | 
			
		||||
  // Render Props
 | 
			
		||||
  renderMessageSearchResult: (id: string) => JSX.Element;
 | 
			
		||||
  renderConversationListItemContextMenu: (
 | 
			
		||||
    props: RenderConversationListItemContextMenuProps
 | 
			
		||||
  ) => JSX.Element;
 | 
			
		||||
  renderNetworkStatus: (
 | 
			
		||||
    _: Readonly<{ containerWidthBreakpoint: WidthBreakpoint }>
 | 
			
		||||
  ) => JSX.Element;
 | 
			
		||||
| 
						 | 
				
			
			@ -188,6 +192,7 @@ export type PropsType = {
 | 
			
		|||
  renderCaptchaDialog: (props: { onSkip(): void }) => JSX.Element;
 | 
			
		||||
  renderCrashReportDialog: () => JSX.Element;
 | 
			
		||||
  renderExpiredBuildDialog: (_: DialogExpiredBuildPropsType) => JSX.Element;
 | 
			
		||||
  renderLeftPaneChatFolders: () => JSX.Element;
 | 
			
		||||
  renderToastManager: (_: {
 | 
			
		||||
    containerWidthBreakpoint: WidthBreakpoint;
 | 
			
		||||
  }) => JSX.Element;
 | 
			
		||||
| 
						 | 
				
			
			@ -237,7 +242,9 @@ export function LeftPane({
 | 
			
		|||
  renderCaptchaDialog,
 | 
			
		||||
  renderCrashReportDialog,
 | 
			
		||||
  renderExpiredBuildDialog,
 | 
			
		||||
  renderLeftPaneChatFolders,
 | 
			
		||||
  renderMessageSearchResult,
 | 
			
		||||
  renderConversationListItemContextMenu,
 | 
			
		||||
  renderNetworkStatus,
 | 
			
		||||
  renderUnsupportedOSDialog,
 | 
			
		||||
  renderRelinkDialog,
 | 
			
		||||
| 
						 | 
				
			
			@ -519,6 +526,7 @@ export function LeftPane({
 | 
			
		|||
    createGroup,
 | 
			
		||||
    i18n,
 | 
			
		||||
    removeSelectedContact: toggleConversationInChooseMembers,
 | 
			
		||||
    renderLeftPaneChatFolders,
 | 
			
		||||
    setComposeGroupAvatar,
 | 
			
		||||
    setComposeGroupExpireTimer,
 | 
			
		||||
    setComposeGroupName,
 | 
			
		||||
| 
						 | 
				
			
			@ -887,6 +895,9 @@ export function LeftPane({
 | 
			
		|||
                }
 | 
			
		||||
                removeConversation={removeConversation}
 | 
			
		||||
                renderMessageSearchResult={renderMessageSearchResult}
 | 
			
		||||
                renderConversationListItemContextMenu={
 | 
			
		||||
                  renderConversationListItemContextMenu
 | 
			
		||||
                }
 | 
			
		||||
                rowCount={helper.getRowCount()}
 | 
			
		||||
                scrollBehavior={scrollBehavior}
 | 
			
		||||
                scrollToRowIndex={rowIndexToScrollTo}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,7 +2,7 @@
 | 
			
		|||
// SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		||||
 | 
			
		||||
import type { KeyboardEventHandler, MouseEventHandler, ReactNode } from 'react';
 | 
			
		||||
import React, { useEffect, useState } from 'react';
 | 
			
		||||
import React, { createContext, useEffect, useState } from 'react';
 | 
			
		||||
import classNames from 'classnames';
 | 
			
		||||
import { useMove } from 'react-aria';
 | 
			
		||||
import { NavTabsToggle } from './NavTabs.js';
 | 
			
		||||
| 
						 | 
				
			
			@ -16,6 +16,9 @@ import {
 | 
			
		|||
import { WidthBreakpoint, getNavSidebarWidthBreakpoint } from './_util.js';
 | 
			
		||||
import type { UnreadStats } from '../util/countUnreadStats.js';
 | 
			
		||||
 | 
			
		||||
export const NavSidebarWidthBreakpointContext =
 | 
			
		||||
  createContext<WidthBreakpoint | null>(null);
 | 
			
		||||
 | 
			
		||||
type NavSidebarActionButtonProps = {
 | 
			
		||||
  icon: ReactNode;
 | 
			
		||||
  label: ReactNode;
 | 
			
		||||
| 
						 | 
				
			
			@ -158,6 +161,7 @@ export function NavSidebar({
 | 
			
		|||
  }, [dragState]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <NavSidebarWidthBreakpointContext.Provider value={widthBreakpoint}>
 | 
			
		||||
      <div
 | 
			
		||||
        role="navigation"
 | 
			
		||||
        className={classNames('NavSidebar', {
 | 
			
		||||
| 
						 | 
				
			
			@ -214,7 +218,8 @@ export function NavSidebar({
 | 
			
		|||
 | 
			
		||||
        <div
 | 
			
		||||
          className={classNames('NavSidebar__DragHandle', {
 | 
			
		||||
          'NavSidebar__DragHandle--dragging': dragState === DragState.DRAGGING,
 | 
			
		||||
            'NavSidebar__DragHandle--dragging':
 | 
			
		||||
              dragState === DragState.DRAGGING,
 | 
			
		||||
          })}
 | 
			
		||||
          role="separator"
 | 
			
		||||
          aria-orientation="vertical"
 | 
			
		||||
| 
						 | 
				
			
			@ -228,6 +233,7 @@ export function NavSidebar({
 | 
			
		|||
 | 
			
		||||
        {renderToastManager({ containerWidthBreakpoint: widthBreakpoint })}
 | 
			
		||||
      </div>
 | 
			
		||||
    </NavSidebarWidthBreakpointContext.Provider>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -38,7 +38,7 @@ const createProps = (
 | 
			
		|||
  unreadConversationsStats: overrideProps.unreadConversationsStats ?? {
 | 
			
		||||
    unreadCount: 0,
 | 
			
		||||
    unreadMentionsCount: 0,
 | 
			
		||||
    markedUnread: false,
 | 
			
		||||
    readChatsMarkedUnreadCount: 0,
 | 
			
		||||
  },
 | 
			
		||||
  unreadStoriesCount: overrideProps.unreadStoriesCount ?? 0,
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -46,19 +46,21 @@ function NavTabsItemBadges({
 | 
			
		|||
 | 
			
		||||
  if (unreadStats != null) {
 | 
			
		||||
    if (unreadStats.unreadCount > 0) {
 | 
			
		||||
      const total =
 | 
			
		||||
        unreadStats.unreadCount + unreadStats.readChatsMarkedUnreadCount;
 | 
			
		||||
      return (
 | 
			
		||||
        <span className="NavTabs__ItemUnreadBadge">
 | 
			
		||||
          <span className="NavTabs__ItemIconLabel">
 | 
			
		||||
            {i18n('icu:NavTabs__ItemIconLabel--UnreadCount', {
 | 
			
		||||
              count: unreadStats.unreadCount,
 | 
			
		||||
              count: total,
 | 
			
		||||
            })}
 | 
			
		||||
          </span>
 | 
			
		||||
          <span aria-hidden>{unreadStats.unreadCount}</span>
 | 
			
		||||
          <span aria-hidden>{total}</span>
 | 
			
		||||
        </span>
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (unreadStats.markedUnread) {
 | 
			
		||||
    if (unreadStats.readChatsMarkedUnreadCount > 0) {
 | 
			
		||||
      return (
 | 
			
		||||
        <span className="NavTabs__ItemUnreadBadge">
 | 
			
		||||
          <span className="NavTabs__ItemIconLabel">
 | 
			
		||||
| 
						 | 
				
			
			@ -307,7 +309,7 @@ export function NavTabs({
 | 
			
		|||
            unreadStats={{
 | 
			
		||||
              unreadCount: unreadCallsCount,
 | 
			
		||||
              unreadMentionsCount: 0,
 | 
			
		||||
              markedUnread: false,
 | 
			
		||||
              readChatsMarkedUnreadCount: 0,
 | 
			
		||||
            }}
 | 
			
		||||
          />
 | 
			
		||||
          {storiesEnabled && (
 | 
			
		||||
| 
						 | 
				
			
			@ -321,7 +323,7 @@ export function NavTabs({
 | 
			
		|||
              unreadStats={{
 | 
			
		||||
                unreadCount: unreadStoriesCount,
 | 
			
		||||
                unreadMentionsCount: 0,
 | 
			
		||||
                markedUnread: false,
 | 
			
		||||
                readChatsMarkedUnreadCount: 0,
 | 
			
		||||
              }}
 | 
			
		||||
            />
 | 
			
		||||
          )}
 | 
			
		||||
| 
						 | 
				
			
			@ -331,11 +333,7 @@ export function NavTabs({
 | 
			
		|||
            label={i18n('icu:NavTabs__ItemLabel--Settings')}
 | 
			
		||||
            iconClassName="NavTabs__ItemIcon--Settings"
 | 
			
		||||
            navTabClassName="NavTabs__Item--Settings"
 | 
			
		||||
            unreadStats={{
 | 
			
		||||
              unreadCount: 0,
 | 
			
		||||
              unreadMentionsCount: 0,
 | 
			
		||||
              markedUnread: false,
 | 
			
		||||
            }}
 | 
			
		||||
            unreadStats={null}
 | 
			
		||||
            hasPendingUpdate={hasPendingUpdate}
 | 
			
		||||
          />
 | 
			
		||||
        </TabList>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -25,7 +25,8 @@ import {
 | 
			
		|||
  UsernameEditState,
 | 
			
		||||
  UsernameLinkState,
 | 
			
		||||
} from '../state/ducks/usernameEnums.js';
 | 
			
		||||
import { ProfileEditorPage, SettingsPage } from '../types/Nav.js';
 | 
			
		||||
import type { SettingsLocation } from '../types/Nav.js';
 | 
			
		||||
import { NavTab, ProfileEditorPage, SettingsPage } from '../types/Nav.js';
 | 
			
		||||
import { PreferencesDonations } from './PreferencesDonations.js';
 | 
			
		||||
import { strictAssert } from '../util/assert.js';
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -220,8 +221,8 @@ function renderProfileEditor({
 | 
			
		|||
 | 
			
		||||
function renderDonationsPane(props: {
 | 
			
		||||
  contentsRef: MutableRefObject<HTMLDivElement | null>;
 | 
			
		||||
  page: SettingsPage;
 | 
			
		||||
  setPage: (page: SettingsPage) => void;
 | 
			
		||||
  settingsLocation: SettingsLocation;
 | 
			
		||||
  setSettingsLocation: (settingsLocation: SettingsLocation) => void;
 | 
			
		||||
  me: typeof me;
 | 
			
		||||
  donationReceipts: ReadonlyArray<DonationReceipt>;
 | 
			
		||||
  saveAttachmentToDisk: (options: {
 | 
			
		||||
| 
						 | 
				
			
			@ -245,8 +246,8 @@ function renderDonationsPane(props: {
 | 
			
		|||
      initialCurrency="usd"
 | 
			
		||||
      resumeWorkflow={action('resumeWorkflow')}
 | 
			
		||||
      isOnline
 | 
			
		||||
      page={props.page}
 | 
			
		||||
      setPage={props.setPage}
 | 
			
		||||
      settingsLocation={props.settingsLocation}
 | 
			
		||||
      setSettingsLocation={props.setSettingsLocation}
 | 
			
		||||
      submitDonation={action('submitDonation')}
 | 
			
		||||
      lastError={undefined}
 | 
			
		||||
      workflow={props.workflow}
 | 
			
		||||
| 
						 | 
				
			
			@ -286,6 +287,8 @@ function renderPreferencesChatFoldersPage(
 | 
			
		|||
      chatFolders={[]}
 | 
			
		||||
      onOpenEditChatFoldersPage={props.onOpenEditChatFoldersPage}
 | 
			
		||||
      onCreateChatFolder={action('onCreateChatFolder')}
 | 
			
		||||
      onDeleteChatFolder={action('onDeletChatFolder')}
 | 
			
		||||
      onUpdateChatFoldersPositions={action('onUpdateChatFoldersPositions')}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -297,10 +300,14 @@ function renderPreferencesEditChatFolderPage(
 | 
			
		|||
    <PreferencesEditChatFolderPage
 | 
			
		||||
      i18n={i18n}
 | 
			
		||||
      theme={ThemeType.light}
 | 
			
		||||
      onBack={props.onBack}
 | 
			
		||||
      previousLocation={{
 | 
			
		||||
        tab: NavTab.Settings,
 | 
			
		||||
        details: { page: SettingsPage.ChatFolders },
 | 
			
		||||
      }}
 | 
			
		||||
      settingsPaneRef={props.settingsPaneRef}
 | 
			
		||||
      existingChatFolderId={props.existingChatFolderId}
 | 
			
		||||
      initChatFolderParams={CHAT_FOLDER_DEFAULTS}
 | 
			
		||||
      changeLocation={action('changeLocation')}
 | 
			
		||||
      onCreateChatFolder={action('onCreateChatFolder')}
 | 
			
		||||
      onUpdateChatFolder={action('onUpdateChatFolder')}
 | 
			
		||||
      onDeleteChatFolder={action('onDeleteChatFolder')}
 | 
			
		||||
| 
						 | 
				
			
			@ -403,9 +410,12 @@ export default {
 | 
			
		|||
    otherTabsUnreadStats: {
 | 
			
		||||
      unreadCount: 0,
 | 
			
		||||
      unreadMentionsCount: 0,
 | 
			
		||||
      markedUnread: false,
 | 
			
		||||
      readChatsMarkedUnreadCount: 0,
 | 
			
		||||
    },
 | 
			
		||||
    settingsLocation: {
 | 
			
		||||
      page: SettingsPage.Profile,
 | 
			
		||||
      state: ProfileEditorPage.None,
 | 
			
		||||
    },
 | 
			
		||||
    preferredSystemLocales: ['en'],
 | 
			
		||||
    preferredWidthFromStorage: 300,
 | 
			
		||||
    resolvedLocale: 'en',
 | 
			
		||||
| 
						 | 
				
			
			@ -424,17 +434,17 @@ export default {
 | 
			
		|||
 | 
			
		||||
    renderDonationsPane: ({
 | 
			
		||||
      contentsRef,
 | 
			
		||||
      page,
 | 
			
		||||
      setPage,
 | 
			
		||||
      settingsLocation,
 | 
			
		||||
      setSettingsLocation,
 | 
			
		||||
    }: {
 | 
			
		||||
      contentsRef: MutableRefObject<HTMLDivElement | null>;
 | 
			
		||||
      page: SettingsPage;
 | 
			
		||||
      setPage: (page: SettingsPage) => void;
 | 
			
		||||
      settingsLocation: SettingsLocation;
 | 
			
		||||
      setSettingsLocation: (settingsLocation: SettingsLocation) => void;
 | 
			
		||||
    }) =>
 | 
			
		||||
      renderDonationsPane({
 | 
			
		||||
        contentsRef,
 | 
			
		||||
        page,
 | 
			
		||||
        setPage,
 | 
			
		||||
        settingsLocation,
 | 
			
		||||
        setSettingsLocation,
 | 
			
		||||
        me,
 | 
			
		||||
        donationReceipts: [],
 | 
			
		||||
        saveAttachmentToDisk: async () => {
 | 
			
		||||
| 
						 | 
				
			
			@ -534,7 +544,7 @@ export default {
 | 
			
		|||
    setGlobalDefaultConversationColor: action(
 | 
			
		||||
      'setGlobalDefaultConversationColor'
 | 
			
		||||
    ),
 | 
			
		||||
    setPage: action('setPage'),
 | 
			
		||||
    setSettingsLocation: action('setSettingsLocation'),
 | 
			
		||||
    showToast: action('showToast'),
 | 
			
		||||
    validateBackup: async () => {
 | 
			
		||||
      return {
 | 
			
		||||
| 
						 | 
				
			
			@ -559,18 +569,17 @@ export default {
 | 
			
		|||
 | 
			
		||||
// eslint-disable-next-line react/function-component-definition
 | 
			
		||||
const Template: StoryFn<PropsType> = args => {
 | 
			
		||||
  const [page, setPage] = useState(args.page);
 | 
			
		||||
  const [settingsLocation, setSettingsLocation] = useState(
 | 
			
		||||
    args.settingsLocation
 | 
			
		||||
  );
 | 
			
		||||
  return (
 | 
			
		||||
    <Preferences
 | 
			
		||||
      {...args}
 | 
			
		||||
      page={page}
 | 
			
		||||
      setPage={(
 | 
			
		||||
        newPage: SettingsPage,
 | 
			
		||||
        profilePage: ProfileEditorPage | undefined
 | 
			
		||||
      ) => {
 | 
			
		||||
      settingsLocation={settingsLocation}
 | 
			
		||||
      setSettingsLocation={(newSettingsLocation: SettingsLocation) => {
 | 
			
		||||
        // eslint-disable-next-line no-console
 | 
			
		||||
        console.log('setPage:', newPage, profilePage);
 | 
			
		||||
        setPage(newPage);
 | 
			
		||||
        console.log('setSettingsLocation:', newSettingsLocation);
 | 
			
		||||
        setSettingsLocation(newSettingsLocation);
 | 
			
		||||
      }}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
| 
						 | 
				
			
			@ -580,62 +589,69 @@ export const _Preferences = Template.bind({});
 | 
			
		|||
 | 
			
		||||
export const General = Template.bind({});
 | 
			
		||||
General.args = {
 | 
			
		||||
  page: SettingsPage.General,
 | 
			
		||||
  settingsLocation: { page: SettingsPage.General },
 | 
			
		||||
};
 | 
			
		||||
export const Appearance = Template.bind({});
 | 
			
		||||
Appearance.args = {
 | 
			
		||||
  page: SettingsPage.Appearance,
 | 
			
		||||
  settingsLocation: { page: SettingsPage.Appearance },
 | 
			
		||||
};
 | 
			
		||||
export const Chats = Template.bind({});
 | 
			
		||||
Chats.args = {
 | 
			
		||||
  page: SettingsPage.Chats,
 | 
			
		||||
  settingsLocation: { page: SettingsPage.Chats },
 | 
			
		||||
};
 | 
			
		||||
export const ChatFolders = Template.bind({});
 | 
			
		||||
ChatFolders.args = {
 | 
			
		||||
  page: SettingsPage.ChatFolders,
 | 
			
		||||
  settingsLocation: { page: SettingsPage.ChatFolders },
 | 
			
		||||
};
 | 
			
		||||
export const EditChatFolder = Template.bind({});
 | 
			
		||||
EditChatFolder.args = {
 | 
			
		||||
  settingsLocation: {
 | 
			
		||||
    page: SettingsPage.EditChatFolder,
 | 
			
		||||
    chatFolderId: null,
 | 
			
		||||
    previousLocation: {
 | 
			
		||||
      tab: NavTab.Settings,
 | 
			
		||||
      details: { page: SettingsPage.ChatFolders },
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
export const Calls = Template.bind({});
 | 
			
		||||
Calls.args = {
 | 
			
		||||
  page: SettingsPage.Calls,
 | 
			
		||||
  settingsLocation: { page: SettingsPage.Calls },
 | 
			
		||||
};
 | 
			
		||||
export const Notifications = Template.bind({});
 | 
			
		||||
Notifications.args = {
 | 
			
		||||
  page: SettingsPage.Notifications,
 | 
			
		||||
  settingsLocation: { page: SettingsPage.Notifications },
 | 
			
		||||
};
 | 
			
		||||
export const Privacy = Template.bind({});
 | 
			
		||||
Privacy.args = {
 | 
			
		||||
  page: SettingsPage.Privacy,
 | 
			
		||||
  settingsLocation: { page: SettingsPage.Privacy },
 | 
			
		||||
};
 | 
			
		||||
export const DataUsage = Template.bind({});
 | 
			
		||||
DataUsage.args = {
 | 
			
		||||
  page: SettingsPage.DataUsage,
 | 
			
		||||
  settingsLocation: { page: SettingsPage.DataUsage },
 | 
			
		||||
};
 | 
			
		||||
export const Donations = Template.bind({});
 | 
			
		||||
Donations.args = {
 | 
			
		||||
  donationsFeatureEnabled: true,
 | 
			
		||||
  page: SettingsPage.Donations,
 | 
			
		||||
  settingsLocation: { page: SettingsPage.Donations },
 | 
			
		||||
};
 | 
			
		||||
export const DonationsDonateFlow = Template.bind({});
 | 
			
		||||
DonationsDonateFlow.args = {
 | 
			
		||||
  donationsFeatureEnabled: true,
 | 
			
		||||
  page: SettingsPage.DonationsDonateFlow,
 | 
			
		||||
  settingsLocation: { page: SettingsPage.DonationsDonateFlow },
 | 
			
		||||
  renderDonationsPane: ({
 | 
			
		||||
    contentsRef,
 | 
			
		||||
  }: {
 | 
			
		||||
    contentsRef: MutableRefObject<HTMLDivElement | null>;
 | 
			
		||||
    page: SettingsPage;
 | 
			
		||||
    setPage: (page: SettingsPage) => void;
 | 
			
		||||
    settingsLocation: SettingsLocation;
 | 
			
		||||
    setSettingsLocation: (settingsLocation: SettingsLocation) => void;
 | 
			
		||||
  }) =>
 | 
			
		||||
    renderDonationsPane({
 | 
			
		||||
      contentsRef,
 | 
			
		||||
      me,
 | 
			
		||||
      donationReceipts: [],
 | 
			
		||||
      page: SettingsPage.DonationsDonateFlow,
 | 
			
		||||
      setPage: action('setPage'),
 | 
			
		||||
      settingsLocation: { page: SettingsPage.DonationsDonateFlow },
 | 
			
		||||
      setSettingsLocation: action('setSettingsLocation'),
 | 
			
		||||
      saveAttachmentToDisk: async () => {
 | 
			
		||||
        action('saveAttachmentToDisk')();
 | 
			
		||||
        return { fullPath: '/mock/path/to/file.png', name: 'file.png' };
 | 
			
		||||
| 
						 | 
				
			
			@ -650,13 +666,13 @@ DonationsDonateFlow.args = {
 | 
			
		|||
export const DonationReceipts = Template.bind({});
 | 
			
		||||
DonationReceipts.args = {
 | 
			
		||||
  donationsFeatureEnabled: true,
 | 
			
		||||
  page: SettingsPage.DonationsDonateFlow,
 | 
			
		||||
  settingsLocation: { page: SettingsPage.DonationsDonateFlow },
 | 
			
		||||
  renderDonationsPane: ({
 | 
			
		||||
    contentsRef,
 | 
			
		||||
  }: {
 | 
			
		||||
    contentsRef: MutableRefObject<HTMLDivElement | null>;
 | 
			
		||||
    page: SettingsPage;
 | 
			
		||||
    setPage: (page: SettingsPage) => void;
 | 
			
		||||
    settingsLocation: SettingsLocation;
 | 
			
		||||
    setSettingsLocation: (settingsLocation: SettingsLocation) => void;
 | 
			
		||||
  }) =>
 | 
			
		||||
    renderDonationsPane({
 | 
			
		||||
      contentsRef,
 | 
			
		||||
| 
						 | 
				
			
			@ -675,8 +691,8 @@ DonationReceipts.args = {
 | 
			
		|||
          timestamp: 1753995255509,
 | 
			
		||||
        },
 | 
			
		||||
      ],
 | 
			
		||||
      page: SettingsPage.DonationsReceiptList,
 | 
			
		||||
      setPage: action('setPage'),
 | 
			
		||||
      settingsLocation: { page: SettingsPage.DonationsReceiptList },
 | 
			
		||||
      setSettingsLocation: action('setSettingsLocation'),
 | 
			
		||||
      saveAttachmentToDisk: async () => {
 | 
			
		||||
        action('saveAttachmentToDisk')();
 | 
			
		||||
        return { fullPath: '/mock/path/to/file.png', name: 'file.png' };
 | 
			
		||||
| 
						 | 
				
			
			@ -691,7 +707,7 @@ DonationReceipts.args = {
 | 
			
		|||
export const DonationsHomeWithInProgressDonation = Template.bind({});
 | 
			
		||||
DonationsHomeWithInProgressDonation.args = {
 | 
			
		||||
  donationsFeatureEnabled: true,
 | 
			
		||||
  page: SettingsPage.Donations,
 | 
			
		||||
  settingsLocation: { page: SettingsPage.Donations },
 | 
			
		||||
  renderDonationsPane: ({
 | 
			
		||||
    contentsRef,
 | 
			
		||||
  }: {
 | 
			
		||||
| 
						 | 
				
			
			@ -701,8 +717,8 @@ DonationsHomeWithInProgressDonation.args = {
 | 
			
		|||
      contentsRef,
 | 
			
		||||
      me,
 | 
			
		||||
      donationReceipts: [],
 | 
			
		||||
      page: SettingsPage.Donations,
 | 
			
		||||
      setPage: action('setPage'),
 | 
			
		||||
      settingsLocation: { page: SettingsPage.Donations },
 | 
			
		||||
      setSettingsLocation: action('setSettingsLocation'),
 | 
			
		||||
      saveAttachmentToDisk: async () => {
 | 
			
		||||
        action('saveAttachmentToDisk')();
 | 
			
		||||
        return { fullPath: '/mock/path/to/file.png', name: 'file.png' };
 | 
			
		||||
| 
						 | 
				
			
			@ -727,45 +743,45 @@ DonationsHomeWithInProgressDonation.args = {
 | 
			
		|||
};
 | 
			
		||||
export const Internal = Template.bind({});
 | 
			
		||||
Internal.args = {
 | 
			
		||||
  page: SettingsPage.Internal,
 | 
			
		||||
  settingsLocation: { page: SettingsPage.Internal },
 | 
			
		||||
  isInternalUser: true,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const Blocked1 = Template.bind({});
 | 
			
		||||
Blocked1.args = {
 | 
			
		||||
  blockedCount: 1,
 | 
			
		||||
  page: SettingsPage.Privacy,
 | 
			
		||||
  settingsLocation: { page: SettingsPage.Privacy },
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const BlockedMany = Template.bind({});
 | 
			
		||||
BlockedMany.args = {
 | 
			
		||||
  blockedCount: 55,
 | 
			
		||||
  page: SettingsPage.Privacy,
 | 
			
		||||
  settingsLocation: { page: SettingsPage.Privacy },
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const CustomUniversalExpireTimer = Template.bind({});
 | 
			
		||||
CustomUniversalExpireTimer.args = {
 | 
			
		||||
  universalExpireTimer: DurationInSeconds.fromSeconds(9000),
 | 
			
		||||
  page: SettingsPage.Privacy,
 | 
			
		||||
  settingsLocation: { page: SettingsPage.Privacy },
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const PNPSharingDisabled = Template.bind({});
 | 
			
		||||
PNPSharingDisabled.args = {
 | 
			
		||||
  whoCanSeeMe: PhoneNumberSharingMode.Nobody,
 | 
			
		||||
  whoCanFindMe: PhoneNumberDiscoverability.Discoverable,
 | 
			
		||||
  page: SettingsPage.PNP,
 | 
			
		||||
  settingsLocation: { page: SettingsPage.PNP },
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const PNPDiscoverabilityDisabled = Template.bind({});
 | 
			
		||||
PNPDiscoverabilityDisabled.args = {
 | 
			
		||||
  whoCanSeeMe: PhoneNumberSharingMode.Nobody,
 | 
			
		||||
  whoCanFindMe: PhoneNumberDiscoverability.NotDiscoverable,
 | 
			
		||||
  page: SettingsPage.PNP,
 | 
			
		||||
  settingsLocation: { page: SettingsPage.PNP },
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const BackupsMediaDownloadActive = Template.bind({});
 | 
			
		||||
BackupsMediaDownloadActive.args = {
 | 
			
		||||
  page: SettingsPage.BackupsDetails,
 | 
			
		||||
  settingsLocation: { page: SettingsPage.BackupsDetails },
 | 
			
		||||
  backupFeatureEnabled: true,
 | 
			
		||||
  backupLocalBackupsEnabled: true,
 | 
			
		||||
  cloudBackupStatus: {
 | 
			
		||||
| 
						 | 
				
			
			@ -789,7 +805,7 @@ BackupsMediaDownloadActive.args = {
 | 
			
		|||
};
 | 
			
		||||
export const BackupsMediaDownloadPaused = Template.bind({});
 | 
			
		||||
BackupsMediaDownloadPaused.args = {
 | 
			
		||||
  page: SettingsPage.BackupsDetails,
 | 
			
		||||
  settingsLocation: { page: SettingsPage.BackupsDetails },
 | 
			
		||||
  backupFeatureEnabled: true,
 | 
			
		||||
  backupLocalBackupsEnabled: true,
 | 
			
		||||
  cloudBackupStatus: {
 | 
			
		||||
| 
						 | 
				
			
			@ -814,7 +830,7 @@ BackupsMediaDownloadPaused.args = {
 | 
			
		|||
 | 
			
		||||
export const BackupsPaidActive = Template.bind({});
 | 
			
		||||
BackupsPaidActive.args = {
 | 
			
		||||
  page: SettingsPage.Backups,
 | 
			
		||||
  settingsLocation: { page: SettingsPage.Backups },
 | 
			
		||||
  backupFeatureEnabled: true,
 | 
			
		||||
  backupLocalBackupsEnabled: true,
 | 
			
		||||
  cloudBackupStatus: {
 | 
			
		||||
| 
						 | 
				
			
			@ -833,7 +849,7 @@ BackupsPaidActive.args = {
 | 
			
		|||
 | 
			
		||||
export const BackupsPaidCanceled = Template.bind({});
 | 
			
		||||
BackupsPaidCanceled.args = {
 | 
			
		||||
  page: SettingsPage.Backups,
 | 
			
		||||
  settingsLocation: { page: SettingsPage.Backups },
 | 
			
		||||
  backupFeatureEnabled: true,
 | 
			
		||||
  backupLocalBackupsEnabled: true,
 | 
			
		||||
  cloudBackupStatus: {
 | 
			
		||||
| 
						 | 
				
			
			@ -852,7 +868,7 @@ BackupsPaidCanceled.args = {
 | 
			
		|||
 | 
			
		||||
export const BackupsFree = Template.bind({});
 | 
			
		||||
BackupsFree.args = {
 | 
			
		||||
  page: SettingsPage.Backups,
 | 
			
		||||
  settingsLocation: { page: SettingsPage.Backups },
 | 
			
		||||
  backupFeatureEnabled: true,
 | 
			
		||||
  backupLocalBackupsEnabled: true,
 | 
			
		||||
  backupSubscriptionStatus: {
 | 
			
		||||
| 
						 | 
				
			
			@ -862,7 +878,7 @@ BackupsFree.args = {
 | 
			
		|||
};
 | 
			
		||||
export const BackupsFreeNoLocal = Template.bind({});
 | 
			
		||||
BackupsFreeNoLocal.args = {
 | 
			
		||||
  page: SettingsPage.Backups,
 | 
			
		||||
  settingsLocation: { page: SettingsPage.Backups },
 | 
			
		||||
  backupFeatureEnabled: true,
 | 
			
		||||
  backupLocalBackupsEnabled: false,
 | 
			
		||||
  backupSubscriptionStatus: {
 | 
			
		||||
| 
						 | 
				
			
			@ -873,28 +889,28 @@ BackupsFreeNoLocal.args = {
 | 
			
		|||
 | 
			
		||||
export const BackupsOff = Template.bind({});
 | 
			
		||||
BackupsOff.args = {
 | 
			
		||||
  page: SettingsPage.Backups,
 | 
			
		||||
  settingsLocation: { page: SettingsPage.Backups },
 | 
			
		||||
  backupFeatureEnabled: true,
 | 
			
		||||
  backupLocalBackupsEnabled: true,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const BackupsLocalBackups = Template.bind({});
 | 
			
		||||
BackupsLocalBackups.args = {
 | 
			
		||||
  page: SettingsPage.Backups,
 | 
			
		||||
  settingsLocation: { page: SettingsPage.Backups },
 | 
			
		||||
  backupFeatureEnabled: true,
 | 
			
		||||
  backupLocalBackupsEnabled: true,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const BackupsRemoteEnabledLocalDisabled = Template.bind({});
 | 
			
		||||
BackupsRemoteEnabledLocalDisabled.args = {
 | 
			
		||||
  page: SettingsPage.Backups,
 | 
			
		||||
  settingsLocation: { page: SettingsPage.Backups },
 | 
			
		||||
  backupFeatureEnabled: true,
 | 
			
		||||
  backupLocalBackupsEnabled: false,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const BackupsSubscriptionNotFound = Template.bind({});
 | 
			
		||||
BackupsSubscriptionNotFound.args = {
 | 
			
		||||
  page: SettingsPage.Backups,
 | 
			
		||||
  settingsLocation: { page: SettingsPage.Backups },
 | 
			
		||||
  backupFeatureEnabled: true,
 | 
			
		||||
  backupLocalBackupsEnabled: true,
 | 
			
		||||
  backupSubscriptionStatus: {
 | 
			
		||||
| 
						 | 
				
			
			@ -908,7 +924,7 @@ BackupsSubscriptionNotFound.args = {
 | 
			
		|||
 | 
			
		||||
export const BackupsSubscriptionExpired = Template.bind({});
 | 
			
		||||
BackupsSubscriptionExpired.args = {
 | 
			
		||||
  page: SettingsPage.Backups,
 | 
			
		||||
  settingsLocation: { page: SettingsPage.Backups },
 | 
			
		||||
  backupFeatureEnabled: true,
 | 
			
		||||
  backupLocalBackupsEnabled: true,
 | 
			
		||||
  backupSubscriptionStatus: {
 | 
			
		||||
| 
						 | 
				
			
			@ -918,7 +934,7 @@ BackupsSubscriptionExpired.args = {
 | 
			
		|||
 | 
			
		||||
export const LocalBackups = Template.bind({});
 | 
			
		||||
LocalBackups.args = {
 | 
			
		||||
  page: SettingsPage.LocalBackups,
 | 
			
		||||
  settingsLocation: { page: SettingsPage.LocalBackups },
 | 
			
		||||
  backupFeatureEnabled: true,
 | 
			
		||||
  backupLocalBackupsEnabled: true,
 | 
			
		||||
  backupKeyViewed: true,
 | 
			
		||||
| 
						 | 
				
			
			@ -927,14 +943,14 @@ LocalBackups.args = {
 | 
			
		|||
 | 
			
		||||
export const LocalBackupsSetupChooseFolder = Template.bind({});
 | 
			
		||||
LocalBackupsSetupChooseFolder.args = {
 | 
			
		||||
  page: SettingsPage.LocalBackupsSetupFolder,
 | 
			
		||||
  settingsLocation: { page: SettingsPage.LocalBackupsSetupFolder },
 | 
			
		||||
  backupFeatureEnabled: true,
 | 
			
		||||
  backupLocalBackupsEnabled: true,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const LocalBackupsSetupViewBackupKey = Template.bind({});
 | 
			
		||||
LocalBackupsSetupViewBackupKey.args = {
 | 
			
		||||
  page: SettingsPage.LocalBackupsSetupKey,
 | 
			
		||||
  settingsLocation: { page: SettingsPage.LocalBackupsSetupKey },
 | 
			
		||||
  backupFeatureEnabled: true,
 | 
			
		||||
  backupLocalBackupsEnabled: true,
 | 
			
		||||
  localBackupFolder: '/home/signaluser/Signal Backups/',
 | 
			
		||||
| 
						 | 
				
			
			@ -957,7 +973,7 @@ NavTabsCollapsedWithBadges.args = {
 | 
			
		|||
  otherTabsUnreadStats: {
 | 
			
		||||
    unreadCount: 1,
 | 
			
		||||
    unreadMentionsCount: 2,
 | 
			
		||||
    markedUnread: false,
 | 
			
		||||
    readChatsMarkedUnreadCount: 0,
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -968,7 +984,7 @@ NavTabsCollapsedWithExclamation.args = {
 | 
			
		|||
  otherTabsUnreadStats: {
 | 
			
		||||
    unreadCount: 1,
 | 
			
		||||
    unreadMentionsCount: 2,
 | 
			
		||||
    markedUnread: true,
 | 
			
		||||
    readChatsMarkedUnreadCount: 0,
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -54,7 +54,8 @@ import { PreferencesInternal } from './PreferencesInternal.js';
 | 
			
		|||
import { FunEmojiLocalizationProvider } from './fun/FunEmojiLocalizationProvider.js';
 | 
			
		||||
import { Avatar, AvatarSize } from './Avatar.js';
 | 
			
		||||
import { NavSidebar } from './NavSidebar.js';
 | 
			
		||||
import { SettingsPage, ProfileEditorPage } from '../types/Nav.js';
 | 
			
		||||
import type { SettingsLocation } from '../types/Nav.js';
 | 
			
		||||
import { SettingsPage, ProfileEditorPage, NavTab } from '../types/Nav.js';
 | 
			
		||||
 | 
			
		||||
import type { MediaDeviceSettings } from '../types/Calling.js';
 | 
			
		||||
import type { ValidationResultType as BackupValidationResultType } from '../services/backups/index.js';
 | 
			
		||||
| 
						 | 
				
			
			@ -149,7 +150,7 @@ export type PropsDataType = {
 | 
			
		|||
  hasTextFormatting: boolean;
 | 
			
		||||
  hasTypingIndicators: boolean;
 | 
			
		||||
  hasKeepMutedChatsArchived: boolean;
 | 
			
		||||
  page: SettingsPage;
 | 
			
		||||
  settingsLocation: SettingsLocation;
 | 
			
		||||
  lastSyncTime?: number;
 | 
			
		||||
  notificationContent: NotificationSettingType;
 | 
			
		||||
  phoneNumber: string | undefined;
 | 
			
		||||
| 
						 | 
				
			
			@ -204,8 +205,8 @@ type PropsFunctionType = {
 | 
			
		|||
  // Render props
 | 
			
		||||
  renderDonationsPane: (options: {
 | 
			
		||||
    contentsRef: MutableRefObject<HTMLDivElement | null>;
 | 
			
		||||
    page: SettingsPage;
 | 
			
		||||
    setPage: (page: SettingsPage, profilePage?: ProfileEditorPage) => void;
 | 
			
		||||
    settingsLocation: SettingsLocation;
 | 
			
		||||
    setSettingsLocation: (settingsLocation: SettingsLocation) => void;
 | 
			
		||||
  }) => JSX.Element;
 | 
			
		||||
  renderProfileEditor: (options: {
 | 
			
		||||
    contentsRef: MutableRefObject<HTMLDivElement | null>;
 | 
			
		||||
| 
						 | 
				
			
			@ -255,7 +256,7 @@ type PropsFunctionType = {
 | 
			
		|||
      value: CustomColorType;
 | 
			
		||||
    }
 | 
			
		||||
  ) => unknown;
 | 
			
		||||
  setPage: (page: SettingsPage, editState?: ProfileEditorPage) => unknown;
 | 
			
		||||
  setSettingsLocation: (settingsLocation: SettingsLocation) => unknown;
 | 
			
		||||
  showToast: (toast: AnyToast) => unknown;
 | 
			
		||||
  validateBackup: () => Promise<BackupValidationResultType>;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -472,7 +473,7 @@ export function Preferences({
 | 
			
		|||
  onWhoCanFindMeChange,
 | 
			
		||||
  onZoomFactorChange,
 | 
			
		||||
  otherTabsUnreadStats,
 | 
			
		||||
  page,
 | 
			
		||||
  settingsLocation,
 | 
			
		||||
  phoneNumber = '',
 | 
			
		||||
  pickLocalBackupFolder,
 | 
			
		||||
  preferredSystemLocales,
 | 
			
		||||
| 
						 | 
				
			
			@ -497,7 +498,7 @@ export function Preferences({
 | 
			
		|||
  selectedSpeaker,
 | 
			
		||||
  sentMediaQualitySetting,
 | 
			
		||||
  setGlobalDefaultConversationColor,
 | 
			
		||||
  setPage,
 | 
			
		||||
  setSettingsLocation,
 | 
			
		||||
  shouldShowUpdateDialog,
 | 
			
		||||
  showToast,
 | 
			
		||||
  localeOverride,
 | 
			
		||||
| 
						 | 
				
			
			@ -537,22 +538,20 @@ export function Preferences({
 | 
			
		|||
  const [confirmPnpNotDiscoverable, setConfirmPnpNoDiscoverable] =
 | 
			
		||||
    useState(false);
 | 
			
		||||
 | 
			
		||||
  const [editChatFolderPageId, setEditChatFolderPageId] =
 | 
			
		||||
    useState<ChatFolderId | null>(null);
 | 
			
		||||
 | 
			
		||||
  const handleOpenEditChatFoldersPage = useCallback(
 | 
			
		||||
    (chatFolderId: ChatFolderId | null) => {
 | 
			
		||||
      setPage(SettingsPage.EditChatFolder);
 | 
			
		||||
      setEditChatFolderPageId(chatFolderId);
 | 
			
		||||
      setSettingsLocation({
 | 
			
		||||
        page: SettingsPage.EditChatFolder,
 | 
			
		||||
        chatFolderId,
 | 
			
		||||
        previousLocation: {
 | 
			
		||||
          tab: NavTab.Settings,
 | 
			
		||||
          details: settingsLocation,
 | 
			
		||||
        },
 | 
			
		||||
    [setPage]
 | 
			
		||||
      });
 | 
			
		||||
    },
 | 
			
		||||
    [setSettingsLocation, settingsLocation]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const handleCloseEditChatFoldersPage = useCallback(() => {
 | 
			
		||||
    setPage(SettingsPage.ChatFolders);
 | 
			
		||||
    setEditChatFolderPageId(null);
 | 
			
		||||
  }, [setPage]);
 | 
			
		||||
 | 
			
		||||
  function closeLanguageDialog() {
 | 
			
		||||
    setLanguageDialog(null);
 | 
			
		||||
    setSelectedLanguageLocale(localeOverride);
 | 
			
		||||
| 
						 | 
				
			
			@ -560,14 +559,17 @@ export function Preferences({
 | 
			
		|||
  const shouldShowBackupsPage =
 | 
			
		||||
    backupFeatureEnabled || backupLocalBackupsEnabled;
 | 
			
		||||
 | 
			
		||||
  if (page === SettingsPage.Backups && !shouldShowBackupsPage) {
 | 
			
		||||
    setPage(SettingsPage.General);
 | 
			
		||||
  if (
 | 
			
		||||
    settingsLocation.page === SettingsPage.Backups &&
 | 
			
		||||
    !shouldShowBackupsPage
 | 
			
		||||
  ) {
 | 
			
		||||
    setSettingsLocation({ page: SettingsPage.General });
 | 
			
		||||
  }
 | 
			
		||||
  if (isDonationsPage(page) && !donationsFeatureEnabled) {
 | 
			
		||||
    setPage(SettingsPage.General);
 | 
			
		||||
  if (isDonationsPage(settingsLocation.page) && !donationsFeatureEnabled) {
 | 
			
		||||
    setSettingsLocation({ page: SettingsPage.General });
 | 
			
		||||
  }
 | 
			
		||||
  if (page === SettingsPage.Internal && !isInternalUser) {
 | 
			
		||||
    setPage(SettingsPage.General);
 | 
			
		||||
  if (settingsLocation.page === SettingsPage.Internal && !isInternalUser) {
 | 
			
		||||
    setSettingsLocation({ page: SettingsPage.General });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  let maybeUpdateDialog: JSX.Element | undefined;
 | 
			
		||||
| 
						 | 
				
			
			@ -625,7 +627,7 @@ export function Preferences({
 | 
			
		|||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    elements[0]?.focus();
 | 
			
		||||
  }, [page]);
 | 
			
		||||
  }, [settingsLocation.page]);
 | 
			
		||||
 | 
			
		||||
  const onAudioOutputSelectChange = useCallback(
 | 
			
		||||
    (value: string) => {
 | 
			
		||||
| 
						 | 
				
			
			@ -729,11 +731,11 @@ export function Preferences({
 | 
			
		|||
 | 
			
		||||
  let content: JSX.Element | undefined;
 | 
			
		||||
 | 
			
		||||
  if (page === SettingsPage.Profile) {
 | 
			
		||||
  if (settingsLocation.page === SettingsPage.Profile) {
 | 
			
		||||
    content = renderProfileEditor({
 | 
			
		||||
      contentsRef: settingsPaneRef,
 | 
			
		||||
    });
 | 
			
		||||
  } else if (page === SettingsPage.General) {
 | 
			
		||||
  } else if (settingsLocation.page === SettingsPage.General) {
 | 
			
		||||
    const pageContents = (
 | 
			
		||||
      <>
 | 
			
		||||
        <SettingsRow>
 | 
			
		||||
| 
						 | 
				
			
			@ -861,13 +863,13 @@ export function Preferences({
 | 
			
		|||
        title={i18n('icu:Preferences__button--general')}
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
  } else if (isDonationsPage(page)) {
 | 
			
		||||
  } else if (isDonationsPage(settingsLocation.page)) {
 | 
			
		||||
    content = renderDonationsPane({
 | 
			
		||||
      contentsRef: settingsPaneRef,
 | 
			
		||||
      page,
 | 
			
		||||
      setPage,
 | 
			
		||||
      settingsLocation,
 | 
			
		||||
      setSettingsLocation,
 | 
			
		||||
    });
 | 
			
		||||
  } else if (page === SettingsPage.Appearance) {
 | 
			
		||||
  } else if (settingsLocation.page === SettingsPage.Appearance) {
 | 
			
		||||
    let zoomFactors = DEFAULT_ZOOM_FACTORS;
 | 
			
		||||
 | 
			
		||||
    if (
 | 
			
		||||
| 
						 | 
				
			
			@ -1048,7 +1050,7 @@ export function Preferences({
 | 
			
		|||
          icon
 | 
			
		||||
          left={i18n('icu:showChatColorEditor')}
 | 
			
		||||
          onClick={() => {
 | 
			
		||||
            setPage(SettingsPage.ChatColor);
 | 
			
		||||
            setSettingsLocation({ page: SettingsPage.ChatColor });
 | 
			
		||||
          }}
 | 
			
		||||
          right={
 | 
			
		||||
            <div
 | 
			
		||||
| 
						 | 
				
			
			@ -1087,7 +1089,7 @@ export function Preferences({
 | 
			
		|||
        title={i18n('icu:Preferences__button--appearance')}
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
  } else if (page === SettingsPage.Chats) {
 | 
			
		||||
  } else if (settingsLocation.page === SettingsPage.Chats) {
 | 
			
		||||
    let spellCheckDirtyText: string | undefined;
 | 
			
		||||
    if (
 | 
			
		||||
      hasSpellCheck !== undefined &&
 | 
			
		||||
| 
						 | 
				
			
			@ -1188,7 +1190,9 @@ export function Preferences({
 | 
			
		|||
                </>
 | 
			
		||||
              }
 | 
			
		||||
              right={null}
 | 
			
		||||
              onClick={() => setPage(SettingsPage.ChatFolders)}
 | 
			
		||||
              onClick={() =>
 | 
			
		||||
                setSettingsLocation({ page: SettingsPage.ChatFolders })
 | 
			
		||||
              }
 | 
			
		||||
            />
 | 
			
		||||
          </SettingsRow>
 | 
			
		||||
        )}
 | 
			
		||||
| 
						 | 
				
			
			@ -1255,7 +1259,7 @@ export function Preferences({
 | 
			
		|||
        title={i18n('icu:Preferences__button--chats')}
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
  } else if (page === SettingsPage.Calls) {
 | 
			
		||||
  } else if (settingsLocation.page === SettingsPage.Calls) {
 | 
			
		||||
    const pageContents = (
 | 
			
		||||
      <>
 | 
			
		||||
        <SettingsRow title={i18n('icu:calling')}>
 | 
			
		||||
| 
						 | 
				
			
			@ -1404,7 +1408,7 @@ export function Preferences({
 | 
			
		|||
        title={i18n('icu:Preferences__button--calls')}
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
  } else if (page === SettingsPage.Notifications) {
 | 
			
		||||
  } else if (settingsLocation.page === SettingsPage.Notifications) {
 | 
			
		||||
    const pageContents = (
 | 
			
		||||
      <>
 | 
			
		||||
        <SettingsRow>
 | 
			
		||||
| 
						 | 
				
			
			@ -1492,7 +1496,7 @@ export function Preferences({
 | 
			
		|||
        title={i18n('icu:Preferences__button--notifications')}
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
  } else if (page === SettingsPage.Privacy) {
 | 
			
		||||
  } else if (settingsLocation.page === SettingsPage.Privacy) {
 | 
			
		||||
    const isCustomDisappearingMessageValue =
 | 
			
		||||
      !DEFAULT_DURATIONS_SET.has(universalExpireTimer);
 | 
			
		||||
    const pageContents = (
 | 
			
		||||
| 
						 | 
				
			
			@ -1519,7 +1523,7 @@ export function Preferences({
 | 
			
		|||
              )}
 | 
			
		||||
            >
 | 
			
		||||
              <Button
 | 
			
		||||
                onClick={() => setPage(SettingsPage.PNP)}
 | 
			
		||||
                onClick={() => setSettingsLocation({ page: SettingsPage.PNP })}
 | 
			
		||||
                variant={ButtonVariant.Secondary}
 | 
			
		||||
              >
 | 
			
		||||
                {i18n('icu:Preferences__pnp__row--button')}
 | 
			
		||||
| 
						 | 
				
			
			@ -1770,7 +1774,7 @@ export function Preferences({
 | 
			
		|||
        title={i18n('icu:Preferences__button--privacy')}
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
  } else if (page === SettingsPage.DataUsage) {
 | 
			
		||||
  } else if (settingsLocation.page === SettingsPage.DataUsage) {
 | 
			
		||||
    const pageContents = (
 | 
			
		||||
      <>
 | 
			
		||||
        <SettingsRow title={i18n('icu:Preferences__media-auto-download')}>
 | 
			
		||||
| 
						 | 
				
			
			@ -1882,12 +1886,12 @@ export function Preferences({
 | 
			
		|||
        title={i18n('icu:Preferences__button--data-usage')}
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
  } else if (page === SettingsPage.ChatColor) {
 | 
			
		||||
  } else if (settingsLocation.page === SettingsPage.ChatColor) {
 | 
			
		||||
    const backButton = (
 | 
			
		||||
      <button
 | 
			
		||||
        aria-label={i18n('icu:goBack')}
 | 
			
		||||
        className="Preferences__back-icon"
 | 
			
		||||
        onClick={() => setPage(SettingsPage.Appearance)}
 | 
			
		||||
        onClick={() => setSettingsLocation({ page: SettingsPage.Appearance })}
 | 
			
		||||
        type="button"
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
| 
						 | 
				
			
			@ -1918,19 +1922,19 @@ export function Preferences({
 | 
			
		|||
        title={i18n('icu:ChatColorPicker__menu-title')}
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
  } else if (page === SettingsPage.ChatFolders) {
 | 
			
		||||
  } else if (settingsLocation.page === SettingsPage.ChatFolders) {
 | 
			
		||||
    content = renderPreferencesChatFoldersPage({
 | 
			
		||||
      onBack: () => setPage(SettingsPage.Chats),
 | 
			
		||||
      onBack: () => setSettingsLocation({ page: SettingsPage.Chats }),
 | 
			
		||||
      onOpenEditChatFoldersPage: handleOpenEditChatFoldersPage,
 | 
			
		||||
      settingsPaneRef,
 | 
			
		||||
    });
 | 
			
		||||
  } else if (page === SettingsPage.EditChatFolder) {
 | 
			
		||||
  } else if (settingsLocation.page === SettingsPage.EditChatFolder) {
 | 
			
		||||
    content = renderPreferencesEditChatFolderPage({
 | 
			
		||||
      onBack: handleCloseEditChatFoldersPage,
 | 
			
		||||
      previousLocation: settingsLocation.previousLocation,
 | 
			
		||||
      settingsPaneRef,
 | 
			
		||||
      existingChatFolderId: editChatFolderPageId,
 | 
			
		||||
      existingChatFolderId: settingsLocation.chatFolderId,
 | 
			
		||||
    });
 | 
			
		||||
  } else if (page === SettingsPage.PNP) {
 | 
			
		||||
  } else if (settingsLocation.page === SettingsPage.PNP) {
 | 
			
		||||
    let sharingDescription: string;
 | 
			
		||||
 | 
			
		||||
    if (whoCanSeeMe === PhoneNumberSharingMode.Everybody) {
 | 
			
		||||
| 
						 | 
				
			
			@ -1951,7 +1955,7 @@ export function Preferences({
 | 
			
		|||
      <button
 | 
			
		||||
        aria-label={i18n('icu:goBack')}
 | 
			
		||||
        className="Preferences__back-icon"
 | 
			
		||||
        onClick={() => setPage(SettingsPage.Privacy)}
 | 
			
		||||
        onClick={() => setSettingsLocation({ page: SettingsPage.Privacy })}
 | 
			
		||||
        type="button"
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
| 
						 | 
				
			
			@ -2071,19 +2075,22 @@ export function Preferences({
 | 
			
		|||
        title={i18n('icu:Preferences__pnp--page-title')}
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
  } else if (isBackupPage(page)) {
 | 
			
		||||
  } else if (isBackupPage(settingsLocation.page)) {
 | 
			
		||||
    let pageTitle: string | undefined;
 | 
			
		||||
    if (page === SettingsPage.Backups || page === SettingsPage.BackupsDetails) {
 | 
			
		||||
    if (
 | 
			
		||||
      settingsLocation.page === SettingsPage.Backups ||
 | 
			
		||||
      settingsLocation.page === SettingsPage.BackupsDetails
 | 
			
		||||
    ) {
 | 
			
		||||
      pageTitle = i18n('icu:Preferences__button--backups');
 | 
			
		||||
    } else if (page === SettingsPage.LocalBackups) {
 | 
			
		||||
    } else if (settingsLocation.page === SettingsPage.LocalBackups) {
 | 
			
		||||
      pageTitle = i18n('icu:Preferences__local-backups');
 | 
			
		||||
    }
 | 
			
		||||
    // Local backups setup page titles intentionally left blank
 | 
			
		||||
 | 
			
		||||
    let backPage: PreferencesBackupPage | undefined;
 | 
			
		||||
    if (page === SettingsPage.LocalBackupsKeyReference) {
 | 
			
		||||
    if (settingsLocation.page === SettingsPage.LocalBackupsKeyReference) {
 | 
			
		||||
      backPage = SettingsPage.LocalBackups;
 | 
			
		||||
    } else if (page !== SettingsPage.Backups) {
 | 
			
		||||
    } else if (settingsLocation.page !== SettingsPage.Backups) {
 | 
			
		||||
      backPage = SettingsPage.Backups;
 | 
			
		||||
    }
 | 
			
		||||
    let backButton: JSX.Element | undefined;
 | 
			
		||||
| 
						 | 
				
			
			@ -2092,7 +2099,7 @@ export function Preferences({
 | 
			
		|||
        <button
 | 
			
		||||
          aria-label={i18n('icu:goBack')}
 | 
			
		||||
          className="Preferences__back-icon"
 | 
			
		||||
          onClick={() => setPage(backPage)}
 | 
			
		||||
          onClick={() => setSettingsLocation({ page: backPage })}
 | 
			
		||||
          type="button"
 | 
			
		||||
        />
 | 
			
		||||
      );
 | 
			
		||||
| 
						 | 
				
			
			@ -2114,11 +2121,11 @@ export function Preferences({
 | 
			
		|||
        localBackupFolder={localBackupFolder}
 | 
			
		||||
        onBackupKeyViewedChange={onBackupKeyViewedChange}
 | 
			
		||||
        pickLocalBackupFolder={pickLocalBackupFolder}
 | 
			
		||||
        page={page}
 | 
			
		||||
        settingsLocation={settingsLocation}
 | 
			
		||||
        promptOSAuth={promptOSAuth}
 | 
			
		||||
        refreshCloudBackupStatus={refreshCloudBackupStatus}
 | 
			
		||||
        refreshBackupSubscriptionStatus={refreshBackupSubscriptionStatus}
 | 
			
		||||
        setPage={setPage}
 | 
			
		||||
        setSettingsLocation={setSettingsLocation}
 | 
			
		||||
        showToast={showToast}
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
| 
						 | 
				
			
			@ -2130,7 +2137,7 @@ export function Preferences({
 | 
			
		|||
        title={pageTitle}
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
  } else if (page === SettingsPage.Internal) {
 | 
			
		||||
  } else if (settingsLocation.page === SettingsPage.Internal) {
 | 
			
		||||
    content = (
 | 
			
		||||
      <PreferencesContent
 | 
			
		||||
        contents={
 | 
			
		||||
| 
						 | 
				
			
			@ -2185,7 +2192,7 @@ export function Preferences({
 | 
			
		|||
                className={classNames({
 | 
			
		||||
                  'Preferences__profile-chip': true,
 | 
			
		||||
                  'Preferences__profile-chip--selected':
 | 
			
		||||
                    page === SettingsPage.Profile,
 | 
			
		||||
                    settingsLocation.page === SettingsPage.Profile,
 | 
			
		||||
                })}
 | 
			
		||||
              >
 | 
			
		||||
                <div className="Preferences__profile-chip__avatar">
 | 
			
		||||
| 
						 | 
				
			
			@ -2224,7 +2231,10 @@ export function Preferences({
 | 
			
		|||
                  className="Preferences__profile-chip__button"
 | 
			
		||||
                  aria-label={i18n('icu:ProfileEditor__open')}
 | 
			
		||||
                  onClick={() => {
 | 
			
		||||
                    setPage(SettingsPage.Profile);
 | 
			
		||||
                    setSettingsLocation({
 | 
			
		||||
                      page: SettingsPage.Profile,
 | 
			
		||||
                      state: ProfileEditorPage.None,
 | 
			
		||||
                    });
 | 
			
		||||
                  }}
 | 
			
		||||
                >
 | 
			
		||||
                  <span className="Preferences__profile-chip__screenreader-only">
 | 
			
		||||
| 
						 | 
				
			
			@ -2236,10 +2246,10 @@ export function Preferences({
 | 
			
		|||
                  className="Preferences__profile-chip__qr-icon-button"
 | 
			
		||||
                  aria-label={i18n('icu:ProfileEditor__username-link__open')}
 | 
			
		||||
                  onClick={() => {
 | 
			
		||||
                    setPage(
 | 
			
		||||
                      SettingsPage.Profile,
 | 
			
		||||
                      ProfileEditorPage.UsernameLink
 | 
			
		||||
                    );
 | 
			
		||||
                    setSettingsLocation({
 | 
			
		||||
                      page: SettingsPage.Profile,
 | 
			
		||||
                      state: ProfileEditorPage.UsernameLink,
 | 
			
		||||
                    });
 | 
			
		||||
                  }}
 | 
			
		||||
                >
 | 
			
		||||
                  <div className="Preferences__profile-chip__qr-icon" />
 | 
			
		||||
| 
						 | 
				
			
			@ -2251,9 +2261,11 @@ export function Preferences({
 | 
			
		|||
                  Preferences__button: true,
 | 
			
		||||
                  'Preferences__button--general': true,
 | 
			
		||||
                  'Preferences__button--selected':
 | 
			
		||||
                    page === SettingsPage.General,
 | 
			
		||||
                    settingsLocation.page === SettingsPage.General,
 | 
			
		||||
                })}
 | 
			
		||||
                onClick={() => setPage(SettingsPage.General)}
 | 
			
		||||
                onClick={() =>
 | 
			
		||||
                  setSettingsLocation({ page: SettingsPage.General })
 | 
			
		||||
                }
 | 
			
		||||
              >
 | 
			
		||||
                {i18n('icu:Preferences__button--general')}
 | 
			
		||||
              </button>
 | 
			
		||||
| 
						 | 
				
			
			@ -2263,10 +2275,12 @@ export function Preferences({
 | 
			
		|||
                  Preferences__button: true,
 | 
			
		||||
                  'Preferences__button--appearance': true,
 | 
			
		||||
                  'Preferences__button--selected':
 | 
			
		||||
                    page === SettingsPage.Appearance ||
 | 
			
		||||
                    page === SettingsPage.ChatColor,
 | 
			
		||||
                    settingsLocation.page === SettingsPage.Appearance ||
 | 
			
		||||
                    settingsLocation.page === SettingsPage.ChatColor,
 | 
			
		||||
                })}
 | 
			
		||||
                onClick={() => setPage(SettingsPage.Appearance)}
 | 
			
		||||
                onClick={() =>
 | 
			
		||||
                  setSettingsLocation({ page: SettingsPage.Appearance })
 | 
			
		||||
                }
 | 
			
		||||
              >
 | 
			
		||||
                {i18n('icu:Preferences__button--appearance')}
 | 
			
		||||
              </button>
 | 
			
		||||
| 
						 | 
				
			
			@ -2275,9 +2289,12 @@ export function Preferences({
 | 
			
		|||
                className={classNames({
 | 
			
		||||
                  Preferences__button: true,
 | 
			
		||||
                  'Preferences__button--chats': true,
 | 
			
		||||
                  'Preferences__button--selected': page === SettingsPage.Chats,
 | 
			
		||||
                  'Preferences__button--selected':
 | 
			
		||||
                    settingsLocation.page === SettingsPage.Chats,
 | 
			
		||||
                })}
 | 
			
		||||
                onClick={() => setPage(SettingsPage.Chats)}
 | 
			
		||||
                onClick={() =>
 | 
			
		||||
                  setSettingsLocation({ page: SettingsPage.Chats })
 | 
			
		||||
                }
 | 
			
		||||
              >
 | 
			
		||||
                {i18n('icu:Preferences__button--chats')}
 | 
			
		||||
              </button>
 | 
			
		||||
| 
						 | 
				
			
			@ -2286,9 +2303,12 @@ export function Preferences({
 | 
			
		|||
                className={classNames({
 | 
			
		||||
                  Preferences__button: true,
 | 
			
		||||
                  'Preferences__button--calls': true,
 | 
			
		||||
                  'Preferences__button--selected': page === SettingsPage.Calls,
 | 
			
		||||
                  'Preferences__button--selected':
 | 
			
		||||
                    settingsLocation.page === SettingsPage.Calls,
 | 
			
		||||
                })}
 | 
			
		||||
                onClick={() => setPage(SettingsPage.Calls)}
 | 
			
		||||
                onClick={() =>
 | 
			
		||||
                  setSettingsLocation({ page: SettingsPage.Calls })
 | 
			
		||||
                }
 | 
			
		||||
              >
 | 
			
		||||
                {i18n('icu:Preferences__button--calls')}
 | 
			
		||||
              </button>
 | 
			
		||||
| 
						 | 
				
			
			@ -2298,9 +2318,11 @@ export function Preferences({
 | 
			
		|||
                  Preferences__button: true,
 | 
			
		||||
                  'Preferences__button--notifications': true,
 | 
			
		||||
                  'Preferences__button--selected':
 | 
			
		||||
                    page === SettingsPage.Notifications,
 | 
			
		||||
                    settingsLocation.page === SettingsPage.Notifications,
 | 
			
		||||
                })}
 | 
			
		||||
                onClick={() => setPage(SettingsPage.Notifications)}
 | 
			
		||||
                onClick={() =>
 | 
			
		||||
                  setSettingsLocation({ page: SettingsPage.Notifications })
 | 
			
		||||
                }
 | 
			
		||||
              >
 | 
			
		||||
                {i18n('icu:Preferences__button--notifications')}
 | 
			
		||||
              </button>
 | 
			
		||||
| 
						 | 
				
			
			@ -2310,9 +2332,12 @@ export function Preferences({
 | 
			
		|||
                  Preferences__button: true,
 | 
			
		||||
                  'Preferences__button--privacy': true,
 | 
			
		||||
                  'Preferences__button--selected':
 | 
			
		||||
                    page === SettingsPage.Privacy || page === SettingsPage.PNP,
 | 
			
		||||
                    settingsLocation.page === SettingsPage.Privacy ||
 | 
			
		||||
                    settingsLocation.page === SettingsPage.PNP,
 | 
			
		||||
                })}
 | 
			
		||||
                onClick={() => setPage(SettingsPage.Privacy)}
 | 
			
		||||
                onClick={() =>
 | 
			
		||||
                  setSettingsLocation({ page: SettingsPage.Privacy })
 | 
			
		||||
                }
 | 
			
		||||
              >
 | 
			
		||||
                {i18n('icu:Preferences__button--privacy')}
 | 
			
		||||
              </button>
 | 
			
		||||
| 
						 | 
				
			
			@ -2322,9 +2347,11 @@ export function Preferences({
 | 
			
		|||
                  Preferences__button: true,
 | 
			
		||||
                  'Preferences__button--data-usage': true,
 | 
			
		||||
                  'Preferences__button--selected':
 | 
			
		||||
                    page === SettingsPage.DataUsage,
 | 
			
		||||
                    settingsLocation.page === SettingsPage.DataUsage,
 | 
			
		||||
                })}
 | 
			
		||||
                onClick={() => setPage(SettingsPage.DataUsage)}
 | 
			
		||||
                onClick={() =>
 | 
			
		||||
                  setSettingsLocation({ page: SettingsPage.DataUsage })
 | 
			
		||||
                }
 | 
			
		||||
              >
 | 
			
		||||
                {i18n('icu:Preferences__button--data-usage')}
 | 
			
		||||
              </button>
 | 
			
		||||
| 
						 | 
				
			
			@ -2334,9 +2361,13 @@ export function Preferences({
 | 
			
		|||
                  className={classNames({
 | 
			
		||||
                    Preferences__button: true,
 | 
			
		||||
                    'Preferences__button--backups': true,
 | 
			
		||||
                    'Preferences__button--selected': isBackupPage(page),
 | 
			
		||||
                    'Preferences__button--selected': isBackupPage(
 | 
			
		||||
                      settingsLocation.page
 | 
			
		||||
                    ),
 | 
			
		||||
                  })}
 | 
			
		||||
                  onClick={() => setPage(SettingsPage.Backups)}
 | 
			
		||||
                  onClick={() =>
 | 
			
		||||
                    setSettingsLocation({ page: SettingsPage.Backups })
 | 
			
		||||
                  }
 | 
			
		||||
                >
 | 
			
		||||
                  {i18n('icu:Preferences__button--backups')}
 | 
			
		||||
                </button>
 | 
			
		||||
| 
						 | 
				
			
			@ -2347,9 +2378,13 @@ export function Preferences({
 | 
			
		|||
                  className={classNames({
 | 
			
		||||
                    Preferences__button: true,
 | 
			
		||||
                    'Preferences__button--donations': true,
 | 
			
		||||
                    'Preferences__button--selected': isDonationsPage(page),
 | 
			
		||||
                    'Preferences__button--selected': isDonationsPage(
 | 
			
		||||
                      settingsLocation.page
 | 
			
		||||
                    ),
 | 
			
		||||
                  })}
 | 
			
		||||
                  onClick={() => setPage(SettingsPage.Donations)}
 | 
			
		||||
                  onClick={() =>
 | 
			
		||||
                    setSettingsLocation({ page: SettingsPage.Donations })
 | 
			
		||||
                  }
 | 
			
		||||
                >
 | 
			
		||||
                  {i18n('icu:Preferences__button--donate')}
 | 
			
		||||
                </button>
 | 
			
		||||
| 
						 | 
				
			
			@ -2361,9 +2396,11 @@ export function Preferences({
 | 
			
		|||
                    Preferences__button: true,
 | 
			
		||||
                    'Preferences__button--internal': true,
 | 
			
		||||
                    'Preferences__button--selected':
 | 
			
		||||
                      page === SettingsPage.Internal,
 | 
			
		||||
                      settingsLocation.page === SettingsPage.Internal,
 | 
			
		||||
                  })}
 | 
			
		||||
                  onClick={() => setPage(SettingsPage.Internal)}
 | 
			
		||||
                  onClick={() =>
 | 
			
		||||
                    setSettingsLocation({ page: SettingsPage.Internal })
 | 
			
		||||
                  }
 | 
			
		||||
                >
 | 
			
		||||
                  {i18n('icu:Preferences__button--internal')}
 | 
			
		||||
                </button>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -19,7 +19,7 @@ import {
 | 
			
		|||
} from './PreferencesUtil.js';
 | 
			
		||||
import { missingCaseError } from '../util/missingCaseError.js';
 | 
			
		||||
import { Button, ButtonVariant } from './Button.js';
 | 
			
		||||
import type { PreferencesBackupPage } from '../types/PreferencesBackupPage.js';
 | 
			
		||||
import type { SettingsLocation } from '../types/Nav.js';
 | 
			
		||||
import { SettingsPage } from '../types/Nav.js';
 | 
			
		||||
import { I18n } from './I18n.js';
 | 
			
		||||
import { PreferencesLocalBackups } from './PreferencesLocalBackups.js';
 | 
			
		||||
| 
						 | 
				
			
			@ -64,11 +64,11 @@ export function PreferencesBackups({
 | 
			
		|||
  cancelBackupMediaDownload,
 | 
			
		||||
  pauseBackupMediaDownload,
 | 
			
		||||
  resumeBackupMediaDownload,
 | 
			
		||||
  page,
 | 
			
		||||
  settingsLocation,
 | 
			
		||||
  promptOSAuth,
 | 
			
		||||
  refreshCloudBackupStatus,
 | 
			
		||||
  refreshBackupSubscriptionStatus,
 | 
			
		||||
  setPage,
 | 
			
		||||
  setSettingsLocation,
 | 
			
		||||
  showToast,
 | 
			
		||||
}: {
 | 
			
		||||
  accountEntropyPool: string | undefined;
 | 
			
		||||
| 
						 | 
				
			
			@ -81,7 +81,7 @@ export function PreferencesBackups({
 | 
			
		|||
  isRemoteBackupsEnabled: boolean;
 | 
			
		||||
  locale: string;
 | 
			
		||||
  onBackupKeyViewedChange: (keyViewed: boolean) => void;
 | 
			
		||||
  page: PreferencesBackupPage;
 | 
			
		||||
  settingsLocation: SettingsLocation;
 | 
			
		||||
  backupMediaDownloadStatus: BackupMediaDownloadStatusType | undefined;
 | 
			
		||||
  cancelBackupMediaDownload: () => void;
 | 
			
		||||
  pauseBackupMediaDownload: () => void;
 | 
			
		||||
| 
						 | 
				
			
			@ -92,7 +92,7 @@ export function PreferencesBackups({
 | 
			
		|||
  ) => Promise<PromptOSAuthResultType>;
 | 
			
		||||
  refreshCloudBackupStatus: () => void;
 | 
			
		||||
  refreshBackupSubscriptionStatus: () => void;
 | 
			
		||||
  setPage: (page: PreferencesBackupPage) => void;
 | 
			
		||||
  setSettingsLocation: (settingsLocation: SettingsLocation) => void;
 | 
			
		||||
  showToast: ShowToastAction;
 | 
			
		||||
}): JSX.Element | null {
 | 
			
		||||
  const [authError, setAuthError] =
 | 
			
		||||
| 
						 | 
				
			
			@ -100,27 +100,31 @@ export function PreferencesBackups({
 | 
			
		|||
  const [isAuthPending, setIsAuthPending] = useState<boolean>(false);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (page === SettingsPage.Backups) {
 | 
			
		||||
    if (settingsLocation.page === SettingsPage.Backups) {
 | 
			
		||||
      refreshBackupSubscriptionStatus();
 | 
			
		||||
    } else if (page === SettingsPage.BackupsDetails) {
 | 
			
		||||
    } else if (settingsLocation.page === SettingsPage.BackupsDetails) {
 | 
			
		||||
      refreshBackupSubscriptionStatus();
 | 
			
		||||
      refreshCloudBackupStatus();
 | 
			
		||||
    }
 | 
			
		||||
  }, [page, refreshBackupSubscriptionStatus, refreshCloudBackupStatus]);
 | 
			
		||||
  }, [
 | 
			
		||||
    settingsLocation.page,
 | 
			
		||||
    refreshBackupSubscriptionStatus,
 | 
			
		||||
    refreshCloudBackupStatus,
 | 
			
		||||
  ]);
 | 
			
		||||
 | 
			
		||||
  if (!isRemoteBackupsEnabled && isRemoteBackupsPage(page)) {
 | 
			
		||||
    setPage(SettingsPage.Backups);
 | 
			
		||||
  if (!isRemoteBackupsEnabled && isRemoteBackupsPage(settingsLocation.page)) {
 | 
			
		||||
    setSettingsLocation({ page: SettingsPage.Backups });
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (!isLocalBackupsEnabled && isLocalBackupsPage(page)) {
 | 
			
		||||
    setPage(SettingsPage.Backups);
 | 
			
		||||
  if (!isLocalBackupsEnabled && isLocalBackupsPage(settingsLocation.page)) {
 | 
			
		||||
    setSettingsLocation({ page: SettingsPage.Backups });
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (page === SettingsPage.BackupsDetails) {
 | 
			
		||||
  if (settingsLocation.page === SettingsPage.BackupsDetails) {
 | 
			
		||||
    if (backupSubscriptionStatus.status === 'off') {
 | 
			
		||||
      setPage(SettingsPage.Backups);
 | 
			
		||||
      setSettingsLocation({ page: SettingsPage.Backups });
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
    return (
 | 
			
		||||
| 
						 | 
				
			
			@ -137,7 +141,7 @@ export function PreferencesBackups({
 | 
			
		|||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (isLocalBackupsPage(page)) {
 | 
			
		||||
  if (isLocalBackupsPage(settingsLocation.page)) {
 | 
			
		||||
    return (
 | 
			
		||||
      <PreferencesLocalBackups
 | 
			
		||||
        accountEntropyPool={accountEntropyPool}
 | 
			
		||||
| 
						 | 
				
			
			@ -145,10 +149,10 @@ export function PreferencesBackups({
 | 
			
		|||
        i18n={i18n}
 | 
			
		||||
        localBackupFolder={localBackupFolder}
 | 
			
		||||
        onBackupKeyViewedChange={onBackupKeyViewedChange}
 | 
			
		||||
        page={page}
 | 
			
		||||
        settingsLocation={settingsLocation}
 | 
			
		||||
        pickLocalBackupFolder={pickLocalBackupFolder}
 | 
			
		||||
        promptOSAuth={promptOSAuth}
 | 
			
		||||
        setPage={setPage}
 | 
			
		||||
        setSettingsLocation={setSettingsLocation}
 | 
			
		||||
        showToast={showToast}
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
| 
						 | 
				
			
			@ -211,7 +215,9 @@ export function PreferencesBackups({
 | 
			
		|||
                )}
 | 
			
		||||
              >
 | 
			
		||||
                <Button
 | 
			
		||||
                  onClick={() => setPage(SettingsPage.BackupsDetails)}
 | 
			
		||||
                  onClick={() =>
 | 
			
		||||
                    setSettingsLocation({ page: SettingsPage.BackupsDetails })
 | 
			
		||||
                  }
 | 
			
		||||
                  variant={ButtonVariant.Secondary}
 | 
			
		||||
                >
 | 
			
		||||
                  {i18n('icu:Preferences__button--manage')}
 | 
			
		||||
| 
						 | 
				
			
			@ -270,7 +276,7 @@ export function PreferencesBackups({
 | 
			
		|||
                    }
 | 
			
		||||
                  }
 | 
			
		||||
 | 
			
		||||
                  setPage(SettingsPage.LocalBackups);
 | 
			
		||||
                  setSettingsLocation({ page: SettingsPage.LocalBackups });
 | 
			
		||||
                }}
 | 
			
		||||
                variant={ButtonVariant.Secondary}
 | 
			
		||||
              >
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -10,6 +10,7 @@ import { getDateTimeFormatter } from '../util/formatTimestamp.js';
 | 
			
		|||
 | 
			
		||||
import type { LocalizerType, ThemeType } from '../types/Util.js';
 | 
			
		||||
import { PreferencesContent } from './Preferences.js';
 | 
			
		||||
import type { SettingsLocation } from '../types/Nav.js';
 | 
			
		||||
import { SettingsPage } from '../types/Nav.js';
 | 
			
		||||
import { PreferencesDonateFlow } from './PreferencesDonateFlow.js';
 | 
			
		||||
import type {
 | 
			
		||||
| 
						 | 
				
			
			@ -67,7 +68,7 @@ export type PropsDataType = {
 | 
			
		|||
  i18n: LocalizerType;
 | 
			
		||||
  initialCurrency: string;
 | 
			
		||||
  isOnline: boolean;
 | 
			
		||||
  page: SettingsPage;
 | 
			
		||||
  settingsLocation: SettingsLocation;
 | 
			
		||||
  didResumeWorkflowAtStartup: boolean;
 | 
			
		||||
  lastError: DonationErrorType | undefined;
 | 
			
		||||
  workflow: DonationWorkflow | undefined;
 | 
			
		||||
| 
						 | 
				
			
			@ -106,7 +107,7 @@ type PropsActionType = {
 | 
			
		|||
  }) => void;
 | 
			
		||||
  clearWorkflow: () => void;
 | 
			
		||||
  resumeWorkflow: () => void;
 | 
			
		||||
  setPage: (page: SettingsPage) => void;
 | 
			
		||||
  setSettingsLocation: (settingsLocation: SettingsLocation) => void;
 | 
			
		||||
  showToast: (toast: AnyToast) => void;
 | 
			
		||||
  submitDonation: (payload: SubmitDonationType) => void;
 | 
			
		||||
  updateLastError: (error: DonationErrorType | undefined) => void;
 | 
			
		||||
| 
						 | 
				
			
			@ -123,12 +124,11 @@ type PreferencesHomeProps = Pick<
 | 
			
		|||
  PropsType,
 | 
			
		||||
  | 'contentsRef'
 | 
			
		||||
  | 'i18n'
 | 
			
		||||
  | 'setPage'
 | 
			
		||||
  | 'setSettingsLocation'
 | 
			
		||||
  | 'isOnline'
 | 
			
		||||
  | 'donationReceipts'
 | 
			
		||||
  | 'workflow'
 | 
			
		||||
> & {
 | 
			
		||||
  navigateToPage: (newPage: SettingsPage) => void;
 | 
			
		||||
  renderDonationHero: () => JSX.Element;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -205,8 +205,7 @@ function DonationHero({
 | 
			
		|||
function DonationsHome({
 | 
			
		||||
  i18n,
 | 
			
		||||
  renderDonationHero,
 | 
			
		||||
  navigateToPage,
 | 
			
		||||
  setPage,
 | 
			
		||||
  setSettingsLocation,
 | 
			
		||||
  isOnline,
 | 
			
		||||
  donationReceipts,
 | 
			
		||||
  workflow,
 | 
			
		||||
| 
						 | 
				
			
			@ -224,9 +223,9 @@ function DonationsHome({
 | 
			
		|||
    if (inProgressDonationAmount) {
 | 
			
		||||
      setIsInProgressVisible(true);
 | 
			
		||||
    } else {
 | 
			
		||||
      setPage(SettingsPage.DonationsDonateFlow);
 | 
			
		||||
      setSettingsLocation({ page: SettingsPage.DonationsDonateFlow });
 | 
			
		||||
    }
 | 
			
		||||
  }, [inProgressDonationAmount, setPage]);
 | 
			
		||||
  }, [inProgressDonationAmount, setSettingsLocation]);
 | 
			
		||||
 | 
			
		||||
  const handleInProgressDonationClicked = useCallback(() => {
 | 
			
		||||
    setIsInProgressVisible(true);
 | 
			
		||||
| 
						 | 
				
			
			@ -299,7 +298,7 @@ function DonationsHome({
 | 
			
		|||
          <ListBoxItem
 | 
			
		||||
            className="PreferencesDonations__list-item"
 | 
			
		||||
            onAction={() => {
 | 
			
		||||
              navigateToPage(SettingsPage.DonationsReceiptList);
 | 
			
		||||
              setSettingsLocation({ page: SettingsPage.DonationsReceiptList });
 | 
			
		||||
            }}
 | 
			
		||||
          >
 | 
			
		||||
            <span className="PreferencesDonations__list-item__icon PreferencesDonations__list-item__icon--receipts" />
 | 
			
		||||
| 
						 | 
				
			
			@ -542,14 +541,14 @@ export function PreferencesDonations({
 | 
			
		|||
  i18n,
 | 
			
		||||
  initialCurrency,
 | 
			
		||||
  isOnline,
 | 
			
		||||
  page,
 | 
			
		||||
  settingsLocation,
 | 
			
		||||
  workflow,
 | 
			
		||||
  didResumeWorkflowAtStartup,
 | 
			
		||||
  lastError,
 | 
			
		||||
  applyDonationBadge,
 | 
			
		||||
  clearWorkflow,
 | 
			
		||||
  resumeWorkflow,
 | 
			
		||||
  setPage,
 | 
			
		||||
  setSettingsLocation,
 | 
			
		||||
  submitDonation,
 | 
			
		||||
  badge,
 | 
			
		||||
  color,
 | 
			
		||||
| 
						 | 
				
			
			@ -575,19 +574,12 @@ export function PreferencesDonations({
 | 
			
		|||
  useEffect(() => {
 | 
			
		||||
    if (
 | 
			
		||||
      workflow?.type === donationStateSchema.Enum.DONE &&
 | 
			
		||||
      page === SettingsPage.Donations &&
 | 
			
		||||
      settingsLocation.page === SettingsPage.Donations &&
 | 
			
		||||
      !donationBadge
 | 
			
		||||
    ) {
 | 
			
		||||
      drop(fetchBadgeData());
 | 
			
		||||
    }
 | 
			
		||||
  }, [workflow, page, donationBadge, fetchBadgeData]);
 | 
			
		||||
 | 
			
		||||
  const navigateToPage = useCallback(
 | 
			
		||||
    (newPage: SettingsPage) => {
 | 
			
		||||
      setPage(newPage);
 | 
			
		||||
    },
 | 
			
		||||
    [setPage]
 | 
			
		||||
  );
 | 
			
		||||
  }, [workflow, settingsLocation.page, donationBadge, fetchBadgeData]);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (lastError) {
 | 
			
		||||
| 
						 | 
				
			
			@ -618,7 +610,7 @@ export function PreferencesDonations({
 | 
			
		|||
    [badge, color, firstName, i18n, profileAvatarUrl, theme]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  if (!isDonationPage(page)) {
 | 
			
		||||
  if (!isDonationPage(settingsLocation.page)) {
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -649,7 +641,7 @@ export function PreferencesDonations({
 | 
			
		|||
        i18n={i18n}
 | 
			
		||||
        onCancelDonation={() => {
 | 
			
		||||
          clearWorkflow();
 | 
			
		||||
          setPage(SettingsPage.Donations);
 | 
			
		||||
          setSettingsLocation({ page: SettingsPage.Donations });
 | 
			
		||||
          showToast({ toastType: ToastType.DonationCanceled });
 | 
			
		||||
        }}
 | 
			
		||||
        onRetryDonation={() => {
 | 
			
		||||
| 
						 | 
				
			
			@ -663,7 +655,7 @@ export function PreferencesDonations({
 | 
			
		|||
        i18n={i18n}
 | 
			
		||||
        onCancelDonation={() => {
 | 
			
		||||
          clearWorkflow();
 | 
			
		||||
          setPage(SettingsPage.Donations);
 | 
			
		||||
          setSettingsLocation({ page: SettingsPage.Donations });
 | 
			
		||||
          showToast({ toastType: ToastType.DonationCanceled });
 | 
			
		||||
        }}
 | 
			
		||||
        onOpenBrowser={() => {
 | 
			
		||||
| 
						 | 
				
			
			@ -672,7 +664,7 @@ export function PreferencesDonations({
 | 
			
		|||
        onTimedOut={() => {
 | 
			
		||||
          clearWorkflow();
 | 
			
		||||
          updateLastError(donationErrorTypeSchema.Enum.TimedOut);
 | 
			
		||||
          setPage(SettingsPage.Donations);
 | 
			
		||||
          setSettingsLocation({ page: SettingsPage.Donations });
 | 
			
		||||
        }}
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
| 
						 | 
				
			
			@ -695,7 +687,7 @@ export function PreferencesDonations({
 | 
			
		|||
      />
 | 
			
		||||
    );
 | 
			
		||||
  } else if (
 | 
			
		||||
    page === SettingsPage.DonationsDonateFlow &&
 | 
			
		||||
    settingsLocation.page === SettingsPage.DonationsDonateFlow &&
 | 
			
		||||
    (isSubmitted ||
 | 
			
		||||
      workflow?.type === donationStateSchema.Enum.INTENT_CONFIRMED ||
 | 
			
		||||
      workflow?.type === donationStateSchema.Enum.RECEIPT)
 | 
			
		||||
| 
						 | 
				
			
			@ -711,7 +703,7 @@ export function PreferencesDonations({
 | 
			
		|||
        <DonationStillProcessingModal
 | 
			
		||||
          i18n={i18n}
 | 
			
		||||
          onClose={() => {
 | 
			
		||||
            setPage(SettingsPage.Donations);
 | 
			
		||||
            setSettingsLocation({ page: SettingsPage.Donations });
 | 
			
		||||
            // We need to delay until we've transitioned away from this page, or we'll
 | 
			
		||||
            // go back to showing the spinner.
 | 
			
		||||
            setTimeout(() => setHasProcessingExpired(false), 500);
 | 
			
		||||
| 
						 | 
				
			
			@ -736,7 +728,7 @@ export function PreferencesDonations({
 | 
			
		|||
  ) : null;
 | 
			
		||||
 | 
			
		||||
  let content;
 | 
			
		||||
  if (page === SettingsPage.DonationsDonateFlow) {
 | 
			
		||||
  if (settingsLocation.page === SettingsPage.DonationsDonateFlow) {
 | 
			
		||||
    // DonateFlow has to control Back button to switch between CC form and Amount picker
 | 
			
		||||
    return (
 | 
			
		||||
      <>
 | 
			
		||||
| 
						 | 
				
			
			@ -758,25 +750,24 @@ export function PreferencesDonations({
 | 
			
		|||
            submitDonation(details);
 | 
			
		||||
          }}
 | 
			
		||||
          showPrivacyModal={() => setIsPrivacyModalVisible(true)}
 | 
			
		||||
          onBack={() => setPage(SettingsPage.Donations)}
 | 
			
		||||
          onBack={() => setSettingsLocation({ page: SettingsPage.Donations })}
 | 
			
		||||
        />
 | 
			
		||||
      </>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
  if (page === SettingsPage.Donations) {
 | 
			
		||||
  if (settingsLocation.page === SettingsPage.Donations) {
 | 
			
		||||
    content = (
 | 
			
		||||
      <DonationsHome
 | 
			
		||||
        contentsRef={contentsRef}
 | 
			
		||||
        i18n={i18n}
 | 
			
		||||
        isOnline={isOnline}
 | 
			
		||||
        navigateToPage={navigateToPage}
 | 
			
		||||
        donationReceipts={donationReceipts}
 | 
			
		||||
        renderDonationHero={renderDonationHero}
 | 
			
		||||
        setPage={setPage}
 | 
			
		||||
        setSettingsLocation={setSettingsLocation}
 | 
			
		||||
        workflow={workflow}
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
  } else if (page === SettingsPage.DonationsReceiptList) {
 | 
			
		||||
  } else if (settingsLocation.page === SettingsPage.DonationsReceiptList) {
 | 
			
		||||
    content = (
 | 
			
		||||
      <PreferencesReceiptList
 | 
			
		||||
        i18n={i18n}
 | 
			
		||||
| 
						 | 
				
			
			@ -790,15 +781,15 @@ export function PreferencesDonations({
 | 
			
		|||
 | 
			
		||||
  let title: string | undefined;
 | 
			
		||||
  let backButton: JSX.Element | undefined;
 | 
			
		||||
  if (page === SettingsPage.Donations) {
 | 
			
		||||
  if (settingsLocation.page === SettingsPage.Donations) {
 | 
			
		||||
    title = i18n('icu:Preferences__DonateTitle');
 | 
			
		||||
  } else if (page === SettingsPage.DonationsReceiptList) {
 | 
			
		||||
  } else if (settingsLocation.page === SettingsPage.DonationsReceiptList) {
 | 
			
		||||
    title = i18n('icu:PreferencesDonations__receipts');
 | 
			
		||||
    backButton = (
 | 
			
		||||
      <button
 | 
			
		||||
        aria-label={i18n('icu:goBack')}
 | 
			
		||||
        className="Preferences__back-icon"
 | 
			
		||||
        onClick={() => setPage(SettingsPage.Donations)}
 | 
			
		||||
        onClick={() => setSettingsLocation({ page: SettingsPage.Donations })}
 | 
			
		||||
        type="button"
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -23,7 +23,7 @@ import {
 | 
			
		|||
  SIGNAL_BACKUPS_LEARN_MORE_URL,
 | 
			
		||||
} from './PreferencesBackups.js';
 | 
			
		||||
import { I18n } from './I18n.js';
 | 
			
		||||
import type { PreferencesBackupPage } from '../types/PreferencesBackupPage.js';
 | 
			
		||||
import type { SettingsLocation } from '../types/Nav.js';
 | 
			
		||||
import { SettingsPage } from '../types/Nav.js';
 | 
			
		||||
import { ToastType } from '../types/Toast.js';
 | 
			
		||||
import type { ShowToastAction } from '../state/ducks/toast.js';
 | 
			
		||||
| 
						 | 
				
			
			@ -43,10 +43,10 @@ export function PreferencesLocalBackups({
 | 
			
		|||
  i18n,
 | 
			
		||||
  localBackupFolder,
 | 
			
		||||
  onBackupKeyViewedChange,
 | 
			
		||||
  page,
 | 
			
		||||
  settingsLocation,
 | 
			
		||||
  pickLocalBackupFolder,
 | 
			
		||||
  promptOSAuth,
 | 
			
		||||
  setPage,
 | 
			
		||||
  setSettingsLocation,
 | 
			
		||||
  showToast,
 | 
			
		||||
}: {
 | 
			
		||||
  accountEntropyPool: string | undefined;
 | 
			
		||||
| 
						 | 
				
			
			@ -54,12 +54,12 @@ export function PreferencesLocalBackups({
 | 
			
		|||
  i18n: LocalizerType;
 | 
			
		||||
  localBackupFolder: string | undefined;
 | 
			
		||||
  onBackupKeyViewedChange: (keyViewed: boolean) => void;
 | 
			
		||||
  page: PreferencesBackupPage;
 | 
			
		||||
  settingsLocation: SettingsLocation;
 | 
			
		||||
  pickLocalBackupFolder: () => Promise<string | undefined>;
 | 
			
		||||
  promptOSAuth: (
 | 
			
		||||
    reason: PromptOSAuthReasonType
 | 
			
		||||
  ) => Promise<PromptOSAuthResultType>;
 | 
			
		||||
  setPage: (page: PreferencesBackupPage) => void;
 | 
			
		||||
  setSettingsLocation: (settingsLocation: SettingsLocation) => void;
 | 
			
		||||
  showToast: ShowToastAction;
 | 
			
		||||
}): JSX.Element {
 | 
			
		||||
  const [authError, setAuthError] =
 | 
			
		||||
| 
						 | 
				
			
			@ -75,7 +75,8 @@ export function PreferencesLocalBackups({
 | 
			
		|||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const isReferencingBackupKey = page === SettingsPage.LocalBackupsKeyReference;
 | 
			
		||||
  const isReferencingBackupKey =
 | 
			
		||||
    settingsLocation.page === SettingsPage.LocalBackupsKeyReference;
 | 
			
		||||
  if (!backupKeyViewed || isReferencingBackupKey) {
 | 
			
		||||
    strictAssert(accountEntropyPool, 'AEP is required for backup key viewer');
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -86,7 +87,9 @@ export function PreferencesLocalBackups({
 | 
			
		|||
        isReferencing={isReferencingBackupKey}
 | 
			
		||||
        onBackupKeyViewed={() => {
 | 
			
		||||
          if (backupKeyViewed) {
 | 
			
		||||
            setPage(SettingsPage.LocalBackups);
 | 
			
		||||
            setSettingsLocation({
 | 
			
		||||
              page: SettingsPage.LocalBackups,
 | 
			
		||||
            });
 | 
			
		||||
          } else {
 | 
			
		||||
            onBackupKeyViewedChange(true);
 | 
			
		||||
          }
 | 
			
		||||
| 
						 | 
				
			
			@ -160,7 +163,9 @@ export function PreferencesLocalBackups({
 | 
			
		|||
                  setIsAuthPending(true);
 | 
			
		||||
                  const result = await promptOSAuth('view-aep');
 | 
			
		||||
                  if (result === 'success' || result === 'unsupported') {
 | 
			
		||||
                    setPage(SettingsPage.LocalBackupsKeyReference);
 | 
			
		||||
                    setSettingsLocation({
 | 
			
		||||
                      page: SettingsPage.LocalBackupsKeyReference,
 | 
			
		||||
                    });
 | 
			
		||||
                  } else {
 | 
			
		||||
                    setAuthError(result);
 | 
			
		||||
                  }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -38,8 +38,8 @@ import {
 | 
			
		|||
  MessageRequestState,
 | 
			
		||||
} from './MessageRequestActionsConfirmation.js';
 | 
			
		||||
import type { MinimalConversation } from '../../hooks/useMinimalConversation.js';
 | 
			
		||||
import { LocalDeleteWarningModal } from '../LocalDeleteWarningModal.js';
 | 
			
		||||
import { InAnotherCallTooltip } from './InAnotherCallTooltip.js';
 | 
			
		||||
import { DeleteMessagesConfirmationDialog } from '../DeleteMessagesConfirmationDialog.js';
 | 
			
		||||
 | 
			
		||||
function HeaderInfoTitle({
 | 
			
		||||
  name,
 | 
			
		||||
| 
						 | 
				
			
			@ -1003,50 +1003,3 @@ function CannotLeaveGroupBecauseYouAreLastAdminAlert({
 | 
			
		|||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DeleteMessagesConfirmationDialog({
 | 
			
		||||
  i18n,
 | 
			
		||||
  localDeleteWarningShown,
 | 
			
		||||
  onDestroyMessages,
 | 
			
		||||
  onClose,
 | 
			
		||||
  setLocalDeleteWarningShown,
 | 
			
		||||
}: {
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
  localDeleteWarningShown: boolean;
 | 
			
		||||
  onDestroyMessages: () => void;
 | 
			
		||||
  onClose: () => void;
 | 
			
		||||
  setLocalDeleteWarningShown: () => void;
 | 
			
		||||
}) {
 | 
			
		||||
  if (!localDeleteWarningShown) {
 | 
			
		||||
    return (
 | 
			
		||||
      <LocalDeleteWarningModal
 | 
			
		||||
        i18n={i18n}
 | 
			
		||||
        onClose={setLocalDeleteWarningShown}
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const dialogBody = i18n(
 | 
			
		||||
    'icu:ConversationHeader__DeleteConversationConfirmation__description-with-sync'
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <ConfirmationDialog
 | 
			
		||||
      dialogName="ConversationHeader.destroyMessages"
 | 
			
		||||
      title={i18n(
 | 
			
		||||
        'icu:ConversationHeader__DeleteConversationConfirmation__title'
 | 
			
		||||
      )}
 | 
			
		||||
      actions={[
 | 
			
		||||
        {
 | 
			
		||||
          action: onDestroyMessages,
 | 
			
		||||
          style: 'negative',
 | 
			
		||||
          text: i18n('icu:delete'),
 | 
			
		||||
        },
 | 
			
		||||
      ]}
 | 
			
		||||
      i18n={i18n}
 | 
			
		||||
      onClose={onClose}
 | 
			
		||||
    >
 | 
			
		||||
      {dialogBody}
 | 
			
		||||
    </ConfirmationDialog>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -209,13 +209,13 @@ export function PollMessageContents({
 | 
			
		|||
 | 
			
		||||
      {totalVotes > 0 ? (
 | 
			
		||||
        <div className={tw('mt-4 flex justify-center scheme-light')}>
 | 
			
		||||
          <AxoButton
 | 
			
		||||
          <AxoButton.Root
 | 
			
		||||
            size="medium"
 | 
			
		||||
            variant="floating-secondary"
 | 
			
		||||
            onClick={() => setShowVotesModal(true)}
 | 
			
		||||
          >
 | 
			
		||||
            {i18n('icu:PollMessage__ViewVotesButton')}
 | 
			
		||||
          </AxoButton>
 | 
			
		||||
          </AxoButton.Root>
 | 
			
		||||
        </div>
 | 
			
		||||
      ) : (
 | 
			
		||||
        <div
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -34,6 +34,11 @@ const CHECKBOX_CONTAINER_CLASS_NAME = `${BASE_CLASS_NAME}__checkbox--container`;
 | 
			
		|||
const CHECKBOX_CLASS_NAME = `${BASE_CLASS_NAME}__checkbox`;
 | 
			
		||||
export const SPINNER_CLASS_NAME = `${BASE_CLASS_NAME}__spinner`;
 | 
			
		||||
 | 
			
		||||
export type RenderConversationListItemContextMenuProps = Readonly<{
 | 
			
		||||
  conversationId: string;
 | 
			
		||||
  children: ReactNode;
 | 
			
		||||
}>;
 | 
			
		||||
 | 
			
		||||
type PropsType = {
 | 
			
		||||
  buttonAriaLabel?: string;
 | 
			
		||||
  checked?: boolean;
 | 
			
		||||
| 
						 | 
				
			
			@ -58,6 +63,9 @@ type PropsType = {
 | 
			
		|||
  unreadMentionsCount?: number;
 | 
			
		||||
  avatarSize?: AvatarSize;
 | 
			
		||||
  testId?: string;
 | 
			
		||||
  renderConversationListItemContextMenu?: (
 | 
			
		||||
    props: RenderConversationListItemContextMenuProps
 | 
			
		||||
  ) => JSX.Element;
 | 
			
		||||
} & Pick<
 | 
			
		||||
  ConversationType,
 | 
			
		||||
  | 'avatarPlaceholderGradient'
 | 
			
		||||
| 
						 | 
				
			
			@ -114,6 +122,7 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
 | 
			
		|||
      unreadCount,
 | 
			
		||||
      unreadMentionsCount,
 | 
			
		||||
      serviceId,
 | 
			
		||||
      renderConversationListItemContextMenu,
 | 
			
		||||
    } = props;
 | 
			
		||||
 | 
			
		||||
    const identifier = id ? cleanId(id) : undefined;
 | 
			
		||||
| 
						 | 
				
			
			@ -275,8 +284,10 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
 | 
			
		|||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let wrapper: JSX.Element;
 | 
			
		||||
 | 
			
		||||
    if (onClick) {
 | 
			
		||||
      return (
 | 
			
		||||
      wrapper = (
 | 
			
		||||
        <button
 | 
			
		||||
          aria-label={
 | 
			
		||||
            buttonAriaLabel ||
 | 
			
		||||
| 
						 | 
				
			
			@ -298,9 +309,8 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
 | 
			
		|||
          {contents}
 | 
			
		||||
        </button>
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
    } else {
 | 
			
		||||
      wrapper = (
 | 
			
		||||
        <div
 | 
			
		||||
          className={commonClassNames}
 | 
			
		||||
          data-id={identifier}
 | 
			
		||||
| 
						 | 
				
			
			@ -309,6 +319,16 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
 | 
			
		|||
          {contents}
 | 
			
		||||
        </div>
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (renderConversationListItemContextMenu != null && id != null) {
 | 
			
		||||
      return renderConversationListItemContextMenu({
 | 
			
		||||
        conversationId: id,
 | 
			
		||||
        children: wrapper,
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return wrapper;
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
function Timestamp({
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -5,6 +5,7 @@ import type { FunctionComponent, ReactNode } from 'react';
 | 
			
		|||
import React, { useCallback } from 'react';
 | 
			
		||||
import classNames from 'classnames';
 | 
			
		||||
 | 
			
		||||
import type { RenderConversationListItemContextMenuProps } from './BaseConversationListItem.js';
 | 
			
		||||
import {
 | 
			
		||||
  BaseConversationListItem,
 | 
			
		||||
  HEADER_NAME_CLASS_NAME,
 | 
			
		||||
| 
						 | 
				
			
			@ -77,6 +78,9 @@ type PropsHousekeeping = {
 | 
			
		|||
  onClick: (id: string) => void;
 | 
			
		||||
  onMouseDown: (id: string) => void;
 | 
			
		||||
  theme: ThemeType;
 | 
			
		||||
  renderConversationListItemContextMenu?: (
 | 
			
		||||
    props: RenderConversationListItemContextMenuProps
 | 
			
		||||
  ) => JSX.Element;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type Props = PropsData & PropsHousekeeping;
 | 
			
		||||
| 
						 | 
				
			
			@ -115,6 +119,7 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
 | 
			
		|||
    unreadCount,
 | 
			
		||||
    unreadMentionsCount,
 | 
			
		||||
    serviceId,
 | 
			
		||||
    renderConversationListItemContextMenu,
 | 
			
		||||
  }) {
 | 
			
		||||
    const isMuted = Boolean(muteExpiresAt && Date.now() < muteExpiresAt);
 | 
			
		||||
    const isSomeoneTyping =
 | 
			
		||||
| 
						 | 
				
			
			@ -243,6 +248,9 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
 | 
			
		|||
        unreadCount={unreadCount}
 | 
			
		||||
        unreadMentionsCount={unreadMentionsCount}
 | 
			
		||||
        serviceId={serviceId}
 | 
			
		||||
        renderConversationListItemContextMenu={
 | 
			
		||||
          renderConversationListItemContextMenu
 | 
			
		||||
        }
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										390
									
								
								ts/components/leftPane/LeftPaneChatFolders.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										390
									
								
								ts/components/leftPane/LeftPaneChatFolders.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,390 @@
 | 
			
		|||
// Copyright 2025 Signal Messenger, LLC
 | 
			
		||||
// SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		||||
import React, {
 | 
			
		||||
  useCallback,
 | 
			
		||||
  useMemo,
 | 
			
		||||
  type FocusEvent,
 | 
			
		||||
  type ReactNode,
 | 
			
		||||
} from 'react';
 | 
			
		||||
import {
 | 
			
		||||
  ChatFolderType,
 | 
			
		||||
  type ChatFolder,
 | 
			
		||||
  type ChatFolderId,
 | 
			
		||||
} from '../../types/ChatFolder.js';
 | 
			
		||||
import type { LocalizerType } from '../../types/I18N.js';
 | 
			
		||||
import { ExperimentalAxoSegmentedControl } from '../../axo/AxoSegmentedControl.js';
 | 
			
		||||
import { tw } from '../../axo/tw.js';
 | 
			
		||||
import type {
 | 
			
		||||
  AllChatFoldersUnreadStats,
 | 
			
		||||
  UnreadStats,
 | 
			
		||||
} from '../../util/countUnreadStats.js';
 | 
			
		||||
import { WidthBreakpoint } from '../_util.js';
 | 
			
		||||
import { AxoSelect } from '../../axo/AxoSelect.js';
 | 
			
		||||
import { AxoContextMenu } from '../../axo/AxoContextMenu.js';
 | 
			
		||||
import { getMuteValuesOptions } from '../../util/getMuteOptions.js';
 | 
			
		||||
import type {
 | 
			
		||||
  AllChatFoldersMutedStats,
 | 
			
		||||
  MutedStats,
 | 
			
		||||
} from '../../util/countMutedStats.js';
 | 
			
		||||
import type { AxoSymbol } from '../../axo/AxoSymbol.js';
 | 
			
		||||
 | 
			
		||||
export type LeftPaneChatFoldersProps = Readonly<{
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
  navSidebarWidthBreakpoint: WidthBreakpoint | null;
 | 
			
		||||
  sortedChatFolders: ReadonlyArray<ChatFolder>;
 | 
			
		||||
  allChatFoldersUnreadStats: AllChatFoldersUnreadStats;
 | 
			
		||||
  allChatFoldersMutedStats: AllChatFoldersMutedStats;
 | 
			
		||||
  selectedChatFolder: ChatFolder | null;
 | 
			
		||||
  onSelectedChatFolderIdChange: (newValue: ChatFolderId) => void;
 | 
			
		||||
  onChatFolderMarkRead: (chatFolderId: ChatFolderId) => void;
 | 
			
		||||
  onChatFolderUpdateMute: (chatFolderId: ChatFolderId, value: number) => void;
 | 
			
		||||
  onChatFolderOpenSettings: (chatFolderId: ChatFolderId) => void;
 | 
			
		||||
}>;
 | 
			
		||||
 | 
			
		||||
function getBadgeValue(
 | 
			
		||||
  unreadStats: UnreadStats | null
 | 
			
		||||
): ExperimentalAxoSegmentedControl.ExperimentalItemBadgeProps['value'] | null {
 | 
			
		||||
  if (unreadStats == null) {
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
  if (unreadStats.unreadCount > 0) {
 | 
			
		||||
    return unreadStats.unreadCount + unreadStats.readChatsMarkedUnreadCount;
 | 
			
		||||
  }
 | 
			
		||||
  if (unreadStats.readChatsMarkedUnreadCount > 0) {
 | 
			
		||||
    return unreadStats.readChatsMarkedUnreadCount;
 | 
			
		||||
  }
 | 
			
		||||
  return null;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getChatFolderLabel(
 | 
			
		||||
  i18n: LocalizerType,
 | 
			
		||||
  chatFolder: ChatFolder,
 | 
			
		||||
  preferShort: boolean
 | 
			
		||||
): string {
 | 
			
		||||
  if (chatFolder.folderType === ChatFolderType.ALL) {
 | 
			
		||||
    if (preferShort) {
 | 
			
		||||
      return i18n('icu:LeftPaneChatFolders__ItemLabel--All--Short');
 | 
			
		||||
    }
 | 
			
		||||
    return i18n('icu:LeftPaneChatFolders__ItemLabel--All');
 | 
			
		||||
  }
 | 
			
		||||
  if (chatFolder.folderType === ChatFolderType.CUSTOM) {
 | 
			
		||||
    return chatFolder.name;
 | 
			
		||||
  }
 | 
			
		||||
  return '';
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getChatFolderIconName(
 | 
			
		||||
  chatFolder: ChatFolder | null
 | 
			
		||||
): AxoSymbol.IconName {
 | 
			
		||||
  if (chatFolder == null) {
 | 
			
		||||
    return 'message';
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return chatFolder.folderType === ChatFolderType.ALL ? 'message' : 'folder';
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function LeftPaneChatFolders(
 | 
			
		||||
  props: LeftPaneChatFoldersProps
 | 
			
		||||
): JSX.Element | null {
 | 
			
		||||
  const { i18n, onSelectedChatFolderIdChange } = props;
 | 
			
		||||
 | 
			
		||||
  const handleValueChange = useCallback(
 | 
			
		||||
    (newValue: string | null) => {
 | 
			
		||||
      if (newValue != null) {
 | 
			
		||||
        onSelectedChatFolderIdChange(newValue as ChatFolderId);
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    [onSelectedChatFolderIdChange]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const handleFocus = useCallback((event: FocusEvent<HTMLDivElement>) => {
 | 
			
		||||
    event.target.scrollIntoView({
 | 
			
		||||
      behavior: 'smooth',
 | 
			
		||||
      inline: 'nearest',
 | 
			
		||||
    });
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  if (props.sortedChatFolders.length < 2) {
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (props.navSidebarWidthBreakpoint === WidthBreakpoint.Narrow) {
 | 
			
		||||
    return (
 | 
			
		||||
      <div className={tw('px-2')}>
 | 
			
		||||
        <AxoSelect.Root
 | 
			
		||||
          value={props.selectedChatFolder?.id ?? null}
 | 
			
		||||
          onValueChange={handleValueChange}
 | 
			
		||||
        >
 | 
			
		||||
          <AxoSelect.Trigger
 | 
			
		||||
            variant="floating"
 | 
			
		||||
            width="full"
 | 
			
		||||
            placeholder=""
 | 
			
		||||
            chevron="on-hover"
 | 
			
		||||
          />
 | 
			
		||||
          <AxoSelect.Content position="dropdown">
 | 
			
		||||
            {props.sortedChatFolders.map(chatFolder => {
 | 
			
		||||
              const unreadStats =
 | 
			
		||||
                props.allChatFoldersUnreadStats.get(chatFolder.id) ?? null;
 | 
			
		||||
              return (
 | 
			
		||||
                <ChatFolderSelectItem
 | 
			
		||||
                  key={chatFolder.id}
 | 
			
		||||
                  i18n={i18n}
 | 
			
		||||
                  chatFolder={chatFolder}
 | 
			
		||||
                  unreadStats={unreadStats}
 | 
			
		||||
                />
 | 
			
		||||
              );
 | 
			
		||||
            })}
 | 
			
		||||
          </AxoSelect.Content>
 | 
			
		||||
        </AxoSelect.Root>
 | 
			
		||||
      </div>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      className={tw(
 | 
			
		||||
        'scroll-px-[20%] overflow-x-auto overflow-y-clip px-4 py-2 [scrollbar-width:none]'
 | 
			
		||||
      )}
 | 
			
		||||
      onFocus={handleFocus}
 | 
			
		||||
    >
 | 
			
		||||
      <ExperimentalAxoSegmentedControl.Root
 | 
			
		||||
        variant="no-track"
 | 
			
		||||
        width="full"
 | 
			
		||||
        itemWidth="fit"
 | 
			
		||||
        value={props.selectedChatFolder?.id ?? null}
 | 
			
		||||
        onValueChange={handleValueChange}
 | 
			
		||||
      >
 | 
			
		||||
        {props.sortedChatFolders.map(chatFolder => {
 | 
			
		||||
          const unreadStats =
 | 
			
		||||
            props.allChatFoldersUnreadStats.get(chatFolder.id) ?? null;
 | 
			
		||||
          const mutedStats =
 | 
			
		||||
            props.allChatFoldersMutedStats.get(chatFolder.id) ?? null;
 | 
			
		||||
          return (
 | 
			
		||||
            <ChatFolderSegmentedControlItem
 | 
			
		||||
              key={chatFolder.id}
 | 
			
		||||
              i18n={i18n}
 | 
			
		||||
              chatFolder={chatFolder}
 | 
			
		||||
              unreadStats={unreadStats}
 | 
			
		||||
              mutedStats={mutedStats}
 | 
			
		||||
              onChatFolderMarkRead={props.onChatFolderMarkRead}
 | 
			
		||||
              onChatFolderUpdateMute={props.onChatFolderUpdateMute}
 | 
			
		||||
              onChatFolderOpenSettings={props.onChatFolderOpenSettings}
 | 
			
		||||
            />
 | 
			
		||||
          );
 | 
			
		||||
        })}
 | 
			
		||||
      </ExperimentalAxoSegmentedControl.Root>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const UNREAD_BADGE_MAX_COUNT = 999;
 | 
			
		||||
 | 
			
		||||
function ChatFolderSelectItem(props: {
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
  chatFolder: ChatFolder;
 | 
			
		||||
  unreadStats: UnreadStats | null;
 | 
			
		||||
}): JSX.Element {
 | 
			
		||||
  const { i18n, unreadStats } = props;
 | 
			
		||||
 | 
			
		||||
  const badgeValue = useMemo(() => {
 | 
			
		||||
    return getBadgeValue(unreadStats);
 | 
			
		||||
  }, [unreadStats]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <AxoSelect.Item
 | 
			
		||||
      key={props.chatFolder.id}
 | 
			
		||||
      value={props.chatFolder.id}
 | 
			
		||||
      symbol={getChatFolderIconName(props.chatFolder)}
 | 
			
		||||
    >
 | 
			
		||||
      <AxoSelect.ItemText>
 | 
			
		||||
        {getChatFolderLabel(i18n, props.chatFolder, true)}
 | 
			
		||||
      </AxoSelect.ItemText>
 | 
			
		||||
      {badgeValue != null && (
 | 
			
		||||
        <AxoSelect.ExperimentalItemBadge
 | 
			
		||||
          value={badgeValue}
 | 
			
		||||
          max={UNREAD_BADGE_MAX_COUNT}
 | 
			
		||||
          maxDisplay={i18n(
 | 
			
		||||
            'icu:LeftPaneChatFolders__ItemUnreadBadge__MaxCount',
 | 
			
		||||
            {
 | 
			
		||||
              maxCount: UNREAD_BADGE_MAX_COUNT,
 | 
			
		||||
            }
 | 
			
		||||
          )}
 | 
			
		||||
          aria-label={null}
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
    </AxoSelect.Item>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function ChatFolderSegmentedControlItem(props: {
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
  chatFolder: ChatFolder;
 | 
			
		||||
  unreadStats: UnreadStats | null;
 | 
			
		||||
  mutedStats: MutedStats | null;
 | 
			
		||||
  onChatFolderMarkRead: (chatFolderId: ChatFolderId) => void;
 | 
			
		||||
  onChatFolderUpdateMute: (chatFolderId: ChatFolderId, value: number) => void;
 | 
			
		||||
  onChatFolderOpenSettings: (chatFolderId: ChatFolderId) => void;
 | 
			
		||||
}): JSX.Element {
 | 
			
		||||
  const { i18n, unreadStats } = props;
 | 
			
		||||
 | 
			
		||||
  const badgeValue = useMemo(() => {
 | 
			
		||||
    return getBadgeValue(unreadStats);
 | 
			
		||||
  }, [unreadStats]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <ChatFolderSegmentedControlItemContextMenu
 | 
			
		||||
      i18n={i18n}
 | 
			
		||||
      chatFolder={props.chatFolder}
 | 
			
		||||
      unreadStats={props.unreadStats}
 | 
			
		||||
      mutedStats={props.mutedStats}
 | 
			
		||||
      onChatFolderMarkRead={props.onChatFolderMarkRead}
 | 
			
		||||
      onChatFolderUpdateMute={props.onChatFolderUpdateMute}
 | 
			
		||||
      onChatFolderOpenSettings={props.onChatFolderOpenSettings}
 | 
			
		||||
    >
 | 
			
		||||
      <ExperimentalAxoSegmentedControl.Item value={props.chatFolder.id}>
 | 
			
		||||
        <ExperimentalAxoSegmentedControl.ItemText maxWidth="12ch">
 | 
			
		||||
          {getChatFolderLabel(i18n, props.chatFolder, false)}
 | 
			
		||||
        </ExperimentalAxoSegmentedControl.ItemText>
 | 
			
		||||
        {badgeValue != null && (
 | 
			
		||||
          <ExperimentalAxoSegmentedControl.ExperimentalItemBadge
 | 
			
		||||
            value={badgeValue}
 | 
			
		||||
            max={UNREAD_BADGE_MAX_COUNT}
 | 
			
		||||
            maxDisplay={i18n(
 | 
			
		||||
              'icu:LeftPaneChatFolders__ItemUnreadBadge__MaxCount',
 | 
			
		||||
              { maxCount: UNREAD_BADGE_MAX_COUNT }
 | 
			
		||||
            )}
 | 
			
		||||
            aria-label={null}
 | 
			
		||||
          />
 | 
			
		||||
        )}
 | 
			
		||||
      </ExperimentalAxoSegmentedControl.Item>
 | 
			
		||||
    </ChatFolderSegmentedControlItemContextMenu>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function ChatFolderSegmentedControlItemContextMenu(props: {
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
  chatFolder: ChatFolder;
 | 
			
		||||
  unreadStats: UnreadStats | null;
 | 
			
		||||
  mutedStats: MutedStats | null;
 | 
			
		||||
  onChatFolderMarkRead: (chatFolderId: ChatFolderId) => void;
 | 
			
		||||
  onChatFolderUpdateMute: (chatFolderId: ChatFolderId, value: number) => void;
 | 
			
		||||
  onChatFolderOpenSettings: (chatFolderId: ChatFolderId) => void;
 | 
			
		||||
  children: ReactNode;
 | 
			
		||||
}) {
 | 
			
		||||
  const {
 | 
			
		||||
    i18n,
 | 
			
		||||
    onChatFolderMarkRead,
 | 
			
		||||
    onChatFolderUpdateMute,
 | 
			
		||||
    onChatFolderOpenSettings,
 | 
			
		||||
  } = props;
 | 
			
		||||
  const chatFolderId = props.chatFolder.id;
 | 
			
		||||
 | 
			
		||||
  const muteValuesOptions = useMemo(() => {
 | 
			
		||||
    return getMuteValuesOptions(i18n);
 | 
			
		||||
  }, [i18n]);
 | 
			
		||||
 | 
			
		||||
  const someChatsUnread =
 | 
			
		||||
    (props.unreadStats?.unreadCount ?? 0) > 0 ||
 | 
			
		||||
    (props.unreadStats?.readChatsMarkedUnreadCount ?? 0) > 0;
 | 
			
		||||
  const someChatsMuted = (props.mutedStats?.chatsMutedCount ?? 0) > 0;
 | 
			
		||||
  const someChatsUnmuted = (props.mutedStats?.chatsUnmutedCount ?? 0) > 0;
 | 
			
		||||
 | 
			
		||||
  const showOnlyUnmuteAll = someChatsMuted && !someChatsUnmuted;
 | 
			
		||||
 | 
			
		||||
  const handleChatFolderMarkRead = useCallback(() => {
 | 
			
		||||
    onChatFolderMarkRead(chatFolderId);
 | 
			
		||||
  }, [chatFolderId, onChatFolderMarkRead]);
 | 
			
		||||
 | 
			
		||||
  const handleChatFolderUpdateMute = useCallback(
 | 
			
		||||
    (value: number) => {
 | 
			
		||||
      onChatFolderUpdateMute(chatFolderId, value);
 | 
			
		||||
    },
 | 
			
		||||
    [chatFolderId, onChatFolderUpdateMute]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const handleChatFolderOpenSettings = useCallback(() => {
 | 
			
		||||
    onChatFolderOpenSettings(chatFolderId);
 | 
			
		||||
  }, [chatFolderId, onChatFolderOpenSettings]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <AxoContextMenu.Root>
 | 
			
		||||
      <AxoContextMenu.Trigger>{props.children}</AxoContextMenu.Trigger>
 | 
			
		||||
      <AxoContextMenu.Content>
 | 
			
		||||
        {someChatsUnread && (
 | 
			
		||||
          <AxoContextMenu.Item
 | 
			
		||||
            symbol="message-check"
 | 
			
		||||
            onSelect={handleChatFolderMarkRead}
 | 
			
		||||
          >
 | 
			
		||||
            {i18n('icu:LeftPaneChatFolders__Item__ContextMenu__MarkAllRead')}
 | 
			
		||||
          </AxoContextMenu.Item>
 | 
			
		||||
        )}
 | 
			
		||||
        {!showOnlyUnmuteAll && (
 | 
			
		||||
          <AxoContextMenu.Sub>
 | 
			
		||||
            <AxoContextMenu.SubTrigger symbol="bell-slash">
 | 
			
		||||
              {i18n(
 | 
			
		||||
                'icu:LeftPaneChatFolders__Item__ContextMenu__MuteNotifications'
 | 
			
		||||
              )}
 | 
			
		||||
            </AxoContextMenu.SubTrigger>
 | 
			
		||||
            <AxoContextMenu.SubContent>
 | 
			
		||||
              {someChatsMuted && (
 | 
			
		||||
                <ContextMenuMuteNotificationsItem
 | 
			
		||||
                  value={0}
 | 
			
		||||
                  onSelect={handleChatFolderUpdateMute}
 | 
			
		||||
                >
 | 
			
		||||
                  {i18n(
 | 
			
		||||
                    'icu:LeftPaneChatFolders__Item__ContextMenu__MuteNotifications__UnmuteAll'
 | 
			
		||||
                  )}
 | 
			
		||||
                </ContextMenuMuteNotificationsItem>
 | 
			
		||||
              )}
 | 
			
		||||
              {muteValuesOptions.map(option => {
 | 
			
		||||
                return (
 | 
			
		||||
                  <ContextMenuMuteNotificationsItem
 | 
			
		||||
                    key={option.value}
 | 
			
		||||
                    value={option.value}
 | 
			
		||||
                    onSelect={handleChatFolderUpdateMute}
 | 
			
		||||
                  >
 | 
			
		||||
                    {option.name}
 | 
			
		||||
                  </ContextMenuMuteNotificationsItem>
 | 
			
		||||
                );
 | 
			
		||||
              })}
 | 
			
		||||
            </AxoContextMenu.SubContent>
 | 
			
		||||
          </AxoContextMenu.Sub>
 | 
			
		||||
        )}
 | 
			
		||||
        {showOnlyUnmuteAll && (
 | 
			
		||||
          <ContextMenuMuteNotificationsItem
 | 
			
		||||
            symbol="bell"
 | 
			
		||||
            value={0}
 | 
			
		||||
            onSelect={handleChatFolderUpdateMute}
 | 
			
		||||
          >
 | 
			
		||||
            {i18n('icu:LeftPaneChatFolders__Item__ContextMenu__UnmuteAll')}
 | 
			
		||||
          </ContextMenuMuteNotificationsItem>
 | 
			
		||||
        )}
 | 
			
		||||
        {props.chatFolder.folderType === ChatFolderType.CUSTOM && (
 | 
			
		||||
          <AxoContextMenu.Item
 | 
			
		||||
            symbol="pencil"
 | 
			
		||||
            onSelect={handleChatFolderOpenSettings}
 | 
			
		||||
          >
 | 
			
		||||
            {i18n('icu:LeftPaneChatFolders__Item__ContextMenu__EditFolder')}
 | 
			
		||||
          </AxoContextMenu.Item>
 | 
			
		||||
        )}
 | 
			
		||||
      </AxoContextMenu.Content>
 | 
			
		||||
    </AxoContextMenu.Root>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function ContextMenuMuteNotificationsItem(props: {
 | 
			
		||||
  symbol?: AxoSymbol.IconName;
 | 
			
		||||
  value: number;
 | 
			
		||||
  onSelect: (value: number) => void;
 | 
			
		||||
  children: ReactNode;
 | 
			
		||||
}): JSX.Element {
 | 
			
		||||
  const { value, onSelect } = props;
 | 
			
		||||
  const handleSelect = useCallback(() => {
 | 
			
		||||
    onSelect(value);
 | 
			
		||||
  }, [onSelect, value]);
 | 
			
		||||
  return (
 | 
			
		||||
    <AxoContextMenu.Item symbol={props.symbol} onSelect={handleSelect}>
 | 
			
		||||
      {props.children}
 | 
			
		||||
    </AxoContextMenu.Item>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,275 @@
 | 
			
		|||
// Copyright 2025 Signal Messenger, LLC
 | 
			
		||||
// SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		||||
import type { FC, ReactNode } from 'react';
 | 
			
		||||
import React, { memo, useCallback, useMemo, useState } from 'react';
 | 
			
		||||
import { AxoContextMenu } from '../../axo/AxoContextMenu.js';
 | 
			
		||||
import type { LocalizerType } from '../../types/I18N.js';
 | 
			
		||||
import type { ConversationType } from '../../state/ducks/conversations.js';
 | 
			
		||||
import { isConversationUnread } from '../../util/isConversationUnread.js';
 | 
			
		||||
import {
 | 
			
		||||
  Environment,
 | 
			
		||||
  getEnvironment,
 | 
			
		||||
  isMockEnvironment,
 | 
			
		||||
} from '../../environment.js';
 | 
			
		||||
import { isAlpha } from '../../util/version.js';
 | 
			
		||||
import { drop } from '../../util/drop.js';
 | 
			
		||||
import { DeleteMessagesConfirmationDialog } from '../DeleteMessagesConfirmationDialog.js';
 | 
			
		||||
import { getMuteOptions } from '../../util/getMuteOptions.js';
 | 
			
		||||
 | 
			
		||||
function isEnabled() {
 | 
			
		||||
  const env = getEnvironment();
 | 
			
		||||
 | 
			
		||||
  if (
 | 
			
		||||
    env === Environment.Development ||
 | 
			
		||||
    env === Environment.Test ||
 | 
			
		||||
    isMockEnvironment()
 | 
			
		||||
  ) {
 | 
			
		||||
    return true;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const version = window.getVersion?.();
 | 
			
		||||
 | 
			
		||||
  if (version != null) {
 | 
			
		||||
    if (isAlpha(version)) {
 | 
			
		||||
      return true;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return false;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type LeftPaneConversationListItemContextMenuProps = Readonly<{
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
  conversation: ConversationType;
 | 
			
		||||
  onMarkUnread: (conversationId: string) => void;
 | 
			
		||||
  onMarkRead: (conversationId: string) => void;
 | 
			
		||||
  onPin: (conversationId: string) => void;
 | 
			
		||||
  onUnpin: (conversationId: string) => void;
 | 
			
		||||
  onUpdateMute: (conversationId: string, muteExpiresAt: number) => void;
 | 
			
		||||
  onArchive: (conversationId: string) => void;
 | 
			
		||||
  onUnarchive: (conversationId: string) => void;
 | 
			
		||||
  onDelete: (conversationId: string) => void;
 | 
			
		||||
  localDeleteWarningShown: boolean;
 | 
			
		||||
  setLocalDeleteWarningShown: () => void;
 | 
			
		||||
  children: ReactNode;
 | 
			
		||||
}>;
 | 
			
		||||
 | 
			
		||||
export const LeftPaneConversationListItemContextMenu: FC<LeftPaneConversationListItemContextMenuProps> =
 | 
			
		||||
  memo(function ConversationListItemContextMenu(props) {
 | 
			
		||||
    const {
 | 
			
		||||
      i18n,
 | 
			
		||||
      conversation,
 | 
			
		||||
      onMarkUnread,
 | 
			
		||||
      onMarkRead,
 | 
			
		||||
      onPin,
 | 
			
		||||
      onUnpin,
 | 
			
		||||
      onUpdateMute,
 | 
			
		||||
      onArchive,
 | 
			
		||||
      onUnarchive,
 | 
			
		||||
      onDelete,
 | 
			
		||||
    } = props;
 | 
			
		||||
 | 
			
		||||
    const { id: conversationId, muteExpiresAt } = conversation;
 | 
			
		||||
 | 
			
		||||
    const muteOptions = useMemo(() => {
 | 
			
		||||
      return getMuteOptions(muteExpiresAt, i18n);
 | 
			
		||||
    }, [muteExpiresAt, i18n]);
 | 
			
		||||
 | 
			
		||||
    const [showConfirmDeleteDialog, setShowConfirmDeleteDialog] =
 | 
			
		||||
      useState(false);
 | 
			
		||||
 | 
			
		||||
    const handleOpenConfirmDeleteDialog = useCallback(() => {
 | 
			
		||||
      setShowConfirmDeleteDialog(true);
 | 
			
		||||
    }, []);
 | 
			
		||||
 | 
			
		||||
    const handleCloseConfirmDeleteDialog = useCallback(() => {
 | 
			
		||||
      setShowConfirmDeleteDialog(false);
 | 
			
		||||
    }, []);
 | 
			
		||||
 | 
			
		||||
    const isUnread = useMemo(() => {
 | 
			
		||||
      return isConversationUnread(conversation);
 | 
			
		||||
    }, [conversation]);
 | 
			
		||||
 | 
			
		||||
    const handleMarkUnread = useCallback(() => {
 | 
			
		||||
      onMarkUnread(conversationId);
 | 
			
		||||
    }, [onMarkUnread, conversationId]);
 | 
			
		||||
 | 
			
		||||
    const handleMarkRead = useCallback(() => {
 | 
			
		||||
      onMarkRead(conversationId);
 | 
			
		||||
    }, [onMarkRead, conversationId]);
 | 
			
		||||
 | 
			
		||||
    const handlePin = useCallback(() => {
 | 
			
		||||
      onPin(conversationId);
 | 
			
		||||
    }, [onPin, conversationId]);
 | 
			
		||||
 | 
			
		||||
    const handleUnpin = useCallback(() => {
 | 
			
		||||
      onUnpin(conversationId);
 | 
			
		||||
    }, [onUnpin, conversationId]);
 | 
			
		||||
 | 
			
		||||
    const handleUpdateMute = useCallback(
 | 
			
		||||
      (value: number) => {
 | 
			
		||||
        onUpdateMute(conversationId, value);
 | 
			
		||||
      },
 | 
			
		||||
      [onUpdateMute, conversationId]
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const handleArchive = useCallback(() => {
 | 
			
		||||
      onArchive(conversationId);
 | 
			
		||||
    }, [onArchive, conversationId]);
 | 
			
		||||
 | 
			
		||||
    const handleUnarchive = useCallback(() => {
 | 
			
		||||
      onUnarchive(conversationId);
 | 
			
		||||
    }, [onUnarchive, conversationId]);
 | 
			
		||||
 | 
			
		||||
    const handleDelete = useCallback(() => {
 | 
			
		||||
      onDelete(conversationId);
 | 
			
		||||
    }, [onDelete, conversationId]);
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <>
 | 
			
		||||
        <AxoContextMenu.Root>
 | 
			
		||||
          <AxoContextMenu.Trigger>{props.children}</AxoContextMenu.Trigger>
 | 
			
		||||
          <AxoContextMenu.Content>
 | 
			
		||||
            {isUnread && (
 | 
			
		||||
              <AxoContextMenu.Item
 | 
			
		||||
                symbol="message-check"
 | 
			
		||||
                onSelect={handleMarkRead}
 | 
			
		||||
              >
 | 
			
		||||
                {i18n('icu:markRead')}
 | 
			
		||||
              </AxoContextMenu.Item>
 | 
			
		||||
            )}
 | 
			
		||||
            {!isUnread && !conversation.markedUnread && (
 | 
			
		||||
              <AxoContextMenu.Item
 | 
			
		||||
                symbol="message-badge"
 | 
			
		||||
                onSelect={handleMarkUnread}
 | 
			
		||||
              >
 | 
			
		||||
                {i18n('icu:markUnread')}
 | 
			
		||||
              </AxoContextMenu.Item>
 | 
			
		||||
            )}
 | 
			
		||||
            {!conversation.isPinned && (
 | 
			
		||||
              <AxoContextMenu.Item symbol="pin" onSelect={handlePin}>
 | 
			
		||||
                {i18n('icu:pinConversation')}
 | 
			
		||||
              </AxoContextMenu.Item>
 | 
			
		||||
            )}
 | 
			
		||||
            {conversation.isPinned && (
 | 
			
		||||
              <AxoContextMenu.Item symbol="pin-slash" onSelect={handleUnpin}>
 | 
			
		||||
                {i18n('icu:unpinConversation')}
 | 
			
		||||
              </AxoContextMenu.Item>
 | 
			
		||||
            )}
 | 
			
		||||
            <AxoContextMenu.Sub>
 | 
			
		||||
              <AxoContextMenu.SubTrigger symbol="bell-slash">
 | 
			
		||||
                {i18n('icu:muteNotificationsTitle')}
 | 
			
		||||
              </AxoContextMenu.SubTrigger>
 | 
			
		||||
              <AxoContextMenu.SubContent>
 | 
			
		||||
                {muteOptions.map(muteOption => {
 | 
			
		||||
                  return (
 | 
			
		||||
                    <ContextMenuMuteNotificationsItem
 | 
			
		||||
                      key={muteOption.value}
 | 
			
		||||
                      value={muteOption.value}
 | 
			
		||||
                      disabled={muteOption.disabled}
 | 
			
		||||
                      onSelect={handleUpdateMute}
 | 
			
		||||
                    >
 | 
			
		||||
                      {muteOption.name}
 | 
			
		||||
                    </ContextMenuMuteNotificationsItem>
 | 
			
		||||
                  );
 | 
			
		||||
                })}
 | 
			
		||||
              </AxoContextMenu.SubContent>
 | 
			
		||||
            </AxoContextMenu.Sub>
 | 
			
		||||
            {!conversation.isArchived && (
 | 
			
		||||
              <AxoContextMenu.Item symbol="archive" onSelect={handleArchive}>
 | 
			
		||||
                {i18n('icu:archiveConversation')}
 | 
			
		||||
              </AxoContextMenu.Item>
 | 
			
		||||
            )}
 | 
			
		||||
            {conversation.isArchived && (
 | 
			
		||||
              <AxoContextMenu.Item
 | 
			
		||||
                symbol="archive-up"
 | 
			
		||||
                onSelect={handleUnarchive}
 | 
			
		||||
              >
 | 
			
		||||
                {i18n('icu:moveConversationToInbox')}
 | 
			
		||||
              </AxoContextMenu.Item>
 | 
			
		||||
            )}
 | 
			
		||||
            <AxoContextMenu.Item
 | 
			
		||||
              symbol="trash"
 | 
			
		||||
              onSelect={handleOpenConfirmDeleteDialog}
 | 
			
		||||
            >
 | 
			
		||||
              {i18n('icu:deleteConversation')}
 | 
			
		||||
            </AxoContextMenu.Item>
 | 
			
		||||
            {isEnabled() && (
 | 
			
		||||
              <>
 | 
			
		||||
                <AxoContextMenu.Separator />
 | 
			
		||||
                <AxoContextMenu.Group>
 | 
			
		||||
                  <AxoContextMenu.Label>Internal</AxoContextMenu.Label>
 | 
			
		||||
                  <ContextMenuCopyTextItem value={conversation.id}>
 | 
			
		||||
                    Copy Conversation ID
 | 
			
		||||
                  </ContextMenuCopyTextItem>
 | 
			
		||||
                  {conversation.serviceId != null && (
 | 
			
		||||
                    <ContextMenuCopyTextItem value={conversation.serviceId}>
 | 
			
		||||
                      Copy Service ID
 | 
			
		||||
                    </ContextMenuCopyTextItem>
 | 
			
		||||
                  )}
 | 
			
		||||
                  {conversation.pni != null && (
 | 
			
		||||
                    <ContextMenuCopyTextItem value={conversation.pni}>
 | 
			
		||||
                      Copy PNI
 | 
			
		||||
                    </ContextMenuCopyTextItem>
 | 
			
		||||
                  )}
 | 
			
		||||
                  {conversation.groupId != null && (
 | 
			
		||||
                    <ContextMenuCopyTextItem value={conversation.groupId}>
 | 
			
		||||
                      Copy Group ID
 | 
			
		||||
                    </ContextMenuCopyTextItem>
 | 
			
		||||
                  )}
 | 
			
		||||
                  {conversation.e164 != null && (
 | 
			
		||||
                    <ContextMenuCopyTextItem value={conversation.e164}>
 | 
			
		||||
                      Copy E164
 | 
			
		||||
                    </ContextMenuCopyTextItem>
 | 
			
		||||
                  )}
 | 
			
		||||
                </AxoContextMenu.Group>
 | 
			
		||||
              </>
 | 
			
		||||
            )}
 | 
			
		||||
          </AxoContextMenu.Content>
 | 
			
		||||
        </AxoContextMenu.Root>
 | 
			
		||||
        {showConfirmDeleteDialog && (
 | 
			
		||||
          <DeleteMessagesConfirmationDialog
 | 
			
		||||
            i18n={i18n}
 | 
			
		||||
            localDeleteWarningShown={props.localDeleteWarningShown}
 | 
			
		||||
            onDestroyMessages={handleDelete}
 | 
			
		||||
            onClose={handleCloseConfirmDeleteDialog}
 | 
			
		||||
            setLocalDeleteWarningShown={props.setLocalDeleteWarningShown}
 | 
			
		||||
          />
 | 
			
		||||
        )}
 | 
			
		||||
      </>
 | 
			
		||||
    );
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
function ContextMenuMuteNotificationsItem(props: {
 | 
			
		||||
  disabled?: boolean;
 | 
			
		||||
  value: number;
 | 
			
		||||
  onSelect: (value: number) => void;
 | 
			
		||||
  children: ReactNode;
 | 
			
		||||
}): JSX.Element {
 | 
			
		||||
  const { value, onSelect } = props;
 | 
			
		||||
  const handleSelect = useCallback(() => {
 | 
			
		||||
    onSelect(value);
 | 
			
		||||
  }, [onSelect, value]);
 | 
			
		||||
  return (
 | 
			
		||||
    <AxoContextMenu.Item disabled={props.disabled} onSelect={handleSelect}>
 | 
			
		||||
      {props.children}
 | 
			
		||||
    </AxoContextMenu.Item>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function ContextMenuCopyTextItem(props: {
 | 
			
		||||
  value: string;
 | 
			
		||||
  children: ReactNode;
 | 
			
		||||
}): JSX.Element {
 | 
			
		||||
  const { value } = props;
 | 
			
		||||
 | 
			
		||||
  const handleSelect = useCallback((): void => {
 | 
			
		||||
    drop(window.navigator.clipboard.writeText(value));
 | 
			
		||||
  }, [value]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <AxoContextMenu.Item symbol="copy" onSelect={handleSelect}>
 | 
			
		||||
      {props.children}
 | 
			
		||||
    </AxoContextMenu.Item>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -88,6 +88,7 @@ export abstract class LeftPaneHelper<T> {
 | 
			
		|||
      createGroup: () => unknown;
 | 
			
		||||
      i18n: LocalizerType;
 | 
			
		||||
      removeSelectedContact: (_: string) => unknown;
 | 
			
		||||
      renderLeftPaneChatFolders: () => JSX.Element;
 | 
			
		||||
      setComposeGroupAvatar: (_: undefined | Uint8Array) => unknown;
 | 
			
		||||
      setComposeGroupExpireTimer: (_: DurationInSeconds) => void;
 | 
			
		||||
      setComposeGroupName: (_: string) => unknown;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -131,6 +131,14 @@ export class LeftPaneInboxHelper extends LeftPaneHelper<LeftPaneInboxPropsType>
 | 
			
		|||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  override getPreRowsNode({
 | 
			
		||||
    renderLeftPaneChatFolders,
 | 
			
		||||
  }: Readonly<{
 | 
			
		||||
    renderLeftPaneChatFolders: () => JSX.Element;
 | 
			
		||||
  }>): ReactChild {
 | 
			
		||||
    return renderLeftPaneChatFolders();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  override getBackgroundNode({
 | 
			
		||||
    i18n,
 | 
			
		||||
  }: Readonly<{
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,36 @@
 | 
			
		|||
// Copyright 2025 Signal Messenger, LLC
 | 
			
		||||
// SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		||||
 | 
			
		||||
import React from 'react';
 | 
			
		||||
import type { LocalizerType } from '../../../types/I18N.js';
 | 
			
		||||
import { ConfirmationDialog } from '../../ConfirmationDialog.js';
 | 
			
		||||
 | 
			
		||||
export function DeleteChatFolderDialog(props: {
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
  title: string;
 | 
			
		||||
  description: string;
 | 
			
		||||
  cancelText: string;
 | 
			
		||||
  deleteText: string;
 | 
			
		||||
  onConfirm: () => void;
 | 
			
		||||
  onClose: () => void;
 | 
			
		||||
}): JSX.Element {
 | 
			
		||||
  const { i18n } = props;
 | 
			
		||||
  return (
 | 
			
		||||
    <ConfirmationDialog
 | 
			
		||||
      i18n={i18n}
 | 
			
		||||
      dialogName="Preferences__DeleteChatFolderDialog"
 | 
			
		||||
      title={props.title}
 | 
			
		||||
      cancelText={props.cancelText}
 | 
			
		||||
      actions={[
 | 
			
		||||
        {
 | 
			
		||||
          text: props.deleteText,
 | 
			
		||||
          style: 'affirmative',
 | 
			
		||||
          action: props.onConfirm,
 | 
			
		||||
        },
 | 
			
		||||
      ]}
 | 
			
		||||
      onClose={props.onClose}
 | 
			
		||||
    >
 | 
			
		||||
      {props.description}
 | 
			
		||||
    </ConfirmationDialog>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,8 +1,10 @@
 | 
			
		|||
// Copyright 2025 Signal Messenger, LLC
 | 
			
		||||
// SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		||||
 | 
			
		||||
import React, { useCallback, useMemo } from 'react';
 | 
			
		||||
import type { MutableRefObject } from 'react';
 | 
			
		||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
 | 
			
		||||
import type { MutableRefObject, ReactNode } from 'react';
 | 
			
		||||
import { ListBox, ListBoxItem, useDragAndDrop } from 'react-aria-components';
 | 
			
		||||
import { partition } from 'lodash';
 | 
			
		||||
import type { LocalizerType } from '../../../types/I18N.js';
 | 
			
		||||
import { PreferencesContent } from '../../Preferences.js';
 | 
			
		||||
import { SettingsRow } from '../../PreferencesUtil.js';
 | 
			
		||||
| 
						 | 
				
			
			@ -18,30 +20,128 @@ import type {
 | 
			
		|||
  ChatFolder,
 | 
			
		||||
} from '../../../types/ChatFolder.js';
 | 
			
		||||
import { Button, ButtonVariant } from '../../Button.js';
 | 
			
		||||
import { AxoContextMenu } from '../../../axo/AxoContextMenu.js';
 | 
			
		||||
import { DeleteChatFolderDialog } from './DeleteChatFolderDialog.js';
 | 
			
		||||
import { strictAssert } from '../../../util/assert.js';
 | 
			
		||||
import { tw } from '../../../axo/tw.js';
 | 
			
		||||
// import { showToast } from '../../state/ducks/toast';
 | 
			
		||||
 | 
			
		||||
function moveChatFolders(
 | 
			
		||||
  chatFolders: ReadonlyArray<ChatFolder>,
 | 
			
		||||
  target: ChatFolderId,
 | 
			
		||||
  moving: Set<ChatFolderId>,
 | 
			
		||||
  position: 'before' | 'after'
 | 
			
		||||
) {
 | 
			
		||||
  const [toSplice, toInsert] = partition(chatFolders, chatFolder => {
 | 
			
		||||
    return !moving.has(chatFolder.id);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const targetIndex = toSplice.findIndex(chatFolder => {
 | 
			
		||||
    return chatFolder.id === target;
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  if (targetIndex === -1) {
 | 
			
		||||
    return chatFolders;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const spliceIndex = position === 'before' ? targetIndex : targetIndex + 1;
 | 
			
		||||
 | 
			
		||||
  return toSplice.toSpliced(spliceIndex, 0, ...toInsert);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type PreferencesChatFoldersPageProps = Readonly<{
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
  onBack: () => void;
 | 
			
		||||
  onOpenEditChatFoldersPage: (chatFolderId: ChatFolderId | null) => void;
 | 
			
		||||
  chatFolders: ReadonlyArray<ChatFolder>;
 | 
			
		||||
  onCreateChatFolder: (params: ChatFolderParams) => void;
 | 
			
		||||
  onDeleteChatFolder: (chatFolderId: ChatFolderId) => void;
 | 
			
		||||
  onUpdateChatFoldersPositions: (
 | 
			
		||||
    chatFolderIds: ReadonlyArray<ChatFolderId>
 | 
			
		||||
  ) => void;
 | 
			
		||||
  settingsPaneRef: MutableRefObject<HTMLDivElement | null>;
 | 
			
		||||
}>;
 | 
			
		||||
 | 
			
		||||
export function PreferencesChatFoldersPage(
 | 
			
		||||
  props: PreferencesChatFoldersPageProps
 | 
			
		||||
): JSX.Element {
 | 
			
		||||
  const { i18n, onOpenEditChatFoldersPage, chatFolders } = props;
 | 
			
		||||
  const {
 | 
			
		||||
    i18n,
 | 
			
		||||
    onOpenEditChatFoldersPage,
 | 
			
		||||
    onDeleteChatFolder,
 | 
			
		||||
    onUpdateChatFoldersPositions,
 | 
			
		||||
    chatFolders,
 | 
			
		||||
  } = props;
 | 
			
		||||
  const [confirmDeleteChatFolder, setConfirmDeleteChatFolder] =
 | 
			
		||||
    useState<ChatFolder | null>(null);
 | 
			
		||||
 | 
			
		||||
  // showToast(
 | 
			
		||||
  //   i18n("icu:Preferences__ChatFoldersPage__SuggestedFoldersSection__AddButton__Toast")
 | 
			
		||||
  // )
 | 
			
		||||
 | 
			
		||||
  const handleOpenEditChatFoldersPageForNew = useCallback(() => {
 | 
			
		||||
  const handleChatFolderCreate = useCallback(() => {
 | 
			
		||||
    onOpenEditChatFoldersPage(null);
 | 
			
		||||
  }, [onOpenEditChatFoldersPage]);
 | 
			
		||||
 | 
			
		||||
  const handleChatFolderEdit = useCallback(
 | 
			
		||||
    (chatFolder: ChatFolder) => {
 | 
			
		||||
      onOpenEditChatFoldersPage(chatFolder.id);
 | 
			
		||||
    },
 | 
			
		||||
    [onOpenEditChatFoldersPage]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const handleChatFolderDeleteInit = useCallback((chatFolder: ChatFolder) => {
 | 
			
		||||
    setConfirmDeleteChatFolder(chatFolder);
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  const handleChatFolderDeleteCancel = useCallback(() => {
 | 
			
		||||
    setConfirmDeleteChatFolder(null);
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  const handleChatFolderDeleteConfirm = useCallback(() => {
 | 
			
		||||
    strictAssert(confirmDeleteChatFolder, 'Missing chat folder to delete');
 | 
			
		||||
    onDeleteChatFolder(confirmDeleteChatFolder.id);
 | 
			
		||||
  }, [confirmDeleteChatFolder, onDeleteChatFolder]);
 | 
			
		||||
 | 
			
		||||
  const [chatFoldersReordered, setChatFoldersReordered] = useState(chatFolders);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    setChatFoldersReordered(chatFolders);
 | 
			
		||||
  }, [chatFolders]);
 | 
			
		||||
 | 
			
		||||
  const { dragAndDropHooks } = useDragAndDrop({
 | 
			
		||||
    getItems: () => {
 | 
			
		||||
      return chatFolders.map(chatFolder => {
 | 
			
		||||
        return { 'signal-chat-folder-id': chatFolder.id.slice(-3) };
 | 
			
		||||
      });
 | 
			
		||||
    },
 | 
			
		||||
    acceptedDragTypes: ['signal-chat-folder-id'],
 | 
			
		||||
    getDropOperation: () => 'move',
 | 
			
		||||
    onDragEnd: () => {
 | 
			
		||||
      onUpdateChatFoldersPositions(
 | 
			
		||||
        chatFoldersReordered.map(chatFolder => {
 | 
			
		||||
          return chatFolder.id;
 | 
			
		||||
        })
 | 
			
		||||
      );
 | 
			
		||||
    },
 | 
			
		||||
    onReorder: event => {
 | 
			
		||||
      const target = event.target.key as ChatFolderId;
 | 
			
		||||
      const moving = event.keys as Set<ChatFolderId>;
 | 
			
		||||
      const position = event.target.dropPosition;
 | 
			
		||||
 | 
			
		||||
      if (position !== 'before' && position !== 'after') {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      setChatFoldersReordered(prevChatFolders => {
 | 
			
		||||
        return moveChatFolders(prevChatFolders, target, moving, position);
 | 
			
		||||
      });
 | 
			
		||||
    },
 | 
			
		||||
    renderDropIndicator: () => {
 | 
			
		||||
      return <div className={tw('h-12')} />;
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const presetItemsConfigs = useMemo(() => {
 | 
			
		||||
    const initial: ReadonlyArray<ChatFolderPresetItemConfig> = [
 | 
			
		||||
      {
 | 
			
		||||
| 
						 | 
				
			
			@ -86,6 +186,7 @@ export function PreferencesChatFoldersPage(
 | 
			
		|||
  }, [i18n, chatFolders]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <PreferencesContent
 | 
			
		||||
        backButton={
 | 
			
		||||
          <button
 | 
			
		||||
| 
						 | 
				
			
			@ -104,13 +205,12 @@ export function PreferencesChatFoldersPage(
 | 
			
		|||
              title={i18n(
 | 
			
		||||
                'icu:Preferences__ChatFoldersPage__FoldersSection__Title'
 | 
			
		||||
              )}
 | 
			
		||||
              className={tw('mt-4')}
 | 
			
		||||
            >
 | 
			
		||||
            <ul data-testid="ChatFoldersList">
 | 
			
		||||
              <li>
 | 
			
		||||
              <button
 | 
			
		||||
                type="button"
 | 
			
		||||
                className="Preferences__ChatFolders__ChatSelection__Item Preferences__ChatFolders__ChatSelection__Item--Button"
 | 
			
		||||
                  onClick={handleOpenEditChatFoldersPageForNew}
 | 
			
		||||
                onClick={handleChatFolderCreate}
 | 
			
		||||
              >
 | 
			
		||||
                <span className="Preferences__ChatFolders__ChatSelection__ItemAvatar Preferences__ChatFolders__ChatSelection__ItemAvatar--Add" />
 | 
			
		||||
                <span className="Preferences__ChatFolders__ChatSelection__ItemTitle">
 | 
			
		||||
| 
						 | 
				
			
			@ -119,18 +219,23 @@ export function PreferencesChatFoldersPage(
 | 
			
		|||
                  )}
 | 
			
		||||
                </span>
 | 
			
		||||
              </button>
 | 
			
		||||
              </li>
 | 
			
		||||
              {props.chatFolders.map(chatFolder => {
 | 
			
		||||
              <ListBox
 | 
			
		||||
                selectionMode="single"
 | 
			
		||||
                data-testid="ChatFoldersList"
 | 
			
		||||
                items={chatFoldersReordered}
 | 
			
		||||
                dragAndDropHooks={dragAndDropHooks}
 | 
			
		||||
              >
 | 
			
		||||
                {chatFolder => {
 | 
			
		||||
                  return (
 | 
			
		||||
                    <ChatFolderListItem
 | 
			
		||||
                    key={chatFolder.id}
 | 
			
		||||
                      i18n={i18n}
 | 
			
		||||
                      chatFolder={chatFolder}
 | 
			
		||||
                    onOpenEditChatFoldersPage={props.onOpenEditChatFoldersPage}
 | 
			
		||||
                      onChatFolderEdit={handleChatFolderEdit}
 | 
			
		||||
                      onChatFolderDelete={handleChatFolderDeleteInit}
 | 
			
		||||
                    />
 | 
			
		||||
                  );
 | 
			
		||||
              })}
 | 
			
		||||
            </ul>
 | 
			
		||||
                }}
 | 
			
		||||
              </ListBox>
 | 
			
		||||
            </SettingsRow>
 | 
			
		||||
            {presetItemsConfigs.length > 0 && (
 | 
			
		||||
              <SettingsRow
 | 
			
		||||
| 
						 | 
				
			
			@ -145,6 +250,7 @@ export function PreferencesChatFoldersPage(
 | 
			
		|||
                  {presetItemsConfigs.map(presetItemConfig => {
 | 
			
		||||
                    return (
 | 
			
		||||
                      <ChatFolderPresetItem
 | 
			
		||||
                        key={presetItemConfig.id}
 | 
			
		||||
                        i18n={i18n}
 | 
			
		||||
                        config={presetItemConfig}
 | 
			
		||||
                        onCreateChatFolder={props.onCreateChatFolder}
 | 
			
		||||
| 
						 | 
				
			
			@ -159,6 +265,27 @@ export function PreferencesChatFoldersPage(
 | 
			
		|||
        contentsRef={props.settingsPaneRef}
 | 
			
		||||
        title={i18n('icu:Preferences__ChatFoldersPage__Title')}
 | 
			
		||||
      />
 | 
			
		||||
      {confirmDeleteChatFolder != null && (
 | 
			
		||||
        <DeleteChatFolderDialog
 | 
			
		||||
          i18n={i18n}
 | 
			
		||||
          title={i18n(
 | 
			
		||||
            'icu:Preferences__ChatsPage__DeleteChatFolderDialog__Title'
 | 
			
		||||
          )}
 | 
			
		||||
          description={i18n(
 | 
			
		||||
            'icu:Preferences__ChatsPage__DeleteChatFolderDialog__Description',
 | 
			
		||||
            { chatFolderTitle: confirmDeleteChatFolder.name }
 | 
			
		||||
          )}
 | 
			
		||||
          deleteText={i18n(
 | 
			
		||||
            'icu:Preferences__ChatsPage__DeleteChatFolderDialog__DeleteButton'
 | 
			
		||||
          )}
 | 
			
		||||
          cancelText={i18n(
 | 
			
		||||
            'icu:Preferences__ChatsPage__DeleteChatFolderDialog__CancelButton'
 | 
			
		||||
          )}
 | 
			
		||||
          onClose={handleChatFolderDeleteCancel}
 | 
			
		||||
          onConfirm={handleChatFolderDeleteConfirm}
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -214,87 +341,96 @@ function ChatFolderPresetItem(props: ChatFolderPresetItemProps) {
 | 
			
		|||
function ChatFolderListItem(props: {
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
  chatFolder: ChatFolder;
 | 
			
		||||
  onOpenEditChatFoldersPage: (chatFolderId: ChatFolderId) => void;
 | 
			
		||||
  onChatFolderEdit: (chatFolder: ChatFolder) => void;
 | 
			
		||||
  onChatFolderDelete: (chatFolder: ChatFolder) => void;
 | 
			
		||||
}): JSX.Element {
 | 
			
		||||
  const { i18n, chatFolder, onOpenEditChatFoldersPage } = props;
 | 
			
		||||
  const { i18n, chatFolder, onChatFolderEdit } = props;
 | 
			
		||||
 | 
			
		||||
  const handleAction = useCallback(() => {
 | 
			
		||||
    onOpenEditChatFoldersPage(chatFolder.id);
 | 
			
		||||
  }, [chatFolder, onOpenEditChatFoldersPage]);
 | 
			
		||||
  const handleClickChatFolder = useCallback(() => {
 | 
			
		||||
    onChatFolderEdit(chatFolder);
 | 
			
		||||
  }, [chatFolder, onChatFolderEdit]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <li>
 | 
			
		||||
      <button
 | 
			
		||||
        type="button"
 | 
			
		||||
    <>
 | 
			
		||||
      {props.chatFolder.folderType === ChatFolderType.ALL && (
 | 
			
		||||
        <ListBoxItem
 | 
			
		||||
          id={chatFolder.id}
 | 
			
		||||
          data-testid={`ChatFolder--${chatFolder.id}`}
 | 
			
		||||
        onClick={handleAction}
 | 
			
		||||
        className="Preferences__ChatFolders__ChatSelection__Item Preferences__ChatFolders__ChatSelection__Item--Button"
 | 
			
		||||
          className="Preferences__ChatFolders__ChatSelection__Item"
 | 
			
		||||
        >
 | 
			
		||||
          <span className="Preferences__ChatFolders__ChatSelection__ItemAvatar Preferences__ChatFolders__ChatSelection__ItemAvatar--Folder" />
 | 
			
		||||
        <span className="Preferences__ChatFolders__ChatSelection__ItemTitle">
 | 
			
		||||
          {props.chatFolder.folderType === ChatFolderType.ALL &&
 | 
			
		||||
            i18n(
 | 
			
		||||
          {i18n(
 | 
			
		||||
            'icu:Preferences__ChatFoldersPage__FoldersSection__AllChatsFolder__Title'
 | 
			
		||||
          )}
 | 
			
		||||
          {props.chatFolder.folderType === ChatFolderType.CUSTOM && (
 | 
			
		||||
            <>{props.chatFolder.name}</>
 | 
			
		||||
        </ListBoxItem>
 | 
			
		||||
      )}
 | 
			
		||||
        </span>
 | 
			
		||||
      </button>
 | 
			
		||||
    </li>
 | 
			
		||||
 | 
			
		||||
      {props.chatFolder.folderType === ChatFolderType.CUSTOM && (
 | 
			
		||||
        <ListBoxItem
 | 
			
		||||
          id={chatFolder.id}
 | 
			
		||||
          data-testid={`ChatFolder--${chatFolder.id}`}
 | 
			
		||||
          textValue={props.chatFolder.name}
 | 
			
		||||
          onAction={handleClickChatFolder}
 | 
			
		||||
        >
 | 
			
		||||
          <ChatFolderListItemContextMenu
 | 
			
		||||
            i18n={i18n}
 | 
			
		||||
            chatFolder={props.chatFolder}
 | 
			
		||||
            onChatFolderEdit={props.onChatFolderEdit}
 | 
			
		||||
            onChatFolderDelete={props.onChatFolderDelete}
 | 
			
		||||
          >
 | 
			
		||||
            <div className="Preferences__ChatFolders__ChatSelection__Item Preferences__ChatFolders__ChatSelection__Item--Button">
 | 
			
		||||
              <span className="Preferences__ChatFolders__ChatSelection__ItemAvatar Preferences__ChatFolders__ChatSelection__ItemAvatar--Folder" />
 | 
			
		||||
              {props.chatFolder.name}
 | 
			
		||||
            </div>
 | 
			
		||||
          </ChatFolderListItemContextMenu>
 | 
			
		||||
        </ListBoxItem>
 | 
			
		||||
      )}
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// function ChatFolderContextMenu(props: {
 | 
			
		||||
//   i18n: LocalizerType;
 | 
			
		||||
//   children: ReactNode;
 | 
			
		||||
// }) {
 | 
			
		||||
//   const { i18n } = props;
 | 
			
		||||
//   return (
 | 
			
		||||
//     <AxoContextMenu.Root>
 | 
			
		||||
//       <AxoContextMenu.Trigger>{props.children}</AxoContextMenu.Trigger>
 | 
			
		||||
//       <AxoContextMenu.Content>
 | 
			
		||||
//         <AxoContextMenu.Item>
 | 
			
		||||
//           {i18n(
 | 
			
		||||
// eslint-disable-next-line max-len
 | 
			
		||||
//             'icu:Preferences__ChatsPage__ChatFoldersSection__ChatFolderItem__ContextMenu__EditFolder'
 | 
			
		||||
//           )}
 | 
			
		||||
//         </AxoContextMenu.Item>
 | 
			
		||||
//         <AxoContextMenu.Item>
 | 
			
		||||
//           {i18n(
 | 
			
		||||
// eslint-disable-next-line max-len
 | 
			
		||||
//             'icu:Preferences__ChatsPage__ChatFoldersSection__ChatFolderItem__ContextMenu__DeleteFolder'
 | 
			
		||||
//           )}
 | 
			
		||||
//         </AxoContextMenu.Item>
 | 
			
		||||
//       </AxoContextMenu.Content>
 | 
			
		||||
//     </AxoContextMenu.Root>
 | 
			
		||||
//   );
 | 
			
		||||
// }
 | 
			
		||||
function ChatFolderListItemContextMenu(props: {
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
  chatFolder: ChatFolder;
 | 
			
		||||
  onChatFolderEdit: (chatFolder: ChatFolder) => void;
 | 
			
		||||
  onChatFolderDelete: (chatFolder: ChatFolder) => void;
 | 
			
		||||
  children: ReactNode;
 | 
			
		||||
}) {
 | 
			
		||||
  const { i18n, chatFolder, onChatFolderEdit, onChatFolderDelete } = props;
 | 
			
		||||
 | 
			
		||||
// function DeleteChatFolderDialog(props: { i18n: LocalizerType }): JSX.Element {
 | 
			
		||||
//   const { i18n } = props;
 | 
			
		||||
//   return (
 | 
			
		||||
//     <ConfirmationDialog
 | 
			
		||||
//       i18n={i18n}
 | 
			
		||||
//       dialogName="Preferences__ChatsPage__DeleteChatFolderDialog"
 | 
			
		||||
//       title={i18n('icu:Preferences__ChatsPage__DeleteChatFolderDialog__Title')}
 | 
			
		||||
//       cancelText={i18n(
 | 
			
		||||
//         'icu:Preferences__ChatsPage__DeleteChatFolderDialog__CancelButton'
 | 
			
		||||
//       )}
 | 
			
		||||
//       actions={[
 | 
			
		||||
//         {
 | 
			
		||||
//           text: i18n(
 | 
			
		||||
//             'icu:Preferences__ChatsPage__DeleteChatFolderDialog__DeleteButton'
 | 
			
		||||
//           ),
 | 
			
		||||
//           style: 'affirmative',
 | 
			
		||||
//           action: () => null,
 | 
			
		||||
//         },
 | 
			
		||||
//       ]}
 | 
			
		||||
//       onClose={() => null}
 | 
			
		||||
//     >
 | 
			
		||||
//       {i18n('icu:Preferences__ChatsPage__DeleteChatFolderDialog__Description', {
 | 
			
		||||
//         chatFolderTitle: '',
 | 
			
		||||
//       })}
 | 
			
		||||
//     </ConfirmationDialog>
 | 
			
		||||
//   );
 | 
			
		||||
// }
 | 
			
		||||
  const handleSelectChatFolderEdit = useCallback(() => {
 | 
			
		||||
    onChatFolderEdit(chatFolder);
 | 
			
		||||
  }, [chatFolder, onChatFolderEdit]);
 | 
			
		||||
 | 
			
		||||
  const handleSelectChatFolderDelete = useCallback(() => {
 | 
			
		||||
    onChatFolderDelete(chatFolder);
 | 
			
		||||
  }, [chatFolder, onChatFolderDelete]);
 | 
			
		||||
 | 
			
		||||
  if (chatFolder.folderType !== ChatFolderType.CUSTOM) {
 | 
			
		||||
    return <>{props.children}</>;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <AxoContextMenu.Root>
 | 
			
		||||
      <AxoContextMenu.Trigger>{props.children}</AxoContextMenu.Trigger>
 | 
			
		||||
      <AxoContextMenu.Content>
 | 
			
		||||
        <AxoContextMenu.Item
 | 
			
		||||
          symbol="pencil"
 | 
			
		||||
          onSelect={handleSelectChatFolderEdit}
 | 
			
		||||
        >
 | 
			
		||||
          {i18n(
 | 
			
		||||
            'icu:Preferences__ChatsPage__ChatFoldersSection__ChatFolderItem__ContextMenu__EditFolder'
 | 
			
		||||
          )}
 | 
			
		||||
        </AxoContextMenu.Item>
 | 
			
		||||
        <AxoContextMenu.Item
 | 
			
		||||
          symbol="trash"
 | 
			
		||||
          onSelect={handleSelectChatFolderDelete}
 | 
			
		||||
        >
 | 
			
		||||
          {i18n(
 | 
			
		||||
            'icu:Preferences__ChatsPage__ChatFoldersSection__ChatFolderItem__ContextMenu__DeleteFolder'
 | 
			
		||||
          )}
 | 
			
		||||
        </AxoContextMenu.Item>
 | 
			
		||||
      </AxoContextMenu.Content>
 | 
			
		||||
    </AxoContextMenu.Root>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,7 +1,7 @@
 | 
			
		|||
// Copyright 2025 Signal Messenger, LLC
 | 
			
		||||
// SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		||||
import type { MutableRefObject } from 'react';
 | 
			
		||||
import React, { useCallback, useMemo, useState } from 'react';
 | 
			
		||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
 | 
			
		||||
import type { ConversationType } from '../../../state/ducks/conversations.js';
 | 
			
		||||
import type { PreferredBadgeSelectorType } from '../../../state/selectors/badges.js';
 | 
			
		||||
import type { LocalizerType } from '../../../types/I18N.js';
 | 
			
		||||
| 
						 | 
				
			
			@ -28,12 +28,17 @@ import type {
 | 
			
		|||
import type { GetConversationByIdType } from '../../../state/selectors/conversations.js';
 | 
			
		||||
import { strictAssert } from '../../../util/assert.js';
 | 
			
		||||
import { parseStrict } from '../../../util/schemas.js';
 | 
			
		||||
import { BeforeNavigateResponse } from '../../../services/BeforeNavigate.js';
 | 
			
		||||
import { type Location } from '../../../types/Nav.js';
 | 
			
		||||
import { useNavBlocker } from '../../../hooks/useNavBlocker.js';
 | 
			
		||||
import { DeleteChatFolderDialog } from './DeleteChatFolderDialog.js';
 | 
			
		||||
 | 
			
		||||
export type PreferencesEditChatFolderPageProps = Readonly<{
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
  previousLocation: Location;
 | 
			
		||||
  existingChatFolderId: ChatFolderId | null;
 | 
			
		||||
  initChatFolderParams: ChatFolderParams;
 | 
			
		||||
  onBack: () => void;
 | 
			
		||||
  changeLocation: (location: Location) => void;
 | 
			
		||||
  conversations: ReadonlyArray<ConversationType>;
 | 
			
		||||
  preferredBadgeSelector: PreferredBadgeSelectorType;
 | 
			
		||||
  theme: ThemeType;
 | 
			
		||||
| 
						 | 
				
			
			@ -52,12 +57,13 @@ export function PreferencesEditChatFolderPage(
 | 
			
		|||
): JSX.Element {
 | 
			
		||||
  const {
 | 
			
		||||
    i18n,
 | 
			
		||||
    previousLocation,
 | 
			
		||||
    initChatFolderParams,
 | 
			
		||||
    existingChatFolderId,
 | 
			
		||||
    onCreateChatFolder,
 | 
			
		||||
    onUpdateChatFolder,
 | 
			
		||||
    onDeleteChatFolder,
 | 
			
		||||
    onBack,
 | 
			
		||||
    changeLocation,
 | 
			
		||||
    conversationSelector,
 | 
			
		||||
  } = props;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -67,7 +73,6 @@ export function PreferencesEditChatFolderPage(
 | 
			
		|||
  const [showInclusionsDialog, setShowInclusionsDialog] = useState(false);
 | 
			
		||||
  const [showExclusionsDialog, setShowExclusionsDialog] = useState(false);
 | 
			
		||||
  const [showDeleteFolderDialog, setShowDeleteFolderDialog] = useState(false);
 | 
			
		||||
  const [showSaveChangesDialog, setShowSaveChangesDialog] = useState(false);
 | 
			
		||||
 | 
			
		||||
  const normalizedChatFolderParams = useMemo(() => {
 | 
			
		||||
    return parseStrict(ChatFolderParamsSchema, chatFolderParams);
 | 
			
		||||
| 
						 | 
				
			
			@ -80,6 +85,12 @@ export function PreferencesEditChatFolderPage(
 | 
			
		|||
    );
 | 
			
		||||
  }, [initChatFolderParams, normalizedChatFolderParams]);
 | 
			
		||||
 | 
			
		||||
  const didSaveOrDiscardChangesRef = useRef(false);
 | 
			
		||||
 | 
			
		||||
  const blocker = useNavBlocker('PreferencesEditChatFoldersPage', () => {
 | 
			
		||||
    return isChanged && !didSaveOrDiscardChangesRef.current;
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const isValid = useMemo(() => {
 | 
			
		||||
    return validateChatFolderParams(normalizedChatFolderParams);
 | 
			
		||||
  }, [normalizedChatFolderParams]);
 | 
			
		||||
| 
						 | 
				
			
			@ -102,23 +113,16 @@ export function PreferencesEditChatFolderPage(
 | 
			
		|||
    });
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  const handleBackInit = useCallback(() => {
 | 
			
		||||
    if (!isChanged) {
 | 
			
		||||
      onBack();
 | 
			
		||||
    } else {
 | 
			
		||||
      setShowSaveChangesDialog(true);
 | 
			
		||||
    }
 | 
			
		||||
  }, [isChanged, onBack]);
 | 
			
		||||
  const handleBack = useCallback(() => {
 | 
			
		||||
    changeLocation(previousLocation);
 | 
			
		||||
  }, [changeLocation, previousLocation]);
 | 
			
		||||
 | 
			
		||||
  const handleDiscard = useCallback(() => {
 | 
			
		||||
    onBack();
 | 
			
		||||
  }, [onBack]);
 | 
			
		||||
  const handleDiscardAndBack = useCallback(() => {
 | 
			
		||||
    didSaveOrDiscardChangesRef.current = true;
 | 
			
		||||
    handleBack();
 | 
			
		||||
  }, [handleBack]);
 | 
			
		||||
 | 
			
		||||
  const handleSaveClose = useCallback(() => {
 | 
			
		||||
    setShowSaveChangesDialog(false);
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  const handleSave = useCallback(() => {
 | 
			
		||||
  const handleSaveChanges = useCallback(() => {
 | 
			
		||||
    strictAssert(isChanged, 'tried saving when unchanged');
 | 
			
		||||
    strictAssert(isValid, 'tried saving when invalid');
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -127,9 +131,9 @@ export function PreferencesEditChatFolderPage(
 | 
			
		|||
    } else {
 | 
			
		||||
      onCreateChatFolder(normalizedChatFolderParams);
 | 
			
		||||
    }
 | 
			
		||||
    onBack();
 | 
			
		||||
 | 
			
		||||
    didSaveOrDiscardChangesRef.current = true;
 | 
			
		||||
  }, [
 | 
			
		||||
    onBack,
 | 
			
		||||
    existingChatFolderId,
 | 
			
		||||
    isChanged,
 | 
			
		||||
    isValid,
 | 
			
		||||
| 
						 | 
				
			
			@ -138,6 +142,24 @@ export function PreferencesEditChatFolderPage(
 | 
			
		|||
    onUpdateChatFolder,
 | 
			
		||||
  ]);
 | 
			
		||||
 | 
			
		||||
  const handleSaveChangesAndBack = useCallback(() => {
 | 
			
		||||
    handleSaveChanges();
 | 
			
		||||
    handleBack();
 | 
			
		||||
  }, [handleSaveChanges, handleBack]);
 | 
			
		||||
 | 
			
		||||
  const handleBlockerCancelNavigation = useCallback(() => {
 | 
			
		||||
    blocker.respond?.(BeforeNavigateResponse.CancelNavigation);
 | 
			
		||||
  }, [blocker]);
 | 
			
		||||
 | 
			
		||||
  const handleBlockerSaveChanges = useCallback(() => {
 | 
			
		||||
    handleSaveChanges();
 | 
			
		||||
    blocker.respond?.(BeforeNavigateResponse.WaitedForUser);
 | 
			
		||||
  }, [handleSaveChanges, blocker]);
 | 
			
		||||
 | 
			
		||||
  const handleBlockerDiscardChanges = useCallback(() => {
 | 
			
		||||
    blocker.respond?.(BeforeNavigateResponse.WaitedForUser);
 | 
			
		||||
  }, [blocker]);
 | 
			
		||||
 | 
			
		||||
  const handleDeleteInit = useCallback(() => {
 | 
			
		||||
    setShowDeleteFolderDialog(true);
 | 
			
		||||
  }, []);
 | 
			
		||||
| 
						 | 
				
			
			@ -145,8 +167,8 @@ export function PreferencesEditChatFolderPage(
 | 
			
		|||
    strictAssert(existingChatFolderId, 'Missing existing chat folder id');
 | 
			
		||||
    onDeleteChatFolder(existingChatFolderId);
 | 
			
		||||
    setShowDeleteFolderDialog(false);
 | 
			
		||||
    onBack();
 | 
			
		||||
  }, [existingChatFolderId, onDeleteChatFolder, onBack]);
 | 
			
		||||
    handleBack();
 | 
			
		||||
  }, [existingChatFolderId, onDeleteChatFolder, handleBack]);
 | 
			
		||||
  const handleDeleteClose = useCallback(() => {
 | 
			
		||||
    setShowDeleteFolderDialog(false);
 | 
			
		||||
  }, []);
 | 
			
		||||
| 
						 | 
				
			
			@ -193,7 +215,7 @@ export function PreferencesEditChatFolderPage(
 | 
			
		|||
        <button
 | 
			
		||||
          aria-label={i18n('icu:goBack')}
 | 
			
		||||
          className="Preferences__back-icon"
 | 
			
		||||
          onClick={handleBackInit}
 | 
			
		||||
          onClick={handleBack}
 | 
			
		||||
          type="button"
 | 
			
		||||
        />
 | 
			
		||||
      }
 | 
			
		||||
| 
						 | 
				
			
			@ -415,16 +437,28 @@ export function PreferencesEditChatFolderPage(
 | 
			
		|||
          {showDeleteFolderDialog && (
 | 
			
		||||
            <DeleteChatFolderDialog
 | 
			
		||||
              i18n={i18n}
 | 
			
		||||
              title={i18n(
 | 
			
		||||
                'icu:Preferences__EditChatFolderPage__DeleteChatFolderDialog__Title'
 | 
			
		||||
              )}
 | 
			
		||||
              description={i18n(
 | 
			
		||||
                'icu:Preferences__EditChatFolderPage__DeleteChatFolderDialog__Description'
 | 
			
		||||
              )}
 | 
			
		||||
              cancelText={i18n(
 | 
			
		||||
                'icu:Preferences__EditChatFolderPage__DeleteChatFolderDialog__CancelButton'
 | 
			
		||||
              )}
 | 
			
		||||
              deleteText={i18n(
 | 
			
		||||
                'icu:Preferences__EditChatFolderPage__DeleteChatFolderDialog__DeleteButton'
 | 
			
		||||
              )}
 | 
			
		||||
              onConfirm={handleDeleteConfirm}
 | 
			
		||||
              onClose={handleDeleteClose}
 | 
			
		||||
            />
 | 
			
		||||
          )}
 | 
			
		||||
          {showSaveChangesDialog && (
 | 
			
		||||
          {blocker.state === 'blocked' && (
 | 
			
		||||
            <SaveChangesFolderDialog
 | 
			
		||||
              i18n={i18n}
 | 
			
		||||
              onSave={handleSave}
 | 
			
		||||
              onCancel={handleDiscard}
 | 
			
		||||
              onClose={handleSaveClose}
 | 
			
		||||
              onSave={handleBlockerSaveChanges}
 | 
			
		||||
              onDiscard={handleBlockerDiscardChanges}
 | 
			
		||||
              onClose={handleBlockerCancelNavigation}
 | 
			
		||||
            />
 | 
			
		||||
          )}
 | 
			
		||||
        </>
 | 
			
		||||
| 
						 | 
				
			
			@ -433,12 +467,15 @@ export function PreferencesEditChatFolderPage(
 | 
			
		|||
      title={i18n('icu:Preferences__EditChatFolderPage__Title')}
 | 
			
		||||
      actions={
 | 
			
		||||
        <>
 | 
			
		||||
          <Button variant={ButtonVariant.Secondary} onClick={handleDiscard}>
 | 
			
		||||
          <Button
 | 
			
		||||
            variant={ButtonVariant.Secondary}
 | 
			
		||||
            onClick={handleDiscardAndBack}
 | 
			
		||||
          >
 | 
			
		||||
            {i18n('icu:Preferences__EditChatFolderPage__CancelButton')}
 | 
			
		||||
          </Button>
 | 
			
		||||
          <Button
 | 
			
		||||
            variant={ButtonVariant.Primary}
 | 
			
		||||
            onClick={handleSave}
 | 
			
		||||
            onClick={handleSaveChangesAndBack}
 | 
			
		||||
            disabled={!(isChanged && isValid)}
 | 
			
		||||
          >
 | 
			
		||||
            {i18n('icu:Preferences__EditChatFolderPage__SaveButton')}
 | 
			
		||||
| 
						 | 
				
			
			@ -449,44 +486,10 @@ export function PreferencesEditChatFolderPage(
 | 
			
		|||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DeleteChatFolderDialog(props: {
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
  onConfirm: () => void;
 | 
			
		||||
  onClose: () => void;
 | 
			
		||||
}) {
 | 
			
		||||
  const { i18n } = props;
 | 
			
		||||
  return (
 | 
			
		||||
    <ConfirmationDialog
 | 
			
		||||
      i18n={i18n}
 | 
			
		||||
      dialogName="Preferences__EditChatFolderPage__DeleteChatFolderDialog"
 | 
			
		||||
      title={i18n(
 | 
			
		||||
        'icu:Preferences__EditChatFolderPage__DeleteChatFolderDialog__Title'
 | 
			
		||||
      )}
 | 
			
		||||
      cancelText={i18n(
 | 
			
		||||
        'icu:Preferences__EditChatFolderPage__DeleteChatFolderDialog__CancelButton'
 | 
			
		||||
      )}
 | 
			
		||||
      actions={[
 | 
			
		||||
        {
 | 
			
		||||
          text: i18n(
 | 
			
		||||
            'icu:Preferences__EditChatFolderPage__DeleteChatFolderDialog__DeleteButton'
 | 
			
		||||
          ),
 | 
			
		||||
          style: 'affirmative',
 | 
			
		||||
          action: props.onConfirm,
 | 
			
		||||
        },
 | 
			
		||||
      ]}
 | 
			
		||||
      onClose={props.onClose}
 | 
			
		||||
    >
 | 
			
		||||
      {i18n(
 | 
			
		||||
        'icu:Preferences__EditChatFolderPage__DeleteChatFolderDialog__Description'
 | 
			
		||||
      )}
 | 
			
		||||
    </ConfirmationDialog>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function SaveChangesFolderDialog(props: {
 | 
			
		||||
  i18n: LocalizerType;
 | 
			
		||||
  onSave: () => void;
 | 
			
		||||
  onCancel: () => void;
 | 
			
		||||
  onDiscard: () => void;
 | 
			
		||||
  onClose: () => void;
 | 
			
		||||
}) {
 | 
			
		||||
  const { i18n } = props;
 | 
			
		||||
| 
						 | 
				
			
			@ -510,7 +513,7 @@ function SaveChangesFolderDialog(props: {
 | 
			
		|||
          action: props.onSave,
 | 
			
		||||
        },
 | 
			
		||||
      ]}
 | 
			
		||||
      onCancel={props.onCancel}
 | 
			
		||||
      onCancel={props.onDiscard}
 | 
			
		||||
      onClose={props.onClose}
 | 
			
		||||
    >
 | 
			
		||||
      {i18n(
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										96
									
								
								ts/hooks/useNavBlocker.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										96
									
								
								ts/hooks/useNavBlocker.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,96 @@
 | 
			
		|||
// Copyright 2025 Signal Messenger, LLC
 | 
			
		||||
// SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		||||
 | 
			
		||||
import { useEffect, useRef, useState } from 'react';
 | 
			
		||||
import { BeforeNavigateResponse } from '../services/BeforeNavigate.js';
 | 
			
		||||
import type {
 | 
			
		||||
  BeforeNavigateCallback,
 | 
			
		||||
  BeforeNavigateTransitionDetails,
 | 
			
		||||
} from '../services/BeforeNavigate.js';
 | 
			
		||||
 | 
			
		||||
type NavBlockerBlocked = Readonly<{
 | 
			
		||||
  state: 'blocked';
 | 
			
		||||
  respond: (response: BeforeNavigateResponse) => void;
 | 
			
		||||
}>;
 | 
			
		||||
 | 
			
		||||
type NavBlockerUnblocked = Readonly<{
 | 
			
		||||
  state: 'unblocked';
 | 
			
		||||
  respond?: never;
 | 
			
		||||
}>;
 | 
			
		||||
 | 
			
		||||
export type NavBlocker = NavBlockerBlocked | NavBlockerUnblocked;
 | 
			
		||||
 | 
			
		||||
export type NavBlockerFunction = (
 | 
			
		||||
  details: BeforeNavigateTransitionDetails
 | 
			
		||||
) => boolean;
 | 
			
		||||
 | 
			
		||||
export type ShouldBlock = boolean | NavBlockerFunction;
 | 
			
		||||
 | 
			
		||||
function checkShouldBlock(
 | 
			
		||||
  shouldBlock: ShouldBlock,
 | 
			
		||||
  details: BeforeNavigateTransitionDetails
 | 
			
		||||
): boolean {
 | 
			
		||||
  if (typeof shouldBlock === 'function') {
 | 
			
		||||
    return shouldBlock(details);
 | 
			
		||||
  }
 | 
			
		||||
  return shouldBlock;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function useNavBlocker(
 | 
			
		||||
  name: string,
 | 
			
		||||
  shouldBlock: ShouldBlock
 | 
			
		||||
): NavBlocker {
 | 
			
		||||
  const nameRef = useRef(name);
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    nameRef.current = name;
 | 
			
		||||
  }, [name]);
 | 
			
		||||
 | 
			
		||||
  const shouldBlockRef = useRef(shouldBlock);
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    shouldBlockRef.current = shouldBlock;
 | 
			
		||||
  }, [shouldBlock]);
 | 
			
		||||
 | 
			
		||||
  const [blocker, setBlocker] = useState<NavBlocker>(() => {
 | 
			
		||||
    return { state: 'unblocked' };
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const nameValue = nameRef.current;
 | 
			
		||||
 | 
			
		||||
    const callback: BeforeNavigateCallback = async details => {
 | 
			
		||||
      const shouldBlockNav = checkShouldBlock(shouldBlockRef.current, details);
 | 
			
		||||
 | 
			
		||||
      if (!shouldBlockNav) {
 | 
			
		||||
        return BeforeNavigateResponse.Noop;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const { promise, resolve } =
 | 
			
		||||
        Promise.withResolvers<BeforeNavigateResponse>();
 | 
			
		||||
 | 
			
		||||
      function respond(response: BeforeNavigateResponse) {
 | 
			
		||||
        setBlocker({ state: 'unblocked' });
 | 
			
		||||
        resolve(response);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      setBlocker({
 | 
			
		||||
        state: 'blocked',
 | 
			
		||||
        respond,
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      return promise;
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    window.Signal.Services.beforeNavigate.registerCallback({
 | 
			
		||||
      callback,
 | 
			
		||||
      name: nameValue,
 | 
			
		||||
    });
 | 
			
		||||
    return () => {
 | 
			
		||||
      window.Signal.Services.beforeNavigate.unregisterCallback({
 | 
			
		||||
        callback,
 | 
			
		||||
        name: nameValue,
 | 
			
		||||
      });
 | 
			
		||||
    };
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  return blocker;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										3
									
								
								ts/model-types.d.ts
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								ts/model-types.d.ts
									
										
									
									
										vendored
									
									
								
							| 
						 | 
				
			
			@ -41,6 +41,7 @@ import AccessRequiredEnum = Proto.AccessControl.AccessRequired;
 | 
			
		|||
import MemberRoleEnum = Proto.Member.Role;
 | 
			
		||||
import type { MessageRequestResponseEvent } from './types/MessageRequestResponseEvent.js';
 | 
			
		||||
import type { QuotedMessageForComposerType } from './state/ducks/composer.js';
 | 
			
		||||
import type { SEALED_SENDER } from './types/SealedSender.js';
 | 
			
		||||
 | 
			
		||||
export type LastMessageStatus =
 | 
			
		||||
  | 'paused'
 | 
			
		||||
| 
						 | 
				
			
			@ -399,7 +400,7 @@ export type ConversationAttributesType = {
 | 
			
		|||
   * TODO: Rename this key to be specific to the accessKey on the conversation
 | 
			
		||||
   * It's not used for group endorsements.
 | 
			
		||||
   */
 | 
			
		||||
  sealedSender?: unknown;
 | 
			
		||||
  sealedSender?: SEALED_SENDER;
 | 
			
		||||
  sentMessageCount?: number;
 | 
			
		||||
  sharedGroupNames?: ReadonlyArray<string>;
 | 
			
		||||
  voiceNotePlaybackRate?: number;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -17,10 +17,16 @@ export enum BeforeNavigateResponse {
 | 
			
		|||
  CancelNavigation = 'CancelNavigation',
 | 
			
		||||
  TimedOut = 'TimedOut',
 | 
			
		||||
}
 | 
			
		||||
export type BeforeNavigateCallback = (options: {
 | 
			
		||||
  existingLocation?: Location;
 | 
			
		||||
 | 
			
		||||
export type BeforeNavigateTransitionDetails = Readonly<{
 | 
			
		||||
  existingLocation: Location;
 | 
			
		||||
  newLocation: Location;
 | 
			
		||||
}) => Promise<BeforeNavigateResponse>;
 | 
			
		||||
}>;
 | 
			
		||||
 | 
			
		||||
export type BeforeNavigateCallback = (
 | 
			
		||||
  details: BeforeNavigateTransitionDetails
 | 
			
		||||
) => Promise<BeforeNavigateResponse>;
 | 
			
		||||
 | 
			
		||||
export type BeforeNavigateEntry = {
 | 
			
		||||
  name: string;
 | 
			
		||||
  callback: BeforeNavigateCallback;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -88,7 +88,11 @@ import { isDone as isRegistrationDone } from '../util/registration.js';
 | 
			
		|||
import { callLinkRefreshJobQueue } from '../jobs/callLinkRefreshJobQueue.js';
 | 
			
		||||
import { isMockEnvironment } from '../environment.js';
 | 
			
		||||
import { validateConversation } from '../util/validateConversation.js';
 | 
			
		||||
import type { ChatFolder } from '../types/ChatFolder.js';
 | 
			
		||||
import {
 | 
			
		||||
  ChatFolderType,
 | 
			
		||||
  toCurrentChatFolders,
 | 
			
		||||
  type ChatFolder,
 | 
			
		||||
} from '../types/ChatFolder.js';
 | 
			
		||||
 | 
			
		||||
const { debounce, isNumber, chunk } = lodash;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -1658,6 +1662,22 @@ async function processManifest(
 | 
			
		|||
        storageVersion: null,
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    const chatFoldersHasAllChatsFolder = chatFolders.some(chatFolder => {
 | 
			
		||||
      return (
 | 
			
		||||
        chatFolder.folderType === ChatFolderType.ALL &&
 | 
			
		||||
        chatFolder.deletedAtTimestampMs === 0
 | 
			
		||||
      );
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    if (!chatFoldersHasAllChatsFolder) {
 | 
			
		||||
      log.info(`process(${version}): creating all chats chat folder`);
 | 
			
		||||
      await DataWriter.createAllChatsChatFolder();
 | 
			
		||||
      const currentChatFolders = await DataReader.getCurrentChatFolders();
 | 
			
		||||
      window.reduxActions.chatFolders.replaceAllChatFolderRecords(
 | 
			
		||||
        toCurrentChatFolders(currentChatFolders)
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  log.info(`process(${version}): done`);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1246,6 +1246,7 @@ type WritableInterface = {
 | 
			
		|||
  createDonationReceipt(profile: DonationReceipt): void;
 | 
			
		||||
 | 
			
		||||
  createChatFolder: (chatFolder: ChatFolder) => void;
 | 
			
		||||
  createAllChatsChatFolder: () => ChatFolder;
 | 
			
		||||
  updateChatFolder: (chatFolder: ChatFolder) => void;
 | 
			
		||||
  updateChatFolderPositions: (chatFolders: ReadonlyArray<ChatFolder>) => void;
 | 
			
		||||
  updateChatFolderDeletedAtTimestampMsFromSync: (
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -239,6 +239,7 @@ import {
 | 
			
		|||
  getCurrentChatFolders,
 | 
			
		||||
  getChatFolder,
 | 
			
		||||
  createChatFolder,
 | 
			
		||||
  createAllChatsChatFolder,
 | 
			
		||||
  updateChatFolder,
 | 
			
		||||
  markChatFolderDeleted,
 | 
			
		||||
  getOldestDeletedChatFolder,
 | 
			
		||||
| 
						 | 
				
			
			@ -700,6 +701,7 @@ export const DataWriter: ServerWritableInterface = {
 | 
			
		|||
  createDonationReceipt,
 | 
			
		||||
 | 
			
		||||
  createChatFolder,
 | 
			
		||||
  createAllChatsChatFolder,
 | 
			
		||||
  updateChatFolder,
 | 
			
		||||
  markChatFolderDeleted,
 | 
			
		||||
  deleteExpiredChatFolders,
 | 
			
		||||
| 
						 | 
				
			
			@ -7655,8 +7657,8 @@ function hydrateNotificationProfile(
 | 
			
		|||
    allowAllMentions: Boolean(profile.allowAllMentions),
 | 
			
		||||
    scheduleEnabled: Boolean(profile.scheduleEnabled),
 | 
			
		||||
    allowedMembers: profile.allowedMembersJson
 | 
			
		||||
      ? new Set(JSON.parse(profile.allowedMembersJson))
 | 
			
		||||
      : new Set(),
 | 
			
		||||
      ? new Set<string>(JSON.parse(profile.allowedMembersJson))
 | 
			
		||||
      : new Set<string>(),
 | 
			
		||||
    scheduleStartTime: profile.scheduleStartTime || undefined,
 | 
			
		||||
    scheduleEndTime: profile.scheduleEndTime || undefined,
 | 
			
		||||
    scheduleDaysEnabled: profile.scheduleDaysEnabledJson
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,9 +1,12 @@
 | 
			
		|||
// Copyright 2025 Signal Messenger, LLC
 | 
			
		||||
// SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		||||
import { v4 as generateUuid } from 'uuid';
 | 
			
		||||
import {
 | 
			
		||||
  type ChatFolderId,
 | 
			
		||||
  type ChatFolder,
 | 
			
		||||
  CHAT_FOLDER_DELETED_POSITION,
 | 
			
		||||
  CHAT_FOLDER_DEFAULTS,
 | 
			
		||||
  ChatFolderType,
 | 
			
		||||
} from '../../types/ChatFolder.js';
 | 
			
		||||
import type { ReadableDB, WritableDB } from '../Interface.js';
 | 
			
		||||
import { sql } from '../util.js';
 | 
			
		||||
| 
						 | 
				
			
			@ -97,8 +100,7 @@ export function getChatFolder(
 | 
			
		|||
  })();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function createChatFolder(db: WritableDB, chatFolder: ChatFolder): void {
 | 
			
		||||
  return db.transaction(() => {
 | 
			
		||||
function _insertChatFolder(db: WritableDB, chatFolder: ChatFolder): void {
 | 
			
		||||
  const chatFolderRow = chatFolderToRow(chatFolder);
 | 
			
		||||
  const [chatFolderQuery, chatFolderParams] = sql`
 | 
			
		||||
    INSERT INTO chatFolders (
 | 
			
		||||
| 
						 | 
				
			
			@ -136,6 +138,33 @@ export function createChatFolder(db: WritableDB, chatFolder: ChatFolder): void {
 | 
			
		|||
    )
 | 
			
		||||
  `;
 | 
			
		||||
  db.prepare(chatFolderQuery).run(chatFolderParams);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function createChatFolder(db: WritableDB, chatFolder: ChatFolder): void {
 | 
			
		||||
  return db.transaction(() => {
 | 
			
		||||
    _insertChatFolder(db, chatFolder);
 | 
			
		||||
  })();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function createAllChatsChatFolder(db: WritableDB): ChatFolder {
 | 
			
		||||
  return db.transaction(() => {
 | 
			
		||||
    const allChatsChatFolder: ChatFolder = {
 | 
			
		||||
      ...CHAT_FOLDER_DEFAULTS,
 | 
			
		||||
      id: generateUuid() as ChatFolderId,
 | 
			
		||||
      folderType: ChatFolderType.ALL,
 | 
			
		||||
      position: 0,
 | 
			
		||||
      deletedAtTimestampMs: 0,
 | 
			
		||||
      storageID: null,
 | 
			
		||||
      storageVersion: null,
 | 
			
		||||
      storageUnknownFields: null,
 | 
			
		||||
      storageNeedsSync: true,
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    // shift all positions over 1
 | 
			
		||||
    _resetAllChatFolderPositions(db, 1);
 | 
			
		||||
    _insertChatFolder(db, allChatsChatFolder);
 | 
			
		||||
 | 
			
		||||
    return allChatsChatFolder;
 | 
			
		||||
  })();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -187,11 +216,11 @@ export function markChatFolderDeleted(
 | 
			
		|||
      WHERE id = ${id}
 | 
			
		||||
    `;
 | 
			
		||||
    db.prepare(query).run(params);
 | 
			
		||||
    _resetAllChatFolderPositions(db);
 | 
			
		||||
    _resetAllChatFolderPositions(db, 0);
 | 
			
		||||
  })();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function _resetAllChatFolderPositions(db: WritableDB) {
 | 
			
		||||
function _resetAllChatFolderPositions(db: WritableDB, offset: number) {
 | 
			
		||||
  const [query, params] = sql`
 | 
			
		||||
    SELECT id FROM chatFolders
 | 
			
		||||
    WHERE deletedAtTimestampMs IS 0
 | 
			
		||||
| 
						 | 
				
			
			@ -204,7 +233,7 @@ function _resetAllChatFolderPositions(db: WritableDB) {
 | 
			
		|||
      const [update, updateParams] = sql`
 | 
			
		||||
        UPDATE chatFolders
 | 
			
		||||
        SET
 | 
			
		||||
          position = ${index},
 | 
			
		||||
          position = ${offset + index},
 | 
			
		||||
          storageNeedsSync = 1
 | 
			
		||||
        WHERE id = ${id}
 | 
			
		||||
      `;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -8,25 +8,43 @@ import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions.j
 | 
			
		|||
import { useBoundActions } from '../../hooks/useBoundActions.js';
 | 
			
		||||
import {
 | 
			
		||||
  ChatFolderParamsSchema,
 | 
			
		||||
  lookupCurrentChatFolder,
 | 
			
		||||
  toCurrentChatFolders,
 | 
			
		||||
  getSortedCurrentChatFolders,
 | 
			
		||||
  type ChatFolder,
 | 
			
		||||
  type ChatFolderId,
 | 
			
		||||
  type ChatFolderParams,
 | 
			
		||||
  type CurrentChatFolders,
 | 
			
		||||
} from '../../types/ChatFolder.js';
 | 
			
		||||
import { getCurrentChatFolders } from '../selectors/chatFolders.js';
 | 
			
		||||
import { DataWriter } from '../../sql/Client.js';
 | 
			
		||||
import { strictAssert } from '../../util/assert.js';
 | 
			
		||||
import { storageServiceUploadJob } from '../../services/storage.js';
 | 
			
		||||
import { parseStrict } from '../../util/schemas.js';
 | 
			
		||||
import { chatFolderCleanupService } from '../../services/expiring/chatFolderCleanupService.js';
 | 
			
		||||
import { drop } from '../../util/drop.js';
 | 
			
		||||
import {
 | 
			
		||||
  TARGETED_CONVERSATION_CHANGED,
 | 
			
		||||
  type TargetedConversationChangedActionType,
 | 
			
		||||
} from './conversations.js';
 | 
			
		||||
 | 
			
		||||
export type ChatFoldersState = ReadonlyDeep<{
 | 
			
		||||
  currentChatFolders: ReadonlyArray<ChatFolder>;
 | 
			
		||||
  currentChatFolders: CurrentChatFolders;
 | 
			
		||||
  selectedChatFolderId: ChatFolderId | null;
 | 
			
		||||
  stableSelectedConversationIdInChatFolder: string | null;
 | 
			
		||||
}>;
 | 
			
		||||
 | 
			
		||||
const CHAT_FOLDER_RECORD_REPLACE_ALL =
 | 
			
		||||
  'chatFolders/CHAT_FOLDER_RECORD_REPLACE_ALL';
 | 
			
		||||
const CHAT_FOLDER_RECORD_ADD = 'chatFolders/RECORD_ADD';
 | 
			
		||||
const CHAT_FOLDER_RECORD_REPLACE = 'chatFolders/RECORD_REPLACE';
 | 
			
		||||
const CHAT_FOLDER_RECORD_REMOVE = 'chatFolders/RECORD_REMOVE';
 | 
			
		||||
const CHAT_FOLDER_CHANGE_SELECTED_CHAT_FOLDER_ID =
 | 
			
		||||
  'chatFolders/CHANGE_SELECTED_CHAT_FOLDER_ID';
 | 
			
		||||
 | 
			
		||||
export type ChatFolderRecordReplaceAll = ReadonlyDeep<{
 | 
			
		||||
  type: typeof CHAT_FOLDER_RECORD_REPLACE_ALL;
 | 
			
		||||
  payload: CurrentChatFolders;
 | 
			
		||||
}>;
 | 
			
		||||
 | 
			
		||||
export type ChatFolderRecordAdd = ReadonlyDeep<{
 | 
			
		||||
  type: typeof CHAT_FOLDER_RECORD_ADD;
 | 
			
		||||
| 
						 | 
				
			
			@ -43,13 +61,36 @@ export type ChatFolderRecordRemove = ReadonlyDeep<{
 | 
			
		|||
  payload: ChatFolderId;
 | 
			
		||||
}>;
 | 
			
		||||
 | 
			
		||||
export type ChatFolderChangeSelectedChatFolderId = ReadonlyDeep<{
 | 
			
		||||
  type: typeof CHAT_FOLDER_CHANGE_SELECTED_CHAT_FOLDER_ID;
 | 
			
		||||
  payload: ChatFolderId | null;
 | 
			
		||||
}>;
 | 
			
		||||
 | 
			
		||||
export type ChatFolderAction = ReadonlyDeep<
 | 
			
		||||
  ChatFolderRecordAdd | ChatFolderRecordReplace | ChatFolderRecordRemove
 | 
			
		||||
  | ChatFolderRecordReplaceAll
 | 
			
		||||
  | ChatFolderRecordAdd
 | 
			
		||||
  | ChatFolderRecordReplace
 | 
			
		||||
  | ChatFolderRecordRemove
 | 
			
		||||
  | ChatFolderChangeSelectedChatFolderId
 | 
			
		||||
>;
 | 
			
		||||
 | 
			
		||||
export function getEmptyState(): ChatFoldersState {
 | 
			
		||||
  return {
 | 
			
		||||
    currentChatFolders: [],
 | 
			
		||||
    currentChatFolders: {
 | 
			
		||||
      order: [],
 | 
			
		||||
      lookup: {},
 | 
			
		||||
    },
 | 
			
		||||
    selectedChatFolderId: null,
 | 
			
		||||
    stableSelectedConversationIdInChatFolder: null,
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function replaceAllChatFolderRecords(
 | 
			
		||||
  currentChatFolders: CurrentChatFolders
 | 
			
		||||
): ChatFolderRecordReplaceAll {
 | 
			
		||||
  return {
 | 
			
		||||
    type: CHAT_FOLDER_RECORD_REPLACE_ALL,
 | 
			
		||||
    payload: currentChatFolders,
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -87,7 +128,7 @@ function createChatFolder(
 | 
			
		|||
    const chatFolder: ChatFolder = {
 | 
			
		||||
      ...chatFolderParams,
 | 
			
		||||
      id: generateUuid() as ChatFolderId,
 | 
			
		||||
      position: chatFolders.length,
 | 
			
		||||
      position: chatFolders.order.length,
 | 
			
		||||
      deletedAtTimestampMs: 0,
 | 
			
		||||
      storageID: null,
 | 
			
		||||
      storageVersion: null,
 | 
			
		||||
| 
						 | 
				
			
			@ -106,12 +147,12 @@ function updateChatFolder(
 | 
			
		|||
  chatFolderParams: ChatFolderParams
 | 
			
		||||
): ThunkAction<void, RootStateType, unknown, ChatFolderRecordReplace> {
 | 
			
		||||
  return async (dispatch, getState) => {
 | 
			
		||||
    const chatFolders = getCurrentChatFolders(getState());
 | 
			
		||||
    const currentChatFolders = getCurrentChatFolders(getState());
 | 
			
		||||
 | 
			
		||||
    const prevChatFolder = chatFolders.find(chatFolder => {
 | 
			
		||||
      return chatFolder.id === chatFolderId;
 | 
			
		||||
    });
 | 
			
		||||
    strictAssert(prevChatFolder != null, 'Missing chat folder');
 | 
			
		||||
    const prevChatFolder = lookupCurrentChatFolder(
 | 
			
		||||
      currentChatFolders,
 | 
			
		||||
      chatFolderId
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const nextChatFolder: ChatFolder = {
 | 
			
		||||
      ...prevChatFolder,
 | 
			
		||||
| 
						 | 
				
			
			@ -136,58 +177,102 @@ function deleteChatFolder(
 | 
			
		|||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function updateChatFoldersPositions(
 | 
			
		||||
  chatFolderIds: ReadonlyArray<ChatFolderId>
 | 
			
		||||
): ThunkAction<void, RootStateType, unknown, ChatFolderRecordReplaceAll> {
 | 
			
		||||
  return async (dispatch, getState) => {
 | 
			
		||||
    const currentChatFolders = getCurrentChatFolders(getState());
 | 
			
		||||
    const chatFolders = chatFolderIds.map((chatFolderId, index) => {
 | 
			
		||||
      const chatFolder = lookupCurrentChatFolder(
 | 
			
		||||
        currentChatFolders,
 | 
			
		||||
        chatFolderId
 | 
			
		||||
      );
 | 
			
		||||
      return { ...chatFolder, position: index + 1 };
 | 
			
		||||
    });
 | 
			
		||||
    await DataWriter.updateChatFolderPositions(chatFolders);
 | 
			
		||||
    storageServiceUploadJob({ reason: 'updateChatFoldersPositions' });
 | 
			
		||||
    dispatch(replaceAllChatFolderRecords(toCurrentChatFolders(chatFolders)));
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function updateSelectedChangeFolderId(
 | 
			
		||||
  chatFolderId: ChatFolderId | null
 | 
			
		||||
): ChatFolderChangeSelectedChatFolderId {
 | 
			
		||||
  return {
 | 
			
		||||
    type: CHAT_FOLDER_CHANGE_SELECTED_CHAT_FOLDER_ID,
 | 
			
		||||
    payload: chatFolderId,
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const actions = {
 | 
			
		||||
  replaceAllChatFolderRecords,
 | 
			
		||||
  addChatFolderRecord,
 | 
			
		||||
  replaceChatFolderRecord,
 | 
			
		||||
  removeChatFolderRecord,
 | 
			
		||||
  createChatFolder,
 | 
			
		||||
  updateChatFolder,
 | 
			
		||||
  deleteChatFolder,
 | 
			
		||||
  updateChatFoldersPositions,
 | 
			
		||||
  updateSelectedChangeFolderId,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const useChatFolderActions = (): BoundActionCreatorsMapObject<
 | 
			
		||||
  typeof actions
 | 
			
		||||
> => useBoundActions(actions);
 | 
			
		||||
 | 
			
		||||
function toSortedChatFolders(
 | 
			
		||||
  chatFolders: ReadonlyArray<ChatFolder>
 | 
			
		||||
): ReadonlyArray<ChatFolder> {
 | 
			
		||||
  return chatFolders.toSorted((a, b) => a.position - b.position);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function reducer(
 | 
			
		||||
  state: ChatFoldersState = getEmptyState(),
 | 
			
		||||
  action: ChatFolderAction
 | 
			
		||||
  action: ChatFolderAction | TargetedConversationChangedActionType
 | 
			
		||||
): ChatFoldersState {
 | 
			
		||||
  switch (action.type) {
 | 
			
		||||
    case CHAT_FOLDER_RECORD_REPLACE_ALL:
 | 
			
		||||
      return {
 | 
			
		||||
        ...state,
 | 
			
		||||
        currentChatFolders: action.payload,
 | 
			
		||||
      };
 | 
			
		||||
    case CHAT_FOLDER_RECORD_ADD:
 | 
			
		||||
      return {
 | 
			
		||||
        ...state,
 | 
			
		||||
        currentChatFolders: toSortedChatFolders([
 | 
			
		||||
          ...state.currentChatFolders,
 | 
			
		||||
        currentChatFolders: toCurrentChatFolders([
 | 
			
		||||
          ...getSortedCurrentChatFolders(state.currentChatFolders),
 | 
			
		||||
          action.payload,
 | 
			
		||||
        ]),
 | 
			
		||||
      };
 | 
			
		||||
    case CHAT_FOLDER_RECORD_REPLACE:
 | 
			
		||||
      return {
 | 
			
		||||
        ...state,
 | 
			
		||||
        currentChatFolders: toSortedChatFolders(
 | 
			
		||||
          state.currentChatFolders.map(chatFolder => {
 | 
			
		||||
            return chatFolder.id === action.payload.id
 | 
			
		||||
              ? action.payload
 | 
			
		||||
              : chatFolder;
 | 
			
		||||
          })
 | 
			
		||||
        currentChatFolders: toCurrentChatFolders([
 | 
			
		||||
          ...getSortedCurrentChatFolders(state.currentChatFolders).filter(
 | 
			
		||||
            chatFolder => {
 | 
			
		||||
              return chatFolder.id !== action.payload.id;
 | 
			
		||||
            }
 | 
			
		||||
          ),
 | 
			
		||||
          action.payload,
 | 
			
		||||
        ]),
 | 
			
		||||
      };
 | 
			
		||||
    case CHAT_FOLDER_RECORD_REMOVE:
 | 
			
		||||
      return {
 | 
			
		||||
        ...state,
 | 
			
		||||
        currentChatFolders: toSortedChatFolders(
 | 
			
		||||
          state.currentChatFolders.filter(chatFolder => {
 | 
			
		||||
        currentChatFolders: toCurrentChatFolders(
 | 
			
		||||
          getSortedCurrentChatFolders(state.currentChatFolders).filter(
 | 
			
		||||
            chatFolder => {
 | 
			
		||||
              return chatFolder.id !== action.payload;
 | 
			
		||||
          })
 | 
			
		||||
            }
 | 
			
		||||
          )
 | 
			
		||||
        ),
 | 
			
		||||
      };
 | 
			
		||||
    case CHAT_FOLDER_CHANGE_SELECTED_CHAT_FOLDER_ID:
 | 
			
		||||
      return {
 | 
			
		||||
        ...state,
 | 
			
		||||
        selectedChatFolderId: action.payload,
 | 
			
		||||
        stableSelectedConversationIdInChatFolder: null,
 | 
			
		||||
      };
 | 
			
		||||
    case TARGETED_CONVERSATION_CHANGED:
 | 
			
		||||
      return {
 | 
			
		||||
        ...state,
 | 
			
		||||
        stableSelectedConversationIdInChatFolder:
 | 
			
		||||
          action.payload.conversationId ?? null,
 | 
			
		||||
      };
 | 
			
		||||
    default:
 | 
			
		||||
      return state;
 | 
			
		||||
  }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -83,6 +83,7 @@ import {
 | 
			
		|||
  getMe,
 | 
			
		||||
  getMessagesByConversation,
 | 
			
		||||
  getPendingAvatarDownloadSelector,
 | 
			
		||||
  getAllConversations,
 | 
			
		||||
} from '../selectors/conversations.js';
 | 
			
		||||
import { getIntl } from '../selectors/user.js';
 | 
			
		||||
import type {
 | 
			
		||||
| 
						 | 
				
			
			@ -215,6 +216,13 @@ import { cleanupMessages } from '../../util/cleanup.js';
 | 
			
		|||
import type { ConversationModel } from '../../models/conversations.js';
 | 
			
		||||
import { MessageRequestResponseSource } from '../../types/MessageRequestResponseEvent.js';
 | 
			
		||||
import { JobCancelReason } from '../../jobs/types.js';
 | 
			
		||||
import type { ChatFolderId } from '../../types/ChatFolder.js';
 | 
			
		||||
import {
 | 
			
		||||
  isConversationInChatFolder,
 | 
			
		||||
  lookupCurrentChatFolder,
 | 
			
		||||
} from '../../types/ChatFolder.js';
 | 
			
		||||
import { getCurrentChatFolders } from '../selectors/chatFolders.js';
 | 
			
		||||
import { isConversationUnread } from '../../util/isConversationUnread.js';
 | 
			
		||||
 | 
			
		||||
const {
 | 
			
		||||
  chunk,
 | 
			
		||||
| 
						 | 
				
			
			@ -1180,6 +1188,8 @@ export const actions = {
 | 
			
		|||
  loadOlderMessages,
 | 
			
		||||
  markAttachmentAsCorrupted,
 | 
			
		||||
  markMessageRead,
 | 
			
		||||
  markConversationRead,
 | 
			
		||||
  markChatFolderRead,
 | 
			
		||||
  markOpenConversationRead,
 | 
			
		||||
  messageChanged,
 | 
			
		||||
  messageDeleted,
 | 
			
		||||
| 
						 | 
				
			
			@ -1237,6 +1247,7 @@ export const actions = {
 | 
			
		|||
  setMessageLoadingState,
 | 
			
		||||
  setMessageToEdit,
 | 
			
		||||
  setMuteExpiration,
 | 
			
		||||
  setChatFolderMuteExpiration,
 | 
			
		||||
  setPinned,
 | 
			
		||||
  setPreJoinConversation,
 | 
			
		||||
  setProfileUpdateError,
 | 
			
		||||
| 
						 | 
				
			
			@ -1448,6 +1459,57 @@ function loadOlderMessages(
 | 
			
		|||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function _getAllConversationsInChatFolder(
 | 
			
		||||
  state: RootStateType,
 | 
			
		||||
  chatFolderId: ChatFolderId
 | 
			
		||||
) {
 | 
			
		||||
  const currentChatFolders = getCurrentChatFolders(state);
 | 
			
		||||
  const chatFolder = lookupCurrentChatFolder(currentChatFolders, chatFolderId);
 | 
			
		||||
  const allConversations = getAllConversations(state);
 | 
			
		||||
  return allConversations.filter(conversation => {
 | 
			
		||||
    return isConversationInChatFolder(chatFolder, conversation);
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function markChatFolderRead(
 | 
			
		||||
  chatFolderId: ChatFolderId
 | 
			
		||||
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
 | 
			
		||||
  return async (dispatch, getState) => {
 | 
			
		||||
    const chatFolderConversations = _getAllConversationsInChatFolder(
 | 
			
		||||
      getState(),
 | 
			
		||||
      chatFolderId
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const unreadChatFolderConversations = chatFolderConversations.filter(
 | 
			
		||||
      conversation => {
 | 
			
		||||
        return isConversationUnread(conversation);
 | 
			
		||||
      }
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    for (const conversation of unreadChatFolderConversations) {
 | 
			
		||||
      dispatch(markConversationRead(conversation.id));
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function markConversationRead(
 | 
			
		||||
  conversationId: string
 | 
			
		||||
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
 | 
			
		||||
  return async dispatch => {
 | 
			
		||||
    const model = window.ConversationController.get(conversationId);
 | 
			
		||||
    strictAssert(model, 'Conversation must be found');
 | 
			
		||||
    model.setMarkedUnread(false);
 | 
			
		||||
 | 
			
		||||
    const lastMessage = await DataReader.getLastConversationMessage({
 | 
			
		||||
      conversationId,
 | 
			
		||||
    });
 | 
			
		||||
    if (lastMessage == null) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    dispatch(markMessageRead(conversationId, lastMessage.id));
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function markMessageRead(
 | 
			
		||||
  conversationId: string,
 | 
			
		||||
  messageId: string
 | 
			
		||||
| 
						 | 
				
			
			@ -1727,6 +1789,22 @@ function setDontNotifyForMentionsIfMuted(
 | 
			
		|||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function setChatFolderMuteExpiration(
 | 
			
		||||
  chatFolderId: ChatFolderId,
 | 
			
		||||
  muteExpiresAt: number
 | 
			
		||||
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
 | 
			
		||||
  return async (dispatch, getState) => {
 | 
			
		||||
    const chatFolderConversations = _getAllConversationsInChatFolder(
 | 
			
		||||
      getState(),
 | 
			
		||||
      chatFolderId
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    for (const conversation of chatFolderConversations) {
 | 
			
		||||
      dispatch(setMuteExpiration(conversation.id, muteExpiresAt));
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function setMuteExpiration(
 | 
			
		||||
  conversationId: string,
 | 
			
		||||
  muteExpiresAt = 0
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -47,10 +47,7 @@ import { createLogger } from '../../logging/log.js';
 | 
			
		|||
import { searchConversationTitles } from '../../util/searchConversationTitles.js';
 | 
			
		||||
import { isDirectConversation } from '../../util/whatTypeOfConversation.js';
 | 
			
		||||
import { isConversationSMSOnly } from '../../util/isConversationSMSOnly.js';
 | 
			
		||||
import {
 | 
			
		||||
  countConversationUnreadStats,
 | 
			
		||||
  hasUnread,
 | 
			
		||||
} from '../../util/countUnreadStats.js';
 | 
			
		||||
import { isConversationUnread } from '../../util/countUnreadStats.js';
 | 
			
		||||
 | 
			
		||||
const { debounce, omit, reject } = lodash;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -336,9 +333,7 @@ function shouldRemoveConversationFromUnreadList(
 | 
			
		|||
    conversation &&
 | 
			
		||||
    (selectedConversationId == null ||
 | 
			
		||||
      selectedConversationId !== conversation.id) &&
 | 
			
		||||
    !hasUnread(
 | 
			
		||||
      countConversationUnreadStats(conversation, { includeMuted: true })
 | 
			
		||||
    )
 | 
			
		||||
    !isConversationUnread(conversation, { includeMuted: true })
 | 
			
		||||
  ) {
 | 
			
		||||
    return true;
 | 
			
		||||
  }
 | 
			
		||||
| 
						 | 
				
			
			@ -503,11 +498,9 @@ const doSearch = debounce(
 | 
			
		|||
              selectedConversationId &&
 | 
			
		||||
              selectedConversation &&
 | 
			
		||||
              state.search.conversationIds.includes(selectedConversationId) &&
 | 
			
		||||
              !hasUnread(
 | 
			
		||||
                countConversationUnreadStats(selectedConversation, {
 | 
			
		||||
              !isConversationUnread(selectedConversation, {
 | 
			
		||||
                includeMuted: true,
 | 
			
		||||
              })
 | 
			
		||||
              )
 | 
			
		||||
                ? selectedConversation
 | 
			
		||||
                : undefined,
 | 
			
		||||
          });
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -40,6 +40,7 @@ import { getEmptyState as usernameEmptyState } from './ducks/username.js';
 | 
			
		|||
import OS from '../util/os/osMain.js';
 | 
			
		||||
import { getInteractionMode } from '../services/InteractionMode.js';
 | 
			
		||||
import { makeLookup } from '../util/makeLookup.js';
 | 
			
		||||
import { toCurrentChatFolders } from '../types/ChatFolder.js';
 | 
			
		||||
 | 
			
		||||
import type { StateType } from './reducer.js';
 | 
			
		||||
import type { MainWindowStatsType } from '../windows/context.js';
 | 
			
		||||
| 
						 | 
				
			
			@ -91,7 +92,7 @@ export function getInitialState(
 | 
			
		|||
    },
 | 
			
		||||
    chatFolders: {
 | 
			
		||||
      ...chatFoldersEmptyState(),
 | 
			
		||||
      currentChatFolders: chatFolders,
 | 
			
		||||
      currentChatFolders: toCurrentChatFolders(chatFolders),
 | 
			
		||||
    },
 | 
			
		||||
    donations,
 | 
			
		||||
    emojis: recentEmoji,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -9,11 +9,14 @@ import type { Store } from 'redux';
 | 
			
		|||
 | 
			
		||||
import { SmartApp } from '../smart/App.js';
 | 
			
		||||
import { SmartVoiceNotesPlaybackProvider } from '../smart/VoiceNotesPlaybackProvider.js';
 | 
			
		||||
import { AxoProvider } from '../../axo/AxoProvider.js';
 | 
			
		||||
 | 
			
		||||
export const createApp = (store: Store): ReactElement => (
 | 
			
		||||
  <AxoProvider dir={window.i18n.getLocaleDirection()}>
 | 
			
		||||
    <Provider store={store}>
 | 
			
		||||
      <SmartVoiceNotesPlaybackProvider>
 | 
			
		||||
        <SmartApp />
 | 
			
		||||
      </SmartVoiceNotesPlaybackProvider>
 | 
			
		||||
    </Provider>
 | 
			
		||||
  </AxoProvider>
 | 
			
		||||
);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -3,15 +3,44 @@
 | 
			
		|||
 | 
			
		||||
import { createSelector } from 'reselect';
 | 
			
		||||
import type { StateType } from '../reducer.js';
 | 
			
		||||
import type { StateSelector } from '../types.js';
 | 
			
		||||
import type { ChatFoldersState } from '../ducks/chatFolders.js';
 | 
			
		||||
import type { CurrentChatFolders, ChatFolder } from '../../types/ChatFolder.js';
 | 
			
		||||
import {
 | 
			
		||||
  getSortedCurrentChatFolders,
 | 
			
		||||
  lookupCurrentChatFolder,
 | 
			
		||||
} from '../../types/ChatFolder.js';
 | 
			
		||||
 | 
			
		||||
export function getChatFoldersState(state: StateType): ChatFoldersState {
 | 
			
		||||
  return state.chatFolders;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const getCurrentChatFolders = createSelector(
 | 
			
		||||
  getChatFoldersState,
 | 
			
		||||
  state => {
 | 
			
		||||
export const getCurrentChatFolders: StateSelector<CurrentChatFolders> =
 | 
			
		||||
  createSelector(getChatFoldersState, state => {
 | 
			
		||||
    return state.currentChatFolders;
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
export const getSortedChatFolders: StateSelector<ReadonlyArray<ChatFolder>> =
 | 
			
		||||
  createSelector(getCurrentChatFolders, currentChatFolders => {
 | 
			
		||||
    return getSortedCurrentChatFolders(currentChatFolders);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
export const getSelectedChatFolder: StateSelector<ChatFolder | null> =
 | 
			
		||||
  createSelector(
 | 
			
		||||
    getChatFoldersState,
 | 
			
		||||
    getCurrentChatFolders,
 | 
			
		||||
    (state, currentChatFolders) => {
 | 
			
		||||
      const selectedChatFolderId =
 | 
			
		||||
        state.selectedChatFolderId ?? currentChatFolders.order.at(0);
 | 
			
		||||
      if (selectedChatFolderId == null) {
 | 
			
		||||
        return null;
 | 
			
		||||
      }
 | 
			
		||||
);
 | 
			
		||||
      return lookupCurrentChatFolder(currentChatFolders, selectedChatFolderId);
 | 
			
		||||
    }
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
export const getStableSelectedConversationIdInChatFolder: StateSelector<
 | 
			
		||||
  string | null
 | 
			
		||||
> = createSelector(getChatFoldersState, state => {
 | 
			
		||||
  return state.stableSelectedConversationIdInChatFolder;
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -4,9 +4,8 @@
 | 
			
		|||
import memoizee from 'memoizee';
 | 
			
		||||
import lodash from 'lodash';
 | 
			
		||||
import { createSelector } from 'reselect';
 | 
			
		||||
 | 
			
		||||
import type { StateType } from '../reducer.js';
 | 
			
		||||
 | 
			
		||||
import type { StateSelector } from '../types.js';
 | 
			
		||||
import type {
 | 
			
		||||
  ConversationLookupType,
 | 
			
		||||
  ConversationMessageType,
 | 
			
		||||
| 
						 | 
				
			
			@ -64,10 +63,25 @@ import type { HasStories } from '../../types/Stories.js';
 | 
			
		|||
import { getHasStoriesSelector } from './stories2.js';
 | 
			
		||||
import { canEditMessage } from '../../util/canEditMessage.js';
 | 
			
		||||
import { isOutgoing } from '../../messages/helpers.js';
 | 
			
		||||
import {
 | 
			
		||||
  countAllConversationsUnreadStats,
 | 
			
		||||
  type UnreadStats,
 | 
			
		||||
import type {
 | 
			
		||||
  AllChatFoldersUnreadStats,
 | 
			
		||||
  UnreadStats,
 | 
			
		||||
} from '../../util/countUnreadStats.js';
 | 
			
		||||
import {
 | 
			
		||||
  isConversationInChatFolder,
 | 
			
		||||
  type ChatFolder,
 | 
			
		||||
} from '../../types/ChatFolder.js';
 | 
			
		||||
import {
 | 
			
		||||
  getSelectedChatFolder,
 | 
			
		||||
  getSortedChatFolders,
 | 
			
		||||
  getStableSelectedConversationIdInChatFolder,
 | 
			
		||||
} from './chatFolders.js';
 | 
			
		||||
import {
 | 
			
		||||
  countAllChatFoldersUnreadStats,
 | 
			
		||||
  countAllConversationsUnreadStats,
 | 
			
		||||
} from '../../util/countUnreadStats.js';
 | 
			
		||||
import type { AllChatFoldersMutedStats } from '../../util/countMutedStats.js';
 | 
			
		||||
import { countAllChatFoldersMutedStats } from '../../util/countMutedStats.js';
 | 
			
		||||
 | 
			
		||||
const { isNumber, pick } = lodash;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -364,21 +378,71 @@ type LeftPaneLists = Readonly<{
 | 
			
		|||
  pinnedConversations: ReadonlyArray<ConversationType>;
 | 
			
		||||
}>;
 | 
			
		||||
 | 
			
		||||
export const _getLeftPaneLists = (
 | 
			
		||||
  lookup: ConversationLookupType,
 | 
			
		||||
  comparator: (left: ConversationType, right: ConversationType) => number,
 | 
			
		||||
  selectedConversation?: string,
 | 
			
		||||
  pinnedConversationIds?: ReadonlyArray<string>
 | 
			
		||||
): LeftPaneLists => {
 | 
			
		||||
function _shouldIncludeInChatFolder(
 | 
			
		||||
  conversation: ConversationType,
 | 
			
		||||
  selectedChatFolder: ChatFolder | null,
 | 
			
		||||
  stableSelectedConversationIdInChatFolder: string | null
 | 
			
		||||
): boolean {
 | 
			
		||||
  if (selectedChatFolder == null) {
 | 
			
		||||
    return true;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // This keeps conversation items from instantly disappearing from the left
 | 
			
		||||
  // pane list when you open them and they get marked read
 | 
			
		||||
  if (
 | 
			
		||||
    stableSelectedConversationIdInChatFolder != null &&
 | 
			
		||||
    conversation.id === stableSelectedConversationIdInChatFolder
 | 
			
		||||
  ) {
 | 
			
		||||
    return true;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (isConversationInChatFolder(selectedChatFolder, conversation)) {
 | 
			
		||||
    return true;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return false;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type GetLeftPaneListsProps = Readonly<{
 | 
			
		||||
  conversationLookup: ConversationLookupType;
 | 
			
		||||
  conversationComparator: (
 | 
			
		||||
    left: ConversationType,
 | 
			
		||||
    right: ConversationType
 | 
			
		||||
  ) => number;
 | 
			
		||||
  selectedConversationId: string | undefined;
 | 
			
		||||
  pinnedConversationIds: ReadonlyArray<string> | null;
 | 
			
		||||
  selectedChatFolder: ChatFolder | null;
 | 
			
		||||
  stableSelectedConversationIdInChatFolder: string | null;
 | 
			
		||||
}>;
 | 
			
		||||
 | 
			
		||||
export const _getLeftPaneLists = ({
 | 
			
		||||
  conversationLookup,
 | 
			
		||||
  conversationComparator,
 | 
			
		||||
  selectedConversationId,
 | 
			
		||||
  pinnedConversationIds,
 | 
			
		||||
  selectedChatFolder,
 | 
			
		||||
  stableSelectedConversationIdInChatFolder,
 | 
			
		||||
}: GetLeftPaneListsProps): LeftPaneLists => {
 | 
			
		||||
  const conversations: Array<ConversationType> = [];
 | 
			
		||||
  const archivedConversations: Array<ConversationType> = [];
 | 
			
		||||
  const pinnedConversations: Array<ConversationType> = [];
 | 
			
		||||
 | 
			
		||||
  const values = Object.values(lookup);
 | 
			
		||||
  const values = Object.values(conversationLookup);
 | 
			
		||||
  const max = values.length;
 | 
			
		||||
  for (let i = 0; i < max; i += 1) {
 | 
			
		||||
    let conversation = values[i];
 | 
			
		||||
    if (selectedConversation === conversation.id) {
 | 
			
		||||
 | 
			
		||||
    if (
 | 
			
		||||
      !_shouldIncludeInChatFolder(
 | 
			
		||||
        conversation,
 | 
			
		||||
        selectedChatFolder,
 | 
			
		||||
        stableSelectedConversationIdInChatFolder
 | 
			
		||||
      )
 | 
			
		||||
    ) {
 | 
			
		||||
      continue;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (selectedConversationId === conversation.id) {
 | 
			
		||||
      conversation = {
 | 
			
		||||
        ...conversation,
 | 
			
		||||
        isSelected: true,
 | 
			
		||||
| 
						 | 
				
			
			@ -400,8 +464,8 @@ export const _getLeftPaneLists = (
 | 
			
		|||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  conversations.sort(comparator);
 | 
			
		||||
  archivedConversations.sort(comparator);
 | 
			
		||||
  conversations.sort(conversationComparator);
 | 
			
		||||
  archivedConversations.sort(conversationComparator);
 | 
			
		||||
 | 
			
		||||
  pinnedConversations.sort(
 | 
			
		||||
    (a, b) =>
 | 
			
		||||
| 
						 | 
				
			
			@ -417,7 +481,25 @@ export const getLeftPaneLists = createSelector(
 | 
			
		|||
  getConversationComparator,
 | 
			
		||||
  getSelectedConversationId,
 | 
			
		||||
  getPinnedConversationIds,
 | 
			
		||||
  _getLeftPaneLists
 | 
			
		||||
  getSelectedChatFolder,
 | 
			
		||||
  getStableSelectedConversationIdInChatFolder,
 | 
			
		||||
  (
 | 
			
		||||
    conversationLookup,
 | 
			
		||||
    conversationComparator,
 | 
			
		||||
    selectedConversationId,
 | 
			
		||||
    pinnedConversationIds,
 | 
			
		||||
    selectedChatFolder,
 | 
			
		||||
    stableSelectedConversationIdInChatFolder
 | 
			
		||||
  ) => {
 | 
			
		||||
    return _getLeftPaneLists({
 | 
			
		||||
      conversationLookup,
 | 
			
		||||
      conversationComparator,
 | 
			
		||||
      selectedConversationId,
 | 
			
		||||
      pinnedConversationIds,
 | 
			
		||||
      selectedChatFolder,
 | 
			
		||||
      stableSelectedConversationIdInChatFolder,
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export const getMaximumGroupSizeModalState = createSelector(
 | 
			
		||||
| 
						 | 
				
			
			@ -615,6 +697,30 @@ export const getAllConversationsUnreadStats = createSelector(
 | 
			
		|||
  }
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export const getAllChatFoldersUnreadStats: StateSelector<AllChatFoldersUnreadStats> =
 | 
			
		||||
  createSelector(
 | 
			
		||||
    getSortedChatFolders,
 | 
			
		||||
    getAllConversations,
 | 
			
		||||
    (sortedChatFolders, allConversations) => {
 | 
			
		||||
      return countAllChatFoldersUnreadStats(
 | 
			
		||||
        sortedChatFolders,
 | 
			
		||||
        allConversations,
 | 
			
		||||
        {
 | 
			
		||||
          includeMuted: false,
 | 
			
		||||
        }
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
export const getAllChatFoldersMutedStats: StateSelector<AllChatFoldersMutedStats> =
 | 
			
		||||
  createSelector(
 | 
			
		||||
    getSortedChatFolders,
 | 
			
		||||
    getAllConversations,
 | 
			
		||||
    (sortedChatFolders, allConversations) => {
 | 
			
		||||
      return countAllChatFoldersMutedStats(sortedChatFolders, allConversations);
 | 
			
		||||
    }
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * getComposableContacts/getCandidateContactsForNewGroup both return contacts for the
 | 
			
		||||
 * composer and group members, a different list from your primary system contacts.
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -36,12 +36,13 @@ export const getOtherTabsUnreadStats = createSelector(
 | 
			
		|||
  ): UnreadStats => {
 | 
			
		||||
    let unreadCount = 0;
 | 
			
		||||
    let unreadMentionsCount = 0;
 | 
			
		||||
    let markedUnread = false;
 | 
			
		||||
    let readChatsMarkedUnreadCount = 0;
 | 
			
		||||
 | 
			
		||||
    if (selectedNavTab !== NavTab.Chats) {
 | 
			
		||||
      unreadCount += conversationsUnreadStats.unreadCount;
 | 
			
		||||
      unreadMentionsCount += conversationsUnreadStats.unreadMentionsCount;
 | 
			
		||||
      markedUnread ||= conversationsUnreadStats.markedUnread;
 | 
			
		||||
      readChatsMarkedUnreadCount +=
 | 
			
		||||
        conversationsUnreadStats.readChatsMarkedUnreadCount;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Note: Conversation unread stats includes the call history unread count.
 | 
			
		||||
| 
						 | 
				
			
			@ -56,7 +57,7 @@ export const getOtherTabsUnreadStats = createSelector(
 | 
			
		|||
    return {
 | 
			
		||||
      unreadCount,
 | 
			
		||||
      unreadMentionsCount,
 | 
			
		||||
      markedUnread,
 | 
			
		||||
      readChatsMarkedUnreadCount,
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -110,10 +110,18 @@ import {
 | 
			
		|||
  resumeBackupMediaDownload,
 | 
			
		||||
} from '../../util/backupMediaDownload.js';
 | 
			
		||||
import { useNavActions } from '../ducks/nav.js';
 | 
			
		||||
import { SmartLeftPaneChatFolders } from './LeftPaneChatFolders.js';
 | 
			
		||||
import { SmartLeftPaneConversationListItemContextMenu } from './LeftPaneConversationListItemContextMenu.js';
 | 
			
		||||
import type { RenderConversationListItemContextMenuProps } from '../../components/conversationList/BaseConversationListItem.js';
 | 
			
		||||
 | 
			
		||||
function renderMessageSearchResult(id: string): JSX.Element {
 | 
			
		||||
  return <SmartMessageSearchResult id={id} />;
 | 
			
		||||
}
 | 
			
		||||
function renderConversationListItemContextMenu(
 | 
			
		||||
  props: RenderConversationListItemContextMenuProps
 | 
			
		||||
): JSX.Element {
 | 
			
		||||
  return <SmartLeftPaneConversationListItemContextMenu {...props} />;
 | 
			
		||||
}
 | 
			
		||||
function renderNetworkStatus(
 | 
			
		||||
  props: Readonly<{ containerWidthBreakpoint: WidthBreakpoint }>
 | 
			
		||||
): JSX.Element {
 | 
			
		||||
| 
						 | 
				
			
			@ -140,6 +148,9 @@ function renderExpiredBuildDialog(
 | 
			
		|||
): JSX.Element {
 | 
			
		||||
  return <DialogExpiredBuild {...props} />;
 | 
			
		||||
}
 | 
			
		||||
function renderLeftPaneChatFolders(): JSX.Element {
 | 
			
		||||
  return <SmartLeftPaneChatFolders />;
 | 
			
		||||
}
 | 
			
		||||
function renderUnsupportedOSDialog(
 | 
			
		||||
  props: Readonly<SmartUnsupportedOSDialogPropsType>
 | 
			
		||||
): JSX.Element {
 | 
			
		||||
| 
						 | 
				
			
			@ -420,7 +431,11 @@ export const SmartLeftPane = memo(function SmartLeftPane({
 | 
			
		|||
      renderCaptchaDialog={renderCaptchaDialog}
 | 
			
		||||
      renderCrashReportDialog={renderCrashReportDialog}
 | 
			
		||||
      renderExpiredBuildDialog={renderExpiredBuildDialog}
 | 
			
		||||
      renderLeftPaneChatFolders={renderLeftPaneChatFolders}
 | 
			
		||||
      renderMessageSearchResult={renderMessageSearchResult}
 | 
			
		||||
      renderConversationListItemContextMenu={
 | 
			
		||||
        renderConversationListItemContextMenu
 | 
			
		||||
      }
 | 
			
		||||
      renderNetworkStatus={renderNetworkStatus}
 | 
			
		||||
      renderRelinkDialog={renderRelinkDialog}
 | 
			
		||||
      renderToastManager={renderToastManager}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										76
									
								
								ts/state/smart/LeftPaneChatFolders.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								ts/state/smart/LeftPaneChatFolders.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,76 @@
 | 
			
		|||
// Copyright 2025 Signal Messenger, LLC
 | 
			
		||||
// SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		||||
import React, { memo, useCallback, useContext } from 'react';
 | 
			
		||||
import { useSelector } from 'react-redux';
 | 
			
		||||
import { LeftPaneChatFolders } from '../../components/leftPane/LeftPaneChatFolders.js';
 | 
			
		||||
import {
 | 
			
		||||
  getSelectedChatFolder,
 | 
			
		||||
  getSortedChatFolders,
 | 
			
		||||
} from '../selectors/chatFolders.js';
 | 
			
		||||
import { getIntl } from '../selectors/user.js';
 | 
			
		||||
import {
 | 
			
		||||
  getAllChatFoldersMutedStats,
 | 
			
		||||
  getAllChatFoldersUnreadStats,
 | 
			
		||||
} from '../selectors/conversations.js';
 | 
			
		||||
import { useChatFolderActions } from '../ducks/chatFolders.js';
 | 
			
		||||
import { NavSidebarWidthBreakpointContext } from '../../components/NavSidebar.js';
 | 
			
		||||
import { useNavActions } from '../ducks/nav.js';
 | 
			
		||||
import { NavTab, SettingsPage } from '../../types/Nav.js';
 | 
			
		||||
import {
 | 
			
		||||
  isChatFoldersEnabled,
 | 
			
		||||
  type ChatFolderId,
 | 
			
		||||
} from '../../types/ChatFolder.js';
 | 
			
		||||
import { getSelectedLocation } from '../selectors/nav.js';
 | 
			
		||||
import { useConversationsActions } from '../ducks/conversations.js';
 | 
			
		||||
 | 
			
		||||
export const SmartLeftPaneChatFolders = memo(
 | 
			
		||||
  function SmartLeftPaneChatFolders() {
 | 
			
		||||
    const i18n = useSelector(getIntl);
 | 
			
		||||
    const sortedChatFolders = useSelector(getSortedChatFolders);
 | 
			
		||||
    const allChatFoldersUnreadStats = useSelector(getAllChatFoldersUnreadStats);
 | 
			
		||||
    const allChatFoldersMutedStats = useSelector(getAllChatFoldersMutedStats);
 | 
			
		||||
    const selectedChatFolder = useSelector(getSelectedChatFolder);
 | 
			
		||||
    const navSidebarWidthBreakpoint = useContext(
 | 
			
		||||
      NavSidebarWidthBreakpointContext
 | 
			
		||||
    );
 | 
			
		||||
    const location = useSelector(getSelectedLocation);
 | 
			
		||||
 | 
			
		||||
    const { updateSelectedChangeFolderId } = useChatFolderActions();
 | 
			
		||||
    const { changeLocation } = useNavActions();
 | 
			
		||||
    const { markChatFolderRead, setChatFolderMuteExpiration } =
 | 
			
		||||
      useConversationsActions();
 | 
			
		||||
 | 
			
		||||
    const handleChatFolderOpenSettings = useCallback(
 | 
			
		||||
      (chatFolderId: ChatFolderId) => {
 | 
			
		||||
        changeLocation({
 | 
			
		||||
          tab: NavTab.Settings,
 | 
			
		||||
          details: {
 | 
			
		||||
            page: SettingsPage.EditChatFolder,
 | 
			
		||||
            chatFolderId,
 | 
			
		||||
            previousLocation: location,
 | 
			
		||||
          },
 | 
			
		||||
        });
 | 
			
		||||
      },
 | 
			
		||||
      [changeLocation, location]
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    if (!isChatFoldersEnabled()) {
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <LeftPaneChatFolders
 | 
			
		||||
        i18n={i18n}
 | 
			
		||||
        navSidebarWidthBreakpoint={navSidebarWidthBreakpoint}
 | 
			
		||||
        sortedChatFolders={sortedChatFolders}
 | 
			
		||||
        allChatFoldersUnreadStats={allChatFoldersUnreadStats}
 | 
			
		||||
        allChatFoldersMutedStats={allChatFoldersMutedStats}
 | 
			
		||||
        selectedChatFolder={selectedChatFolder}
 | 
			
		||||
        onSelectedChatFolderIdChange={updateSelectedChangeFolderId}
 | 
			
		||||
        onChatFolderMarkRead={markChatFolderRead}
 | 
			
		||||
        onChatFolderUpdateMute={setChatFolderMuteExpiration}
 | 
			
		||||
        onChatFolderOpenSettings={handleChatFolderOpenSettings}
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
);
 | 
			
		||||
							
								
								
									
										71
									
								
								ts/state/smart/LeftPaneConversationListItemContextMenu.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								ts/state/smart/LeftPaneConversationListItemContextMenu.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,71 @@
 | 
			
		|||
// Copyright 2025 Signal Messenger, LLC
 | 
			
		||||
// SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		||||
 | 
			
		||||
import type { FC } from 'react';
 | 
			
		||||
import React, { memo, useCallback } from 'react';
 | 
			
		||||
import { useSelector } from 'react-redux';
 | 
			
		||||
import { getIntl } from '../selectors/user.js';
 | 
			
		||||
import { getConversationByIdSelector } from '../selectors/conversations.js';
 | 
			
		||||
import { LeftPaneConversationListItemContextMenu } from '../../components/leftPane/LeftPaneConversationListItemContextMenu.js';
 | 
			
		||||
import { strictAssert } from '../../util/assert.js';
 | 
			
		||||
import type { RenderConversationListItemContextMenuProps } from '../../components/conversationList/BaseConversationListItem.js';
 | 
			
		||||
import { useConversationsActions } from '../ducks/conversations.js';
 | 
			
		||||
import { getLocalDeleteWarningShown } from '../selectors/items.js';
 | 
			
		||||
import { useItemsActions } from '../ducks/items.js';
 | 
			
		||||
 | 
			
		||||
export const SmartLeftPaneConversationListItemContextMenu: FC<RenderConversationListItemContextMenuProps> =
 | 
			
		||||
  memo(function SmartLeftPaneConversationListItemContextMenu(props) {
 | 
			
		||||
    const i18n = useSelector(getIntl);
 | 
			
		||||
    const conversationByIdSelector = useSelector(getConversationByIdSelector);
 | 
			
		||||
    const localDeleteWarningShown = useSelector(getLocalDeleteWarningShown);
 | 
			
		||||
    const {
 | 
			
		||||
      onMarkUnread,
 | 
			
		||||
      markConversationRead,
 | 
			
		||||
      setPinned,
 | 
			
		||||
      onArchive,
 | 
			
		||||
      onMoveToInbox,
 | 
			
		||||
      deleteConversation,
 | 
			
		||||
      setMuteExpiration,
 | 
			
		||||
    } = useConversationsActions();
 | 
			
		||||
    const { putItem } = useItemsActions();
 | 
			
		||||
 | 
			
		||||
    const setLocalDeleteWarningShown = useCallback(() => {
 | 
			
		||||
      putItem('localDeleteWarningShown', true);
 | 
			
		||||
    }, [putItem]);
 | 
			
		||||
 | 
			
		||||
    const conversation = conversationByIdSelector(props.conversationId);
 | 
			
		||||
    strictAssert(conversation, 'Missing conversation');
 | 
			
		||||
 | 
			
		||||
    const handlePin = useCallback(
 | 
			
		||||
      (conversationId: string) => {
 | 
			
		||||
        setPinned(conversationId, true);
 | 
			
		||||
      },
 | 
			
		||||
      [setPinned]
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const handleUnpin = useCallback(
 | 
			
		||||
      (conversationId: string) => {
 | 
			
		||||
        setPinned(conversationId, false);
 | 
			
		||||
      },
 | 
			
		||||
      [setPinned]
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <LeftPaneConversationListItemContextMenu
 | 
			
		||||
        i18n={i18n}
 | 
			
		||||
        conversation={conversation}
 | 
			
		||||
        onMarkUnread={onMarkUnread}
 | 
			
		||||
        onMarkRead={markConversationRead}
 | 
			
		||||
        onPin={handlePin}
 | 
			
		||||
        onUnpin={handleUnpin}
 | 
			
		||||
        onUpdateMute={setMuteExpiration}
 | 
			
		||||
        onArchive={onArchive}
 | 
			
		||||
        onUnarchive={onMoveToInbox}
 | 
			
		||||
        onDelete={deleteConversation}
 | 
			
		||||
        localDeleteWarningShown={localDeleteWarningShown}
 | 
			
		||||
        setLocalDeleteWarningShown={setLocalDeleteWarningShown}
 | 
			
		||||
      >
 | 
			
		||||
        {props.children}
 | 
			
		||||
      </LeftPaneConversationListItemContextMenu>
 | 
			
		||||
    );
 | 
			
		||||
  });
 | 
			
		||||
| 
						 | 
				
			
			@ -64,7 +64,8 @@ import {
 | 
			
		|||
import { getPreferredBadgeSelector } from '../selectors/badges.js';
 | 
			
		||||
import { SmartProfileEditor } from './ProfileEditor.js';
 | 
			
		||||
import { useNavActions } from '../ducks/nav.js';
 | 
			
		||||
import { NavTab, ProfileEditorPage, SettingsPage } from '../../types/Nav.js';
 | 
			
		||||
import type { SettingsLocation } from '../../types/Nav.js';
 | 
			
		||||
import { NavTab } from '../../types/Nav.js';
 | 
			
		||||
import { SmartToastManager } from './ToastManager.js';
 | 
			
		||||
import { useToastActions } from '../ducks/toast.js';
 | 
			
		||||
import { DataReader } from '../../sql/Client.js';
 | 
			
		||||
| 
						 | 
				
			
			@ -127,19 +128,19 @@ function renderToastManager(props: {
 | 
			
		|||
 | 
			
		||||
function renderDonationsPane({
 | 
			
		||||
  contentsRef,
 | 
			
		||||
  page,
 | 
			
		||||
  setPage,
 | 
			
		||||
  settingsLocation,
 | 
			
		||||
  setSettingsLocation,
 | 
			
		||||
}: {
 | 
			
		||||
  contentsRef: MutableRefObject<HTMLDivElement | null>;
 | 
			
		||||
  page: SettingsPage;
 | 
			
		||||
  setPage: (page: SettingsPage) => void;
 | 
			
		||||
  settingsLocation: SettingsLocation;
 | 
			
		||||
  setSettingsLocation: (settingsLocation: SettingsLocation) => void;
 | 
			
		||||
}): JSX.Element {
 | 
			
		||||
  return (
 | 
			
		||||
    <DonationsErrorBoundary>
 | 
			
		||||
      <SmartPreferencesDonations
 | 
			
		||||
        contentsRef={contentsRef}
 | 
			
		||||
        page={page}
 | 
			
		||||
        setPage={setPage}
 | 
			
		||||
        settingsLocation={settingsLocation}
 | 
			
		||||
        setSettingsLocation={setSettingsLocation}
 | 
			
		||||
      />
 | 
			
		||||
    </DonationsErrorBoundary>
 | 
			
		||||
  );
 | 
			
		||||
| 
						 | 
				
			
			@ -719,24 +720,12 @@ export function SmartPreferences(): JSX.Element | null {
 | 
			
		|||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const { page } = currentLocation.details;
 | 
			
		||||
  const setPage = (newPage: SettingsPage, editState?: ProfileEditorPage) => {
 | 
			
		||||
    if (newPage === SettingsPage.Profile) {
 | 
			
		||||
      changeLocation({
 | 
			
		||||
        tab: NavTab.Settings,
 | 
			
		||||
        details: {
 | 
			
		||||
          page: newPage,
 | 
			
		||||
          state: editState || ProfileEditorPage.None,
 | 
			
		||||
        },
 | 
			
		||||
      });
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
  const settingsLocation = currentLocation.details;
 | 
			
		||||
 | 
			
		||||
  const setSettingsLocation = (location: SettingsLocation) => {
 | 
			
		||||
    changeLocation({
 | 
			
		||||
      tab: NavTab.Settings,
 | 
			
		||||
      details: {
 | 
			
		||||
        page: newPage,
 | 
			
		||||
      },
 | 
			
		||||
      details: location,
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -873,7 +862,7 @@ export function SmartPreferences(): JSX.Element | null {
 | 
			
		|||
          onWhoCanSeeMeChange={onWhoCanSeeMeChange}
 | 
			
		||||
          onZoomFactorChange={onZoomFactorChange}
 | 
			
		||||
          otherTabsUnreadStats={otherTabsUnreadStats}
 | 
			
		||||
          page={page}
 | 
			
		||||
          settingsLocation={settingsLocation}
 | 
			
		||||
          pickLocalBackupFolder={pickLocalBackupFolder}
 | 
			
		||||
          preferredSystemLocales={preferredSystemLocales}
 | 
			
		||||
          preferredWidthFromStorage={preferredWidthFromStorage}
 | 
			
		||||
| 
						 | 
				
			
			@ -902,7 +891,7 @@ export function SmartPreferences(): JSX.Element | null {
 | 
			
		|||
          selectedSpeaker={selectedSpeaker}
 | 
			
		||||
          sentMediaQualitySetting={sentMediaQualitySetting}
 | 
			
		||||
          setGlobalDefaultConversationColor={setGlobalDefaultConversationColor}
 | 
			
		||||
          setPage={setPage}
 | 
			
		||||
          setSettingsLocation={setSettingsLocation}
 | 
			
		||||
          shouldShowUpdateDialog={shouldShowUpdateDialog}
 | 
			
		||||
          showToast={showToast}
 | 
			
		||||
          theme={theme}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -5,7 +5,7 @@ import { useSelector } from 'react-redux';
 | 
			
		|||
import type { PreferencesChatFoldersPageProps } from '../../components/preferences/chatFolders/PreferencesChatFoldersPage.js';
 | 
			
		||||
import { PreferencesChatFoldersPage } from '../../components/preferences/chatFolders/PreferencesChatFoldersPage.js';
 | 
			
		||||
import { getIntl } from '../selectors/user.js';
 | 
			
		||||
import { getCurrentChatFolders } from '../selectors/chatFolders.js';
 | 
			
		||||
import { getSortedChatFolders } from '../selectors/chatFolders.js';
 | 
			
		||||
import type { ChatFolderId } from '../../types/ChatFolder.js';
 | 
			
		||||
import { useChatFolderActions } from '../ducks/chatFolders.js';
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -19,8 +19,9 @@ export function SmartPreferencesChatFoldersPage(
 | 
			
		|||
  props: SmartPreferencesChatFoldersPageProps
 | 
			
		||||
): JSX.Element {
 | 
			
		||||
  const i18n = useSelector(getIntl);
 | 
			
		||||
  const chatFolders = useSelector(getCurrentChatFolders);
 | 
			
		||||
  const { createChatFolder } = useChatFolderActions();
 | 
			
		||||
  const chatFolders = useSelector(getSortedChatFolders);
 | 
			
		||||
  const { createChatFolder, deleteChatFolder, updateChatFoldersPositions } =
 | 
			
		||||
    useChatFolderActions();
 | 
			
		||||
  return (
 | 
			
		||||
    <PreferencesChatFoldersPage
 | 
			
		||||
      i18n={i18n}
 | 
			
		||||
| 
						 | 
				
			
			@ -29,6 +30,8 @@ export function SmartPreferencesChatFoldersPage(
 | 
			
		|||
      onOpenEditChatFoldersPage={props.onOpenEditChatFoldersPage}
 | 
			
		||||
      chatFolders={chatFolders}
 | 
			
		||||
      onCreateChatFolder={createChatFolder}
 | 
			
		||||
      onDeleteChatFolder={deleteChatFolder}
 | 
			
		||||
      onUpdateChatFoldersPositions={updateChatFoldersPositions}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -9,7 +9,7 @@ import type { MutableRefObject } from 'react';
 | 
			
		|||
import { getIntl, getTheme, getUserNumber } from '../selectors/user.js';
 | 
			
		||||
import { getMe } from '../selectors/conversations.js';
 | 
			
		||||
import { PreferencesDonations } from '../../components/PreferencesDonations.js';
 | 
			
		||||
import type { SettingsPage } from '../../types/Nav.js';
 | 
			
		||||
import type { SettingsLocation } from '../../types/Nav.js';
 | 
			
		||||
import { useDonationsActions } from '../ducks/donations.js';
 | 
			
		||||
import type { StateType } from '../reducer.js';
 | 
			
		||||
import { useConversationsActions } from '../ducks/conversations.js';
 | 
			
		||||
| 
						 | 
				
			
			@ -40,12 +40,12 @@ const log = createLogger('SmartPreferencesDonations');
 | 
			
		|||
export const SmartPreferencesDonations = memo(
 | 
			
		||||
  function SmartPreferencesDonations({
 | 
			
		||||
    contentsRef,
 | 
			
		||||
    page,
 | 
			
		||||
    setPage,
 | 
			
		||||
    settingsLocation,
 | 
			
		||||
    setSettingsLocation,
 | 
			
		||||
  }: {
 | 
			
		||||
    contentsRef: MutableRefObject<HTMLDivElement | null>;
 | 
			
		||||
    page: SettingsPage;
 | 
			
		||||
    setPage: (page: SettingsPage) => void;
 | 
			
		||||
    settingsLocation: SettingsLocation;
 | 
			
		||||
    setSettingsLocation: (settingsLocation: SettingsLocation) => void;
 | 
			
		||||
  }) {
 | 
			
		||||
    const [validCurrencies, setValidCurrencies] = useState<
 | 
			
		||||
      ReadonlyArray<string>
 | 
			
		||||
| 
						 | 
				
			
			@ -142,7 +142,7 @@ export const SmartPreferencesDonations = memo(
 | 
			
		|||
        contentsRef={contentsRef}
 | 
			
		||||
        initialCurrency={initialCurrency}
 | 
			
		||||
        isOnline={isOnline}
 | 
			
		||||
        page={page}
 | 
			
		||||
        settingsLocation={settingsLocation}
 | 
			
		||||
        didResumeWorkflowAtStartup={donationsState.didResumeWorkflowAtStartup}
 | 
			
		||||
        lastError={donationsState.lastError}
 | 
			
		||||
        workflow={donationsState.currentWorkflow}
 | 
			
		||||
| 
						 | 
				
			
			@ -151,7 +151,7 @@ export const SmartPreferencesDonations = memo(
 | 
			
		|||
        resumeWorkflow={resumeWorkflow}
 | 
			
		||||
        updateLastError={updateLastError}
 | 
			
		||||
        submitDonation={submitDonation}
 | 
			
		||||
        setPage={setPage}
 | 
			
		||||
        setSettingsLocation={setSettingsLocation}
 | 
			
		||||
        theme={theme}
 | 
			
		||||
        donationBadge={badgesById[BOOST_ID] ?? undefined}
 | 
			
		||||
        fetchBadgeData={fetchBadgeData}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -12,11 +12,13 @@ import {
 | 
			
		|||
} from '../selectors/conversations.js';
 | 
			
		||||
import { getPreferredBadgeSelector } from '../selectors/badges.js';
 | 
			
		||||
import { useChatFolderActions } from '../ducks/chatFolders.js';
 | 
			
		||||
import { getCurrentChatFolders } from '../selectors/chatFolders.js';
 | 
			
		||||
import { getSortedChatFolders } from '../selectors/chatFolders.js';
 | 
			
		||||
import { strictAssert } from '../../util/assert.js';
 | 
			
		||||
import { useNavActions } from '../ducks/nav.js';
 | 
			
		||||
import type { Location } from '../../types/Nav.js';
 | 
			
		||||
 | 
			
		||||
export type SmartPreferencesEditChatFolderPageProps = Readonly<{
 | 
			
		||||
  onBack: () => void;
 | 
			
		||||
  previousLocation: Location;
 | 
			
		||||
  existingChatFolderId: PreferencesEditChatFolderPageProps['existingChatFolderId'];
 | 
			
		||||
  settingsPaneRef: PreferencesEditChatFolderPageProps['settingsPaneRef'];
 | 
			
		||||
}>;
 | 
			
		||||
| 
						 | 
				
			
			@ -31,9 +33,10 @@ export function SmartPreferencesEditChatFolderPage(
 | 
			
		|||
  const conversations = useSelector(getAllComposableConversations);
 | 
			
		||||
  const conversationSelector = useSelector(getConversationSelector);
 | 
			
		||||
  const preferredBadgeSelector = useSelector(getPreferredBadgeSelector);
 | 
			
		||||
  const chatFolders = useSelector(getCurrentChatFolders);
 | 
			
		||||
  const chatFolders = useSelector(getSortedChatFolders);
 | 
			
		||||
  const { createChatFolder, updateChatFolder, deleteChatFolder } =
 | 
			
		||||
    useChatFolderActions();
 | 
			
		||||
  const { changeLocation } = useNavActions();
 | 
			
		||||
 | 
			
		||||
  const initChatFolderParams = useMemo(() => {
 | 
			
		||||
    if (existingChatFolderId == null) {
 | 
			
		||||
| 
						 | 
				
			
			@ -49,9 +52,10 @@ export function SmartPreferencesEditChatFolderPage(
 | 
			
		|||
  return (
 | 
			
		||||
    <PreferencesEditChatFolderPage
 | 
			
		||||
      i18n={i18n}
 | 
			
		||||
      previousLocation={props.previousLocation}
 | 
			
		||||
      existingChatFolderId={props.existingChatFolderId}
 | 
			
		||||
      initChatFolderParams={initChatFolderParams}
 | 
			
		||||
      onBack={props.onBack}
 | 
			
		||||
      changeLocation={changeLocation}
 | 
			
		||||
      conversations={conversations}
 | 
			
		||||
      preferredBadgeSelector={preferredBadgeSelector}
 | 
			
		||||
      theme={theme}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,6 +1,8 @@
 | 
			
		|||
// Copyright 2021 Signal Messenger, LLC
 | 
			
		||||
// SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		||||
 | 
			
		||||
import type { Selector } from 'reselect';
 | 
			
		||||
import type { StateType } from './reducer.js';
 | 
			
		||||
import type { actions as accounts } from './ducks/accounts.js';
 | 
			
		||||
import type { actions as app } from './ducks/app.js';
 | 
			
		||||
import type { actions as audioPlayer } from './ducks/audioPlayer.js';
 | 
			
		||||
| 
						 | 
				
			
			@ -72,3 +74,5 @@ export type ReduxActions = {
 | 
			
		|||
  user: typeof user;
 | 
			
		||||
  username: typeof username;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type StateSelector<T> = Selector<StateType, T>;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -72,7 +72,7 @@ describe('sql/notificationProfiles', () => {
 | 
			
		|||
      allowAllCalls: false,
 | 
			
		||||
      allowAllMentions: false,
 | 
			
		||||
 | 
			
		||||
      allowedMembers: new Set(),
 | 
			
		||||
      allowedMembers: new Set<string>(),
 | 
			
		||||
      scheduleEnabled: false,
 | 
			
		||||
 | 
			
		||||
      scheduleStartTime: undefined,
 | 
			
		||||
| 
						 | 
				
			
			@ -148,7 +148,7 @@ describe('sql/notificationProfiles', () => {
 | 
			
		|||
      allowAllCalls: false,
 | 
			
		||||
      allowAllMentions: false,
 | 
			
		||||
 | 
			
		||||
      allowedMembers: new Set(),
 | 
			
		||||
      allowedMembers: new Set<string>(),
 | 
			
		||||
      scheduleEnabled: false,
 | 
			
		||||
 | 
			
		||||
      scheduleStartTime: undefined,
 | 
			
		||||
| 
						 | 
				
			
			@ -220,7 +220,7 @@ describe('sql/notificationProfiles', () => {
 | 
			
		|||
      allowAllCalls: false,
 | 
			
		||||
      allowAllMentions: false,
 | 
			
		||||
 | 
			
		||||
      allowedMembers: new Set(),
 | 
			
		||||
      allowedMembers: new Set<string>(),
 | 
			
		||||
      scheduleEnabled: false,
 | 
			
		||||
 | 
			
		||||
      scheduleStartTime: undefined,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -224,9 +224,7 @@ describe('storage service/chat folders', function (this: Mocha.Suite) {
 | 
			
		|||
    );
 | 
			
		||||
 | 
			
		||||
    const confirmDeleteBtn = window
 | 
			
		||||
      .getByTestId(
 | 
			
		||||
        'ConfirmationDialog.Preferences__EditChatFolderPage__DeleteChatFolderDialog'
 | 
			
		||||
      )
 | 
			
		||||
      .getByTestId('ConfirmationDialog.Preferences__DeleteChatFolderDialog')
 | 
			
		||||
      .locator('button:has-text("Delete")');
 | 
			
		||||
 | 
			
		||||
    let state = await phone.expectStorageState('initial state');
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,6 +2,7 @@
 | 
			
		|||
// SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		||||
 | 
			
		||||
import createDebug from 'debug';
 | 
			
		||||
import { v4 as generateUuid } from 'uuid';
 | 
			
		||||
import type {
 | 
			
		||||
  Group,
 | 
			
		||||
  PrimaryDevice,
 | 
			
		||||
| 
						 | 
				
			
			@ -107,6 +108,22 @@ export async function initStorage(
 | 
			
		|||
      },
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    state = state.addRecord({
 | 
			
		||||
      type: IdentifierType.CHAT_FOLDER,
 | 
			
		||||
      record: {
 | 
			
		||||
        chatFolder: {
 | 
			
		||||
          id: uuidToBytes(generateUuid()),
 | 
			
		||||
          name: null,
 | 
			
		||||
          position: 0,
 | 
			
		||||
          showOnlyUnread: false,
 | 
			
		||||
          showMutedChats: true,
 | 
			
		||||
          includeAllIndividualChats: true,
 | 
			
		||||
          includeAllGroupChats: true,
 | 
			
		||||
          folderType: Proto.ChatFolderRecord.FolderType.ALL,
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    await phone.setStorageState(state);
 | 
			
		||||
 | 
			
		||||
    // Link new device
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1149,7 +1149,7 @@ describe('both/state/selectors/conversations-extra', () => {
 | 
			
		|||
 | 
			
		||||
  describe('#_getLeftPaneLists', () => {
 | 
			
		||||
    it('sorts conversations based on timestamp then by intl-friendly title', () => {
 | 
			
		||||
      const data: ConversationLookupType = {
 | 
			
		||||
      const conversationLookup: ConversationLookupType = {
 | 
			
		||||
        id1: getDefaultConversation({
 | 
			
		||||
          id: 'id1',
 | 
			
		||||
          e164: '+18005551111',
 | 
			
		||||
| 
						 | 
				
			
			@ -1256,9 +1256,16 @@ describe('both/state/selectors/conversations-extra', () => {
 | 
			
		|||
          acceptedMessageRequest: true,
 | 
			
		||||
        }),
 | 
			
		||||
      };
 | 
			
		||||
      const comparator = _getConversationComparator();
 | 
			
		||||
      const conversationComparator = _getConversationComparator();
 | 
			
		||||
      const { archivedConversations, conversations, pinnedConversations } =
 | 
			
		||||
        _getLeftPaneLists(data, comparator);
 | 
			
		||||
        _getLeftPaneLists({
 | 
			
		||||
          conversationLookup,
 | 
			
		||||
          conversationComparator,
 | 
			
		||||
          selectedConversationId: undefined,
 | 
			
		||||
          pinnedConversationIds: null,
 | 
			
		||||
          selectedChatFolder: null,
 | 
			
		||||
          stableSelectedConversationIdInChatFolder: null,
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
      assert.strictEqual(conversations[0].name, 'First!');
 | 
			
		||||
      assert.strictEqual(conversations[1].name, 'Á');
 | 
			
		||||
| 
						 | 
				
			
			@ -1274,7 +1281,7 @@ describe('both/state/selectors/conversations-extra', () => {
 | 
			
		|||
 | 
			
		||||
    describe('given pinned conversations', () => {
 | 
			
		||||
      it('sorts pinned conversations based on order in storage', () => {
 | 
			
		||||
        const data: ConversationLookupType = {
 | 
			
		||||
        const conversationLookup: ConversationLookupType = {
 | 
			
		||||
          pin2: getDefaultConversation({
 | 
			
		||||
            id: 'pin2',
 | 
			
		||||
            e164: '+18005551111',
 | 
			
		||||
| 
						 | 
				
			
			@ -1344,9 +1351,16 @@ describe('both/state/selectors/conversations-extra', () => {
 | 
			
		|||
        };
 | 
			
		||||
 | 
			
		||||
        const pinnedConversationIds = ['pin1', 'pin2', 'pin3'];
 | 
			
		||||
        const comparator = _getConversationComparator();
 | 
			
		||||
        const conversationComparator = _getConversationComparator();
 | 
			
		||||
        const { archivedConversations, conversations, pinnedConversations } =
 | 
			
		||||
          _getLeftPaneLists(data, comparator, undefined, pinnedConversationIds);
 | 
			
		||||
          _getLeftPaneLists({
 | 
			
		||||
            conversationLookup,
 | 
			
		||||
            conversationComparator,
 | 
			
		||||
            selectedConversationId: undefined,
 | 
			
		||||
            pinnedConversationIds,
 | 
			
		||||
            selectedChatFolder: null,
 | 
			
		||||
            stableSelectedConversationIdInChatFolder: null,
 | 
			
		||||
          });
 | 
			
		||||
 | 
			
		||||
        assert.strictEqual(pinnedConversations[0].name, 'Pin One');
 | 
			
		||||
        assert.strictEqual(pinnedConversations[1].name, 'Pin Two');
 | 
			
		||||
| 
						 | 
				
			
			@ -1358,7 +1372,7 @@ describe('both/state/selectors/conversations-extra', () => {
 | 
			
		|||
      });
 | 
			
		||||
 | 
			
		||||
      it('includes archived and pinned conversations with no active_at', () => {
 | 
			
		||||
        const data: ConversationLookupType = {
 | 
			
		||||
        const conversationLookup: ConversationLookupType = {
 | 
			
		||||
          pin2: getDefaultConversation({
 | 
			
		||||
            id: 'pin2',
 | 
			
		||||
            e164: '+18005551111',
 | 
			
		||||
| 
						 | 
				
			
			@ -1468,9 +1482,16 @@ describe('both/state/selectors/conversations-extra', () => {
 | 
			
		|||
        };
 | 
			
		||||
 | 
			
		||||
        const pinnedConversationIds = ['pin1', 'pin2', 'pin3'];
 | 
			
		||||
        const comparator = _getConversationComparator();
 | 
			
		||||
        const conversationComparator = _getConversationComparator();
 | 
			
		||||
        const { archivedConversations, conversations, pinnedConversations } =
 | 
			
		||||
          _getLeftPaneLists(data, comparator, undefined, pinnedConversationIds);
 | 
			
		||||
          _getLeftPaneLists({
 | 
			
		||||
            conversationLookup,
 | 
			
		||||
            conversationComparator,
 | 
			
		||||
            selectedConversationId: undefined,
 | 
			
		||||
            pinnedConversationIds,
 | 
			
		||||
            selectedChatFolder: null,
 | 
			
		||||
            stableSelectedConversationIdInChatFolder: null,
 | 
			
		||||
          });
 | 
			
		||||
 | 
			
		||||
        assert.strictEqual(pinnedConversations[0].name, 'Pin One');
 | 
			
		||||
        assert.strictEqual(pinnedConversations[1].name, 'Pin Two');
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,267 +2,198 @@
 | 
			
		|||
// SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		||||
 | 
			
		||||
import { assert } from 'chai';
 | 
			
		||||
 | 
			
		||||
import { v4 as generateUuid } from 'uuid';
 | 
			
		||||
import { countConversationUnreadStats } from '../../util/countUnreadStats.js';
 | 
			
		||||
import type {
 | 
			
		||||
  UnreadStats,
 | 
			
		||||
  ConversationPropsForUnreadStats,
 | 
			
		||||
} from '../../util/countUnreadStats.js';
 | 
			
		||||
 | 
			
		||||
describe('countConversationUnreadStats', () => {
 | 
			
		||||
  const mutedTimestamp = (): number => Date.now() + 12345;
 | 
			
		||||
  const oldMutedTimestamp = (): number => Date.now() - 1000;
 | 
			
		||||
function getFutureMutedTimestamp() {
 | 
			
		||||
  return Date.now() + 12345;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getPastMutedTimestamp() {
 | 
			
		||||
  return Date.now() - 1000;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function mockChat(
 | 
			
		||||
  props: Partial<ConversationPropsForUnreadStats>
 | 
			
		||||
): ConversationPropsForUnreadStats {
 | 
			
		||||
  return {
 | 
			
		||||
    id: generateUuid(),
 | 
			
		||||
    type: 'direct',
 | 
			
		||||
    activeAt: Date.now(),
 | 
			
		||||
    isArchived: false,
 | 
			
		||||
    markedUnread: false,
 | 
			
		||||
    unreadCount: 0,
 | 
			
		||||
    unreadMentionsCount: 0,
 | 
			
		||||
    muteExpiresAt: undefined,
 | 
			
		||||
    left: false,
 | 
			
		||||
    ...props,
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function mockStats(props: Partial<UnreadStats>): UnreadStats {
 | 
			
		||||
  return {
 | 
			
		||||
    unreadCount: 0,
 | 
			
		||||
    unreadMentionsCount: 0,
 | 
			
		||||
    readChatsMarkedUnreadCount: 0,
 | 
			
		||||
    ...props,
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
describe('countUnreadStats', () => {
 | 
			
		||||
  describe('countConversationUnreadStats', () => {
 | 
			
		||||
    it('returns 0 if the conversation is archived', () => {
 | 
			
		||||
      const isArchived = true;
 | 
			
		||||
 | 
			
		||||
      const archivedConversations = [
 | 
			
		||||
      {
 | 
			
		||||
        activeAt: Date.now(),
 | 
			
		||||
        isArchived: true,
 | 
			
		||||
        markedUnread: false,
 | 
			
		||||
        unreadCount: 0,
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        activeAt: Date.now(),
 | 
			
		||||
        isArchived: true,
 | 
			
		||||
        markedUnread: false,
 | 
			
		||||
        unreadCount: 123,
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        activeAt: Date.now(),
 | 
			
		||||
        isArchived: true,
 | 
			
		||||
        markedUnread: true,
 | 
			
		||||
        unreadCount: 0,
 | 
			
		||||
      },
 | 
			
		||||
      { activeAt: Date.now(), isArchived: true, markedUnread: true },
 | 
			
		||||
        mockChat({ isArchived, markedUnread: false, unreadCount: 0 }),
 | 
			
		||||
        mockChat({ isArchived, markedUnread: false, unreadCount: 123 }),
 | 
			
		||||
        mockChat({ isArchived, markedUnread: true, unreadCount: 0 }),
 | 
			
		||||
        mockChat({ isArchived, markedUnread: true, unreadCount: undefined }),
 | 
			
		||||
        mockChat({ isArchived, markedUnread: undefined, unreadCount: 0 }),
 | 
			
		||||
      ];
 | 
			
		||||
 | 
			
		||||
      for (const conversation of archivedConversations) {
 | 
			
		||||
        assert.deepStrictEqual(
 | 
			
		||||
          countConversationUnreadStats(conversation, { includeMuted: true }),
 | 
			
		||||
        {
 | 
			
		||||
          unreadCount: 0,
 | 
			
		||||
          unreadMentionsCount: 0,
 | 
			
		||||
          markedUnread: false,
 | 
			
		||||
        }
 | 
			
		||||
          mockStats({ unreadCount: 0, readChatsMarkedUnreadCount: 0 })
 | 
			
		||||
        );
 | 
			
		||||
        assert.deepStrictEqual(
 | 
			
		||||
          countConversationUnreadStats(conversation, { includeMuted: false }),
 | 
			
		||||
        {
 | 
			
		||||
          unreadCount: 0,
 | 
			
		||||
          unreadMentionsCount: 0,
 | 
			
		||||
          markedUnread: false,
 | 
			
		||||
        }
 | 
			
		||||
          mockStats({ unreadCount: 0, readChatsMarkedUnreadCount: 0 })
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it("returns 0 if the conversation is muted and the user doesn't want to include those in the result", () => {
 | 
			
		||||
      const muteExpiresAt = getFutureMutedTimestamp();
 | 
			
		||||
      const mutedConversations = [
 | 
			
		||||
      {
 | 
			
		||||
        activeAt: Date.now(),
 | 
			
		||||
        muteExpiresAt: mutedTimestamp(),
 | 
			
		||||
        markedUnread: false,
 | 
			
		||||
        unreadCount: 0,
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        activeAt: Date.now(),
 | 
			
		||||
        muteExpiresAt: mutedTimestamp(),
 | 
			
		||||
        markedUnread: false,
 | 
			
		||||
        unreadCount: 9,
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        activeAt: Date.now(),
 | 
			
		||||
        muteExpiresAt: mutedTimestamp(),
 | 
			
		||||
        markedUnread: true,
 | 
			
		||||
        unreadCount: 0,
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        activeAt: Date.now(),
 | 
			
		||||
        muteExpiresAt: mutedTimestamp(),
 | 
			
		||||
        markedUnread: true,
 | 
			
		||||
      },
 | 
			
		||||
        mockChat({ muteExpiresAt, markedUnread: false, unreadCount: 0 }),
 | 
			
		||||
        mockChat({ muteExpiresAt, markedUnread: false, unreadCount: 9 }),
 | 
			
		||||
        mockChat({ muteExpiresAt, markedUnread: true, unreadCount: 0 }),
 | 
			
		||||
        mockChat({ muteExpiresAt, markedUnread: true, unreadCount: undefined }),
 | 
			
		||||
      ];
 | 
			
		||||
      for (const conversation of mutedConversations) {
 | 
			
		||||
        assert.deepStrictEqual(
 | 
			
		||||
          countConversationUnreadStats(conversation, { includeMuted: false }),
 | 
			
		||||
        {
 | 
			
		||||
          unreadCount: 0,
 | 
			
		||||
          unreadMentionsCount: 0,
 | 
			
		||||
          markedUnread: false,
 | 
			
		||||
        }
 | 
			
		||||
          mockStats({ unreadCount: 0, readChatsMarkedUnreadCount: 0 })
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('returns the unread count if nonzero (and not archived)', () => {
 | 
			
		||||
      const conversationsWithUnreadCount = [
 | 
			
		||||
      { activeAt: Date.now(), unreadCount: 9, markedUnread: false },
 | 
			
		||||
      { activeAt: Date.now(), unreadCount: 9, markedUnread: true },
 | 
			
		||||
      {
 | 
			
		||||
        activeAt: Date.now(),
 | 
			
		||||
        unreadCount: 9,
 | 
			
		||||
        markedUnread: false,
 | 
			
		||||
        muteExpiresAt: oldMutedTimestamp(),
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        activeAt: Date.now(),
 | 
			
		||||
        unreadCount: 9,
 | 
			
		||||
        markedUnread: false,
 | 
			
		||||
        isArchived: false,
 | 
			
		||||
      },
 | 
			
		||||
        mockChat({ unreadCount: 9, markedUnread: false }),
 | 
			
		||||
        mockChat({ unreadCount: 9, markedUnread: true }),
 | 
			
		||||
        mockChat({ unreadCount: 9, muteExpiresAt: getPastMutedTimestamp() }),
 | 
			
		||||
      ];
 | 
			
		||||
 | 
			
		||||
      for (const conversation of conversationsWithUnreadCount) {
 | 
			
		||||
        assert.deepStrictEqual(
 | 
			
		||||
          countConversationUnreadStats(conversation, { includeMuted: false }),
 | 
			
		||||
        {
 | 
			
		||||
          unreadCount: 9,
 | 
			
		||||
          unreadMentionsCount: 0,
 | 
			
		||||
          markedUnread: conversation.markedUnread,
 | 
			
		||||
        }
 | 
			
		||||
          mockStats({ unreadCount: 9 })
 | 
			
		||||
        );
 | 
			
		||||
        assert.deepStrictEqual(
 | 
			
		||||
          countConversationUnreadStats(conversation, { includeMuted: true }),
 | 
			
		||||
        {
 | 
			
		||||
          unreadCount: 9,
 | 
			
		||||
          unreadMentionsCount: 0,
 | 
			
		||||
          markedUnread: conversation.markedUnread,
 | 
			
		||||
        }
 | 
			
		||||
          mockStats({ unreadCount: 9 })
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
    const mutedWithUnreads = {
 | 
			
		||||
      activeAt: Date.now(),
 | 
			
		||||
      const mutedWithUnreads = mockChat({
 | 
			
		||||
        unreadCount: 123,
 | 
			
		||||
      markedUnread: false,
 | 
			
		||||
      muteExpiresAt: mutedTimestamp(),
 | 
			
		||||
    };
 | 
			
		||||
        muteExpiresAt: getFutureMutedTimestamp(),
 | 
			
		||||
      });
 | 
			
		||||
      assert.deepStrictEqual(
 | 
			
		||||
        countConversationUnreadStats(mutedWithUnreads, { includeMuted: true }),
 | 
			
		||||
      {
 | 
			
		||||
        unreadCount: 123,
 | 
			
		||||
        unreadMentionsCount: 0,
 | 
			
		||||
        markedUnread: false,
 | 
			
		||||
      }
 | 
			
		||||
        mockStats({ unreadCount: 123 })
 | 
			
		||||
      );
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('returns markedUnread:true if the conversation is marked unread', () => {
 | 
			
		||||
      const conversationsMarkedUnread = [
 | 
			
		||||
      { activeAt: Date.now(), markedUnread: true },
 | 
			
		||||
      { activeAt: Date.now(), markedUnread: true, unreadCount: 0 },
 | 
			
		||||
      {
 | 
			
		||||
        activeAt: Date.now(),
 | 
			
		||||
        mockChat({ markedUnread: true }),
 | 
			
		||||
        mockChat({
 | 
			
		||||
          markedUnread: true,
 | 
			
		||||
        muteExpiresAt: oldMutedTimestamp(),
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        activeAt: Date.now(),
 | 
			
		||||
        markedUnread: true,
 | 
			
		||||
        muteExpiresAt: oldMutedTimestamp(),
 | 
			
		||||
        isArchived: false,
 | 
			
		||||
      },
 | 
			
		||||
          muteExpiresAt: getPastMutedTimestamp(),
 | 
			
		||||
        }),
 | 
			
		||||
      ];
 | 
			
		||||
      for (const conversation of conversationsMarkedUnread) {
 | 
			
		||||
        assert.deepStrictEqual(
 | 
			
		||||
          countConversationUnreadStats(conversation, { includeMuted: false }),
 | 
			
		||||
        {
 | 
			
		||||
          unreadCount: 0,
 | 
			
		||||
          unreadMentionsCount: 0,
 | 
			
		||||
          markedUnread: true,
 | 
			
		||||
        }
 | 
			
		||||
          mockStats({ readChatsMarkedUnreadCount: 1 })
 | 
			
		||||
        );
 | 
			
		||||
        assert.deepStrictEqual(
 | 
			
		||||
          countConversationUnreadStats(conversation, { includeMuted: true }),
 | 
			
		||||
        {
 | 
			
		||||
          unreadCount: 0,
 | 
			
		||||
          unreadMentionsCount: 0,
 | 
			
		||||
          markedUnread: true,
 | 
			
		||||
        }
 | 
			
		||||
          mockStats({ readChatsMarkedUnreadCount: 1 })
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const mutedConversationsMarkedUnread = [
 | 
			
		||||
      {
 | 
			
		||||
        activeAt: Date.now(),
 | 
			
		||||
        mockChat({
 | 
			
		||||
          markedUnread: true,
 | 
			
		||||
        muteExpiresAt: mutedTimestamp(),
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        activeAt: Date.now(),
 | 
			
		||||
          muteExpiresAt: getFutureMutedTimestamp(),
 | 
			
		||||
        }),
 | 
			
		||||
        mockChat({
 | 
			
		||||
          markedUnread: true,
 | 
			
		||||
        muteExpiresAt: mutedTimestamp(),
 | 
			
		||||
          muteExpiresAt: getFutureMutedTimestamp(),
 | 
			
		||||
          unreadCount: 0,
 | 
			
		||||
      },
 | 
			
		||||
        }),
 | 
			
		||||
      ];
 | 
			
		||||
      for (const conversation of mutedConversationsMarkedUnread) {
 | 
			
		||||
        assert.deepStrictEqual(
 | 
			
		||||
          countConversationUnreadStats(conversation, { includeMuted: true }),
 | 
			
		||||
        {
 | 
			
		||||
          unreadCount: 0,
 | 
			
		||||
          unreadMentionsCount: 0,
 | 
			
		||||
          markedUnread: true,
 | 
			
		||||
        }
 | 
			
		||||
          mockStats({ readChatsMarkedUnreadCount: 1 })
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('returns 0 if the conversation is read', () => {
 | 
			
		||||
      const readConversations = [
 | 
			
		||||
      { activeAt: Date.now(), markedUnread: false },
 | 
			
		||||
      { activeAt: Date.now(), markedUnread: false, unreadCount: 0 },
 | 
			
		||||
      {
 | 
			
		||||
        activeAt: Date.now(),
 | 
			
		||||
        mockChat({ markedUnread: false, unreadCount: undefined }),
 | 
			
		||||
        mockChat({ markedUnread: false, unreadCount: 0 }),
 | 
			
		||||
        mockChat({
 | 
			
		||||
          markedUnread: false,
 | 
			
		||||
        mutedTimestamp: mutedTimestamp(),
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        activeAt: Date.now(),
 | 
			
		||||
          muteExpiresAt: getFutureMutedTimestamp(),
 | 
			
		||||
        }),
 | 
			
		||||
        mockChat({
 | 
			
		||||
          markedUnread: false,
 | 
			
		||||
        mutedTimestamp: oldMutedTimestamp(),
 | 
			
		||||
      },
 | 
			
		||||
          muteExpiresAt: getPastMutedTimestamp(),
 | 
			
		||||
        }),
 | 
			
		||||
      ];
 | 
			
		||||
      for (const conversation of readConversations) {
 | 
			
		||||
        assert.deepStrictEqual(
 | 
			
		||||
          countConversationUnreadStats(conversation, { includeMuted: false }),
 | 
			
		||||
        {
 | 
			
		||||
          unreadCount: 0,
 | 
			
		||||
          unreadMentionsCount: 0,
 | 
			
		||||
          markedUnread: false,
 | 
			
		||||
        }
 | 
			
		||||
          mockStats({ unreadCount: 0, readChatsMarkedUnreadCount: 0 })
 | 
			
		||||
        );
 | 
			
		||||
        assert.deepStrictEqual(
 | 
			
		||||
          countConversationUnreadStats(conversation, { includeMuted: true }),
 | 
			
		||||
        {
 | 
			
		||||
          unreadCount: 0,
 | 
			
		||||
          unreadMentionsCount: 0,
 | 
			
		||||
          markedUnread: false,
 | 
			
		||||
        }
 | 
			
		||||
          mockStats({ unreadCount: 0, readChatsMarkedUnreadCount: 0 })
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('returns 0 if the conversation has falsey activeAt', () => {
 | 
			
		||||
      const readConversations = [
 | 
			
		||||
      { activeAt: undefined, markedUnread: false, unreadCount: 2 },
 | 
			
		||||
      {
 | 
			
		||||
        mockChat({ activeAt: undefined, unreadCount: 2 }),
 | 
			
		||||
        mockChat({
 | 
			
		||||
          activeAt: 0,
 | 
			
		||||
          unreadCount: 2,
 | 
			
		||||
        markedUnread: false,
 | 
			
		||||
        mutedTimestamp: oldMutedTimestamp(),
 | 
			
		||||
      },
 | 
			
		||||
          muteExpiresAt: getPastMutedTimestamp(),
 | 
			
		||||
        }),
 | 
			
		||||
      ];
 | 
			
		||||
      for (const conversation of readConversations) {
 | 
			
		||||
        assert.deepStrictEqual(
 | 
			
		||||
          countConversationUnreadStats(conversation, { includeMuted: false }),
 | 
			
		||||
        {
 | 
			
		||||
          unreadCount: 0,
 | 
			
		||||
          unreadMentionsCount: 0,
 | 
			
		||||
          markedUnread: false,
 | 
			
		||||
        }
 | 
			
		||||
          mockStats({ unreadCount: 0, readChatsMarkedUnreadCount: 0 })
 | 
			
		||||
        );
 | 
			
		||||
        assert.deepStrictEqual(
 | 
			
		||||
          countConversationUnreadStats(conversation, { includeMuted: true }),
 | 
			
		||||
        {
 | 
			
		||||
          unreadCount: 0,
 | 
			
		||||
          unreadMentionsCount: 0,
 | 
			
		||||
          markedUnread: false,
 | 
			
		||||
        }
 | 
			
		||||
          mockStats({ unreadCount: 0, readChatsMarkedUnreadCount: 0 })
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,6 +1,7 @@
 | 
			
		|||
// Copyright 2025 Signal Messenger, LLC
 | 
			
		||||
// SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		||||
import { z } from 'zod';
 | 
			
		||||
import type { Simplify } from 'type-fest';
 | 
			
		||||
import {
 | 
			
		||||
  Environment,
 | 
			
		||||
  getEnvironment,
 | 
			
		||||
| 
						 | 
				
			
			@ -9,6 +10,9 @@ import {
 | 
			
		|||
import * as grapheme from '../util/grapheme.js';
 | 
			
		||||
import * as RemoteConfig from '../RemoteConfig.js';
 | 
			
		||||
import { isAlpha, isBeta, isProduction } from '../util/version.js';
 | 
			
		||||
import type { ConversationType } from '../state/ducks/conversations.js';
 | 
			
		||||
import { strictAssert } from '../util/assert.js';
 | 
			
		||||
import { isConversationUnread } from '../util/isConversationUnread.js';
 | 
			
		||||
 | 
			
		||||
export const CHAT_FOLDER_NAME_MAX_CHAR_LENGTH = 32;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -22,7 +26,8 @@ export enum ChatFolderType {
 | 
			
		|||
 | 
			
		||||
export type ChatFolderId = string & { ChatFolderId: never }; // uuid
 | 
			
		||||
 | 
			
		||||
export type ChatFolderPreset = Readonly<{
 | 
			
		||||
export type ChatFolderPreset = Simplify<
 | 
			
		||||
  Readonly<{
 | 
			
		||||
    folderType: ChatFolderType;
 | 
			
		||||
    showOnlyUnread: boolean;
 | 
			
		||||
    showMutedChats: boolean;
 | 
			
		||||
| 
						 | 
				
			
			@ -30,15 +35,19 @@ export type ChatFolderPreset = Readonly<{
 | 
			
		|||
    includeAllGroupChats: boolean;
 | 
			
		||||
    includedConversationIds: ReadonlyArray<string>;
 | 
			
		||||
    excludedConversationIds: ReadonlyArray<string>;
 | 
			
		||||
}>;
 | 
			
		||||
  }>
 | 
			
		||||
>;
 | 
			
		||||
 | 
			
		||||
export type ChatFolderParams = Readonly<
 | 
			
		||||
export type ChatFolderParams = Simplify<
 | 
			
		||||
  Readonly<
 | 
			
		||||
    ChatFolderPreset & {
 | 
			
		||||
      name: string;
 | 
			
		||||
    }
 | 
			
		||||
  >
 | 
			
		||||
>;
 | 
			
		||||
 | 
			
		||||
export type ChatFolder = Readonly<
 | 
			
		||||
export type ChatFolder = Simplify<
 | 
			
		||||
  Readonly<
 | 
			
		||||
    ChatFolderParams & {
 | 
			
		||||
      id: ChatFolderId;
 | 
			
		||||
      position: number;
 | 
			
		||||
| 
						 | 
				
			
			@ -48,6 +57,7 @@ export type ChatFolder = Readonly<
 | 
			
		|||
      storageUnknownFields: Uint8Array | null;
 | 
			
		||||
      storageNeedsSync: boolean;
 | 
			
		||||
    }
 | 
			
		||||
  >
 | 
			
		||||
>;
 | 
			
		||||
 | 
			
		||||
export const ChatFolderPresetSchema = z.object({
 | 
			
		||||
| 
						 | 
				
			
			@ -173,3 +183,91 @@ export function isChatFoldersEnabled(): boolean {
 | 
			
		|||
 | 
			
		||||
  return false;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type ConversationPropsForChatFolder = Pick<
 | 
			
		||||
  ConversationType,
 | 
			
		||||
  'type' | 'id' | 'unreadCount' | 'markedUnread' | 'muteExpiresAt'
 | 
			
		||||
>;
 | 
			
		||||
 | 
			
		||||
function _isConversationIncludedInChatFolder(
 | 
			
		||||
  chatFolder: ChatFolder,
 | 
			
		||||
  conversation: ConversationPropsForChatFolder
 | 
			
		||||
): boolean {
 | 
			
		||||
  if (chatFolder.includeAllIndividualChats && conversation.type === 'direct') {
 | 
			
		||||
    return true; // is individual chat
 | 
			
		||||
  }
 | 
			
		||||
  if (chatFolder.includeAllGroupChats && conversation.type === 'group') {
 | 
			
		||||
    return true; // is group chat
 | 
			
		||||
  }
 | 
			
		||||
  if (chatFolder.includedConversationIds.includes(conversation.id)) {
 | 
			
		||||
    return true; // is included by id
 | 
			
		||||
  }
 | 
			
		||||
  return false;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function _isConversationExcludedFromChatFolder(
 | 
			
		||||
  chatFolder: ChatFolder,
 | 
			
		||||
  conversation: ConversationPropsForChatFolder
 | 
			
		||||
): boolean {
 | 
			
		||||
  if (chatFolder.showOnlyUnread && !isConversationUnread(conversation)) {
 | 
			
		||||
    return true; // not unread, only showing unread
 | 
			
		||||
  }
 | 
			
		||||
  if (!chatFolder.showMutedChats && (conversation.muteExpiresAt ?? 0) > 0) {
 | 
			
		||||
    return true; // muted, not showing muted chats
 | 
			
		||||
  }
 | 
			
		||||
  if (chatFolder.excludedConversationIds.includes(conversation.id)) {
 | 
			
		||||
    return true; // is excluded by id
 | 
			
		||||
  }
 | 
			
		||||
  return false;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function isConversationInChatFolder(
 | 
			
		||||
  chatFolder: ChatFolder,
 | 
			
		||||
  conversation: ConversationPropsForChatFolder
 | 
			
		||||
): boolean {
 | 
			
		||||
  if (chatFolder.folderType === ChatFolderType.ALL) {
 | 
			
		||||
    return true;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    _isConversationIncludedInChatFolder(chatFolder, conversation) &&
 | 
			
		||||
    !_isConversationExcludedFromChatFolder(chatFolder, conversation)
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type CurrentChatFolders = Readonly<{
 | 
			
		||||
  order: ReadonlyArray<ChatFolderId>;
 | 
			
		||||
  lookup: Partial<Record<ChatFolderId, ChatFolder>>;
 | 
			
		||||
}>;
 | 
			
		||||
 | 
			
		||||
export function toCurrentChatFolders(
 | 
			
		||||
  chatFolders: ReadonlyArray<ChatFolder>
 | 
			
		||||
): CurrentChatFolders {
 | 
			
		||||
  const order = chatFolders
 | 
			
		||||
    .toSorted((a, b) => a.position - b.position)
 | 
			
		||||
    .map(chatFolder => chatFolder.id);
 | 
			
		||||
 | 
			
		||||
  const lookup: Record<ChatFolderId, ChatFolder> = {};
 | 
			
		||||
  for (const chatFolder of chatFolders) {
 | 
			
		||||
    lookup[chatFolder.id] = chatFolder;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return { order, lookup };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function getSortedCurrentChatFolders(
 | 
			
		||||
  currentChatFolders: CurrentChatFolders
 | 
			
		||||
): ReadonlyArray<ChatFolder> {
 | 
			
		||||
  return currentChatFolders.order.map(chatFolderId => {
 | 
			
		||||
    return lookupCurrentChatFolder(currentChatFolders, chatFolderId);
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function lookupCurrentChatFolder(
 | 
			
		||||
  currentChatFolders: CurrentChatFolders,
 | 
			
		||||
  chatFolderId: ChatFolderId
 | 
			
		||||
): ChatFolder {
 | 
			
		||||
  const chatFolder = currentChatFolders.lookup[chatFolderId];
 | 
			
		||||
  strictAssert(chatFolder != null, 'Missing chat folder');
 | 
			
		||||
  return chatFolder;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,16 +2,30 @@
 | 
			
		|||
// SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		||||
 | 
			
		||||
import type { ReadonlyDeep } from 'type-fest';
 | 
			
		||||
import type { ChatFolderId } from './ChatFolder.js';
 | 
			
		||||
 | 
			
		||||
export type Location = ReadonlyDeep<
 | 
			
		||||
  | {
 | 
			
		||||
      tab: NavTab.Settings;
 | 
			
		||||
      details:
 | 
			
		||||
export type SettingsLocation = ReadonlyDeep<
 | 
			
		||||
  | {
 | 
			
		||||
      page: SettingsPage.Profile;
 | 
			
		||||
      state: ProfileEditorPage;
 | 
			
		||||
    }
 | 
			
		||||
        | { page: Exclude<SettingsPage, SettingsPage.Profile> };
 | 
			
		||||
  | {
 | 
			
		||||
      page: SettingsPage.EditChatFolder;
 | 
			
		||||
      chatFolderId: ChatFolderId | null;
 | 
			
		||||
      previousLocation: Location;
 | 
			
		||||
    }
 | 
			
		||||
  | {
 | 
			
		||||
      page: Exclude<
 | 
			
		||||
        SettingsPage,
 | 
			
		||||
        SettingsPage.Profile | SettingsPage.EditChatFolder
 | 
			
		||||
      >;
 | 
			
		||||
    }
 | 
			
		||||
>;
 | 
			
		||||
 | 
			
		||||
export type Location = ReadonlyDeep<
 | 
			
		||||
  | {
 | 
			
		||||
      tab: NavTab.Settings;
 | 
			
		||||
      details: SettingsLocation;
 | 
			
		||||
    }
 | 
			
		||||
  | { tab: Exclude<NavTab, NavTab.Settings> }
 | 
			
		||||
>;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										60
									
								
								ts/util/countMutedStats.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								ts/util/countMutedStats.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,60 @@
 | 
			
		|||
// Copyright 2025 Signal Messenger, LLC
 | 
			
		||||
// SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		||||
 | 
			
		||||
import type { ConversationType } from '../state/ducks/conversations.js';
 | 
			
		||||
import {
 | 
			
		||||
  isConversationInChatFolder,
 | 
			
		||||
  type ChatFolder,
 | 
			
		||||
  type ChatFolderId,
 | 
			
		||||
} from '../types/ChatFolder.js';
 | 
			
		||||
import { isConversationMuted } from './isConversationMuted.js';
 | 
			
		||||
 | 
			
		||||
type MutableMutedStats = {
 | 
			
		||||
  chatsMutedCount: number;
 | 
			
		||||
  chatsUnmutedCount: number;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type MutedStats = Readonly<MutableMutedStats>;
 | 
			
		||||
 | 
			
		||||
export type AllChatFoldersMutedStats = Map<ChatFolderId, MutedStats>;
 | 
			
		||||
 | 
			
		||||
function createMutedStats(): MutableMutedStats {
 | 
			
		||||
  return {
 | 
			
		||||
    chatsMutedCount: 0,
 | 
			
		||||
    chatsUnmutedCount: 0,
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type ConversationPropsForMutedStats = Readonly<
 | 
			
		||||
  Pick<ConversationType, 'id' | 'type' | 'activeAt' | 'muteExpiresAt'>
 | 
			
		||||
>;
 | 
			
		||||
 | 
			
		||||
export function countAllChatFoldersMutedStats(
 | 
			
		||||
  sortedChatFolders: ReadonlyArray<ChatFolder>,
 | 
			
		||||
  conversations: ReadonlyArray<ConversationPropsForMutedStats>
 | 
			
		||||
): AllChatFoldersMutedStats {
 | 
			
		||||
  const results = new Map<ChatFolderId, MutableMutedStats>();
 | 
			
		||||
 | 
			
		||||
  for (const conversation of conversations) {
 | 
			
		||||
    const isMuted = isConversationMuted(conversation);
 | 
			
		||||
 | 
			
		||||
    // check which chatFolders should count this conversation
 | 
			
		||||
    for (const chatFolder of sortedChatFolders) {
 | 
			
		||||
      if (isConversationInChatFolder(chatFolder, conversation)) {
 | 
			
		||||
        let mutedStats = results.get(chatFolder.id);
 | 
			
		||||
        if (mutedStats == null) {
 | 
			
		||||
          mutedStats = createMutedStats();
 | 
			
		||||
          results.set(chatFolder.id, mutedStats);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (isMuted) {
 | 
			
		||||
          mutedStats.chatsMutedCount += 1;
 | 
			
		||||
        } else {
 | 
			
		||||
          mutedStats.chatsUnmutedCount += 1;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return results;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -2,23 +2,44 @@
 | 
			
		|||
// SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		||||
 | 
			
		||||
import type { ConversationType } from '../state/ducks/conversations.js';
 | 
			
		||||
import { isConversationInChatFolder } from '../types/ChatFolder.js';
 | 
			
		||||
import type { ChatFolder, ChatFolderId } from '../types/ChatFolder.js';
 | 
			
		||||
import { isConversationMuted } from './isConversationMuted.js';
 | 
			
		||||
 | 
			
		||||
type MutableUnreadStats = {
 | 
			
		||||
  /**
 | 
			
		||||
   * Total of `conversation.unreadCount`
 | 
			
		||||
   * in all countable conversations in the set.
 | 
			
		||||
   *
 | 
			
		||||
   * Note: `conversation.unreadCount` should always include the number of
 | 
			
		||||
   * unread messages with mentions.
 | 
			
		||||
   */
 | 
			
		||||
  unreadCount: number;
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Total of `conversation.unreadMentionsCount`
 | 
			
		||||
   * in all countable conversations in the set.
 | 
			
		||||
   */
 | 
			
		||||
  unreadMentionsCount: number;
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Total of `unreadCount === 0 && markedRead == true`
 | 
			
		||||
   * in all countable conversations in the set.
 | 
			
		||||
   */
 | 
			
		||||
  readChatsMarkedUnreadCount: number;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * This can be used to describe unread counts of chats, stories, and calls,
 | 
			
		||||
 * individually or all of them together.
 | 
			
		||||
 */
 | 
			
		||||
export type UnreadStats = Readonly<{
 | 
			
		||||
  unreadCount: number;
 | 
			
		||||
  unreadMentionsCount: number;
 | 
			
		||||
  markedUnread: boolean;
 | 
			
		||||
}>;
 | 
			
		||||
export type UnreadStats = Readonly<MutableUnreadStats>;
 | 
			
		||||
 | 
			
		||||
function getEmptyUnreadStats(): UnreadStats {
 | 
			
		||||
function createUnreadStats(): MutableUnreadStats {
 | 
			
		||||
  return {
 | 
			
		||||
    unreadCount: 0,
 | 
			
		||||
    unreadMentionsCount: 0,
 | 
			
		||||
    markedUnread: false,
 | 
			
		||||
    readChatsMarkedUnreadCount: 0,
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -29,6 +50,8 @@ export type UnreadStatsOptions = Readonly<{
 | 
			
		|||
export type ConversationPropsForUnreadStats = Readonly<
 | 
			
		||||
  Pick<
 | 
			
		||||
    ConversationType,
 | 
			
		||||
    | 'id'
 | 
			
		||||
    | 'type'
 | 
			
		||||
    | 'activeAt'
 | 
			
		||||
    | 'isArchived'
 | 
			
		||||
    | 'markedUnread'
 | 
			
		||||
| 
						 | 
				
			
			@ -39,7 +62,9 @@ export type ConversationPropsForUnreadStats = Readonly<
 | 
			
		|||
  >
 | 
			
		||||
>;
 | 
			
		||||
 | 
			
		||||
function canCountConversation(
 | 
			
		||||
export type AllChatFoldersUnreadStats = Map<ChatFolderId, UnreadStats>;
 | 
			
		||||
 | 
			
		||||
function _canCountConversation(
 | 
			
		||||
  conversation: ConversationPropsForUnreadStats,
 | 
			
		||||
  options: UnreadStatsOptions
 | 
			
		||||
): boolean {
 | 
			
		||||
| 
						 | 
				
			
			@ -58,39 +83,109 @@ function canCountConversation(
 | 
			
		|||
  return true;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/** @private */
 | 
			
		||||
function _countConversation(
 | 
			
		||||
  unreadStats: MutableUnreadStats,
 | 
			
		||||
  conversation: ConversationPropsForUnreadStats
 | 
			
		||||
): void {
 | 
			
		||||
  const mutable = unreadStats;
 | 
			
		||||
  const {
 | 
			
		||||
    unreadCount = 0,
 | 
			
		||||
    unreadMentionsCount = 0,
 | 
			
		||||
    markedUnread = false,
 | 
			
		||||
  } = conversation;
 | 
			
		||||
 | 
			
		||||
  const hasUnreadCount = unreadCount > 0;
 | 
			
		||||
 | 
			
		||||
  if (hasUnreadCount) {
 | 
			
		||||
    mutable.unreadCount += unreadCount;
 | 
			
		||||
    mutable.unreadMentionsCount += unreadMentionsCount;
 | 
			
		||||
  } else if (markedUnread) {
 | 
			
		||||
    mutable.readChatsMarkedUnreadCount += 1;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function isConversationUnread(
 | 
			
		||||
  conversation: ConversationPropsForUnreadStats,
 | 
			
		||||
  options: UnreadStatsOptions
 | 
			
		||||
): boolean {
 | 
			
		||||
  if (!_canCountConversation(conversation, options)) {
 | 
			
		||||
    return false;
 | 
			
		||||
  }
 | 
			
		||||
  // Note: Don't need to look at unreadMentionsCount
 | 
			
		||||
  const { unreadCount, markedUnread } = conversation;
 | 
			
		||||
  if (unreadCount != null && unreadCount !== 0) {
 | 
			
		||||
    return true;
 | 
			
		||||
  }
 | 
			
		||||
  if (markedUnread) {
 | 
			
		||||
    return true;
 | 
			
		||||
  }
 | 
			
		||||
  return false;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function countConversationUnreadStats(
 | 
			
		||||
  conversation: ConversationPropsForUnreadStats,
 | 
			
		||||
  options: UnreadStatsOptions
 | 
			
		||||
): UnreadStats {
 | 
			
		||||
  if (canCountConversation(conversation, options)) {
 | 
			
		||||
    return {
 | 
			
		||||
      unreadCount: conversation.unreadCount ?? 0,
 | 
			
		||||
      unreadMentionsCount: conversation.unreadMentionsCount ?? 0,
 | 
			
		||||
      markedUnread: conversation.markedUnread ?? false,
 | 
			
		||||
    };
 | 
			
		||||
  const unreadStats = createUnreadStats();
 | 
			
		||||
  if (_canCountConversation(conversation, options)) {
 | 
			
		||||
    _countConversation(unreadStats, conversation);
 | 
			
		||||
  }
 | 
			
		||||
  return getEmptyUnreadStats();
 | 
			
		||||
  return unreadStats;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function countAllConversationsUnreadStats(
 | 
			
		||||
  conversations: ReadonlyArray<ConversationPropsForUnreadStats>,
 | 
			
		||||
  options: UnreadStatsOptions
 | 
			
		||||
): UnreadStats {
 | 
			
		||||
  return conversations.reduce<UnreadStats>((total, conversation) => {
 | 
			
		||||
    const stats = countConversationUnreadStats(conversation, options);
 | 
			
		||||
    return {
 | 
			
		||||
      unreadCount: total.unreadCount + stats.unreadCount,
 | 
			
		||||
      unreadMentionsCount:
 | 
			
		||||
        total.unreadMentionsCount + stats.unreadMentionsCount,
 | 
			
		||||
      markedUnread: total.markedUnread || stats.markedUnread,
 | 
			
		||||
    };
 | 
			
		||||
  }, getEmptyUnreadStats());
 | 
			
		||||
  const unreadStats = createUnreadStats();
 | 
			
		||||
 | 
			
		||||
  for (const conversation of conversations) {
 | 
			
		||||
    if (_canCountConversation(conversation, options)) {
 | 
			
		||||
      _countConversation(unreadStats, conversation);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return unreadStats;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function hasUnread(unreadStats: UnreadStats): boolean {
 | 
			
		||||
  return (
 | 
			
		||||
    unreadStats.unreadCount > 0 ||
 | 
			
		||||
    unreadStats.unreadMentionsCount > 0 ||
 | 
			
		||||
    unreadStats.markedUnread
 | 
			
		||||
  );
 | 
			
		||||
export function countAllChatFoldersUnreadStats(
 | 
			
		||||
  sortedChatFolders: ReadonlyArray<ChatFolder>,
 | 
			
		||||
  conversations: ReadonlyArray<ConversationPropsForUnreadStats>,
 | 
			
		||||
  options: UnreadStatsOptions
 | 
			
		||||
): AllChatFoldersUnreadStats {
 | 
			
		||||
  const results = new Map<ChatFolderId, MutableUnreadStats>();
 | 
			
		||||
 | 
			
		||||
  for (const conversation of conversations) {
 | 
			
		||||
    // skip if we shouldn't count it
 | 
			
		||||
    if (!_canCountConversation(conversation, options)) {
 | 
			
		||||
      continue;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const {
 | 
			
		||||
      unreadCount = 0,
 | 
			
		||||
      unreadMentionsCount = 0,
 | 
			
		||||
      markedUnread = false,
 | 
			
		||||
    } = conversation;
 | 
			
		||||
 | 
			
		||||
    // skip if we don't have any unreads
 | 
			
		||||
    if (unreadCount === 0 && unreadMentionsCount === 0 && !markedUnread) {
 | 
			
		||||
      continue;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // check which chatFolders should count this conversation
 | 
			
		||||
    for (const chatFolder of sortedChatFolders) {
 | 
			
		||||
      if (isConversationInChatFolder(chatFolder, conversation)) {
 | 
			
		||||
        let unreadStats = results.get(chatFolder.id);
 | 
			
		||||
        if (unreadStats == null) {
 | 
			
		||||
          unreadStats = createUnreadStats();
 | 
			
		||||
          results.set(chatFolder.id, unreadStats);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        _countConversation(unreadStats, conversation);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return results;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -6,7 +6,7 @@ import type { ConversationType } from '../state/ducks/conversations.js';
 | 
			
		|||
import { parseAndFormatPhoneNumber } from './libphonenumberInstance.js';
 | 
			
		||||
import { WEEK } from './durations/index.js';
 | 
			
		||||
import { fuseGetFnRemoveDiacritics, getCachedFuseIndex } from './fuse.js';
 | 
			
		||||
import { countConversationUnreadStats, hasUnread } from './countUnreadStats.js';
 | 
			
		||||
import { isConversationUnread } from './countUnreadStats.js';
 | 
			
		||||
import { getE164 } from './getE164.js';
 | 
			
		||||
import { removeDiacritics } from './removeDiacritics.js';
 | 
			
		||||
import { isAciString } from './isAciString.js';
 | 
			
		||||
| 
						 | 
				
			
			@ -69,9 +69,7 @@ function filterConversationsByUnread(
 | 
			
		|||
  includeMuted: boolean
 | 
			
		||||
): Array<ConversationType> {
 | 
			
		||||
  return conversations.filter(conversation => {
 | 
			
		||||
    return hasUnread(
 | 
			
		||||
      countConversationUnreadStats(conversation, { includeMuted })
 | 
			
		||||
    );
 | 
			
		||||
    return isConversationUnread(conversation, { includeMuted });
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -12,24 +12,10 @@ export type MuteOption = {
 | 
			
		|||
  value: number;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export function getMuteOptions(
 | 
			
		||||
  muteExpiresAt: null | undefined | number,
 | 
			
		||||
export function getMuteValuesOptions(
 | 
			
		||||
  i18n: LocalizerType
 | 
			
		||||
): Array<MuteOption> {
 | 
			
		||||
): ReadonlyArray<MuteOption> {
 | 
			
		||||
  return [
 | 
			
		||||
    ...(muteExpiresAt && isConversationMuted({ muteExpiresAt })
 | 
			
		||||
      ? [
 | 
			
		||||
          {
 | 
			
		||||
            name: getMutedUntilText(muteExpiresAt, i18n),
 | 
			
		||||
            disabled: true,
 | 
			
		||||
            value: -1,
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            name: i18n('icu:unmute'),
 | 
			
		||||
            value: 0,
 | 
			
		||||
          },
 | 
			
		||||
        ]
 | 
			
		||||
      : []),
 | 
			
		||||
    {
 | 
			
		||||
      name: i18n('icu:muteHour'),
 | 
			
		||||
      value: durations.HOUR,
 | 
			
		||||
| 
						 | 
				
			
			@ -52,3 +38,25 @@ export function getMuteOptions(
 | 
			
		|||
    },
 | 
			
		||||
  ];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function getMuteOptions(
 | 
			
		||||
  muteExpiresAt: null | undefined | number,
 | 
			
		||||
  i18n: LocalizerType
 | 
			
		||||
): Array<MuteOption> {
 | 
			
		||||
  return [
 | 
			
		||||
    ...(muteExpiresAt && isConversationMuted({ muteExpiresAt })
 | 
			
		||||
      ? [
 | 
			
		||||
          {
 | 
			
		||||
            name: getMutedUntilText(muteExpiresAt, i18n),
 | 
			
		||||
            disabled: true,
 | 
			
		||||
            value: -1,
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            name: i18n('icu:unmute'),
 | 
			
		||||
            value: 0,
 | 
			
		||||
          },
 | 
			
		||||
        ]
 | 
			
		||||
      : []),
 | 
			
		||||
    ...getMuteValuesOptions(i18n),
 | 
			
		||||
  ];
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2083,6 +2083,13 @@
 | 
			
		|||
    "reasonCategory": "usageTrusted",
 | 
			
		||||
    "updated": "2025-02-19T20:14:46.879Z"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "rule": "React-useRef",
 | 
			
		||||
    "path": "ts/components/preferences/chatFolders/PreferencesEditChatFoldersPage.tsx",
 | 
			
		||||
    "line": "  const didSaveOrDiscardChangesRef = useRef(false);",
 | 
			
		||||
    "reasonCategory": "usageTrusted",
 | 
			
		||||
    "updated": "2025-09-24T17:08:10.620Z"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "rule": "React-useRef",
 | 
			
		||||
    "path": "ts/components/preferences/donations/DonateInputAmount.tsx",
 | 
			
		||||
| 
						 | 
				
			
			@ -2141,6 +2148,20 @@
 | 
			
		|||
    "reasonCategory": "usageTrusted",
 | 
			
		||||
    "updated": "2023-10-04T20:50:45.297Z"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "rule": "React-useRef",
 | 
			
		||||
    "path": "ts/hooks/useNavBlocker.ts",
 | 
			
		||||
    "line": "  const nameRef = useRef(name);",
 | 
			
		||||
    "reasonCategory": "usageTrusted",
 | 
			
		||||
    "updated": "2025-09-24T17:08:10.620Z"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "rule": "React-useRef",
 | 
			
		||||
    "path": "ts/hooks/useNavBlocker.ts",
 | 
			
		||||
    "line": "  const shouldBlockRef = useRef(shouldBlock);",
 | 
			
		||||
    "reasonCategory": "usageTrusted",
 | 
			
		||||
    "updated": "2025-09-24T17:08:10.620Z"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "rule": "React-useRef",
 | 
			
		||||
    "path": "ts/hooks/usePrevious.ts",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -27,14 +27,12 @@
 | 
			
		|||
     */
 | 
			
		||||
 | 
			
		||||
    /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
 | 
			
		||||
    "target": "ES2021",
 | 
			
		||||
    "target": "ES2023",
 | 
			
		||||
    /* Specify a set of bundled library declaration files that describe the target runtime environment. */
 | 
			
		||||
    "lib": [
 | 
			
		||||
      "DOM", // Required to access `window`
 | 
			
		||||
      "DOM.Iterable",
 | 
			
		||||
      "ES2022",
 | 
			
		||||
      "ES2023.Array",
 | 
			
		||||
      "ESNext.Disposable" // For `playwright`
 | 
			
		||||
      "ESNext"
 | 
			
		||||
    ],
 | 
			
		||||
    /* Specify what JSX code is generated. */
 | 
			
		||||
    "jsx": "react",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue