/* * 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 . */ 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; onToggle: (userId: string) => void; maxSelections?: number; excludeUserIds?: Array; searchQuery?: string; onSearchQueryChange?: (value: string) => void; showSearchInput?: boolean; stickyUserIds?: Array; } interface FriendGroup { letter: string; friendIds: Array; } export const FriendSelector: React.FC = 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(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> = {}; 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 = 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) => { 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 | undefined, ): ref is React.MutableRefObject => 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 (
{selectedUserIds.map((userId) => { const user = UserStore.getUser(userId); if (!user) return null; return (
{NicknameUtils.getNickname(user)}
); })}
); }; return (
{showSearchInput && ( 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}) } /> )} {groupedFriends.length === 0 && activeStickyUserIds.length === 0 ? (

{searchQuery ? t`No friends found` : t`You have no friends yet`}

) : (
{activeStickyUserIds.length > 0 && (
{activeStickyUserIds.map((userId) => { const user = UserStore.getUser(userId); if (!user) return null; const isSelected = selectedUserIds.includes(userId); const canSelect = !isMaxed || isSelected; return ( ); })}
)} {groupedFriends.map((group) => (
{group.letter}
{group.friendIds.map((userId) => { const user = UserStore.getUser(userId); if (!user) return null; const isSelected = selectedUserIds.includes(userId); const canSelect = !isMaxed || isSelected; return ( ); })}
))}
)}
); }, );