diff --git a/.env.example b/.env.example
index fc533c7d..cb33a134 100644
--- a/.env.example
+++ b/.env.example
@@ -40,6 +40,10 @@ DEFAULT_HOMEPAGE="/home"
# Enable sending invites
ENABLE_SENDING_INVITES=false
+# Show other users (including hidden friends) in the expense participant picker.
+# Enable only on private instances where all users may discover each other.
+ENABLE_USER_DISCOVERY=false
+
# Disable email signup (magic link/OTP login) for new users
DISABLE_EMAIL_SIGNUP=false
#********* END OF REQUIRED ENV VARS *********
diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md
index 6e6eb6de..c7be2293 100644
--- a/docs/CONFIGURATION.md
+++ b/docs/CONFIGURATION.md
@@ -64,6 +64,7 @@ At least one provider must be configured. SplitPro does not support username/pas
### App behavior
- `DEFAULT_HOMEPAGE`: Sets the landing page route, e.g. `/home` or `/balances`.
+- `ENABLE_USER_DISCOVERY`: Default `false`. Show an “Other users” section in the expense participant picker, including hidden friends and users without shared expenses. Excludes yourself and deleted accounts. Enable for private instances where all users may discover each other; this does not add friends to the balance dashboard.
- `ENABLE_SENDING_INVITES`: Enable email invites (requires SMTP config).
- `DISABLE_EMAIL_SIGNUP`: Disable email magic-link signup for new users.
- `UPLOAD_MAX_FILE_SIZE_MB`: Maximum receipt upload size in MB. Used by both client-side pre-check and server-side upload limit. This is also the max size target for image compression, so increase it if you see bad resolution/quality. Default: `10`.
diff --git a/public/locales/en/common.json b/public/locales/en/common.json
index 99015095..c6d88427 100644
--- a/public/locales/en/common.json
+++ b/public/locales/en/common.json
@@ -114,6 +114,7 @@
"deleted_user": "Deleted user",
"all": "All",
"friends": "Friends",
+ "other_users": "Other users",
"groups": "Groups",
"members": "Members",
"owner": "Owner",
diff --git a/src/components/AddExpense/SelectUserOrGroup.tsx b/src/components/AddExpense/SelectUserOrGroup.tsx
index eedade06..cd5ece49 100644
--- a/src/components/AddExpense/SelectUserOrGroup.tsx
+++ b/src/components/AddExpense/SelectUserOrGroup.tsx
@@ -7,7 +7,7 @@ import { toast } from 'sonner';
import { z } from 'zod';
import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils';
-import { useAddExpenseStore } from '~/store/addStore';
+import { type Participant, useAddExpenseStore } from '~/store/addStore';
import { api } from '~/utils/api';
import { deserializeDefaultSplit } from '~/lib/defaultSplit';
import {
@@ -40,6 +40,7 @@ export const SelectUserOrGroup: React.FC<{
useAddExpenseStore((s) => s.actions);
const friendsQuery = api.user.getFriends.useQuery();
+ const discoveryQuery = api.user.getDiscoverableUsers.useQuery();
const groupsQuery = api.group.getAllGroups.useQuery();
const addFriendMutation = api.user.inviteFriend.useMutation();
@@ -53,20 +54,29 @@ export const SelectUserOrGroup: React.FC<{
const canSelectGroups = 1 === participants.length;
const contactsFailed =
(friendsQuery.isError && undefined === friendsQuery.data) ||
+ (discoveryQuery.isError && undefined === discoveryQuery.data) ||
(canSelectGroups && groupsQuery.isError && undefined === groupsQuery.data);
const filteredGroups = canSelectGroups
? groupsQuery.data?.filter((g) => g.group.name.toLowerCase().includes(searchValue))
: [];
- const filteredFriends = friendsQuery.data?.filter((f) =>
- displayName(f).toLowerCase().includes(searchValue),
+ const filteredFriends = friendsQuery.data?.filter(
+ (f) => !f.deletedAt && displayName(f).toLowerCase().includes(searchValue),
);
- /* Both friends and groups populate this list, so either one counts as having contacts. */
+ const filteredOtherUsers = discoveryQuery.data?.filter((user) =>
+ displayName(user).toLowerCase().includes(searchValue),
+ );
+
+ /* Friends, discoverable users, and groups all count as contacts. */
const emptyReason = peopleListEmptyReason({
isLoading:
- contactsFailed || friendsQuery.isPending || (canSelectGroups && groupsQuery.isPending),
+ contactsFailed ||
+ friendsQuery.isPending ||
+ discoveryQuery.isPending ||
+ (canSelectGroups && groupsQuery.isPending),
hasAnyContacts:
0 < (friendsQuery.data?.length ?? 0) ||
+ 0 < (discoveryQuery.data?.length ?? 0) ||
(canSelectGroups && 0 < (groupsQuery.data?.length ?? 0)),
isFiltering: '' !== searchValue,
});
@@ -133,7 +143,7 @@ export const SelectUserOrGroup: React.FC<{
);
const handleFriendClick = useCallback(
- (f: User) => {
+ (f: Participant) => {
const isExisting = participants.some((p) => p.id === f.id);
if (isExisting) {
@@ -183,11 +193,18 @@ export const SelectUserOrGroup: React.FC<{
{t('ui.people_list_load_error')}