diff --git a/.husky/post-merge b/.husky/post-merge new file mode 100755 index 000000000..0b2a3a1fd --- /dev/null +++ b/.husky/post-merge @@ -0,0 +1,5 @@ +if git diff-tree --no-commit-id -r --name-only ORIG_HEAD HEAD | grep -qE "package\.json|bun\.lockb"; then + echo "📦 Dependencies changed, running module install..." + bun i + echo "✅ Module install completed, ready to rock 🪨!" +fi diff --git a/jest/functional/playlist-users.test.ts b/jest/functional/playlist-users.test.ts new file mode 100644 index 000000000..787709c18 --- /dev/null +++ b/jest/functional/playlist-users.test.ts @@ -0,0 +1,299 @@ +import { getApi } from '../../src/stores/auth/utils' +import { getPlaylistsApi } from '@jellyfin/sdk/lib/utils/api' +import { + addPlaylistUser, + getPlaylistUsers, + removePlaylistUser, +} from '../../src/api/queries/playlist/utils/users' +import { BaseItemDto, PlaylistUserPermissions, UserDto } from '@jellyfin/sdk/lib/generated-client' + +jest.mock('../../src/stores') +jest.mock('@jellyfin/sdk/lib/utils/api') + +describe('Playlist Users API Functions', () => { + const mockPlaylistId = 'playlist-123' + const mockUserId = 'user-456' + const mockApi = { basePath: 'http://test' } + + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('getPlaylistUsers', () => { + it('fetches playlist users successfully', async () => { + const mockUsers: PlaylistUserPermissions[] = [ + { UserId: 'user-1', CanEdit: true }, + { UserId: 'user-2', CanEdit: false }, + ] + + const mockPlaylistApi = { + getPlaylistUsers: jest.fn().mockResolvedValue({ data: mockUsers }), + } + + ;(getApi as jest.Mock).mockReturnValue(mockApi) + ;(getPlaylistsApi as jest.Mock).mockReturnValue(mockPlaylistApi) + + const result = await getPlaylistUsers(mockPlaylistId) + + expect(result).toEqual(mockUsers) + expect(getPlaylistsApi).toHaveBeenCalledWith(mockApi) + expect(mockPlaylistApi.getPlaylistUsers).toHaveBeenCalledWith({ + playlistId: mockPlaylistId, + }) + }) + + it('throws error when API instance is not set', async () => { + ;(getApi as jest.Mock).mockReturnValue(null) + + await expect(getPlaylistUsers(mockPlaylistId)).rejects.toThrow('API Instance not set') + }) + }) + + describe('addPlaylistUser', () => { + it('adds a user to playlist with correct permissions', async () => { + const mockPlaylistApi = { + updatePlaylist: jest.fn().mockResolvedValue({}), + } + + ;(getApi as jest.Mock).mockReturnValue(mockApi) + ;(getPlaylistsApi as jest.Mock).mockReturnValue(mockPlaylistApi) + + await addPlaylistUser(mockPlaylistId, mockUserId, true) + + expect(mockPlaylistApi.updatePlaylist).toHaveBeenCalledWith({ + playlistId: mockPlaylistId, + updatePlaylistDto: { + Users: [ + { + UserId: mockUserId, + CanEdit: true, + }, + ], + }, + }) + }) + + it('adds a user to playlist with read-only permission', async () => { + const mockPlaylistApi = { + updatePlaylist: jest.fn().mockResolvedValue({}), + } + + ;(getApi as jest.Mock).mockReturnValue(mockApi) + ;(getPlaylistsApi as jest.Mock).mockReturnValue(mockPlaylistApi) + + await addPlaylistUser(mockPlaylistId, mockUserId, false) + + expect(mockPlaylistApi.updatePlaylist).toHaveBeenCalledWith({ + playlistId: mockPlaylistId, + updatePlaylistDto: { + Users: [ + { + UserId: mockUserId, + CanEdit: false, + }, + ], + }, + }) + }) + }) + + describe('removePlaylistUser', () => { + it('removes a user from playlist', async () => { + const mockPlaylistApi = { + removeUserFromPlaylist: jest.fn().mockResolvedValue({}), + } + + ;(getApi as jest.Mock).mockReturnValue(mockApi) + ;(getPlaylistsApi as jest.Mock).mockReturnValue(mockPlaylistApi) + + await removePlaylistUser(mockPlaylistId, mockUserId) + + expect(mockPlaylistApi.removeUserFromPlaylist).toHaveBeenCalledWith({ + playlistId: mockPlaylistId, + userId: mockUserId, + }) + }) + }) +}) + +describe('Playlist Users Query Client Updates', () => { + const mockPlaylist: BaseItemDto = { + Id: 'playlist-123', + Name: 'Test Playlist', + Type: 'Playlist', + } + + const mockUser: UserDto = { + Id: 'user-456', + Name: 'Test User', + } + + describe('useAddPlaylistUser onSuccess', () => { + it('should add new user to empty playlist users cache', () => { + const previousData: PlaylistUserPermissions[] | undefined = undefined + const newUser: PlaylistUserPermissions = { + UserId: mockUser.Id, + CanEdit: true, + } + + // Simulate the query update function + const updateFn = (previous: PlaylistUserPermissions[] | undefined) => { + if (previous == undefined) { + return [newUser] + } else { + return [...previous, newUser] + } + } + + const result = updateFn(previousData) + expect(result).toEqual([newUser]) + expect(result).toHaveLength(1) + }) + + it('should add new user to existing playlist users list', () => { + const existingUser: PlaylistUserPermissions = { + UserId: 'user-existing', + CanEdit: true, + } + const previousData: PlaylistUserPermissions[] = [existingUser] + const newUser: PlaylistUserPermissions = { + UserId: mockUser.Id, + CanEdit: false, + } + + // Simulate the query update function + const updateFn = (previous: PlaylistUserPermissions[] | undefined) => { + if (previous == undefined) { + return [newUser] + } else { + return [...previous, newUser] + } + } + + const result = updateFn(previousData) + expect(result).toEqual([existingUser, newUser]) + expect(result).toHaveLength(2) + }) + + it('should respect CanEdit permission when adding user', () => { + const previousData: PlaylistUserPermissions[] | undefined = undefined + const canEditValue = false + const newUser: PlaylistUserPermissions = { + UserId: mockUser.Id, + CanEdit: canEditValue, + } + + // Simulate the query update function + const updateFn = (previous: PlaylistUserPermissions[] | undefined) => { + if (previous == undefined) { + return [newUser] + } else { + return [...previous, newUser] + } + } + + const result = updateFn(previousData) + expect(result[0].CanEdit).toBe(false) + }) + }) + + describe('useRemovePlaylistUser onSuccess', () => { + it('should remove user from playlist users cache', () => { + const userToRemove = 'user-456' + const previousData: PlaylistUserPermissions[] = [ + { UserId: 'user-1', CanEdit: true }, + { UserId: userToRemove, CanEdit: false }, + { UserId: 'user-3', CanEdit: true }, + ] + + // Simulate the query update function + const updateFn = (previous: PlaylistUserPermissions[] | undefined) => { + if (previous == undefined) { + return [] + } else { + return previous.filter((user) => user.UserId != userToRemove) + } + } + + const result = updateFn(previousData) + expect(result).toEqual([ + { UserId: 'user-1', CanEdit: true }, + { UserId: 'user-3', CanEdit: true }, + ]) + expect(result).toHaveLength(2) + }) + + it('should handle removing from empty list', () => { + const userToRemove = 'user-456' + const previousData: PlaylistUserPermissions[] | undefined = undefined + + // Simulate the query update function + const updateFn = (previous: PlaylistUserPermissions[] | undefined) => { + if (previous == undefined) { + return [] + } else { + return previous.filter((user) => user.UserId != userToRemove) + } + } + + const result = updateFn(previousData) + expect(result).toEqual([]) + expect(result).toHaveLength(0) + }) + + it('should handle removing non-existent user gracefully', () => { + const userToRemove = 'user-nonexistent' + const previousData: PlaylistUserPermissions[] = [ + { UserId: 'user-1', CanEdit: true }, + { UserId: 'user-2', CanEdit: false }, + ] + + // Simulate the query update function + const updateFn = (previous: PlaylistUserPermissions[] | undefined) => { + if (previous == undefined) { + return [] + } else { + return previous.filter((user) => user.UserId != userToRemove) + } + } + + const result = updateFn(previousData) + expect(result).toEqual(previousData) + expect(result).toHaveLength(2) + }) + }) + + describe('Mutation Variables Validation', () => { + it('should use correct CanEdit value from mutation variables', () => { + const variables = { + playlist: mockPlaylist, + user: mockUser, + CanEdit: false, + } + + // Simulate adding with the CanEdit value from variables + const newUser: PlaylistUserPermissions = { + UserId: variables.user.Id, + CanEdit: variables.CanEdit, + } + + expect(newUser.CanEdit).toBe(false) + expect(newUser.UserId).toBe(mockUser.Id) + }) + + it('should maintain user ID from variables in cache', () => { + const variables = { + playlist: mockPlaylist, + user: mockUser, + CanEdit: true, + } + + const newUser: PlaylistUserPermissions = { + UserId: variables.user.Id, + CanEdit: variables.CanEdit, + } + + expect(newUser.UserId).toBe('user-456') + }) + }) +}) diff --git a/src/api/mutations/playlist/utils/playlists.ts b/src/api/mutations/playlist/utils/playlists.ts index 79ab5f90b..e81148cba 100644 --- a/src/api/mutations/playlist/utils/playlists.ts +++ b/src/api/mutations/playlist/utils/playlists.ts @@ -181,6 +181,7 @@ export async function createPlaylist(name: string) { Name: name, IsPublic: false, MediaType: MediaType.Audio, + UserId: user.id, }, }) .then((result) => { diff --git a/src/api/queries/playlist/index.ts b/src/api/queries/playlist/index.ts index cc8489dbc..a1e790fdb 100644 --- a/src/api/queries/playlist/index.ts +++ b/src/api/queries/playlist/index.ts @@ -1,10 +1,19 @@ -import { PlaylistTracksQueryKey, PublicPlaylistsQueryKey, UserPlaylistsQueryKey } from './keys' -import { useInfiniteQuery } from '@tanstack/react-query' +import { + PlaylistTracksQueryKey, + PlaylistUsersQueryKey, + PublicPlaylistsQueryKey, + UserPlaylistsQueryKey, +} from './keys' +import { useInfiniteQuery, useMutation, useQuery } from '@tanstack/react-query' import { fetchUserPlaylists, fetchPublicPlaylists, fetchPlaylistTracks } from './utils' import { ApiLimits } from '../../../configs/query.config' +import { BaseItemDto, PlaylistUserPermissions, UserDto } from '@jellyfin/sdk/lib/generated-client' import { getApi, getUser } from '../../../stores/auth/utils' -import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client' import { usePlaylistLibrary } from '../libraries' +import { addPlaylistUser, getPlaylistUsers, removePlaylistUser } from './utils/users' +import { ONE_MINUTE, queryClient } from '../../../constants/query-client' +import { triggerHaptic } from '../../../hooks/use-haptic-feedback' +import Toast from 'react-native-toast-message' export const useUserPlaylists = () => { const api = getApi() @@ -55,3 +64,77 @@ export const usePublicPlaylists = () => { initialPageParam: 0, }) } + +//hooks - used in react components +//invoke user functions (getPlaylistUsers, etc) +//following react convention +export const usePlaylistUsers = (playlist: BaseItemDto) => { + return useQuery({ + queryKey: PlaylistUsersQueryKey(playlist), + queryFn: () => getPlaylistUsers(playlist.Id!), + staleTime: ONE_MINUTE * 15, //refreshes every 15mins + }) +} + +interface addPlaylistUserMutation { + playlist: BaseItemDto + user: UserDto + CanEdit: boolean +} + +//mutations not queries for add/remove +//no params +export const useAddPlaylistUser = () => { + return useMutation({ + //playlistId: string, userId: string, CanEdit: boolean + mutationFn: (variables: addPlaylistUserMutation) => + addPlaylistUser(variables.playlist.Id!, variables.user.Id!, variables.CanEdit), + + onSuccess: (data, variables) => { + triggerHaptic('notificationSuccess') + queryClient.setQueryData( + PlaylistUsersQueryKey(variables.playlist), + (previous: PlaylistUserPermissions[] | undefined) => { + if (previous == undefined) { + //return + return [{ UserId: variables.user.Id, CanEdit: true }] + } else { + return [...previous, { UserId: variables.user.Id, CanEdit: true }] + } + }, + ) + }, + + onError: (error, variables) => { + console.log(error) + Toast.show({ type: 'error', text1: 'Unable to add user to playlist.' }) + }, + }) +} + +interface removePlaylistUser { + playlist: BaseItemDto + user: UserDto +} + +//remove user as playlist collaborator +export const useRemovePlaylistUser = () => { + return useMutation({ + mutationFn: (variables: removePlaylistUser) => + removePlaylistUser(variables.playlist.Id!, variables.user.Id!), + onSuccess: (data, variables) => { + triggerHaptic('notificationSuccess') + queryClient.setQueryData( + PlaylistUsersQueryKey(variables.playlist), + (previous: PlaylistUserPermissions[] | undefined) => { + if (previous == undefined) { + //return + return [] + } else { + return previous.filter((user) => user.UserId != variables.user.Id) + } + }, + ) + }, + }) +} diff --git a/src/api/queries/playlist/keys.ts b/src/api/queries/playlist/keys.ts index 542d948c8..8a14f8867 100644 --- a/src/api/queries/playlist/keys.ts +++ b/src/api/queries/playlist/keys.ts @@ -5,6 +5,7 @@ import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client' enum PlaylistQueryKeys { UserPlaylists, PublicPlaylists, + PlaylistUsers, } export const UserPlaylistsQueryKey = ( @@ -22,3 +23,8 @@ export const PublicPlaylistsQueryKey = (library: BaseItemDto | undefined) => [ PlaylistQueryKeys.PublicPlaylists, library?.Id, ] + +export const PlaylistUsersQueryKey = (playlist: BaseItemDto) => [ + PlaylistQueryKeys.PlaylistUsers, + playlist.Id, +] diff --git a/src/api/queries/playlist/utils/users.ts b/src/api/queries/playlist/utils/users.ts new file mode 100644 index 000000000..3ff7bbfa6 --- /dev/null +++ b/src/api/queries/playlist/utils/users.ts @@ -0,0 +1,50 @@ +//playlist id + +import { getApi } from '../../../../stores/auth/utils' +import { getPlaylistsApi } from '@jellyfin/sdk/lib/utils/api' + +//get playlist users +export async function getPlaylistUsers(playlistId: string) { + //use api + const api = getApi() + + if (!api) { + throw new Error('API Instance not set') + } + + const playlist = getPlaylistsApi(api) + + return (await playlist.getPlaylistUsers({ playlistId })).data +} + +//also need user id for add and remove user functions + +export async function addPlaylistUser(playlistId: string, userId: string, CanEdit: boolean) { + //use api + const api = getApi() + const playlist = getPlaylistsApi(api!) + + //use dto + return await playlist.updatePlaylist({ + playlistId, + updatePlaylistDto: { + Users: [ + { + UserId: userId, + CanEdit, + }, + ], + }, + }) +} + +export async function removePlaylistUser(playlistId: string, userId: string) { + //use api + const api = getApi() + const playlist = getPlaylistsApi(api!) + + return await playlist.removeUserFromPlaylist({ + playlistId, + userId, + }) +} diff --git a/src/api/queries/users/index.ts b/src/api/queries/users/index.ts new file mode 100644 index 000000000..86fa81833 --- /dev/null +++ b/src/api/queries/users/index.ts @@ -0,0 +1,30 @@ +import { useQuery } from '@tanstack/react-query' +import { UserQueryKey } from './keys' +import { getApi, getUser } from '../../../stores/auth/utils' +import { getUserApi } from '@jellyfin/sdk/lib/utils/api' + +//hook to get users on server +export const useUsers = () => { + //using a query to call fetchUsers for server (not playlist) + return useQuery({ queryKey: UserQueryKey, queryFn: fetchUsers }) +} + +//function to call get user API (jellyfin), no export because it's only used here +const fetchUsers = async () => { + //use api (only get api when this function is called to get users) + const api = getApi() + + //get owner of playlist (self) + const owner = getUser() + + //check set + if (!api) { + throw new Error('API Instance not set') + } + + const usersResponse = await getUserApi(api).getUsers() + + //return users where there isn't a user with owner id in array + //return users from api + return usersResponse.data.filter((user) => user.Id != owner?.id) +} diff --git a/src/api/queries/users/keys.ts b/src/api/queries/users/keys.ts new file mode 100644 index 000000000..4ccf18fc2 --- /dev/null +++ b/src/api/queries/users/keys.ts @@ -0,0 +1,2 @@ +//key to get users (array of one string) on server +export const UserQueryKey = ['Users'] diff --git a/src/components/Playlist/index.tsx b/src/components/Playlist/index.tsx index 23dd1b659..d16eaa274 100644 --- a/src/components/Playlist/index.tsx +++ b/src/components/Playlist/index.tsx @@ -177,6 +177,16 @@ export default function Playlist({ playlist, canEdit }: PlaylistProps): React.JS navigation.setOptions({ headerRight: () => ( + {playlist.CanDelete && ( + + navigationRef.dispatch( + StackActions.push('AddPlaylistUsers', { playlist }), + ) + } + /> + )} {playlistTracks && !editing && downloadActions} {canEdit && ( diff --git a/src/screens/Library/add-playlist-users.tsx b/src/screens/Library/add-playlist-users.tsx new file mode 100644 index 000000000..aa8c1d1ca --- /dev/null +++ b/src/screens/Library/add-playlist-users.tsx @@ -0,0 +1,136 @@ +import { Paragraph, View, XStack } from 'tamagui' +import { useState } from 'react' +import Input from '../../components/Global/helpers/input' +import { + useAddPlaylistUser, + usePlaylistUsers, + useRemovePlaylistUser, +} from '../../../src/api/queries/playlist' +import { useUsers } from '../../../src/api/queries/users' +import { SectionList } from 'react-native' +import Icon from '../../../src/components/Global/components/icon' +import TurboImage from 'react-native-turbo-image' +import getUserImageUrl from '../../utils/images/users' +import { AddPlaylistUsersProps } from '../types' +import { useSafeAreaInsets } from 'react-native-safe-area-context' + +//screen in react native +export default function addPlaylistUsers({ + navigation, + route, +}: AddPlaylistUsersProps): React.JSX.Element { + const [searchQuery, setSearchQuery] = useState('') + const { playlist } = route.params + const { + data: playlistUsers, + isPending: playlistUserIsPending, + refetch: refetchPlaylistUser, + } = usePlaylistUsers(playlist) //make this playlist an easy access variable (with const variable above) + const { data: users, isPending: useUsersIsPending, refetch: refetchUseUsers } = useUsers() + const { bottom } = useSafeAreaInsets() + + //filter users based on search query + const filterUsersBySearch = (userList: typeof users) => { + if (!searchQuery.trim()) return userList ?? [] + return ( + userList?.filter((user) => + user.Name?.toLowerCase().includes(searchQuery.toLowerCase()), + ) ?? [] + ) + } + + //invoke mutations on icon press + //add + const addUser = useAddPlaylistUser() + //remove + const removeUser = useRemovePlaylistUser() + + //get string array of all playlist user IDs + const playlistUserIds = playlistUsers?.map((playlistUser) => playlistUser.UserId) ?? [] + + //if user exists in playlist already, do not display + //take all users, filter any users that also appear in playlistUserIds + const otherUsers = users?.filter((user) => !playlistUserIds?.includes(user.Id)) ?? [] + + //any user not included in listed users will get filtered out + const usersInPlaylist = users?.filter((user) => playlistUserIds?.includes(user.Id)) ?? [] + + //apply search filter + const filteredOtherUsers = filterUsersBySearch(otherUsers) + const filteredInPlaylistUsers = filterUsersBySearch(usersInPlaylist) + + //use formatting for sections component later on + const playlistUserData = [ + { + title: 'Shared With', + data: filteredInPlaylistUsers, + }, + { + title: 'Users on Server', + data: filteredOtherUsers, + }, + ] + + //return component here + return ( + //return view that occupies full screen + + {/* search bar */} + + + + + {/* conditional in react - only render if some variable meet criteria */} + { + //list of users and section list + {info.section.title} } + renderItem={({ item: user }) => ( + + + + + {user.Name ?? 'Unknown User'} + + {playlistUserIds.includes(user.Id) ? ( //send playlist id and user id (with bang! because it likely won't be undefined) + + removeUser.mutate({ playlist: playlist, user: user }) + } + name='account-remove' + color='$warning' + /> + ) : ( + //same stuff and canEdit as true bcs you know anyone you're sharing with + + addUser.mutate({ + playlist: playlist, + user: user, + CanEdit: true, + }) + } + name='account-plus' + color='$borderColor' + /> + )} + + )} + keyExtractor={(item) => item.Id!} + /> + } + + ) +} diff --git a/src/screens/index.tsx b/src/screens/index.tsx index fb086cd59..ae2847032 100644 --- a/src/screens/index.tsx +++ b/src/screens/index.tsx @@ -1,6 +1,6 @@ import Tabs from './Tabs' import { RootStackParamList } from './types' -import { Paragraph, YStack } from 'tamagui' +import { Paragraph, useTheme, XStack, YStack } from 'tamagui' import { createNativeStackNavigator } from '@react-navigation/native-stack' import Context from './Context' import { getItemName } from '../utils/formatting/item-names' @@ -13,9 +13,12 @@ import { getApi, getLibrary } from '../stores/auth/utils' import DeletePlaylist from './Library/delete-playlist' import { formatArtistNames } from '../utils/formatting/artist-names' import MigrateDownloadsScreen from './MigrateDownloads' +import addPlaylistUsers from './Library/add-playlist-users' +import ItemImage from '../components/Global/components/image' import { addToPlaylistSheetPresentation, bottomSheetPresentation, + canUseFormSheet, playerSheetPresentation, } from '../utils/navigating/form-sheet' import { createStaticNavigation } from '@react-navigation/native' @@ -104,6 +107,16 @@ const RootStack = createNativeStackNavigator({ headerShown: false, }, }, + AddPlaylistUsers: { + screen: addPlaylistUsers, + options: ({ route }) => ({ + title: 'Add Playlist Users', + presentation: bottomSheetPresentation, + sheetAllowedDetents: canUseFormSheet ? [1.0] : undefined, + sheetGrabberVisible: true, + header: () => addPlaylistUsersHeader(route.params.playlist), + }), + }, }, }) @@ -140,3 +153,29 @@ function ContextSheetHeader(item: BaseItemDto): React.JSX.Element { ) } + +function addPlaylistUsersHeader(playlist: BaseItemDto): React.JSX.Element { + return ( + + + Add Users to Playlist + + + + + + + + {getItemName(playlist)} + + + + + + ) +} diff --git a/src/screens/types.d.ts b/src/screens/types.d.ts index d38a17c08..74bc710a3 100644 --- a/src/screens/types.d.ts +++ b/src/screens/types.d.ts @@ -83,6 +83,10 @@ export type RootStackParamList = { } MigrateDownloads: undefined + + AddPlaylistUsers: { + playlist: BaseItemDto + } } export type LoginProps = NativeStackNavigationProp @@ -93,3 +97,17 @@ export type AddToPlaylistProps = NativeStackScreenProps export type MigrateDownloadsProps = NativeStackScreenProps +export type FiltersProps = NativeStackScreenProps +export type SortOptionsProps = NativeStackScreenProps +export type GenreSelectionProps = NativeStackScreenProps +export type YearSelectionProps = NativeStackScreenProps + +export type GenresProps = { + genres: InfiniteData | undefined + fetchNextPage: (options?: FetchNextPageOptions | undefined) => void + hasNextPage: boolean + isPending: boolean + isFetchingNextPage: boolean +} + +export type AddPlaylistUsersProps = NativeStackScreenProps diff --git a/src/utils/images/users.test.ts b/src/utils/images/users.test.ts new file mode 100644 index 000000000..3dba1c6d8 --- /dev/null +++ b/src/utils/images/users.test.ts @@ -0,0 +1,64 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import getUserImageUrl from './users' +import { getApi } from '../../stores/auth/utils' +import { getImageApi } from '@jellyfin/sdk/lib/utils/api' +import { UserDto } from '@jellyfin/sdk/lib/generated-client' +import { Api } from '@jellyfin/sdk' + +jest.mock('../../stores') +jest.mock('@jellyfin/sdk/lib/utils/api') + +const mockGetApi = getApi as jest.MockedFunction +const mockGetImageApi = getImageApi as jest.MockedFunction + +describe('getUserImageUrl', () => { + let mockUser: UserDto + + beforeEach(() => { + mockUser = { Id: 'test-user-id' } as UserDto + jest.clearAllMocks() + }) + + it('should return an empty string when getApi returns null', () => { + mockGetApi.mockReturnValue(null as any) + + const result = getUserImageUrl(mockUser) + + expect(result).toBe('') + expect(mockGetApi).toHaveBeenCalled() + expect(mockGetImageApi).not.toHaveBeenCalled() + }) + + it('should return the image URL when getApi returns a valid api and getUserImageUrl returns a URL', () => { + const mockApi = {} as Api + const mockImageApi = { + getUserImageUrl: jest.fn().mockReturnValue('http://example.com/user-image.jpg'), + } as any + mockGetApi.mockReturnValue(mockApi) + mockGetImageApi.mockReturnValue(mockImageApi) + + const result = getUserImageUrl(mockUser) + + expect(result).toBe('http://example.com/user-image.jpg') + expect(mockGetApi).toHaveBeenCalled() + expect(mockGetImageApi).toHaveBeenCalledWith(mockApi) + expect(mockImageApi.getUserImageUrl).toHaveBeenCalledWith({ Id: mockUser.Id }) + }) + + it('should return an empty string when getUserImageUrl returns null', () => { + const mockApi = {} as Api + const mockImageApi = { + getUserImageUrl: jest.fn().mockReturnValue(null), + } as any + mockGetApi.mockReturnValue(mockApi) + mockGetImageApi.mockReturnValue(mockImageApi) + + const result = getUserImageUrl(mockUser) + + expect(result).toBe('') + expect(mockGetApi).toHaveBeenCalled() + expect(mockGetImageApi).toHaveBeenCalledWith(mockApi) + expect(mockImageApi.getUserImageUrl).toHaveBeenCalledWith({ Id: mockUser.Id }) + }) +}) diff --git a/src/utils/images/users.ts b/src/utils/images/users.ts new file mode 100644 index 000000000..bebb2ac1d --- /dev/null +++ b/src/utils/images/users.ts @@ -0,0 +1,13 @@ +import { getApi } from '../../stores/auth/utils' +import { UserDto } from '@jellyfin/sdk/lib/generated-client' +import { getImageApi } from '@jellyfin/sdk/lib/utils/api' + +export default function getUserImageUrl(user: UserDto): string { + const api = getApi() + + if (!api) return '' + + const imageApi = getImageApi(api) + + return imageApi.getUserImageUrl({ Id: user.Id }) ?? '' +}