diff --git a/src/api/queries/playlist/index.ts b/src/api/queries/playlist/index.ts index 109b25201..b9e6597a4 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 { getApi, getUser } from '../../../stores' -import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client' +import { BaseItemDto, PlaylistUserPermissions, UserDto } 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..8e59f5330 --- /dev/null +++ b/src/api/queries/playlist/utils/users.ts @@ -0,0 +1,50 @@ +//playlist id + +import { getApi, getUser } from '../../../../stores' +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..642b21728 --- /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' +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 543adff44..9200eb614 100644 --- a/src/components/Playlist/index.tsx +++ b/src/components/Playlist/index.tsx @@ -2,7 +2,7 @@ import { ScrollView, Spinner, useTheme, XStack, YStack } from 'tamagui' import Track from '../Global/components/Track' import Icon from '../Global/components/icon' import { PlaylistProps } from './interfaces' -import { StackActions, useNavigation } from '@react-navigation/native' +import { CommonActions, StackActions, useNavigation } from '@react-navigation/native' import { RootStackParamList } from '../../screens/types' import { NativeStackNavigationProp } from '@react-navigation/native-stack' import Sortable from 'react-native-sortables' @@ -31,6 +31,7 @@ import { Text } from '../Global/helpers/text' import { RefreshControl } from 'react-native' import { queryClient } from '../../constants/query-client' import { PlaylistTracksQueryKey } from '../../api/queries/playlist/keys' +import { addPlaylistUser } from '../../api/queries/playlist/utils/users' import { useIsDownloaded } from '../../hooks/downloads' import useDownloadTracks, { useDeleteDownloads } from '../../hooks/downloads/mutations' import { loadNewQueue } from '../../hooks/player/functions/queue' @@ -220,6 +221,14 @@ export default function Playlist({ navigation.setOptions({ headerRight: () => ( + + 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..83b6fd81e --- /dev/null +++ b/src/screens/Library/add-playlist-users.tsx @@ -0,0 +1,134 @@ +import { Paragraph, View, XStack, YStack } from 'tamagui' +import { + useAddPlaylistUser, + usePlaylistUsers, + useRemovePlaylistUser, +} from '../../../src/api/queries/playlist' +import { useUsers } from '../../../src/api/queries/users' +import ItemImage from '../../../src/components/Global/components/image' +import TextTicker from 'react-native-text-ticker' +import { TextTickerConfig } from '../../../src/components/Player/component.config' +import { getItemName } from '../../../src/utils/formatting/item-names' +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' + +//screen in react native +export default function addPlaylistUsers({ + navigation, + route, +}: AddPlaylistUsersProps): React.JSX.Element { + 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() + + //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)) ?? [] + + //use formatting for sections component later on + const playlistUserData = [ + { + title: 'Shared With', + data: usersInPlaylist, + }, + { + title: 'Users on Server', + data: otherUsers, + }, + ] + + //return component here + return ( + //return view that occupies full screen + + { + //no conditional statement here (have to have a playlist to see this view anyways) + + + + + + + {getItemName(playlist)} + + + + {/* + + {`${(source ?? tracks[0])!.ArtistItems?.map((artist) => getItemName(artist)).join(', ')}`} + + */} + + + } + + {/* conditional in react - only render if some variable meet criteria */} + { + //list of users and section list + ( + + + + + {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/Library/index.tsx b/src/screens/Library/index.tsx index b3ae43d2c..19455c88a 100644 --- a/src/screens/Library/index.tsx +++ b/src/screens/Library/index.tsx @@ -11,6 +11,7 @@ import InstantMix from '../../components/InstantMix/component' import { getItemName } from '../../utils/formatting/item-names' import { Platform } from 'react-native' import TracksScreen from '../Tracks' +import addPlaylistUsers from './add-playlist-users' const LibraryStack = createNativeStackNavigator() diff --git a/src/screens/index.tsx b/src/screens/index.tsx index 64ebba981..87580bd36 100644 --- a/src/screens/index.tsx +++ b/src/screens/index.tsx @@ -20,6 +20,7 @@ import SortOptionsSheet from './SortOptions' import GenreSelectionScreen from './GenreSelection' import YearSelectionScreen from './YearSelection' import MigrateDownloadsScreen from './MigrateDownloads' +import addPlaylistUsers from './Library/add-playlist-users' const RootStack = createNativeStackNavigator() @@ -154,6 +155,16 @@ export default function Root(): React.JSX.Element { headerShown: false, }} /> + + ) } diff --git a/src/screens/types.d.ts b/src/screens/types.d.ts index 73c8e7797..edd3acc62 100644 --- a/src/screens/types.d.ts +++ b/src/screens/types.d.ts @@ -89,6 +89,10 @@ export type RootStackParamList = { } MigrateDownloads: undefined + + AddPlaylistUsers: { + playlist: BaseItemDto + } } export type LoginProps = NativeStackNavigationProp @@ -112,3 +116,5 @@ export type GenresProps = { 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..e54f94e09 --- /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' +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..5cfb07359 --- /dev/null +++ b/src/utils/images/users.ts @@ -0,0 +1,13 @@ +import { getApi } from '../../stores' +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 }) ?? '' +}