diff --git a/packages/desktop-client/package.json b/packages/desktop-client/package.json
index 8aa94a5bf8880cb5b8efc7f3a7d8dc05097126d2..922c6485329cc7045c471b4a4281cf9d8e460165 100644
--- a/packages/desktop-client/package.json
+++ b/packages/desktop-client/package.json
@@ -52,6 +52,7 @@
     "promise-retry": "^2.0.1",
     "re-resizable": "^6.9.17",
     "react": "18.2.0",
+    "react-aria": "^3.33.1",
     "react-aria-components": "^1.2.1",
     "react-dnd": "^16.0.1",
     "react-dnd-html5-backend": "^16.0.1",
diff --git a/packages/desktop-client/src/components/Modals.tsx b/packages/desktop-client/src/components/Modals.tsx
index a594d445fecb3189326c0b60da6c58fa02ae16a7..ea138102900adf95c9f6621722ac8acef72dc8d0 100644
--- a/packages/desktop-client/src/components/Modals.tsx
+++ b/packages/desktop-client/src/components/Modals.tsx
@@ -157,6 +157,7 @@ export function Modals() {
           return (
             <ConfirmTransactionDelete
               key={name}
+              message={options.message}
               onConfirm={options.onConfirm}
             />
           );
@@ -342,6 +343,7 @@ export function Modals() {
               transactionIds={options?.transactionIds}
               getTransaction={options?.getTransaction}
               accountName={options?.accountName}
+              onScheduleLinked={options?.onScheduleLinked}
             />
           );
 
diff --git a/packages/desktop-client/src/components/Notifications.tsx b/packages/desktop-client/src/components/Notifications.tsx
index 185241524895bfd795fb55eb5d33201ff343ed9a..4fe464a3e03acbc130e829008b0dd2cdbfb4da21 100644
--- a/packages/desktop-client/src/components/Notifications.tsx
+++ b/packages/desktop-client/src/components/Notifications.tsx
@@ -5,12 +5,12 @@ import React, {
   useMemo,
   type SetStateAction,
 } from 'react';
-import { useSelector } from 'react-redux';
+import { useDispatch, useSelector } from 'react-redux';
 
+import { removeNotification } from 'loot-core/client/actions';
 import { type State } from 'loot-core/src/client/state-types';
 import type { NotificationWithId } from 'loot-core/src/client/state-types/notifications';
 
-import { useActions } from '../hooks/useActions';
 import { AnimatedLoading } from '../icons/AnimatedLoading';
 import { SvgDelete } from '../icons/v0';
 import { useResponsive } from '../ResponsiveProvider';
@@ -120,6 +120,11 @@ function Notification({
     [message, messageActions],
   );
 
+  const { isNarrowWidth } = useResponsive();
+  const narrowStyle: CSSProperties = isNarrowWidth
+    ? { minHeight: styles.mobileMinHeight }
+    : {};
+
   return (
     <View
       style={{
@@ -133,10 +138,11 @@ function Notification({
     >
       <Stack
         align="center"
+        justify="space-between"
         direction="row"
         style={{
           padding: '14px 14px',
-          fontSize: 14,
+          ...styles.mediumText,
           backgroundColor: positive
             ? theme.noticeBackgroundLight
             : error
@@ -156,7 +162,15 @@ function Notification({
       >
         <Stack align="flex-start">
           {title && (
-            <View style={{ fontWeight: 700, marginBottom: 10 }}>{title}</View>
+            <View
+              style={{
+                ...styles.mediumText,
+                fontWeight: 700,
+                marginBottom: 10,
+              }}
+            >
+              {title}
+            </View>
           )}
           <View>{processedMessage}</View>
           {pre
@@ -196,7 +210,7 @@ function Notification({
                       : theme.warningBorder
                 }`,
                 color: 'currentColor',
-                fontSize: 14,
+                ...styles.mediumText,
                 flexShrink: 0,
                 ...(isHovered || isPressed
                   ? {
@@ -207,22 +221,21 @@ function Notification({
                           : theme.warningBackground,
                     }
                   : {}),
+                ...narrowStyle,
               })}
             >
               {button.title}
             </ButtonWithLoading>
           )}
         </Stack>
-        {sticky && (
-          <Button
-            variant="bare"
-            aria-label="Close"
-            style={{ flexShrink: 0, color: 'currentColor' }}
-            onPress={onRemove}
-          >
-            <SvgDelete style={{ width: 9, height: 9, color: 'currentColor' }} />
-          </Button>
-        )}
+        <Button
+          variant="bare"
+          aria-label="Close"
+          style={{ flexShrink: 0, color: 'currentColor' }}
+          onPress={onRemove}
+        >
+          <SvgDelete style={{ width: 9, height: 9, color: 'currentColor' }} />
+        </Button>
       </Stack>
       {overlayLoading && (
         <View
@@ -247,18 +260,22 @@ function Notification({
 }
 
 export function Notifications({ style }: { style?: CSSProperties }) {
-  const { removeNotification } = useActions();
+  const dispatch = useDispatch();
   const { isNarrowWidth } = useResponsive();
   const notifications = useSelector(
     (state: State) => state.notifications.notifications,
   );
+  const notificationInset = useSelector(
+    (state: State) => state.notifications.inset,
+  );
   return (
     <View
       style={{
         position: 'fixed',
-        bottom: 20,
-        right: 13,
-        left: isNarrowWidth ? 13 : undefined,
+        bottom: notificationInset?.bottom || 20,
+        top: notificationInset?.top,
+        right: notificationInset?.right || 13,
+        left: notificationInset?.left || (isNarrowWidth ? 13 : undefined),
         zIndex: 10000,
         ...style,
       }}
@@ -271,7 +288,7 @@ export function Notifications({ style }: { style?: CSSProperties }) {
             if (note.onClose) {
               note.onClose();
             }
-            removeNotification(note.id);
+            dispatch(removeNotification(note.id));
           }}
         />
       ))}
diff --git a/packages/desktop-client/src/components/accounts/Account.jsx b/packages/desktop-client/src/components/accounts/Account.jsx
index dba17d9f679b2fa79e2add1ec7084e3fe6f16cb5..06fddb35e6a521ca3a782b55d30ba2dea7768a09 100644
--- a/packages/desktop-client/src/components/accounts/Account.jsx
+++ b/packages/desktop-client/src/components/accounts/Account.jsx
@@ -15,11 +15,9 @@ import * as queries from 'loot-core/src/client/queries';
 import { runQuery, pagedQuery } from 'loot-core/src/client/query-helpers';
 import { send, listen } from 'loot-core/src/platform/client/fetch';
 import { currentDay } from 'loot-core/src/shared/months';
-import * as monthUtils from 'loot-core/src/shared/months';
 import { q } from 'loot-core/src/shared/query';
 import { getScheduledAmount } from 'loot-core/src/shared/schedules';
 import {
-  deleteTransaction,
   updateTransaction,
   realizeTempTransactions,
   ungroupTransaction,
@@ -42,6 +40,7 @@ import {
   useSplitsExpanded,
 } from '../../hooks/useSplitsExpanded';
 import { useSyncedPref } from '../../hooks/useSyncedPref';
+import { useTransactionBatchActions } from '../../hooks/useTransactionBatchActions';
 import { styles, theme } from '../../style';
 import { Button } from '../common/Button2';
 import { Text } from '../common/Text';
@@ -818,241 +817,26 @@ class AccountInternal extends PureComponent {
     });
   };
 
-  onBatchEdit = async (name, ids) => {
-    const { data } = await runQuery(
-      q('transactions')
-        .filter({ id: { $oneof: ids } })
-        .select('*')
-        .options({ splits: 'grouped' }),
-    );
-    const transactions = ungroupTransactions(data);
-
-    const onChange = async (name, value, mode) => {
-      let transactionsToChange = transactions;
-
-      const newValue = value === null ? '' : value;
-      this.setState({ workingHard: true });
-
-      const changes = { deleted: [], updated: [] };
-
-      // Cleared is a special case right now
-      if (name === 'cleared') {
-        // Clear them if any are uncleared, otherwise unclear them
-        value = !!transactionsToChange.find(t => !t.cleared);
-      }
-
-      const idSet = new Set(ids);
-
-      transactionsToChange.forEach(trans => {
-        if (name === 'cleared' && trans.reconciled) {
-          // Skip transactions that are reconciled. Don't want to set them as
-          // uncleared.
-          return;
-        }
-
-        if (!idSet.has(trans.id)) {
-          // Skip transactions which aren't actually selected, since the query
-          // above also retrieves the siblings & parent of any selected splits.
-          return;
-        }
-
-        if (name === 'notes') {
-          if (mode === 'prepend') {
-            value =
-              trans.notes === null ? newValue : newValue + ' ' + trans.notes;
-          } else if (mode === 'append') {
-            value =
-              trans.notes === null ? newValue : trans.notes + ' ' + newValue;
-          } else if (mode === 'replace') {
-            value = newValue;
-          }
-        }
-        const transaction = {
-          ...trans,
-          [name]: value,
-        };
+  onBatchEdit = (name, ids) => {
+    this.props.onBatchEdit({
+      name,
+      ids,
+      onSuccess: updatedIds => {
+        this.refetchTransactions();
 
-        if (name === 'account' && trans.account !== value) {
-          transaction.reconciled = false;
+        if (this.table.current) {
+          this.table.current.edit(updatedIds[0], 'select', false);
         }
-
-        const { diff } = updateTransaction(transactionsToChange, transaction);
-
-        // TODO: We need to keep an updated list of transactions so
-        // the logic in `updateTransaction`, particularly about
-        // updating split transactions, works. This isn't ideal and we
-        // should figure something else out
-        transactionsToChange = applyChanges(diff, transactionsToChange);
-
-        changes.deleted = changes.deleted
-          ? changes.deleted.concat(diff.deleted)
-          : diff.deleted;
-        changes.updated = changes.updated
-          ? changes.updated.concat(diff.updated)
-          : diff.updated;
-        changes.added = changes.added
-          ? changes.added.concat(diff.added)
-          : diff.added;
-      });
-
-      await send('transactions-batch-update', changes);
-      await this.refetchTransactions();
-
-      if (this.table.current) {
-        this.table.current.edit(transactionsToChange[0].id, 'select', false);
-      }
-    };
-
-    const pushPayeeAutocompleteModal = () => {
-      this.props.pushModal('payee-autocomplete', {
-        onSelect: payeeId => onChange(name, payeeId),
-      });
-    };
-
-    const pushAccountAutocompleteModal = () => {
-      this.props.pushModal('account-autocomplete', {
-        onSelect: accountId => onChange(name, accountId),
-      });
-    };
-
-    const pushCategoryAutocompleteModal = () => {
-      // Only show balances when all selected transaction are in the same month.
-      const transactionMonth = transactions[0]?.date
-        ? monthUtils.monthFromDate(transactions[0]?.date)
-        : null;
-      const transactionsHaveSameMonth =
-        transactionMonth &&
-        transactions.every(
-          t => monthUtils.monthFromDate(t.date) === transactionMonth,
-        );
-      this.props.pushModal('category-autocomplete', {
-        month: transactionsHaveSameMonth ? transactionMonth : undefined,
-        onSelect: categoryId => onChange(name, categoryId),
-      });
-    };
-
-    if (
-      name === 'amount' ||
-      name === 'payee' ||
-      name === 'account' ||
-      name === 'date'
-    ) {
-      const reconciledTransactions = transactions.filter(t => t.reconciled);
-      if (reconciledTransactions.length > 0) {
-        this.props.pushModal('confirm-transaction-edit', {
-          onConfirm: () => {
-            if (name === 'payee') {
-              pushPayeeAutocompleteModal();
-            } else if (name === 'account') {
-              pushAccountAutocompleteModal();
-            } else {
-              this.props.pushModal('edit-field', { name, onSubmit: onChange });
-            }
-          },
-          confirmReason: 'batchEditWithReconciled',
-        });
-        return;
-      }
-    }
-
-    if (name === 'cleared') {
-      // Cleared just toggles it on/off and it depends on the data
-      // loaded. Need to clean this up in the future.
-      onChange('cleared', null);
-    } else if (name === 'category') {
-      pushCategoryAutocompleteModal();
-    } else if (name === 'payee') {
-      pushPayeeAutocompleteModal();
-    } else if (name === 'account') {
-      pushAccountAutocompleteModal();
-    } else {
-      this.props.pushModal('edit-field', { name, onSubmit: onChange });
-    }
+      },
+    });
   };
 
-  onBatchDuplicate = async ids => {
-    const onConfirmDuplicate = async ids => {
-      this.setState({ workingHard: true });
-
-      const { data } = await runQuery(
-        q('transactions')
-          .filter({ id: { $oneof: ids } })
-          .select('*')
-          .options({ splits: 'grouped' }),
-      );
-
-      const changes = {
-        added: data
-          .reduce((newTransactions, trans) => {
-            return newTransactions.concat(
-              realizeTempTransactions(ungroupTransaction(trans)),
-            );
-          }, [])
-          .map(({ sort_order, ...trans }) => ({ ...trans })),
-      };
-
-      await send('transactions-batch-update', changes);
-
-      await this.refetchTransactions();
-    };
-
-    await this.checkForReconciledTransactions(
-      ids,
-      'batchDuplicateWithReconciled',
-      onConfirmDuplicate,
-    );
+  onBatchDuplicate = ids => {
+    this.props.onBatchDuplicate({ ids, onSuccess: this.refetchTransactions });
   };
 
-  onBatchDelete = async ids => {
-    const onConfirmDelete = async ids => {
-      this.setState({ workingHard: true });
-
-      const { data } = await runQuery(
-        q('transactions')
-          .filter({ id: { $oneof: ids } })
-          .select('*')
-          .options({ splits: 'grouped' }),
-      );
-      let transactions = ungroupTransactions(data);
-
-      const idSet = new Set(ids);
-      const changes = { deleted: [], updated: [] };
-
-      transactions.forEach(trans => {
-        const parentId = trans.parent_id;
-
-        // First, check if we're actually deleting this transaction by
-        // checking `idSet`. Then, we don't need to do anything if it's
-        // a child transaction and the parent is already being deleted
-        if (!idSet.has(trans.id) || (parentId && idSet.has(parentId))) {
-          return;
-        }
-
-        const { diff } = deleteTransaction(transactions, trans.id);
-
-        // TODO: We need to keep an updated list of transactions so
-        // the logic in `updateTransaction`, particularly about
-        // updating split transactions, works. This isn't ideal and we
-        // should figure something else out
-        transactions = applyChanges(diff, transactions);
-
-        changes.deleted = diff.deleted
-          ? changes.deleted.concat(diff.deleted)
-          : diff.deleted;
-        changes.updated = diff.updated
-          ? changes.updated.concat(diff.updated)
-          : diff.updated;
-      });
-
-      await send('transactions-batch-update', changes);
-      await this.refetchTransactions();
-    };
-
-    await this.checkForReconciledTransactions(
-      ids,
-      'batchDeleteWithReconciled',
-      onConfirmDelete,
-    );
+  onBatchDelete = ids => {
+    this.props.onBatchDelete({ ids, onSuccess: this.refetchTransactions });
   };
 
   onMakeAsSplitTransaction = async ids => {
@@ -1183,12 +967,19 @@ class AccountInternal extends PureComponent {
     }
   };
 
-  onBatchUnlink = async ids => {
-    await send('transactions-batch-update', {
-      updated: ids.map(id => ({ id, schedule: null })),
+  onBatchLinkSchedule = ids => {
+    this.props.onBatchLinkSchedule({
+      ids,
+      account: this.props.accounts.find(a => a.id === this.props.accountId),
+      onSuccess: this.refetchTransactions,
     });
+  };
 
-    await this.refetchTransactions();
+  onBatchUnlinkSchedule = ids => {
+    this.props.onBatchUnlinkSchedule({
+      ids,
+      onSuccess: this.refetchTransactions,
+    });
   };
 
   onCreateRule = async ids => {
@@ -1714,7 +1505,8 @@ class AccountInternal extends PureComponent {
                 onBatchDelete={this.onBatchDelete}
                 onBatchDuplicate={this.onBatchDuplicate}
                 onBatchEdit={this.onBatchEdit}
-                onBatchUnlink={this.onBatchUnlink}
+                onBatchLinkSchedule={this.onBatchLinkSchedule}
+                onBatchUnlinkSchedule={this.onBatchUnlinkSchedule}
                 onCreateRule={this.onCreateRule}
                 onUpdateFilter={this.onUpdateFilter}
                 onClearFilters={this.onClearFilters}
@@ -1802,10 +1594,22 @@ class AccountInternal extends PureComponent {
 
 function AccountHack(props) {
   const { dispatch: splitsExpandedDispatch } = useSplitsExpanded();
+  const {
+    onBatchEdit,
+    onBatchDuplicate,
+    onBatchLinkSchedule,
+    onBatchUnlinkSchedule,
+    onBatchDelete,
+  } = useTransactionBatchActions();
 
   return (
     <AccountInternal
       splitsExpandedDispatch={splitsExpandedDispatch}
+      onBatchEdit={onBatchEdit}
+      onBatchDuplicate={onBatchDuplicate}
+      onBatchLinkSchedule={onBatchLinkSchedule}
+      onBatchUnlinkSchedule={onBatchUnlinkSchedule}
+      onBatchDelete={onBatchDelete}
       {...props}
     />
   );
diff --git a/packages/desktop-client/src/components/accounts/Header.jsx b/packages/desktop-client/src/components/accounts/Header.jsx
index 84f74aad9e23812b25846ca18ee0dc5c20819ac8..91204e6b3bddc29d33fa3db9f4582166bf0a5c9c 100644
--- a/packages/desktop-client/src/components/accounts/Header.jsx
+++ b/packages/desktop-client/src/components/accounts/Header.jsx
@@ -74,7 +74,8 @@ export function AccountHeader({
   onBatchDelete,
   onBatchDuplicate,
   onBatchEdit,
-  onBatchUnlink,
+  onBatchLinkSchedule,
+  onBatchUnlinkSchedule,
   onCreateRule,
   onApplyFilter,
   onUpdateFilter,
@@ -343,7 +344,8 @@ export function AccountHeader({
               onDuplicate={onBatchDuplicate}
               onDelete={onBatchDelete}
               onEdit={onBatchEdit}
-              onUnlink={onBatchUnlink}
+              onLinkSchedule={onBatchLinkSchedule}
+              onUnlinkSchedule={onBatchUnlinkSchedule}
               onCreateRule={onCreateRule}
               onSetTransfer={onSetTransfer}
               onScheduleAction={onScheduleAction}
diff --git a/packages/desktop-client/src/components/mobile/FloatingActionBar.tsx b/packages/desktop-client/src/components/mobile/FloatingActionBar.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..0f2cf928bb2c906bb142ce6c9a0e328ee693cb50
--- /dev/null
+++ b/packages/desktop-client/src/components/mobile/FloatingActionBar.tsx
@@ -0,0 +1,30 @@
+import { type PropsWithChildren } from 'react';
+
+import { theme, type CSSProperties } from '../../style';
+import { View } from '../common/View';
+
+type FloatingActionBarProps = PropsWithChildren & {
+  style: CSSProperties;
+};
+
+export function FloatingActionBar({ style, children }: FloatingActionBarProps) {
+  return (
+    <View
+      style={{
+        backgroundColor: theme.floatingActionBarBackground,
+        color: theme.floatingActionBarText,
+        position: 'fixed',
+        bottom: 10,
+        margin: '0 10px',
+        width: '95vw',
+        height: 60,
+        zIndex: 100,
+        borderRadius: 8,
+        border: `1px solid ${theme.floatingActionBarBorder}`,
+        ...style,
+      }}
+    >
+      {children}
+    </View>
+  );
+}
diff --git a/packages/desktop-client/src/components/mobile/accounts/AccountTransactions.jsx b/packages/desktop-client/src/components/mobile/accounts/AccountTransactions.jsx
index b75882faeb94d82799cbc5a2e482b819d2c44003..40ccaeaaba6a52db978ad8bf9890154892b74d94 100644
--- a/packages/desktop-client/src/components/mobile/accounts/AccountTransactions.jsx
+++ b/packages/desktop-client/src/components/mobile/accounts/AccountTransactions.jsx
@@ -235,7 +235,7 @@ function TransactionListWithPreviews({ account }) {
     updateSearchQuery(text);
   };
 
-  const onSelectTransaction = transaction => {
+  const onOpenTransaction = transaction => {
     if (!isPreviewId(transaction.id)) {
       navigate(`/transactions/${transaction.id}`);
     } else {
@@ -275,7 +275,7 @@ function TransactionListWithPreviews({ account }) {
       onLoadMore={onLoadMore}
       searchPlaceholder={`Search ${account.name}`}
       onSearch={onSearch}
-      onSelectTransaction={onSelectTransaction}
+      onOpenTransaction={onOpenTransaction}
       onRefresh={onRefresh}
     />
   );
diff --git a/packages/desktop-client/src/components/mobile/budget/CategoryTransactions.jsx b/packages/desktop-client/src/components/mobile/budget/CategoryTransactions.jsx
index 1b9ddee52eca4c498319e2d855dec2c631b7c99d..7b0f6c7cf46b35c04c9bfc755dc6da57de465109 100644
--- a/packages/desktop-client/src/components/mobile/budget/CategoryTransactions.jsx
+++ b/packages/desktop-client/src/components/mobile/budget/CategoryTransactions.jsx
@@ -111,7 +111,7 @@ export function CategoryTransactions({ category, month }) {
     paged.current?.fetchNext();
   };
 
-  const onSelectTransaction = transaction => {
+  const onOpenTranasction = transaction => {
     // details of how the native app used to handle preview transactions here can be found at commit 05e58279
     if (!isPreviewId(transaction.id)) {
       navigate(`/transactions/${transaction.id}`);
@@ -149,7 +149,7 @@ export function CategoryTransactions({ category, month }) {
         searchPlaceholder={`Search ${category.name}`}
         onSearch={onSearch}
         onLoadMore={onLoadMore}
-        onSelectTransaction={onSelectTransaction}
+        onOpenTransaction={onOpenTranasction}
       />
     </Page>
   );
diff --git a/packages/desktop-client/src/components/mobile/transactions/Transaction.jsx b/packages/desktop-client/src/components/mobile/transactions/Transaction.jsx
index 2df640e50da5a931e0d0fd9961f78c9e3aead0be..328f5b929ae88331207364faabf791e8cc7517ef 100644
--- a/packages/desktop-client/src/components/mobile/transactions/Transaction.jsx
+++ b/packages/desktop-client/src/components/mobile/transactions/Transaction.jsx
@@ -1,5 +1,12 @@
 import React, { memo } from 'react';
 
+import {
+  PressResponder,
+  usePress,
+  useLongPress,
+} from '@react-aria/interactions';
+import { mergeProps } from '@react-aria/utils';
+
 import { getScheduledAmount } from 'loot-core/src/shared/schedules';
 import { isPreviewId } from 'loot-core/src/shared/transactions';
 import { integerToCurrency } from 'loot-core/src/shared/util';
@@ -46,8 +53,10 @@ ListItem.displayName = 'ListItem';
 
 export const Transaction = memo(function Transaction({
   transaction,
-  added,
-  onSelect,
+  isAdded,
+  isSelected,
+  onPress,
+  onLongPress,
   style,
 }) {
   const { list: categories } = useCategories();
@@ -70,6 +79,24 @@ export const Transaction = memo(function Transaction({
   const transferAcct = useAccount(payee?.transfer_acct);
 
   const isPreview = isPreviewId(id);
+
+  const { longPressProps } = useLongPress({
+    accessibilityDescription: 'Long press to select multiple transactions',
+    onLongPress: () => {
+      if (isPreview) {
+        return;
+      }
+
+      onLongPress(transaction);
+    },
+  });
+
+  const { pressProps } = usePress({
+    onPress: () => {
+      onPress(transaction);
+    },
+  });
+
   let amount = originalAmount;
   if (isPreview) {
     amount = getScheduledAmount(amount);
@@ -99,124 +126,134 @@ export const Transaction = memo(function Transaction({
   };
 
   return (
-    <Button
-      onPress={() => {
-        onSelect(transaction);
-      }}
-      style={{
-        backgroundColor: theme.tableBackground,
-        border: 'none',
-        width: '100%',
-        height: 60,
-        ...(isPreview && {
-          backgroundColor: theme.tableRowHeaderBackground,
-        }),
-      }}
-    >
-      <ListItem
+    <PressResponder {...mergeProps(pressProps, longPressProps)}>
+      <Button
         style={{
-          flex: 1,
-          ...style,
+          backgroundColor: theme.tableBackground,
+          ...(isSelected
+            ? {
+                borderWidth: '0 0 0 4px',
+                borderColor: theme.mobileTransactionSelected,
+                borderStyle: 'solid',
+              }
+            : {
+                border: 'none',
+              }),
+          userSelect: 'none',
+          width: '100%',
+          height: 60,
+          ...(isPreview
+            ? {
+                backgroundColor: theme.tableRowHeaderBackground,
+              }
+            : {}),
         }}
       >
-        <View style={{ flex: 1 }}>
-          <View style={{ flexDirection: 'row', alignItems: 'center' }}>
-            {schedule && (
-              <SvgArrowsSynchronize
-                style={{
-                  width: 12,
-                  height: 12,
-                  marginRight: 5,
-                  color: textStyle.color || theme.menuItemText,
-                }}
-              />
-            )}
-            <TextOneLine
-              style={{
-                ...styles.text,
-                ...textStyle,
-                fontSize: 14,
-                fontWeight: added ? '600' : '400',
-                ...(prettyDescription === '' && {
-                  color: theme.tableTextLight,
-                  fontStyle: 'italic',
-                }),
-              }}
-            >
-              {prettyDescription || 'Empty'}
-            </TextOneLine>
-          </View>
-          {isPreview ? (
-            <Status status={notes} />
-          ) : (
-            <View
-              style={{
-                flexDirection: 'row',
-                alignItems: 'center',
-                marginTop: 3,
-              }}
-            >
-              {isReconciled ? (
-                <SvgLockClosed
-                  style={{
-                    width: 11,
-                    height: 11,
-                    color: theme.noticeTextLight,
-                    marginRight: 5,
-                  }}
-                />
-              ) : (
-                <SvgCheckCircle1
-                  style={{
-                    width: 11,
-                    height: 11,
-                    color: cleared
-                      ? theme.noticeTextLight
-                      : theme.pageTextSubdued,
-                    marginRight: 5,
-                  }}
-                />
-              )}
-              {(isParent || isChild) && (
-                <SvgSplit
+        <ListItem
+          style={{
+            flex: 1,
+            ...style,
+          }}
+        >
+          <View style={{ flex: 1 }}>
+            <View style={{ flexDirection: 'row', alignItems: 'center' }}>
+              {schedule && (
+                <SvgArrowsSynchronize
                   style={{
                     width: 12,
                     height: 12,
                     marginRight: 5,
+                    color: textStyle.color || theme.menuItemText,
                   }}
                 />
               )}
               <TextOneLine
                 style={{
-                  fontSize: 11,
-                  marginTop: 1,
-                  fontWeight: '400',
-                  color: prettyCategory
-                    ? theme.tableText
-                    : theme.menuItemTextSelected,
-                  fontStyle:
-                    specialCategory || !prettyCategory ? 'italic' : undefined,
-                  textAlign: 'left',
+                  ...styles.text,
+                  ...textStyle,
+                  fontSize: 14,
+                  fontWeight: isAdded ? '600' : '400',
+                  ...(prettyDescription === '' && {
+                    color: theme.tableTextLight,
+                    fontStyle: 'italic',
+                  }),
                 }}
               >
-                {prettyCategory || 'Uncategorized'}
+                {prettyDescription || 'Empty'}
               </TextOneLine>
             </View>
-          )}
-        </View>
-        <Text
-          style={{
-            ...styles.text,
-            ...textStyle,
-            marginLeft: 25,
-            marginRight: 5,
-            fontSize: 14,
-            ...makeAmountFullStyle(amount),
-          }}
-        >
-          {integerToCurrency(amount)}
-        </Text>
-      </ListItem>
-    </Button>
+            {isPreview ? (
+              <Status status={notes} />
+            ) : (
+              <View
+                style={{
+                  flexDirection: 'row',
+                  alignItems: 'center',
+                  marginTop: 3,
+                }}
+              >
+                {isReconciled ? (
+                  <SvgLockClosed
+                    style={{
+                      width: 11,
+                      height: 11,
+                      color: theme.noticeTextLight,
+                      marginRight: 5,
+                    }}
+                  />
+                ) : (
+                  <SvgCheckCircle1
+                    style={{
+                      width: 11,
+                      height: 11,
+                      color: cleared
+                        ? theme.noticeTextLight
+                        : theme.pageTextSubdued,
+                      marginRight: 5,
+                    }}
+                  />
+                )}
+                {(isParent || isChild) && (
+                  <SvgSplit
+                    style={{
+                      width: 12,
+                      height: 12,
+                      marginRight: 5,
+                    }}
+                  />
+                )}
+                <TextOneLine
+                  style={{
+                    fontSize: 11,
+                    marginTop: 1,
+                    fontWeight: '400',
+                    color: prettyCategory
+                      ? theme.tableText
+                      : theme.menuItemTextSelected,
+                    fontStyle:
+                      specialCategory || !prettyCategory ? 'italic' : undefined,
+                    textAlign: 'left',
+                  }}
+                >
+                  {prettyCategory || 'Uncategorized'}
+                </TextOneLine>
+              </View>
+            )}
+          </View>
+          <Text
+            style={{
+              ...styles.text,
+              ...textStyle,
+              marginLeft: 25,
+              marginRight: 5,
+              fontSize: 14,
+              ...makeAmountFullStyle(amount),
+            }}
+          >
+            {integerToCurrency(amount)}
+          </Text>
+        </ListItem>
+      </Button>
+    </PressResponder>
   );
 });
diff --git a/packages/desktop-client/src/components/mobile/transactions/TransactionList.jsx b/packages/desktop-client/src/components/mobile/transactions/TransactionList.jsx
index 6a1d4ba98d9171bd88b1c90a999b2b63f1d8e4e7..34a310e531c52df3f3f3d36d4039f1bdfcc0a203 100644
--- a/packages/desktop-client/src/components/mobile/transactions/TransactionList.jsx
+++ b/packages/desktop-client/src/components/mobile/transactions/TransactionList.jsx
@@ -1,23 +1,50 @@
-import React, { useMemo } from 'react';
+import React, {
+  useCallback,
+  useEffect,
+  useMemo,
+  useRef,
+  useState,
+} from 'react';
+import { useDispatch } from 'react-redux';
 
 import { Item, Section } from '@react-stately/collections';
 
+import { setNotificationInset } from 'loot-core/client/actions';
+import { groupById, integerToCurrency } from 'loot-core/shared/util';
 import * as monthUtils from 'loot-core/src/shared/months';
 import { isPreviewId } from 'loot-core/src/shared/transactions';
 
+import { useAccounts } from '../../../hooks/useAccounts';
+import { useCategories } from '../../../hooks/useCategories';
+import { useNavigate } from '../../../hooks/useNavigate';
+import { usePayees } from '../../../hooks/usePayees';
+import {
+  useSelectedDispatch,
+  useSelectedItems,
+} from '../../../hooks/useSelected';
+import { useTransactionBatchActions } from '../../../hooks/useTransactionBatchActions';
+import { useUndo } from '../../../hooks/useUndo';
 import { AnimatedLoading } from '../../../icons/AnimatedLoading';
-import { theme } from '../../../style';
+import { SvgDelete } from '../../../icons/v0';
+import { SvgDotsHorizontalTriple } from '../../../icons/v1';
+import { styles, theme } from '../../../style';
+import { Button } from '../../common/Button';
+import { Menu } from '../../common/Menu';
+import { Popover } from '../../common/Popover';
 import { Text } from '../../common/Text';
 import { View } from '../../common/View';
+import { FloatingActionBar } from '../FloatingActionBar';
 
 import { ListBox } from './ListBox';
 import { Transaction } from './Transaction';
 
+const NOTIFICATION_BOTTOM_INSET = 75;
+
 export function TransactionList({
   isLoading,
   transactions,
   isNewTransaction,
-  onSelect,
+  onOpenTransaction,
   scrollProps = {},
   onLoadMore,
 }) {
@@ -49,6 +76,19 @@ export function TransactionList({
     return sections;
   }, [transactions]);
 
+  const dispatchSelected = useSelectedDispatch();
+  const selectedTransactions = useSelectedItems();
+
+  const onTransactionPress = (transaction, isLongPress = false) => {
+    const isPreview = isPreviewId(transaction.id);
+
+    if (!isPreview && (isLongPress || selectedTransactions.size > 0)) {
+      dispatchSelected({ type: 'select', id: transaction.id });
+    } else {
+      onOpenTransaction(transaction);
+    }
+  };
+
   if (isLoading) {
     return (
       <View
@@ -109,8 +149,10 @@ export function TransactionList({
                   >
                     <Transaction
                       transaction={transaction}
-                      added={isNewTransaction(transaction.id)}
-                      onSelect={onSelect}
+                      isAdded={isNewTransaction(transaction.id)}
+                      isSelected={selectedTransactions.has(transaction.id)}
+                      onPress={trans => onTransactionPress(trans)}
+                      onLongPress={trans => onTransactionPress(trans, true)}
                     />
                   </Item>
                 );
@@ -119,6 +161,329 @@ export function TransactionList({
           );
         })}
       </ListBox>
+      {selectedTransactions.size > 0 && (
+        <SelectedTransactionsFloatingActionBar transactions={transactions} />
+      )}
     </>
   );
 }
+
+function SelectedTransactionsFloatingActionBar({ transactions, style }) {
+  const editMenuTriggerRef = useRef(null);
+  const [isEditMenuOpen, setIsEditMenuOpen] = useState(false);
+  const moreOptionsMenuTriggerRef = useRef(null);
+  const [isMoreOptionsMenuOpen, setIsMoreOptionsMenuOpen] = useState(false);
+  const getMenuItemStyle = useCallback(
+    item => ({
+      ...styles.mobileMenuItem,
+      color: theme.mobileHeaderText,
+      ...(item.name === 'delete' && { color: theme.errorTextMenu }),
+    }),
+    [],
+  );
+  const selectedTransactions = useSelectedItems();
+  const selectedTransactionsArray = Array.from(selectedTransactions);
+  const dispatchSelected = useSelectedDispatch();
+
+  const buttonProps = useMemo(
+    () => ({
+      style: {
+        ...styles.mobileMenuItem,
+        color: 'currentColor',
+        height: styles.mobileMinHeight,
+      },
+      activeStyle: {
+        color: 'currentColor',
+      },
+      hoveredStyle: {
+        color: 'currentColor',
+      },
+    }),
+    [],
+  );
+
+  const allTransactionsAreLinked = useMemo(() => {
+    return transactions
+      .filter(t => selectedTransactions.has(t.id))
+      .every(t => t.schedule);
+  }, [transactions, selectedTransactions]);
+
+  const isMoreThanOne = selectedTransactions.size > 1;
+
+  const { showUndoNotification } = useUndo();
+  const {
+    onBatchEdit,
+    onBatchDuplicate,
+    onBatchDelete,
+    onBatchLinkSchedule,
+    onBatchUnlinkSchedule,
+  } = useTransactionBatchActions();
+
+  const navigate = useNavigate();
+  const accounts = useAccounts();
+  const accountsById = useMemo(() => groupById(accounts), [accounts]);
+
+  const payees = usePayees();
+  const payeesById = useMemo(() => groupById(payees), [payees]);
+
+  const { list: categories } = useCategories();
+  const categoriesById = useMemo(() => groupById(categories), [categories]);
+
+  const dispatch = useDispatch();
+  useEffect(() => {
+    dispatch(setNotificationInset({ bottom: NOTIFICATION_BOTTOM_INSET }));
+    return () => {
+      dispatch(setNotificationInset(null));
+    };
+  }, [dispatch]);
+
+  return (
+    <FloatingActionBar style={style}>
+      <View
+        style={{
+          flex: 1,
+          padding: 8,
+          flexDirection: 'row',
+          alignItems: 'center',
+          justifyContent: 'space-between',
+        }}
+      >
+        <View
+          style={{
+            flexDirection: 'row',
+            alignItems: 'center',
+            justifyContent: 'flex-start',
+          }}
+        >
+          <Button
+            type="bare"
+            {...buttonProps}
+            style={{ ...buttonProps.style, marginRight: 4 }}
+            onClick={() => {
+              if (selectedTransactions.size > 0) {
+                dispatchSelected({ type: 'select-none' });
+              }
+            }}
+          >
+            <SvgDelete width={10} height={10} />
+          </Button>
+          <Text style={styles.mediumText}>
+            {selectedTransactions.size}{' '}
+            {isMoreThanOne ? 'transactions' : 'transaction'} selected
+          </Text>
+        </View>
+        <View
+          style={{
+            flexDirection: 'row',
+            alignItems: 'center',
+            justifyContent: 'flex-end',
+            gap: 4,
+          }}
+        >
+          <Button
+            type="bare"
+            ref={editMenuTriggerRef}
+            aria-label="Edit fields"
+            onClick={() => {
+              setIsEditMenuOpen(true);
+            }}
+            {...buttonProps}
+          >
+            Edit
+          </Button>
+
+          <Popover
+            triggerRef={editMenuTriggerRef}
+            isOpen={isEditMenuOpen}
+            onOpenChange={() => setIsEditMenuOpen(false)}
+            style={{ width: 200 }}
+          >
+            <Menu
+              getItemStyle={getMenuItemStyle}
+              style={{ backgroundColor: theme.floatingActionBarBackground }}
+              onMenuSelect={name => {
+                onBatchEdit?.({
+                  name,
+                  ids: selectedTransactionsArray,
+                  onSuccess: (ids, name, value, mode) => {
+                    let displayValue = value;
+                    switch (name) {
+                      case 'account':
+                        displayValue = accountsById[value]?.name ?? value;
+                        break;
+                      case 'category':
+                        displayValue = categoriesById[value]?.name ?? value;
+                        break;
+                      case 'payee':
+                        displayValue = payeesById[value]?.name ?? value;
+                        break;
+                      case 'amount':
+                        displayValue = integerToCurrency(value);
+                        break;
+                      case 'notes':
+                        displayValue = `${mode} with ${value}`;
+                        break;
+                      default:
+                        displayValue = value;
+                        break;
+                    }
+
+                    showUndoNotification({
+                      message: `Successfully updated ${name} of ${ids.length} transaction${ids.length > 1 ? 's' : ''} to [${displayValue}](#${displayValue}).`,
+                      messageActions: {
+                        [String(displayValue)]: () => {
+                          switch (name) {
+                            case 'account':
+                              navigate(`/accounts/${value}`);
+                              break;
+                            case 'category':
+                              navigate(`/categories/${value}`);
+                              break;
+                            case 'payee':
+                              navigate(`/payees`);
+                              break;
+                            default:
+                              break;
+                          }
+                        },
+                      },
+                    });
+                  },
+                });
+                setIsEditMenuOpen(false);
+              }}
+              items={[
+                // Add support later on.
+                // Pikaday doesn't play well will mobile.
+                // We should consider switching to react-aria date picker.
+                // {
+                //   name: 'date',
+                //   text: 'Date',
+                // },
+                {
+                  name: 'account',
+                  text: 'Account',
+                },
+                {
+                  name: 'payee',
+                  text: 'Payee',
+                },
+                {
+                  name: 'notes',
+                  text: 'Notes',
+                },
+                {
+                  name: 'category',
+                  text: 'Category',
+                },
+                // Add support later on until we have more user friendly amount input modal.
+                // {
+                //   name: 'amount',
+                //   text: 'Amount',
+                // },
+                {
+                  name: 'cleared',
+                  text: 'Cleared',
+                },
+              ]}
+            />
+          </Popover>
+
+          <Button
+            type="bare"
+            ref={moreOptionsMenuTriggerRef}
+            aria-label="More options"
+            onClick={() => {
+              setIsMoreOptionsMenuOpen(true);
+            }}
+            {...buttonProps}
+          >
+            <SvgDotsHorizontalTriple
+              width={16}
+              height={16}
+              style={{ color: 'currentColor' }}
+            />
+          </Button>
+
+          <Popover
+            triggerRef={moreOptionsMenuTriggerRef}
+            isOpen={isMoreOptionsMenuOpen}
+            onOpenChange={() => setIsMoreOptionsMenuOpen(false)}
+            style={{ width: 200 }}
+          >
+            <Menu
+              getItemStyle={getMenuItemStyle}
+              style={{ backgroundColor: theme.floatingActionBarBackground }}
+              onMenuSelect={type => {
+                if (type === 'duplicate') {
+                  onBatchDuplicate?.({
+                    ids: selectedTransactionsArray,
+                    onSuccess: ids => {
+                      showUndoNotification({
+                        message: `Successfully duplicated ${ids.length} transaction${ids.length > 1 ? 's' : ''}.`,
+                      });
+                    },
+                  });
+                } else if (type === 'link-schedule') {
+                  onBatchLinkSchedule?.({
+                    ids: selectedTransactionsArray,
+                    onSuccess: (ids, schedule) => {
+                      // TODO: When schedule becomes available in mobile, update undo notification message
+                      // with `messageActions` to open the schedule when the schedule name is clicked.
+                      showUndoNotification({
+                        message: `Successfully linked ${ids.length} transaction${ids.length > 1 ? 's' : ''} to ${schedule.name}.`,
+                      });
+                    },
+                  });
+                } else if (type === 'unlink-schedule') {
+                  onBatchUnlinkSchedule?.({
+                    ids: selectedTransactionsArray,
+                    onSuccess: ids => {
+                      showUndoNotification({
+                        message: `Successfully unlinked ${ids.length} transaction${ids.length > 1 ? 's' : ''} from their respective schedules.`,
+                      });
+                    },
+                  });
+                } else if (type === 'delete') {
+                  onBatchDelete?.({
+                    ids: selectedTransactionsArray,
+                    onSuccess: ids => {
+                      showUndoNotification({
+                        type: 'warning',
+                        message: `Successfully deleted ${ids.length} transaction${ids.length > 1 ? 's' : ''}.`,
+                      });
+                    },
+                  });
+                }
+                setIsMoreOptionsMenuOpen(false);
+              }}
+              items={[
+                {
+                  name: 'duplicate',
+                  text: 'Duplicate',
+                },
+                ...(allTransactionsAreLinked
+                  ? [
+                      {
+                        name: 'unlink-schedule',
+                        text: 'Unlink schedule',
+                      },
+                    ]
+                  : [
+                      {
+                        name: 'link-schedule',
+                        text: 'Link schedule',
+                      },
+                    ]),
+                {
+                  name: 'delete',
+                  text: 'Delete',
+                },
+              ]}
+            />
+          </Popover>
+        </View>
+      </View>
+    </FloatingActionBar>
+  );
+}
diff --git a/packages/desktop-client/src/components/mobile/transactions/TransactionListWithBalances.jsx b/packages/desktop-client/src/components/mobile/transactions/TransactionListWithBalances.jsx
index f29994ddc46ffb39424b6968f7c351539111046e..24ce42a6d7e566d9112cf6b237295e01f1e1a482 100644
--- a/packages/desktop-client/src/components/mobile/transactions/TransactionListWithBalances.jsx
+++ b/packages/desktop-client/src/components/mobile/transactions/TransactionListWithBalances.jsx
@@ -1,6 +1,7 @@
 import React, { useState } from 'react';
 import { useSelector } from 'react-redux';
 
+import { SelectedProvider, useSelected } from '../../../hooks/useSelected';
 import { SvgSearchAlternate } from '../../../icons/v2';
 import { styles, theme } from '../../../style';
 import { InputWithContent } from '../../common/InputWithContent';
@@ -64,7 +65,7 @@ export function TransactionListWithBalances({
   searchPlaceholder = 'Search...',
   onSearch,
   onLoadMore,
-  onSelectTransaction,
+  onOpenTransaction,
   onRefresh,
 }) {
   const newTransactions = useSelector(state => state.queries.newTransactions);
@@ -74,9 +75,10 @@ export function TransactionListWithBalances({
   };
 
   const unclearedAmount = useSheetValue(balanceUncleared);
+  const selectedInst = useSelected('transactions', transactions);
 
   return (
-    <>
+    <SelectedProvider instance={selectedInst}>
       <View
         style={{
           flexShrink: 0,
@@ -159,9 +161,9 @@ export function TransactionListWithBalances({
           transactions={transactions}
           isNewTransaction={isNewTransaction}
           onLoadMore={onLoadMore}
-          onSelect={onSelectTransaction}
+          onOpenTransaction={onOpenTransaction}
         />
       </PullToRefresh>
-    </>
+    </SelectedProvider>
   );
 }
diff --git a/packages/desktop-client/src/components/modals/ConfirmTransactionDelete.tsx b/packages/desktop-client/src/components/modals/ConfirmTransactionDelete.tsx
index 3c1ceaba0255bafca5435dc4497c4ce068fa8693..26f2ecaf230811e369b48ebd1374dfe344e492cb 100644
--- a/packages/desktop-client/src/components/modals/ConfirmTransactionDelete.tsx
+++ b/packages/desktop-client/src/components/modals/ConfirmTransactionDelete.tsx
@@ -8,10 +8,12 @@ import { Paragraph } from '../common/Paragraph';
 import { View } from '../common/View';
 
 type ConfirmTransactionDeleteProps = {
+  message?: string;
   onConfirm: () => void;
 };
 
 export function ConfirmTransactionDelete({
+  message = 'Are you sure you want to delete the transaction?',
   onConfirm,
 }: ConfirmTransactionDeleteProps) {
   const { isNarrowWidth } = useResponsive();
@@ -30,9 +32,7 @@ export function ConfirmTransactionDelete({
             rightContent={<ModalCloseButton onClick={close} />}
           />
           <View style={{ lineHeight: 1.5 }}>
-            <Paragraph>
-              Are you sure you want to delete the transaction?
-            </Paragraph>
+            <Paragraph>{message}</Paragraph>
             <View
               style={{
                 flexDirection: 'row',
diff --git a/packages/desktop-client/src/components/payees/ManagePayees.jsx b/packages/desktop-client/src/components/payees/ManagePayees.jsx
index bc0ce9df8196afc45a6e1082c187481325f50d65..2843bc644ba418d748c509a22376c478d1de4e85 100644
--- a/packages/desktop-client/src/components/payees/ManagePayees.jsx
+++ b/packages/desktop-client/src/components/payees/ManagePayees.jsx
@@ -59,7 +59,9 @@ function PayeeTableHeader() {
           exposed={true}
           focused={false}
           selected={selectedItems.size > 0}
-          onSelect={e => dispatchSelected({ type: 'select-all', event: e })}
+          onSelect={e =>
+            dispatchSelected({ type: 'select-all', isRangeSelect: e.shiftKey })
+          }
         />
         <Cell value="Name" width="flex" />
       </TableHeader>
diff --git a/packages/desktop-client/src/components/payees/PayeeTableRow.tsx b/packages/desktop-client/src/components/payees/PayeeTableRow.tsx
index 657385c48734a8723c74102287a4446063bce852..637a63da76b340505cfb26fd593c9272d2133c26 100644
--- a/packages/desktop-client/src/components/payees/PayeeTableRow.tsx
+++ b/packages/desktop-client/src/components/payees/PayeeTableRow.tsx
@@ -130,7 +130,11 @@ export const PayeeTableRow = memo(
           focused={focusedField === 'select'}
           selected={selected}
           onSelect={e => {
-            dispatchSelected({ type: 'select', id: payee.id, event: e });
+            dispatchSelected({
+              type: 'select',
+              id: payee.id,
+              isRangeSelect: e.shiftKey,
+            });
           }}
         />
         <CustomCell
diff --git a/packages/desktop-client/src/components/rules/RuleRow.tsx b/packages/desktop-client/src/components/rules/RuleRow.tsx
index 913a6b045dd21cf04e4b22a7aed671e58168fd62..c5c17a5106ad676198f9562ec935c939e7afcbba 100644
--- a/packages/desktop-client/src/components/rules/RuleRow.tsx
+++ b/packages/desktop-client/src/components/rules/RuleRow.tsx
@@ -64,7 +64,11 @@ export const RuleRow = memo(
           exposed={hovered || selected}
           focused={true}
           onSelect={e => {
-            dispatchSelected({ type: 'select', id: rule.id, event: e });
+            dispatchSelected({
+              type: 'select',
+              id: rule.id,
+              isRangeSelect: e.shiftKey,
+            });
           }}
           selected={selected}
         />
diff --git a/packages/desktop-client/src/components/rules/RulesHeader.tsx b/packages/desktop-client/src/components/rules/RulesHeader.tsx
index 40ac46cf06cc175e919bc07b64a385f955366a90..01c61512b50f6930638d7ab495c0af207756f431 100644
--- a/packages/desktop-client/src/components/rules/RulesHeader.tsx
+++ b/packages/desktop-client/src/components/rules/RulesHeader.tsx
@@ -13,7 +13,9 @@ export function RulesHeader() {
         exposed={true}
         focused={false}
         selected={selectedItems.size > 0}
-        onSelect={e => dispatchSelected({ type: 'select-all', event: e })}
+        onSelect={e =>
+          dispatchSelected({ type: 'select-all', isRangeSelect: e.shiftKey })
+        }
       />
       <Cell value="Stage" width={50} />
       <Cell value="Rule" width="flex" />
diff --git a/packages/desktop-client/src/components/schedules/DiscoverSchedules.tsx b/packages/desktop-client/src/components/schedules/DiscoverSchedules.tsx
index 745d2d6d5c085a9112f771998739badb25aae8bd..31d7fc10a241e13af94a0219cd3727e33c4e0909 100644
--- a/packages/desktop-client/src/components/schedules/DiscoverSchedules.tsx
+++ b/packages/desktop-client/src/components/schedules/DiscoverSchedules.tsx
@@ -49,7 +49,11 @@ function DiscoverSchedulesTable({
         height={ROW_HEIGHT}
         inset={15}
         onClick={e => {
-          dispatchSelected({ type: 'select', id: item.id, event: e });
+          dispatchSelected({
+            type: 'select',
+            id: item.id,
+            isRangeSelect: e.shiftKey,
+          });
         }}
         style={{
           borderColor: selected ? theme.tableBorderSelected : theme.tableBorder,
@@ -71,7 +75,11 @@ function DiscoverSchedulesTable({
           focused={false}
           selected={selected}
           onSelect={e => {
-            dispatchSelected({ type: 'select', id: item.id, event: e });
+            dispatchSelected({
+              type: 'select',
+              id: item.id,
+              isRangeSelect: e.shiftKey,
+            });
           }}
         />
         <Field width="flex">
@@ -95,7 +103,9 @@ function DiscoverSchedulesTable({
           exposed={!loading}
           focused={false}
           selected={selectedItems.size > 0}
-          onSelect={e => dispatchSelected({ type: 'select-all', event: e })}
+          onSelect={e =>
+            dispatchSelected({ type: 'select-all', isRangeSelect: e.shiftKey })
+          }
         />
         <Field width="flex">Payee</Field>
         <Field width="flex">Account</Field>
@@ -114,7 +124,7 @@ function DiscoverSchedulesTable({
         }}
         items={schedules}
         loading={loading}
-        isSelected={id => selectedItems.has(id)}
+        isSelected={id => selectedItems.has(String(id))}
         renderItem={renderItem}
         renderEmpty="No schedules found"
       />
diff --git a/packages/desktop-client/src/components/schedules/ScheduleLink.tsx b/packages/desktop-client/src/components/schedules/ScheduleLink.tsx
index a5220a9f7c5713b96bccba1d62918c8ac3fa1e48..74d22edd7b490b9ef352053d1ec817c478415064 100644
--- a/packages/desktop-client/src/components/schedules/ScheduleLink.tsx
+++ b/packages/desktop-client/src/components/schedules/ScheduleLink.tsx
@@ -6,7 +6,10 @@ import { pushModal } from 'loot-core/client/actions';
 import { useSchedules } from 'loot-core/src/client/data-hooks/schedules';
 import { send } from 'loot-core/src/platform/client/fetch';
 import { type Query } from 'loot-core/src/shared/query';
-import { type TransactionEntity } from 'loot-core/src/types/models';
+import {
+  type ScheduleEntity,
+  type TransactionEntity,
+} from 'loot-core/src/types/models';
 
 import { SvgAdd } from '../../icons/v0';
 import { Button } from '../common/Button2';
@@ -21,13 +24,15 @@ export function ScheduleLink({
   transactionIds: ids,
   getTransaction,
   accountName,
+  onScheduleLinked,
 }: {
   transactionIds: string[];
   getTransaction: (transactionId: string) => TransactionEntity;
-  accountName: string;
+  accountName?: string;
+  onScheduleLinked?: (schedule: ScheduleEntity) => void;
 }) {
   const dispatch = useDispatch();
-  const [filter, setFilter] = useState(accountName);
+  const [filter, setFilter] = useState(accountName || '');
 
   const scheduleData = useSchedules({
     transform: useCallback((q: Query) => q.filter({ completed: false }), []),
@@ -45,6 +50,7 @@ export function ScheduleLink({
       await send('transactions-batch-update', {
         updated: ids.map(id => ({ id, schedule: scheduleId })),
       });
+      onScheduleLinked?.(schedules.find(s => s.id === scheduleId));
     }
   }
 
diff --git a/packages/desktop-client/src/components/transactions/SelectedTransactionsButton.jsx b/packages/desktop-client/src/components/transactions/SelectedTransactionsButton.jsx
index ad9cded8a567e692546e5159aee1c3458ff076ea..88ca81ac67ea13dc645438889f305b35af4c8df3 100644
--- a/packages/desktop-client/src/components/transactions/SelectedTransactionsButton.jsx
+++ b/packages/desktop-client/src/components/transactions/SelectedTransactionsButton.jsx
@@ -12,13 +12,13 @@ import { Menu } from '../common/Menu';
 import { SelectedItemsButton } from '../table';
 
 export function SelectedTransactionsButton({
-  account,
   getTransaction,
   onShow,
   onDuplicate,
   onDelete,
   onEdit,
-  onUnlink,
+  onLinkSchedule,
+  onUnlinkSchedule,
   onCreateRule,
   onSetTransfer,
   onScheduleAction,
@@ -115,16 +115,6 @@ export function SelectedTransactionsButton({
     return areNoReconciledTransactions && areAllSplitTransactions;
   }, [selectedIds, types, getTransaction]);
 
-  function onLinkSchedule() {
-    dispatch(
-      pushModal('schedule-link', {
-        transactionIds: selectedIds,
-        getTransaction,
-        accountName: account?.name ?? '',
-      }),
-    );
-  }
-
   function onViewSchedule() {
     const firstId = selectedIds[0];
     let scheduleId;
@@ -285,10 +275,10 @@ export function SelectedTransactionsButton({
             onViewSchedule();
             break;
           case 'link-schedule':
-            onLinkSchedule();
+            onLinkSchedule(selectedIds);
             break;
           case 'unlink-schedule':
-            onUnlink(selectedIds);
+            onUnlinkSchedule(selectedIds);
             break;
           case 'create-rule':
             onCreateRule(selectedIds);
diff --git a/packages/desktop-client/src/components/transactions/SimpleTransactionsTable.jsx b/packages/desktop-client/src/components/transactions/SimpleTransactionsTable.jsx
index 736df89d280b3a25e3eb7bf379be9c9d8c8155e6..a623353769925e55611cdb1fbb05163a5f19ba1e 100644
--- a/packages/desktop-client/src/components/transactions/SimpleTransactionsTable.jsx
+++ b/packages/desktop-client/src/components/transactions/SimpleTransactionsTable.jsx
@@ -54,7 +54,11 @@ const TransactionRow = memo(function TransactionRow({
         exposed={true}
         focused={false}
         onSelect={e => {
-          dispatchSelected({ type: 'select', id: transaction.id, event: e });
+          dispatchSelected({
+            type: 'select',
+            id: transaction.id,
+            isRangeSelect: e.shiftKey,
+          });
         }}
         selected={selected}
       />
@@ -182,7 +186,12 @@ export function SimpleTransactionsTable({
             focused={false}
             selected={selectedItems.size > 0}
             width={20}
-            onSelect={e => dispatchSelected({ type: 'select-all', event: e })}
+            onSelect={e =>
+              dispatchSelected({
+                type: 'select-all',
+                isRangeSelect: e.shiftKey,
+              })
+            }
           />
           {fields.map((field, i) => {
             switch (field) {
diff --git a/packages/desktop-client/src/components/transactions/TransactionsTable.jsx b/packages/desktop-client/src/components/transactions/TransactionsTable.jsx
index 34aa417ae512b4da804a89991df5846f8aa36aaa..21dc4e0e0970aec3a14556a3d3f4a2e26bbc5132 100644
--- a/packages/desktop-client/src/components/transactions/TransactionsTable.jsx
+++ b/packages/desktop-client/src/components/transactions/TransactionsTable.jsx
@@ -208,7 +208,9 @@ const TransactionHeader = memo(
             borderTopWidth: 0,
             borderBottomWidth: 0,
           }}
-          onSelect={e => dispatchSelected({ type: 'select-all', event: e })}
+          onSelect={e =>
+            dispatchSelected({ type: 'select-all', isRangeSelect: e.shiftKey })
+          }
         />
         <HeaderCell
           value="Date"
@@ -1115,7 +1117,11 @@ const Transaction = memo(function Transaction({
           }}
           focused={focusedField === 'select'}
           onSelect={e => {
-            dispatchSelected({ type: 'select', id: transaction.id, event: e });
+            dispatchSelected({
+              type: 'select',
+              id: transaction.id,
+              isRangeSelect: e.shiftKey,
+            });
           }}
           onEdit={() => onEdit(id, 'select')}
           selected={selected}
diff --git a/packages/desktop-client/src/hooks/useSelected.tsx b/packages/desktop-client/src/hooks/useSelected.tsx
index 1b38189e6aeef73c3f008332f0d82815c57e3ce9..15c944e60c3663b25dbf380b0c6305775f5afb1d 100644
--- a/packages/desktop-client/src/hooks/useSelected.tsx
+++ b/packages/desktop-client/src/hooks/useSelected.tsx
@@ -8,7 +8,6 @@ import React, {
   useRef,
   type Dispatch,
   type ReactElement,
-  type MouseEvent,
 } from 'react';
 import { useSelector } from 'react-redux';
 
@@ -16,7 +15,6 @@ import { type State } from 'loot-core/src/client/state-types';
 import { listen } from 'loot-core/src/platform/client/fetch';
 import * as undo from 'loot-core/src/platform/client/undo';
 import { type UndoState } from 'loot-core/src/server/undo';
-import { isNonProductionEnvironment } from 'loot-core/src/shared/environment';
 
 type Range<T> = { start: T; end: T | null };
 type Item = { id: string };
@@ -35,20 +33,20 @@ type SelectedState = {
   selectedItems: Set<string>;
 };
 
-type WithOptionalMouseEvent = {
-  event?: MouseEvent;
-};
 type SelectAction = {
   type: 'select';
   id: string;
-} & WithOptionalMouseEvent;
+  isRangeSelect?: boolean;
+};
 type SelectNoneAction = {
   type: 'select-none';
-} & WithOptionalMouseEvent;
+  isRangeSelect?: boolean;
+};
 type SelectAllAction = {
   type: 'select-all';
   ids?: string[];
-} & WithOptionalMouseEvent;
+  isRangeSelect?: boolean;
+};
 
 type Actions = SelectAction | SelectNoneAction | SelectAllAction;
 
@@ -64,9 +62,9 @@ export function useSelected<T extends Item>(
         case 'select': {
           const { selectedRange } = state;
           const selectedItems = new Set(state.selectedItems);
-          const { id, event } = action;
+          const { id, isRangeSelect } = action;
 
-          if (event.shiftKey && selectedRange) {
+          if (isRangeSelect && selectedRange) {
             const idx = items.findIndex(p => p.id === id);
             const startIdx = items.findIndex(p => p.id === selectedRange.start);
             const endIdx = items.findIndex(p => p.id === selectedRange.end);
@@ -275,24 +273,24 @@ export function SelectedProvider<T extends Item>({
 
   const dispatch = useCallback(
     async (action: Actions) => {
-      if (!action.event && isNonProductionEnvironment()) {
-        throw new Error('SelectedDispatch actions must have an event');
-      }
       if (action.type === 'select-all') {
         if (latestItems.current && latestItems.current.size > 0) {
           return instance.dispatch({
             type: 'select-none',
-            event: action.event,
+            isRangeSelect: action.isRangeSelect,
           });
         } else {
           if (fetchAllIds) {
             return instance.dispatch({
               type: 'select-all',
               ids: await fetchAllIds(),
-              event: action.event,
+              isRangeSelect: action.isRangeSelect,
             });
           }
-          return instance.dispatch({ type: 'select-all', event: action.event });
+          return instance.dispatch({
+            type: 'select-all',
+            isRangeSelect: action.isRangeSelect,
+          });
         }
       }
       return instance.dispatch(action);
diff --git a/packages/desktop-client/src/hooks/useTransactionBatchActions.ts b/packages/desktop-client/src/hooks/useTransactionBatchActions.ts
new file mode 100644
index 0000000000000000000000000000000000000000..a49c1efbf53c25b3b63c8a2850f7996d57fb5886
--- /dev/null
+++ b/packages/desktop-client/src/hooks/useTransactionBatchActions.ts
@@ -0,0 +1,418 @@
+import { useDispatch } from 'react-redux';
+
+import { pushModal } from 'loot-core/client/actions';
+import { runQuery } from 'loot-core/client/query-helpers';
+import { send } from 'loot-core/platform/client/fetch';
+import { q } from 'loot-core/shared/query';
+import {
+  deleteTransaction,
+  realizeTempTransactions,
+  ungroupTransaction,
+  ungroupTransactions,
+  updateTransaction,
+} from 'loot-core/shared/transactions';
+import { applyChanges, type Diff } from 'loot-core/shared/util';
+import * as monthUtils from 'loot-core/src/shared/months';
+import {
+  type AccountEntity,
+  type ScheduleEntity,
+  type TransactionEntity,
+} from 'loot-core/types/models';
+
+type BatchEditProps = {
+  name: keyof TransactionEntity;
+  ids: Array<TransactionEntity['id']>;
+  onSuccess?: (
+    ids: Array<TransactionEntity['id']>,
+    name: keyof TransactionEntity,
+    value: string | number | boolean | null,
+    mode: 'prepend' | 'append' | 'replace' | null | undefined,
+  ) => void;
+};
+
+type BatchDuplicateProps = {
+  ids: Array<TransactionEntity['id']>;
+  onSuccess?: (ids: Array<TransactionEntity['id']>) => void;
+};
+
+type BatchDeleteProps = {
+  ids: Array<TransactionEntity['id']>;
+  onSuccess?: (ids: Array<TransactionEntity['id']>) => void;
+};
+
+type BatchLinkScheduleProps = {
+  ids: Array<TransactionEntity['id']>;
+  account?: AccountEntity;
+  onSuccess?: (
+    ids: Array<TransactionEntity['id']>,
+    schedule: ScheduleEntity,
+  ) => void;
+};
+
+type BatchUnlinkScheduleProps = {
+  ids: Array<TransactionEntity['id']>;
+  onSuccess?: (ids: Array<TransactionEntity['id']>) => void;
+};
+
+export function useTransactionBatchActions() {
+  const dispatch = useDispatch();
+
+  const onBatchEdit = async ({ name, ids, onSuccess }: BatchEditProps) => {
+    const { data } = await runQuery(
+      q('transactions')
+        .filter({ id: { $oneof: ids } })
+        .select('*')
+        .options({ splits: 'grouped' }),
+    );
+    const transactions = ungroupTransactions(data as TransactionEntity[]);
+
+    const onChange = async (
+      name: keyof TransactionEntity,
+      value: string | number | boolean | null,
+      mode?: 'prepend' | 'append' | 'replace' | null | undefined,
+    ) => {
+      let transactionsToChange = transactions;
+
+      value = value === null ? '' : value;
+      const changes: Diff<TransactionEntity> = {
+        added: [],
+        deleted: [],
+        updated: [],
+      };
+
+      // Cleared is a special case right now
+      if (name === 'cleared') {
+        // Clear them if any are uncleared, otherwise unclear them
+        value = !!transactionsToChange.find(t => !t.cleared);
+      }
+
+      const idSet = new Set(ids);
+
+      transactionsToChange.forEach(trans => {
+        if (name === 'cleared' && trans.reconciled) {
+          // Skip transactions that are reconciled. Don't want to set them as
+          // uncleared.
+          return;
+        }
+
+        if (!idSet.has(trans.id)) {
+          // Skip transactions which aren't actually selected, since the query
+          // above also retrieves the siblings & parent of any selected splits.
+          return;
+        }
+
+        let valueToSet = value;
+
+        if (name === 'notes') {
+          if (mode === 'prepend') {
+            valueToSet =
+              trans.notes === null ? value : value + ' ' + trans.notes;
+          } else if (mode === 'append') {
+            valueToSet =
+              trans.notes === null ? value : trans.notes + ' ' + value;
+          } else if (mode === 'replace') {
+            valueToSet = value;
+          }
+        }
+        const transaction = {
+          ...trans,
+          [name]: valueToSet,
+        };
+
+        if (name === 'account' && trans.account !== value) {
+          transaction.reconciled = false;
+        }
+
+        const { diff } = updateTransaction(transactionsToChange, transaction);
+
+        // TODO: We need to keep an updated list of transactions so
+        // the logic in `updateTransaction`, particularly about
+        // updating split transactions, works. This isn't ideal and we
+        // should figure something else out
+        transactionsToChange = applyChanges<TransactionEntity>(
+          diff,
+          transactionsToChange,
+        );
+
+        changes.deleted = changes.deleted
+          ? changes.deleted.concat(diff.deleted)
+          : diff.deleted;
+        changes.updated = changes.updated
+          ? changes.updated.concat(diff.updated)
+          : diff.updated;
+        changes.added = changes.added
+          ? changes.added.concat(diff.added)
+          : diff.added;
+      });
+
+      await send('transactions-batch-update', changes);
+
+      onSuccess?.(ids, name, value, mode);
+    };
+
+    const pushPayeeAutocompleteModal = () => {
+      dispatch(
+        pushModal('payee-autocomplete', {
+          onSelect: payeeId => onChange(name, payeeId),
+        }),
+      );
+    };
+
+    const pushAccountAutocompleteModal = () => {
+      dispatch(
+        pushModal('account-autocomplete', {
+          onSelect: accountId => onChange(name, accountId),
+        }),
+      );
+    };
+
+    const pushEditField = () => {
+      if (name !== 'date' && name !== 'amount' && name !== 'notes') {
+        return;
+      }
+
+      dispatch(
+        pushModal('edit-field', {
+          name,
+          onSubmit: (name, value, mode) => onChange(name, value, mode),
+        }),
+      );
+    };
+
+    const pushCategoryAutocompleteModal = () => {
+      // Only show balances when all selected transaction are in the same month.
+      const transactionMonth = transactions[0]?.date
+        ? monthUtils.monthFromDate(transactions[0]?.date)
+        : null;
+      const transactionsHaveSameMonth =
+        transactionMonth &&
+        transactions.every(
+          t => monthUtils.monthFromDate(t.date) === transactionMonth,
+        );
+      dispatch(
+        pushModal('category-autocomplete', {
+          month: transactionsHaveSameMonth ? transactionMonth : undefined,
+          onSelect: categoryId => onChange(name, categoryId),
+        }),
+      );
+    };
+
+    if (
+      name === 'amount' ||
+      name === 'payee' ||
+      name === 'account' ||
+      name === 'date'
+    ) {
+      const reconciledTransactions = transactions.filter(t => t.reconciled);
+      if (reconciledTransactions.length > 0) {
+        dispatch(
+          pushModal('confirm-transaction-edit', {
+            onConfirm: () => {
+              if (name === 'payee') {
+                pushPayeeAutocompleteModal();
+              } else if (name === 'account') {
+                pushAccountAutocompleteModal();
+              } else {
+                pushEditField();
+              }
+            },
+            confirmReason: 'batchEditWithReconciled',
+          }),
+        );
+        return;
+      }
+    }
+
+    if (name === 'cleared') {
+      // Cleared just toggles it on/off and it depends on the data
+      // loaded. Need to clean this up in the future.
+      onChange('cleared', null);
+    } else if (name === 'category') {
+      pushCategoryAutocompleteModal();
+    } else if (name === 'payee') {
+      pushPayeeAutocompleteModal();
+    } else if (name === 'account') {
+      pushAccountAutocompleteModal();
+    } else {
+      pushEditField();
+    }
+  };
+
+  const onBatchDuplicate = async ({ ids, onSuccess }: BatchDuplicateProps) => {
+    const onConfirmDuplicate = async (ids: Array<TransactionEntity['id']>) => {
+      const { data } = await runQuery(
+        q('transactions')
+          .filter({ id: { $oneof: ids } })
+          .select('*')
+          .options({ splits: 'grouped' }),
+      );
+
+      const transactions = data as TransactionEntity[];
+
+      const changes = {
+        added: transactions
+          .reduce(
+            (
+              newTransactions: TransactionEntity[],
+              trans: TransactionEntity,
+            ) => {
+              return newTransactions.concat(
+                realizeTempTransactions(ungroupTransaction(trans)),
+              );
+            },
+            [],
+          )
+          .map(({ sort_order, ...trans }: TransactionEntity) => ({ ...trans })),
+      };
+
+      await send('transactions-batch-update', changes);
+
+      onSuccess?.(ids);
+    };
+
+    await checkForReconciledTransactions(
+      ids,
+      'batchDuplicateWithReconciled',
+      onConfirmDuplicate,
+    );
+  };
+
+  const onBatchDelete = async ({ ids, onSuccess }: BatchDeleteProps) => {
+    const onConfirmDelete = (ids: Array<TransactionEntity['id']>) => {
+      dispatch(
+        pushModal('confirm-transaction-delete', {
+          message:
+            ids.length > 1
+              ? `Are you sure you want to delete these ${ids.length} transaction${ids.length > 1 ? 's' : ''}?`
+              : undefined,
+          onConfirm: async () => {
+            const { data } = await runQuery(
+              q('transactions')
+                .filter({ id: { $oneof: ids } })
+                .select('*')
+                .options({ splits: 'grouped' }),
+            );
+            let transactions = ungroupTransactions(data as TransactionEntity[]);
+
+            const idSet = new Set(ids);
+            const changes: Diff<TransactionEntity> = {
+              added: [],
+              deleted: [],
+              updated: [],
+            };
+
+            transactions.forEach(trans => {
+              const parentId = trans.parent_id;
+
+              // First, check if we're actually deleting this transaction by
+              // checking `idSet`. Then, we don't need to do anything if it's
+              // a child transaction and the parent is already being deleted
+              if (!idSet.has(trans.id) || (parentId && idSet.has(parentId))) {
+                return;
+              }
+
+              const { diff } = deleteTransaction(transactions, trans.id);
+
+              // TODO: We need to keep an updated list of transactions so
+              // the logic in `updateTransaction`, particularly about
+              // updating split transactions, works. This isn't ideal and we
+              // should figure something else out
+              transactions = applyChanges<TransactionEntity>(
+                diff,
+                transactions,
+              );
+
+              changes.deleted = diff.deleted
+                ? changes.deleted.concat(diff.deleted)
+                : diff.deleted;
+              changes.updated = diff.updated
+                ? changes.updated.concat(diff.updated)
+                : diff.updated;
+            });
+
+            await send('transactions-batch-update', changes);
+            onSuccess?.(ids);
+          },
+        }),
+      );
+    };
+
+    await checkForReconciledTransactions(
+      ids,
+      'batchDeleteWithReconciled',
+      onConfirmDelete,
+    );
+  };
+
+  const onBatchLinkSchedule = async ({
+    ids,
+    account,
+    onSuccess,
+  }: BatchLinkScheduleProps) => {
+    const { data: transactions } = await runQuery(
+      q('transactions')
+        .filter({ id: { $oneof: ids } })
+        .select('*')
+        .options({ splits: 'grouped' }),
+    );
+
+    dispatch(
+      pushModal('schedule-link', {
+        transactionIds: ids,
+        getTransaction: (id: TransactionEntity['id']) =>
+          transactions.find((t: TransactionEntity) => t.id === id),
+        accountName: account?.name ?? '',
+        onScheduleLinked: schedule => {
+          onSuccess?.(ids, schedule);
+        },
+      }),
+    );
+  };
+
+  const onBatchUnlinkSchedule = async ({
+    ids,
+    onSuccess,
+  }: BatchUnlinkScheduleProps) => {
+    const changes = {
+      updated: ids.map(
+        id => ({ id, schedule: null }) as unknown as Partial<TransactionEntity>,
+      ),
+    };
+    await send('transactions-batch-update', changes);
+    onSuccess?.(ids);
+  };
+
+  const checkForReconciledTransactions = async (
+    ids: Array<TransactionEntity['id']>,
+    confirmReason: string,
+    onConfirm: (ids: Array<TransactionEntity['id']>) => void,
+  ) => {
+    const { data } = await runQuery(
+      q('transactions')
+        .filter({ id: { $oneof: ids }, reconciled: true })
+        .select('*')
+        .options({ splits: 'grouped' }),
+    );
+    const transactions = ungroupTransactions(data as TransactionEntity[]);
+    if (transactions.length > 0) {
+      dispatch(
+        pushModal('confirm-transaction-edit', {
+          onConfirm: () => {
+            onConfirm(ids);
+          },
+          confirmReason,
+        }),
+      );
+    } else {
+      onConfirm(ids);
+    }
+  };
+
+  return {
+    onBatchEdit,
+    onBatchDuplicate,
+    onBatchDelete,
+    onBatchLinkSchedule,
+    onBatchUnlinkSchedule,
+  };
+}
diff --git a/packages/desktop-client/src/hooks/useUndo.ts b/packages/desktop-client/src/hooks/useUndo.ts
new file mode 100644
index 0000000000000000000000000000000000000000..4101f668c125d9d790a166497b433a4a36936924
--- /dev/null
+++ b/packages/desktop-client/src/hooks/useUndo.ts
@@ -0,0 +1,67 @@
+import { useCallback } from 'react';
+import { useDispatch } from 'react-redux';
+
+import { undo, redo, addNotification } from 'loot-core/client/actions';
+import { type Notification } from 'loot-core/client/state-types/notifications';
+
+type UndoActions = {
+  undo: () => void;
+  redo: () => void;
+  showUndoNotification: (undoNotification: Notification) => void;
+  showRedoNotification: (redoNotification: Notification) => void;
+};
+
+const timeout = 10000;
+
+export function useUndo(): UndoActions {
+  const dispatch = useDispatch();
+
+  const dispatchUndo = useCallback(() => {
+    dispatch(undo());
+  }, [dispatch]);
+
+  const dispatchRedo = useCallback(() => {
+    dispatch(redo());
+  }, [dispatch]);
+
+  const showUndoNotification = useCallback(
+    (notification: Notification) => {
+      dispatch(
+        addNotification({
+          type: 'message',
+          timeout,
+          button: {
+            title: 'Undo',
+            action: dispatchUndo,
+          },
+          ...notification,
+        }),
+      );
+    },
+    [dispatch, dispatchUndo],
+  );
+
+  const showRedoNotification = useCallback(
+    (notificaton: Notification) => {
+      dispatch(
+        addNotification({
+          type: 'message',
+          timeout,
+          button: {
+            title: 'Redo',
+            action: dispatchRedo,
+          },
+          ...notificaton,
+        }),
+      );
+    },
+    [dispatch, dispatchRedo],
+  );
+
+  return {
+    undo: dispatchUndo,
+    redo: dispatchRedo,
+    showUndoNotification,
+    showRedoNotification,
+  };
+}
diff --git a/packages/desktop-client/src/style/themes/dark.ts b/packages/desktop-client/src/style/themes/dark.ts
index 722d886ab8435ce0d82f2e086a90f64c2bc15ca0..fcd19fb4a8618930da22c7b639967dcaa34f0c3d 100644
--- a/packages/desktop-client/src/style/themes/dark.ts
+++ b/packages/desktop-client/src/style/themes/dark.ts
@@ -75,6 +75,7 @@ export const mobileNavItem = colorPalette.navy150;
 export const mobileNavItemSelected = colorPalette.purple400;
 export const mobileAccountShadow = cardShadow;
 export const mobileAccountText = colorPalette.blue800;
+export const mobileTransactionSelected = colorPalette.purple400;
 
 // Mobile view themes (for the top bar)
 export const mobileViewTheme = mobileHeaderBackground;
@@ -201,3 +202,7 @@ export const budgetOtherMonth = colorPalette.navy900;
 export const budgetCurrentMonth = tableBackground;
 export const budgetHeaderOtherMonth = colorPalette.navy800;
 export const budgetHeaderCurrentMonth = tableHeaderBackground;
+
+export const floatingActionBarBackground = colorPalette.purple800;
+export const floatingActionBarBorder = floatingActionBarBackground;
+export const floatingActionBarText = colorPalette.navy150;
diff --git a/packages/desktop-client/src/style/themes/development.ts b/packages/desktop-client/src/style/themes/development.ts
index 8a1a1f955d7537b8b160a3d86300d1d0c1eb6920..b3a6a698627bef13bf3960b5f26f03ea290ba6b1 100644
--- a/packages/desktop-client/src/style/themes/development.ts
+++ b/packages/desktop-client/src/style/themes/development.ts
@@ -75,6 +75,7 @@ export const mobileNavItem = colorPalette.gray300;
 export const mobileNavItemSelected = colorPalette.purple500;
 export const mobileAccountShadow = colorPalette.navy300;
 export const mobileAccountText = colorPalette.blue800;
+export const mobileTransactionSelected = colorPalette.purple500;
 
 // Mobile view themes (for the top bar)
 export const mobileViewTheme = mobileHeaderBackground;
@@ -200,3 +201,7 @@ export const budgetCurrentMonth = tableBackground;
 export const budgetOtherMonth = colorPalette.gray50;
 export const budgetHeaderCurrentMonth = budgetOtherMonth;
 export const budgetHeaderOtherMonth = colorPalette.gray80;
+
+export const floatingActionBarBackground = colorPalette.purple400;
+export const floatingActionBarBorder = floatingActionBarBackground;
+export const floatingActionBarText = colorPalette.navy50;
diff --git a/packages/desktop-client/src/style/themes/light.ts b/packages/desktop-client/src/style/themes/light.ts
index e5ce2e1db16115eb83c260727b6ff4cd48d9e0d3..aad174b850c60baac3cbad8831e0ee2f5540d843 100644
--- a/packages/desktop-client/src/style/themes/light.ts
+++ b/packages/desktop-client/src/style/themes/light.ts
@@ -77,6 +77,7 @@ export const mobileNavItem = colorPalette.gray300;
 export const mobileNavItemSelected = colorPalette.purple500;
 export const mobileAccountShadow = colorPalette.navy300;
 export const mobileAccountText = colorPalette.blue800;
+export const mobileTransactionSelected = colorPalette.purple500;
 
 // Mobile view themes (for the top bar)
 export const mobileViewTheme = mobileHeaderBackground;
@@ -203,3 +204,7 @@ export const budgetCurrentMonth = tableBackground;
 export const budgetOtherMonth = colorPalette.gray50;
 export const budgetHeaderCurrentMonth = budgetOtherMonth;
 export const budgetHeaderOtherMonth = colorPalette.gray80;
+
+export const floatingActionBarBackground = colorPalette.purple400;
+export const floatingActionBarBorder = floatingActionBarBackground;
+export const floatingActionBarText = colorPalette.navy50;
diff --git a/packages/desktop-client/src/style/themes/midnight.ts b/packages/desktop-client/src/style/themes/midnight.ts
index f19701e6db2dd0b1878b6a913a2804bdf86aeed4..8869a2ba597d85d65a6be28d8518604dd1b9995c 100644
--- a/packages/desktop-client/src/style/themes/midnight.ts
+++ b/packages/desktop-client/src/style/themes/midnight.ts
@@ -76,6 +76,7 @@ export const mobileNavItem = colorPalette.gray150;
 export const mobileNavItemSelected = colorPalette.purple200;
 export const mobileAccountShadow = cardShadow;
 export const mobileAccountText = colorPalette.blue800;
+export const mobileTransactionSelected = colorPalette.purple300;
 
 // Mobile view themes (for the top bar)
 export const mobileViewTheme = mobileHeaderBackground;
@@ -203,3 +204,7 @@ export const budgetOtherMonth = colorPalette.gray700;
 export const budgetCurrentMonth = tableBackground;
 export const budgetHeaderOtherMonth = colorPalette.gray800;
 export const budgetHeaderCurrentMonth = tableHeaderBackground;
+
+export const floatingActionBarBackground = colorPalette.gray900;
+export const floatingActionBarBorder = colorPalette.purple300;
+export const floatingActionBarText = colorPalette.purple200;
diff --git a/packages/loot-core/src/client/actions/notifications.ts b/packages/loot-core/src/client/actions/notifications.ts
index 981426b0e873caad3575815e50b8e567cbe2f44b..9c0221548078294b07335d2877bb693589c66262 100644
--- a/packages/loot-core/src/client/actions/notifications.ts
+++ b/packages/loot-core/src/client/actions/notifications.ts
@@ -6,10 +6,11 @@ import type {
   AddNotificationAction,
   RemoveNotificationAction,
   Notification,
+  SetNotificationInsetAction,
 } from '../state-types/notifications';
 
 export function addNotification(
-  notification: Omit<Notification, 'id'> & { id?: string },
+  notification: Notification,
 ): AddNotificationAction {
   return {
     type: constants.ADD_NOTIFICATION,
@@ -36,3 +37,12 @@ export function removeNotification(id: string): RemoveNotificationAction {
     id,
   };
 }
+
+export function setNotificationInset(
+  inset?: SetNotificationInsetAction['inset'] | null,
+): SetNotificationInsetAction {
+  return {
+    type: constants.SET_NOTIFICATION_INSET,
+    inset: inset ? inset : {},
+  };
+}
diff --git a/packages/loot-core/src/client/constants.ts b/packages/loot-core/src/client/constants.ts
index 272ca0a9e2720e331118b10e3b9b02616c08b598..e7c59748c768f3b52068336c03c4911551b593b7 100644
--- a/packages/loot-core/src/client/constants.ts
+++ b/packages/loot-core/src/client/constants.ts
@@ -22,6 +22,7 @@ export const COLLAPSE_MODALS = 'COLLAPSE_MODALS';
 export const POP_MODAL = 'POP_MODAL';
 export const ADD_NOTIFICATION = 'ADD_NOTIFICATION';
 export const REMOVE_NOTIFICATION = 'REMOVE_NOTIFICATION';
+export const SET_NOTIFICATION_INSET = 'SET_NOTIFICATION_INSET';
 export const GET_USER_DATA = 'GET_USER_DATA';
 export const SET_LAST_UNDO_STATE = 'SET_LAST_UNDO_STATE';
 export const SET_LAST_SPLIT_STATE = 'SET_LAST_SPLIT_STATE';
diff --git a/packages/loot-core/src/client/reducers/notifications.ts b/packages/loot-core/src/client/reducers/notifications.ts
index 2221c6c59d1b5aa41347bcde7c3865561ef19eae..b1dd868d9b718406bb5c5863dbb249bcc89e87f6 100644
--- a/packages/loot-core/src/client/reducers/notifications.ts
+++ b/packages/loot-core/src/client/reducers/notifications.ts
@@ -5,6 +5,7 @@ import type { NotificationsState } from '../state-types/notifications';
 
 const initialState = {
   notifications: [],
+  inset: {},
 };
 
 export function update(
@@ -26,6 +27,11 @@ export function update(
         ...state,
         notifications: state.notifications.filter(n => n.id !== action.id),
       };
+    case constants.SET_NOTIFICATION_INSET:
+      return {
+        ...state,
+        inset: action.inset,
+      };
     default:
   }
 
diff --git a/packages/loot-core/src/client/state-types/modals.d.ts b/packages/loot-core/src/client/state-types/modals.d.ts
index cf801cb2e523455a801587565fb00cc717703f87..078996b3cb9c3017749fbbc200e77398f071cd2f 100644
--- a/packages/loot-core/src/client/state-types/modals.d.ts
+++ b/packages/loot-core/src/client/state-types/modals.d.ts
@@ -4,6 +4,7 @@ import type {
   CategoryEntity,
   CategoryGroupEntity,
   GoCardlessToken,
+  ScheduleEntity,
   TransactionEntity,
 } from '../../types/models';
 import type { NewRuleEntity, RuleEntity } from '../../types/models/rule';
@@ -93,14 +94,17 @@ type FinanceModals = {
   };
 
   'edit-field': {
-    name: string;
-    month: string;
-    onSubmit: (name: string, value: string) => void;
-    onClose: () => void;
+    name: keyof Pick<TransactionEntity, 'date' | 'amount' | 'notes'>;
+    onSubmit: (
+      name: keyof Pick<TransactionEntity, 'date' | 'amount' | 'notes'>,
+      value: string | number,
+      mode?: 'prepend' | 'append' | 'replace' | null,
+    ) => void;
+    onClose?: () => void;
   };
 
   'category-autocomplete': {
-    categoryGroups: CategoryGroupEntity[];
+    categoryGroups?: CategoryGroupEntity[];
     onSelect: (categoryId: string, categoryName: string) => void;
     month?: string;
     showHiddenCategories?: boolean;
@@ -124,7 +128,14 @@ type FinanceModals = {
 
   'schedule-edit': { id: string; transaction?: TransactionEntity } | null;
 
-  'schedule-link': { transactionIds: string[] } | null;
+  'schedule-link': {
+    transactionIds: string[];
+    getTransaction: (
+      transactionId: TransactionEntity['id'],
+    ) => TransactionEntity;
+    accountName?: string;
+    onScheduleLinked?: (schedule: ScheduleEntity) => void;
+  };
 
   'schedules-discover': null;
 
@@ -256,6 +267,7 @@ type FinanceModals = {
     confirmReason: string;
   };
   'confirm-transaction-delete': {
+    message?: string;
     onConfirm: () => void;
   };
 };
diff --git a/packages/loot-core/src/client/state-types/notifications.d.ts b/packages/loot-core/src/client/state-types/notifications.d.ts
index 551916e9f4249c852107290c1913899383e53aec..cebb60b8eba149447f31f546ff233be0875fd581 100644
--- a/packages/loot-core/src/client/state-types/notifications.d.ts
+++ b/packages/loot-core/src/client/state-types/notifications.d.ts
@@ -21,6 +21,12 @@ type NotificationWithId = Notification & { id: string };
 
 export type NotificationsState = {
   notifications: NotificationWithId[];
+  inset?: {
+    bottom?: number;
+    top?: number;
+    right?: number;
+    left?: number;
+  };
 };
 
 type AddNotificationAction = {
@@ -33,6 +39,17 @@ type RemoveNotificationAction = {
   id: string;
 };
 
+type SetNotificationInsetAction = {
+  type: typeof constants.SET_NOTIFICATION_INSET;
+  inset: {
+    bottom?: number;
+    top?: number;
+    right?: number;
+    left?: number;
+  };
+};
+
 export type NotificationsActions =
   | AddNotificationAction
-  | RemoveNotificationAction;
+  | RemoveNotificationAction
+  | SetNotificationInsetAction;
diff --git a/packages/loot-core/src/mocks/budget.ts b/packages/loot-core/src/mocks/budget.ts
index cad37f5aaf803942c49ccd4876c1aa2036bd0bbd..303a458434653dffc647f132ed73ec887e063327 100644
--- a/packages/loot-core/src/mocks/budget.ts
+++ b/packages/loot-core/src/mocks/budget.ts
@@ -1,4 +1,6 @@
 // @ts-strict-ignore
+import { v4 as uuidv4 } from 'uuid';
+
 import { addTransactions } from '../server/accounts/sync';
 import { runQuery as aqlQuery } from '../server/aql';
 import * as budgetActions from '../server/budget/actions';
@@ -13,13 +15,13 @@ import type { Handlers } from '../types/handlers';
 import type {
   CategoryGroupEntity,
   NewCategoryGroupEntity,
-  NewPayeeEntity,
-  NewTransactionEntity,
+  PayeeEntity,
+  TransactionEntity,
 } from '../types/models';
 
 import { random } from './random';
 
-type MockPayeeEntity = NewPayeeEntity & { bill?: boolean };
+type MockPayeeEntity = Partial<PayeeEntity> & { bill?: boolean };
 
 function pickRandom<T>(list: T[]): T {
   return list[Math.floor(random() * list.length) % list.length];
@@ -119,11 +121,17 @@ async function fillPrimaryChecking(
       amount = integer(0, random() < 0.05 ? -8000 : -700);
     }
 
-    const transaction: NewTransactionEntity = {
+    const currentDate = monthUtils.subDays(
+      monthUtils.currentDay(),
+      Math.floor(i / 3),
+    );
+
+    const transaction: TransactionEntity = {
+      id: uuidv4(),
       amount,
       payee: payee.id,
       account: account.id,
-      date: monthUtils.subDays(monthUtils.currentDay(), Math.floor(i / 3)),
+      date: currentDate,
       category: category.id,
     };
     transactions.push(transaction);
@@ -135,9 +143,24 @@ async function fillPrimaryChecking(
           ? incomeGroup.categories.find(c => c.name === 'Income').id
           : pickRandom(expenseCategories).id;
       transaction.subtransactions = [
-        { amount: a, category: pick() },
-        { amount: a, category: pick() },
         {
+          id: uuidv4(),
+          date: currentDate,
+          account: account.id,
+          amount: a,
+          category: pick(),
+        },
+        {
+          id: uuidv4(),
+          date: currentDate,
+          account: account.id,
+          amount: a,
+          category: pick(),
+        },
+        {
+          id: uuidv4(),
+          date: currentDate,
+          account: account.id,
           amount: transaction.amount - a * 2,
           category: pick(),
         },
@@ -403,8 +426,9 @@ async function fillOther(handlers, account, payees, groups) {
   const numTransactions = integer(3, 6);
   const category = incomeGroup.categories.find(c => c.name === 'Income');
 
-  const transactions: NewTransactionEntity[] = [
+  const transactions: TransactionEntity[] = [
     {
+      id: uuidv4(),
       amount: integer(3250, 3700) * 100 * 100,
       payee: payees.find(p => p.name === 'Starting Balance').id,
       account: account.id,
@@ -419,6 +443,7 @@ async function fillOther(handlers, account, payees, groups) {
     const amount = integer(4, 9) * 100 * 100;
 
     transactions.push({
+      id: uuidv4(),
       amount,
       payee: payee.id,
       account: account.id,
@@ -596,7 +621,7 @@ export async function createTestBudget(handlers: Handlers) {
     }
   });
 
-  const payees: Array<MockPayeeEntity> = [
+  const newPayees: Array<MockPayeeEntity> = [
     { name: 'Starting Balance' },
     { name: 'Kroger' },
     { name: 'Publix' },
@@ -611,10 +636,17 @@ export async function createTestBudget(handlers: Handlers) {
     { name: 'T-mobile', bill: true },
   ];
 
+  const payees: PayeeEntity[] = [];
+
   await runMutator(() =>
     batchMessages(async () => {
-      for (const payee of payees) {
-        payee.id = await handlers['payee-create']({ name: payee.name });
+      for (const newPayee of newPayees) {
+        const id = await handlers['payee-create']({ name: newPayee.name });
+        payees.push({
+          id,
+          name: newPayee.name,
+          ...newPayee,
+        });
       }
     }),
   );
diff --git a/packages/loot-core/src/server/accounts/transactions.ts b/packages/loot-core/src/server/accounts/transactions.ts
index 08fb702a7a1948be20faecf6a614f60dd6596257..a056daac112ff117055cd8ad492c495a37473187 100644
--- a/packages/loot-core/src/server/accounts/transactions.ts
+++ b/packages/loot-core/src/server/accounts/transactions.ts
@@ -1,6 +1,7 @@
 // @ts-strict-ignore
 import * as connection from '../../platform/server/connection';
-import { NewTransactionEntity, TransactionEntity } from '../../types/models';
+import { Diff } from '../../shared/util';
+import { TransactionEntity } from '../../types/models';
 import * as db from '../db';
 import { incrFetch, whereIn } from '../db/util';
 import { batchMessages } from '../sync';
@@ -42,10 +43,7 @@ export async function batchUpdateTransactions({
   learnCategories = false,
   detectOrphanPayees = true,
   runTransfers = true,
-}: {
-  added?: Array<Partial<NewTransactionEntity | TransactionEntity>>;
-  deleted?: Array<Partial<NewTransactionEntity | TransactionEntity>>;
-  updated?: Array<Partial<NewTransactionEntity | TransactionEntity>>;
+}: Partial<Diff<TransactionEntity>> & {
   learnCategories?: boolean;
   detectOrphanPayees?: boolean;
   runTransfers?: boolean;
diff --git a/packages/loot-core/src/shared/transactions.test.ts b/packages/loot-core/src/shared/transactions.test.ts
index 1f81ecadd4711c8fa7935691b1cf4d3f312e61bf..7b0c6969723d553771c90a0e2985d19e49ae6db4 100644
--- a/packages/loot-core/src/shared/transactions.test.ts
+++ b/packages/loot-core/src/shared/transactions.test.ts
@@ -16,22 +16,7 @@ function makeTransaction(data: Partial<TransactionEntity>): TransactionEntity {
     id: uuidv4(),
     amount: 2422,
     date: '2020-01-05',
-    account: {
-      id: 'acc-id-1',
-      name: 'account-1',
-      offbudget: 0,
-      closed: 0,
-      sort_order: 1,
-      tombstone: 0,
-      account_id: null,
-      bank: null,
-      mask: null,
-      official_name: null,
-      balance_current: null,
-      balance_available: null,
-      balance_limit: null,
-      account_sync_source: null,
-    },
+    account: 'acc-id-1',
     ...data,
   };
 }
diff --git a/packages/loot-core/src/shared/transactions.ts b/packages/loot-core/src/shared/transactions.ts
index d56389ca3e1c4b3a82cb10496809862f68da6641..5572446d9ed34ab1ba437779f377e438ae904ce3 100644
--- a/packages/loot-core/src/shared/transactions.ts
+++ b/packages/loot-core/src/shared/transactions.ts
@@ -1,9 +1,6 @@
 import { v4 as uuidv4 } from 'uuid';
 
-import {
-  type TransactionEntity,
-  type NewTransactionEntity,
-} from '../types/models';
+import { type TransactionEntity } from '../types/models';
 
 import { last, diffItems, applyChanges } from './util';
 
@@ -35,10 +32,7 @@ function SplitTransactionError(total: number, parent: TransactionEntity) {
   };
 }
 
-type GenericTransactionEntity =
-  | NewTransactionEntity
-  | TransactionEntity
-  | TransactionEntityWithError;
+type GenericTransactionEntity = TransactionEntity | TransactionEntityWithError;
 
 export function makeChild<T extends GenericTransactionEntity>(
   parent: T,
@@ -140,7 +134,7 @@ export function groupTransaction(split: TransactionEntity[]) {
 
 export function ungroupTransaction(split: TransactionEntity | null) {
   if (split == null) {
-    return null;
+    return [];
   }
   return ungroupTransactions([split]);
 }
@@ -163,7 +157,11 @@ function replaceTransactions(
   func: (
     transaction: TransactionEntity,
   ) => TransactionEntity | TransactionEntityWithError | null,
-) {
+): {
+  data: TransactionEntity[];
+  newTransaction: TransactionEntity | TransactionEntityWithError | null;
+  diff: ReturnType<typeof diffItems<TransactionEntity>>;
+} {
   const idx = transactions.findIndex(t => t.id === id);
   const trans = transactions[idx];
   const transactionsCopy = [...transactions];
@@ -176,22 +174,26 @@ function replaceTransactions(
     const parentIndex = findParentIndex(transactions, idx);
     if (parentIndex == null) {
       console.log('Cannot find parent index');
-      return { data: [], diff: { deleted: [], updated: [] } };
+      return {
+        data: [],
+        diff: { added: [], deleted: [], updated: [] },
+        newTransaction: null,
+      };
     }
 
     const split = getSplit(transactions, parentIndex);
     let grouped = func(groupTransaction(split));
     const newSplit = ungroupTransaction(grouped);
 
-    let diff;
+    let diff: ReturnType<typeof diffItems<TransactionEntity>>;
     if (newSplit == null) {
       // If everything was deleted, just delete the parent which will
       // delete everything
-      diff = { deleted: [{ id: split[0].id }], updated: [] };
+      diff = { added: [], deleted: [{ id: split[0].id }], updated: [] };
       grouped = { ...split[0], _deleted: true };
       transactionsCopy.splice(parentIndex, split.length);
     } else {
-      diff = diffItems(split, newSplit);
+      diff = diffItems<TransactionEntity>(split, newSplit);
       transactionsCopy.splice(parentIndex, split.length, ...newSplit);
     }
 
@@ -210,7 +212,7 @@ function replaceTransactions(
         ...trans,
         _deleted: true,
       },
-      diff: diffItems([trans], newTrans),
+      diff: diffItems<TransactionEntity>([trans], newTrans),
     };
   }
 }
@@ -320,16 +322,24 @@ export function splitTransaction(
   });
 }
 
-export function realizeTempTransactions(transactions: TransactionEntity[]) {
-  const parent = { ...transactions.find(t => !t.is_child), id: uuidv4() };
+export function realizeTempTransactions(
+  transactions: TransactionEntity[],
+): TransactionEntity[] {
+  const parent = {
+    ...transactions.find(t => !t.is_child),
+    id: uuidv4(),
+  } as TransactionEntity;
   const children = transactions.filter(t => t.is_child);
   return [
     parent,
-    ...children.map(child => ({
-      ...child,
-      id: uuidv4(),
-      parent_id: parent.id,
-    })),
+    ...children.map(
+      child =>
+        ({
+          ...child,
+          id: uuidv4(),
+          parent_id: parent.id,
+        }) as TransactionEntity,
+    ),
   ];
 }
 
diff --git a/packages/loot-core/src/shared/util.ts b/packages/loot-core/src/shared/util.ts
index 7a8c9681b0448665d981b13b214bb54dc3f5f7b6..4b46d0d73fe31f67d4a4fab3d7f540bedd5aa81d 100644
--- a/packages/loot-core/src/shared/util.ts
+++ b/packages/loot-core/src/shared/util.ts
@@ -42,12 +42,14 @@ export function hasFieldsChanged<T extends object>(
   return changed;
 }
 
+export type Diff<T extends { id: string }> = {
+  added: T[];
+  updated: Partial<T>[];
+  deleted: Partial<T>[];
+};
+
 export function applyChanges<T extends { id: string }>(
-  changes: {
-    added?: T[];
-    updated?: T[];
-    deleted?: T[];
-  },
+  changes: Diff<T>,
   items: T[],
 ) {
   items = [...items];
@@ -118,15 +120,18 @@ function _groupById<T extends { id: string }>(data: T[]) {
   return res;
 }
 
-export function diffItems<T extends { id: string }>(items: T[], newItems: T[]) {
+export function diffItems<T extends { id: string }>(
+  items: T[],
+  newItems: T[],
+): Diff<T> {
   const grouped = _groupById(items);
   const newGrouped = _groupById(newItems);
   const added: T[] = [];
   const updated: Partial<T>[] = [];
 
-  const deleted = items
+  const deleted: Partial<T>[] = items
     .filter(item => !newGrouped.has(item.id))
-    .map(item => ({ id: item.id }));
+    .map(item => ({ id: item.id }) as Partial<T>);
 
   newItems.forEach(newItem => {
     const item = grouped.get(newItem.id);
diff --git a/packages/loot-core/src/types/models/category.d.ts b/packages/loot-core/src/types/models/category.d.ts
index 9e794fe665248141d7b8e2a45cf1b78d66a13389..80f6c121464a9b9273a3cee1bad57203d7f740a1 100644
--- a/packages/loot-core/src/types/models/category.d.ts
+++ b/packages/loot-core/src/types/models/category.d.ts
@@ -1,8 +1,10 @@
+import { CategoryGroupEntity } from './category-group';
+
 export interface CategoryEntity {
   id: string;
   name: string;
   is_income?: boolean;
-  cat_group?: string;
+  cat_group?: CategoryGroupEntity['id'];
   sort_order?: number;
   tombstone?: boolean;
   hidden?: boolean;
diff --git a/packages/loot-core/src/types/models/payee.d.ts b/packages/loot-core/src/types/models/payee.d.ts
index 1d95d00c93d7aaab9fb34360e663845b099bf298..f55f04aa49f3cd7b2acfafd6393ff217c4d9f608 100644
--- a/packages/loot-core/src/types/models/payee.d.ts
+++ b/packages/loot-core/src/types/models/payee.d.ts
@@ -1,11 +1,9 @@
-export interface NewPayeeEntity {
-  id?: string;
+import { AccountEntity } from './account';
+
+export interface PayeeEntity {
+  id: string;
   name: string;
-  transfer_acct?: string;
+  transfer_acct?: AccountEntity['id'];
   favorite?: boolean;
   tombstone?: boolean;
 }
-
-export interface PayeeEntity extends NewPayeeEntity {
-  id: string;
-}
diff --git a/packages/loot-core/src/types/models/transaction.d.ts b/packages/loot-core/src/types/models/transaction.d.ts
index 93699a59bfa1f7fdade2ac7e9d7febe669762770..9cb097fec87055c58f739085560f9c1729aeaff7 100644
--- a/packages/loot-core/src/types/models/transaction.d.ts
+++ b/packages/loot-core/src/types/models/transaction.d.ts
@@ -1,40 +1,27 @@
-import type { AccountEntity } from './account';
-import type { CategoryEntity } from './category';
-import type { PayeeEntity } from './payee';
-import type { ScheduleEntity } from './schedule';
+import { AccountEntity } from './account';
+import { CategoryEntity } from './category';
+import { PayeeEntity } from './payee';
+import { ScheduleEntity } from './schedule';
 
-export interface NewTransactionEntity {
-  id?: string;
+export interface TransactionEntity {
+  id: string;
   is_parent?: boolean;
   is_child?: boolean;
-  parent_id?: string;
-  account: string;
-  category?: string;
+  parent_id?: TransactionEntity['id'];
+  account: AccountEntity['id'];
+  category?: CategoryEntity['id'];
   amount: number;
-  payee?: string;
+  payee?: PayeeEntity['id'];
   notes?: string;
   date: string;
   imported_id?: string;
   imported_payee?: string;
   starting_balance_flag?: boolean;
-  transfer_id?: string;
+  transfer_id?: TransactionEntity['id'];
   sort_order?: number;
   cleared?: boolean;
   reconciled?: boolean;
   tombstone?: boolean;
-  schedule?: string;
-  subtransactions?: Omit<NewTransactionEntity, 'account' | 'date'>[];
-}
-
-export interface TransactionEntity
-  extends Omit<
-    NewTransactionEntity,
-    'account' | 'category' | 'payee' | 'schedule' | 'subtransactions'
-  > {
-  id: string;
-  account: AccountEntity;
-  category?: CategoryEntity;
-  payee?: PayeeEntity;
-  schedule?: ScheduleEntity;
+  schedule?: ScheduleEntity['id'];
   subtransactions?: TransactionEntity[];
 }
diff --git a/packages/loot-core/src/types/server-handlers.d.ts b/packages/loot-core/src/types/server-handlers.d.ts
index 3bea94101c1133982257367610942d7cee0ed20f..300484f7da6ed5a071384c1b25a15d076296485d 100644
--- a/packages/loot-core/src/types/server-handlers.d.ts
+++ b/packages/loot-core/src/types/server-handlers.d.ts
@@ -28,11 +28,8 @@ export interface ServerHandlers {
   redo: () => Promise<void>;
 
   'transactions-batch-update': (
-    arg: Omit<
-      Parameters<typeof batchUpdateTransactions>[0],
-      'detectOrphanPayees'
-    >,
-  ) => Promise<Awaited<ReturnType<typeof batchUpdateTransactions>>>;
+    ...arg: Parameters<typeof batchUpdateTransactions>
+  ) => ReturnType<typeof batchUpdateTransactions>;
 
   'transaction-add': (transaction) => Promise<EmptyObject>;
 
diff --git a/upcoming-release-notes/2892.md b/upcoming-release-notes/2892.md
new file mode 100644
index 0000000000000000000000000000000000000000..41392a9cef6c11d4d382576e67dd16b0be43ea65
--- /dev/null
+++ b/upcoming-release-notes/2892.md
@@ -0,0 +1,6 @@
+---
+category: Features
+authors: [joel-jeremy]
+---
+
+Long press transactions in mobile account view to reveal action bar with more actions.
diff --git a/yarn.lock b/yarn.lock
index bf70e0d942f339d8dfc56799611fc4ce8ac0a811..947d418bec5d42b2739edd73ede8df3303bdf4da 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -105,6 +105,7 @@ __metadata:
     promise-retry: "npm:^2.0.1"
     re-resizable: "npm:^6.9.17"
     react: "npm:18.2.0"
+    react-aria: "npm:^3.33.1"
     react-aria-components: "npm:^1.2.1"
     react-dnd: "npm:^16.0.1"
     react-dnd-html5-backend: "npm:^16.0.1"