293 lines
9.9 KiB
TypeScript
293 lines
9.9 KiB
TypeScript
/*
|
|
* Copyright (C) 2026 Fluxer Contributors
|
|
*
|
|
* This file is part of Fluxer.
|
|
*
|
|
* Fluxer is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as published by
|
|
* the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* Fluxer is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
import styles from '@app/components/common/FriendSelector.module.css';
|
|
import {Input, type RenderInputArgs} from '@app/components/form/Input';
|
|
import {Avatar} from '@app/components/uikit/Avatar';
|
|
import {Checkbox} from '@app/components/uikit/checkbox/Checkbox';
|
|
import FocusRing from '@app/components/uikit/focus_ring/FocusRing';
|
|
import {Scroller} from '@app/components/uikit/Scroller';
|
|
import {StatusAwareAvatar} from '@app/components/uikit/StatusAwareAvatar';
|
|
import type {UserRecord} from '@app/records/UserRecord';
|
|
import RelationshipStore from '@app/stores/RelationshipStore';
|
|
import UserStore from '@app/stores/UserStore';
|
|
import * as NicknameUtils from '@app/utils/NicknameUtils';
|
|
import {RelationshipTypes} from '@fluxer/constants/src/UserConstants';
|
|
import {useLingui} from '@lingui/react/macro';
|
|
import {MagnifyingGlassIcon, XIcon} from '@phosphor-icons/react';
|
|
import {clsx} from 'clsx';
|
|
import {observer} from 'mobx-react-lite';
|
|
import type React from 'react';
|
|
import {useMemo, useRef, useState} from 'react';
|
|
|
|
interface FriendSelectorProps {
|
|
selectedUserIds: Array<string>;
|
|
onToggle: (userId: string) => void;
|
|
maxSelections?: number;
|
|
excludeUserIds?: Array<string>;
|
|
searchQuery?: string;
|
|
onSearchQueryChange?: (value: string) => void;
|
|
showSearchInput?: boolean;
|
|
stickyUserIds?: Array<string>;
|
|
}
|
|
|
|
interface FriendGroup {
|
|
letter: string;
|
|
friendIds: Array<string>;
|
|
}
|
|
|
|
export const FriendSelector: React.FC<FriendSelectorProps> = observer(
|
|
({
|
|
selectedUserIds,
|
|
onToggle,
|
|
maxSelections,
|
|
excludeUserIds = [],
|
|
searchQuery: externalSearchQuery,
|
|
onSearchQueryChange,
|
|
showSearchInput = true,
|
|
stickyUserIds = [],
|
|
}) => {
|
|
const {t} = useLingui();
|
|
const [internalSearchQuery, setInternalSearchQuery] = useState('');
|
|
const searchQuery = externalSearchQuery ?? internalSearchQuery;
|
|
const [inputFocused, setInputFocused] = useState(false);
|
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
|
|
const handleSearchChange = (value: string) => {
|
|
if (onSearchQueryChange) {
|
|
onSearchQueryChange(value);
|
|
} else {
|
|
setInternalSearchQuery(value);
|
|
}
|
|
};
|
|
|
|
const relationships = RelationshipStore.getRelationships();
|
|
const friendUsers = useMemo(() => {
|
|
const friends = relationships.filter(
|
|
(relationship) => relationship.type === RelationshipTypes.FRIEND && !excludeUserIds.includes(relationship.id),
|
|
);
|
|
|
|
return friends
|
|
.map((relationship) => UserStore.getUser(relationship.id))
|
|
.filter((user): user is UserRecord => Boolean(user))
|
|
.sort((a, b) => NicknameUtils.getNickname(a).localeCompare(NicknameUtils.getNickname(b)));
|
|
}, [relationships, excludeUserIds]);
|
|
|
|
const activeStickyUserIds = useMemo(() => {
|
|
return stickyUserIds.filter((id) => selectedUserIds.includes(id));
|
|
}, [stickyUserIds, selectedUserIds]);
|
|
|
|
const groupedFriends = useMemo(() => {
|
|
const filtered = friendUsers.filter((user) => {
|
|
if (!searchQuery) return true;
|
|
return NicknameUtils.getNickname(user).toLowerCase().includes(searchQuery.toLowerCase());
|
|
});
|
|
|
|
const stickySet = new Set(activeStickyUserIds);
|
|
const groups: Record<string, Array<UserRecord>> = {};
|
|
|
|
filtered.forEach((user) => {
|
|
if (stickySet.has(user.id)) return;
|
|
const firstLetter = NicknameUtils.getNickname(user)[0].toUpperCase();
|
|
if (!groups[firstLetter]) {
|
|
groups[firstLetter] = [];
|
|
}
|
|
groups[firstLetter].push(user);
|
|
});
|
|
|
|
const groupArray: Array<FriendGroup> = Object.keys(groups)
|
|
.sort()
|
|
.map((letter) => ({
|
|
letter,
|
|
friendIds: groups[letter].map((user) => user.id),
|
|
}));
|
|
|
|
return groupArray;
|
|
}, [friendUsers, searchQuery, activeStickyUserIds]);
|
|
|
|
const handleRemovePill = (userId: string) => {
|
|
onToggle(userId);
|
|
if (inputRef.current) {
|
|
inputRef.current.focus();
|
|
}
|
|
};
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
if (e.key === 'Backspace' && searchQuery === '' && selectedUserIds.length > 0) {
|
|
onToggle(selectedUserIds[selectedUserIds.length - 1]);
|
|
}
|
|
};
|
|
|
|
const handleToggle = (userId: string) => {
|
|
handleSearchChange('');
|
|
onToggle(userId);
|
|
};
|
|
|
|
const isMaxed = maxSelections !== undefined && selectedUserIds.length >= maxSelections;
|
|
|
|
const isMutableRefObject = (
|
|
ref: React.Ref<HTMLInputElement> | undefined,
|
|
): ref is React.MutableRefObject<HTMLInputElement | null> =>
|
|
typeof ref === 'object' && ref !== null && 'current' in ref;
|
|
|
|
const renderSearchInput = ({inputProps, inputClassName, ref: forwardedRef}: RenderInputArgs) => {
|
|
const handleRef = (node: HTMLInputElement | null) => {
|
|
inputRef.current = node;
|
|
if (typeof forwardedRef === 'function') {
|
|
forwardedRef(node);
|
|
} else if (isMutableRefObject(forwardedRef)) {
|
|
forwardedRef.current = node;
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className={clsx(inputClassName, styles.searchField)}>
|
|
{selectedUserIds.map((userId) => {
|
|
const user = UserStore.getUser(userId);
|
|
if (!user) return null;
|
|
|
|
return (
|
|
<div key={userId} className={styles.selectedPill}>
|
|
<Avatar user={user} size={16} />
|
|
<span>{NicknameUtils.getNickname(user)}</span>
|
|
<FocusRing offset={-2}>
|
|
<button type="button" onClick={() => handleRemovePill(userId)} className={styles.removeButton}>
|
|
<XIcon className={styles.removeIcon} weight="bold" />
|
|
</button>
|
|
</FocusRing>
|
|
</div>
|
|
);
|
|
})}
|
|
<div className={styles.searchFieldInner}>
|
|
<MagnifyingGlassIcon
|
|
className={clsx(styles.searchIcon, inputFocused && styles.searchIconFocused)}
|
|
weight="bold"
|
|
/>
|
|
<input {...inputProps} ref={handleRef} className={styles.searchInput} spellCheck={false} />
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className={styles.container}>
|
|
{showSearchInput && (
|
|
<Input
|
|
value={searchQuery}
|
|
onChange={(e) => handleSearchChange(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
onFocus={() => setInputFocused(true)}
|
|
onBlur={() => setInputFocused(false)}
|
|
placeholder={selectedUserIds.length > 0 ? '' : t`Search friends`}
|
|
renderInput={({inputProps, inputClassName, ref, defaultInput}) =>
|
|
renderSearchInput({inputProps, inputClassName, ref, defaultInput})
|
|
}
|
|
/>
|
|
)}
|
|
|
|
<Scroller
|
|
className={clsx(styles.scroller, !showSearchInput && styles.scrollerNoSearch)}
|
|
key="friend-selector-scroller"
|
|
fade={false}
|
|
>
|
|
{groupedFriends.length === 0 && activeStickyUserIds.length === 0 ? (
|
|
<div className={styles.emptyState}>
|
|
<p className={styles.emptyStateText}>{searchQuery ? t`No friends found` : t`You have no friends yet`}</p>
|
|
</div>
|
|
) : (
|
|
<div className={styles.groupsContainer}>
|
|
{activeStickyUserIds.length > 0 && (
|
|
<div className={styles.friendsList}>
|
|
{activeStickyUserIds.map((userId) => {
|
|
const user = UserStore.getUser(userId);
|
|
if (!user) return null;
|
|
|
|
const isSelected = selectedUserIds.includes(userId);
|
|
const canSelect = !isMaxed || isSelected;
|
|
|
|
return (
|
|
<FocusRing key={userId} offset={-2} enabled={canSelect}>
|
|
<button
|
|
type="button"
|
|
onClick={() => canSelect && handleToggle(userId)}
|
|
disabled={!canSelect}
|
|
className={clsx(
|
|
styles.friendButton,
|
|
isSelected && styles.friendButtonSelected,
|
|
!canSelect && styles.friendButtonDisabled,
|
|
)}
|
|
>
|
|
<div className={styles.friendInfo}>
|
|
<StatusAwareAvatar user={user} size={32} />
|
|
<span className={styles.friendName}>{NicknameUtils.getNickname(user)}</span>
|
|
</div>
|
|
<div className={styles.checkboxContainer}>
|
|
<Checkbox checked={isSelected} readOnly aria-hidden={true} />
|
|
</div>
|
|
</button>
|
|
</FocusRing>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
{groupedFriends.map((group) => (
|
|
<div key={group.letter}>
|
|
<div className={styles.groupLetter}>{group.letter}</div>
|
|
<div className={styles.friendsList}>
|
|
{group.friendIds.map((userId) => {
|
|
const user = UserStore.getUser(userId);
|
|
if (!user) return null;
|
|
|
|
const isSelected = selectedUserIds.includes(userId);
|
|
const canSelect = !isMaxed || isSelected;
|
|
|
|
return (
|
|
<FocusRing key={userId} offset={-2} enabled={canSelect}>
|
|
<button
|
|
type="button"
|
|
onClick={() => canSelect && handleToggle(userId)}
|
|
disabled={!canSelect}
|
|
className={clsx(
|
|
styles.friendButton,
|
|
isSelected && styles.friendButtonSelected,
|
|
!canSelect && styles.friendButtonDisabled,
|
|
)}
|
|
>
|
|
<div className={styles.friendInfo}>
|
|
<StatusAwareAvatar user={user} size={32} />
|
|
<span className={styles.friendName}>{NicknameUtils.getNickname(user)}</span>
|
|
</div>
|
|
<div className={styles.checkboxContainer}>
|
|
<Checkbox checked={isSelected} readOnly aria-hidden={true} />
|
|
</div>
|
|
</button>
|
|
</FocusRing>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</Scroller>
|
|
</div>
|
|
);
|
|
},
|
|
);
|