Add user activity

This commit is contained in:
jeffvli 2022-11-15 13:35:26 -08:00
parent e76267be06
commit f9c5e6f9fb
6 changed files with 514 additions and 13 deletions

View File

@ -53,6 +53,6 @@ const io = new socketio.Server(server, {
});
app.set('socketio', io);
io.on('connection', (socket) => sockets(socket));
io.on('connection', (socket) => sockets(socket, io));
server.listen(9321, () => console.log(`Listening on port ${PORT}`));

View File

@ -1,15 +1,51 @@
import { Socket } from 'socket.io';
import { Socket, Server } from 'socket.io';
export const sockets = (socket: Socket) => {
socket.broadcast.emit('user:connected', {
userID: socket.id,
username: socket.handshake.query.username,
export const sockets = (socket: Socket, io: Server) => {
socket.on('join', function (data) {
socket.join(data.id); // We are using room of socket io
});
socket.on('disconnect', () => {
socket.broadcast.emit('user:disconnected', {
userID: socket.id,
username: socket.handshake.query.username,
socket.broadcast.emit('user:receive:connect', {
socketId: socket.id,
userId: socket.handshake.query.id,
userName: socket.handshake.query.username,
});
socket.on('disconnect', async () => {
socket.broadcast.emit('user:receive:disconnect', {
socketId: socket.id,
userId: socket.handshake.query.id,
userName: socket.handshake.query.username,
});
});
socket.on('user:send:get_online', async (data) => {
const sockets = await io.fetchSockets();
const onlineSockets = sockets?.map((s) => s.handshake.query.id) || [];
io.sockets
.in(data?.userId)
.emit('user:receive:get_online', { online: onlineSockets });
});
socket.on('user:send:change_song', async (data) => {
socket.broadcast.emit('user:receive:change_song', {
...data,
user: { ...data.user, socketId: socket.id },
});
});
socket.on('user:send:status_idle', async (data) => {
socket.broadcast.emit('user:receive:status_idle', {
status: 'idle',
user: { ...data.user, socketId: socket.id },
});
});
socket.on('user:send:status_playing', async (data) => {
socket.broadcast.emit('user:receive:status_playing', {
status: 'playing',
user: { ...data.user, socketId: socket.id },
});
});
};

View File

@ -14,11 +14,13 @@ import {
RiMusicLine,
RiPlayListLine,
RiSearchLine,
RiUser3Line,
RiUserVoiceLine,
} from 'react-icons/ri';
import { useNavigate, Link } from 'react-router-dom';
import styled from 'styled-components';
import { Button, TextInput } from '@/renderer/components';
import { UserActivity } from '@/renderer/features/users';
import { AppRoute } from '@/renderer/router/routes';
import { useAppStore, usePlayerStore } from '@/renderer/store';
import { fadeIn } from '@/renderer/styles';
@ -119,8 +121,10 @@ export const Sidebar = () => {
</SidebarItem.Link>
</SidebarItem>
<Accordion
disableChevronRotation
multiple
styles={{
item: { borderBottom: 'none' },
}}
value={sidebar.expanded}
onChange={(e) => setSidebar({ expanded: e })}
>
@ -161,7 +165,7 @@ export const Sidebar = () => {
<Accordion.Item value="collections">
<Accordion.Control disabled p="1rem">
<Group>
<RiPlayListLine size={20} />
<RiPlayListLine size={15} />
Collections
</Group>
</Accordion.Control>
@ -170,12 +174,25 @@ export const Sidebar = () => {
<Accordion.Item value="playlists">
<Accordion.Control disabled p="1rem">
<Group>
<RiPlayListLine size={20} />
<RiPlayListLine size={15} />
Playlists
</Group>
</Accordion.Control>
<Accordion.Panel />
</Accordion.Item>
<Accordion.Item value="activity">
<Accordion.Control p="1rem">
<Group>
<RiUser3Line size={15} />
User Activity
</Group>
</Accordion.Control>
<Accordion.Panel>
<div>
<UserActivity />
</div>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Stack>
</Stack>

View File

@ -0,0 +1,178 @@
import React from 'react';
import { Avatar, Group, Indicator, Stack } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { motion } from 'framer-motion';
import { IoIosPause } from 'react-icons/io';
import { RiPlayFill, RiServerLine, RiUserLine } from 'react-icons/ri';
import { generatePath } from 'react-router';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import { User } from '@/renderer/api/types';
import { Popover, Text } from '@/renderer/components';
import { useServerMap } from '@/renderer/features/servers';
import { AppRoute } from '@/renderer/router/routes';
import { titleCase } from '../../../utils/title-case';
export type Activity = {
socketId?: string;
song?: {
album?: string;
albumArtists: { id: string; name: string }[];
albumId?: string;
id: string;
name: string;
serverId: string;
};
status?: 'playing' | 'idle' | 'offline';
};
export type UserWithActivity = User & {
activity?: Activity;
avatarUrl?: string;
};
interface UserActivityItemProps {
user: UserWithActivity;
}
const ActivityContainer = styled(motion.div)`
padding: 0.5rem;
`;
const ItemGrid = styled.div`
display: grid;
grid-auto-columns: 1fr;
grid-template-areas: 'image info';
grid-template-rows: 1fr;
grid-template-columns: 50px minmax(0, 1fr);
`;
const ItemImageContainer = styled.div`
display: flex;
grid-area: image;
align-items: center;
`;
const ItemInfoContainer = styled.div`
grid-area: info;
`;
export const UserActivityItem = ({ user }: UserActivityItemProps) => {
const { data: serverMap } = useServerMap();
const [opened, { close, open }] = useDisclosure(false);
const displayedName = user?.displayName
? `${user.displayName} (${user.username})`
: user.username;
const songName = user?.activity?.song?.name;
const songId = user?.activity?.song?.id;
const albumId = user?.activity?.song?.albumId;
const albumName = user?.activity?.song?.album;
const serverId = user?.activity?.song?.serverId;
const status = user?.activity?.status;
const albumArtists = user?.activity?.song?.albumArtists;
console.log('serverMap', serverMap);
console.log('serverId', serverId);
return (
<ActivityContainer>
<ItemGrid>
<ItemImageContainer>
<Popover opened={opened} position="top-start">
<Popover.Target>
<Indicator
color="green"
offset={5}
position="bottom-end"
onMouseEnter={open}
onMouseLeave={close}
>
<Avatar radius="xl" size={40} src={user?.avatarUrl} />
</Indicator>
</Popover.Target>
<Popover.Dropdown>
<Stack spacing={5}>
<Group />
{serverId && <Group />}
</Stack>
<Stack spacing={5}>
<Group>
<RiUserLine /> {displayedName}
</Group>
{serverId && (
<Group>
<RiServerLine /> {serverMap?.data[serverId]?.name} (
{titleCase(serverMap?.data[serverId]?.type || '')})
</Group>
)}
</Stack>
</Popover.Dropdown>
</Popover>
</ItemImageContainer>
<ItemInfoContainer>
<Group noWrap position="apart">
<Stack spacing={0} sx={{ lineHeight: 1, maxWidth: '80%' }}>
<Text overflow="hidden" size="sm">
{songId ? songName : 'Idle...'}
</Text>
<Text $secondary overflow="hidden" size="xs">
{albumArtists?.length ? (
albumArtists.map((artist, index) => (
<React.Fragment
key={`activity-${user.id}-artist-${artist.id}`}
>
{index > 0 ? ', ' : null}
<Text
$link
$secondary
component={Link}
overflow="hidden"
size="xs"
sx={{ width: 'fit-content' }}
to={generatePath(AppRoute.LIBRARY_ALBUMARTISTS_DETAIL, {
albumArtistId: artist.id,
})}
>
{artist.name}
</Text>
</React.Fragment>
))
) : (
<Text $secondary></Text>
)}
</Text>
{albumId ? (
<Text
$link
$secondary
component={Link}
overflow="hidden"
size="xs"
sx={{ width: 'fit-content' }}
to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
albumId,
})}
>
{albumName}
</Text>
) : (
<Text $secondary overflow="hidden" size="xs">
</Text>
)}
</Stack>
<Group>
{status === 'playing' ? (
<RiPlayFill color="var(--main-fg)" size={15} />
) : (
<IoIosPause color="var(--main-fg)" size={15} />
)}
</Group>
</Group>
</ItemInfoContainer>
</ItemGrid>
</ActivityContainer>
);
};

View File

@ -0,0 +1,268 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { motion } from 'framer-motion';
import merge from 'lodash/merge';
import sortBy from 'lodash/sortBy';
import styled from 'styled-components';
import { socket } from '@/renderer/api';
import { UserListResponse } from '@/renderer/api/users.api';
import {
Activity,
UserActivityItem,
UserWithActivity,
} from '@/renderer/features/users/components/user-activity-item';
import { useUserList } from '@/renderer/features/users/queries/get-user-list';
import { useAuthStore, usePlayerStore } from '@/renderer/store';
import { PlayerStatus } from '@/renderer/types';
const UserActivityContainer = styled(motion.div)`
min-height: 10rem;
overflow-x: hidden;
`;
type UserConnectionEvent = {
online?: string[];
socketId: string;
userId: string;
userName: string;
};
type SongChangeEvent = {
song: Activity['song'];
user: UserConnectionEvent;
};
type CheckOnlineEvent = {
online: string[];
};
type PlayStatusChangeEvent = {
status: Activity['status'];
user: UserConnectionEvent;
};
const sortByName = (users: UserWithActivity[]) => {
return sortBy(users, [
(user) => user.displayName?.toLowerCase(),
(user) => user.username.toLowerCase(),
]);
};
export const UserActivity = () => {
const currentSong = usePlayerStore((state) => state.current.song);
const currentUser = useAuthStore((state) => state.permissions);
const playStatus = usePlayerStore((state) => state.current.status);
const [activityList, setActivityList] = useState<UserWithActivity[]>([]);
const userDetails = useMemo(
() => ({ userId: currentUser?.id, userName: currentUser?.username }),
[currentUser?.id, currentUser?.username]
);
useUserList({
onSuccess: async (data: UserListResponse) => {
const userList = data.data.filter((user) => user.id !== currentUser?.id);
setActivityList((prev) => {
const newList = userList.map((user) => {
const existingUser = prev.find((u) => u.id === user.id);
return merge({}, existingUser, user);
});
return sortByName(newList);
});
if (userDetails) {
socket.emit('user:send:get_online', userDetails);
}
},
staleTime: 0,
});
const handleGetOnlineUsers = useCallback((data: CheckOnlineEvent) => {
setActivityList((prev) => {
const updatedUsers = prev.map((user) => {
if (data.online.includes(user.id)) {
return {
...user,
activity: {
...user.activity,
status: 'idle' as Activity['status'],
},
};
}
return {
...user,
activity: {
...user.activity,
status: 'offline' as Activity['status'],
},
};
});
return sortByName(updatedUsers);
});
}, []);
const handleUserConnect = useCallback((data: UserConnectionEvent) => {
setActivityList((prev) => {
const user = prev.find((user) => user.id === data.userId);
if (!user) return prev;
return sortByName([
...prev.filter((user) => user.id !== data.userId),
{
...user,
activity: {
...user?.activity,
socketId: data.socketId,
status: 'idle',
},
},
]);
});
}, []);
const handleUserDisconnect = useCallback((data: UserConnectionEvent) => {
setActivityList((prev) => {
const user = prev.find((user) => user.id === data.userId);
if (!user) return prev;
return sortByName([
...prev.filter((user) => user.id !== data.userId),
{
...user,
activity: {
...user?.activity,
socketId: undefined,
song: undefined,
status: 'offline',
},
},
]);
});
}, []);
const handleUserSongChange = useCallback((data: SongChangeEvent) => {
setActivityList((prev) => {
const user = prev.find((user) => user.id === data.user.userId);
if (!user) return prev;
const shouldUpdateStatus =
!user?.activity?.status || user?.activity?.status === 'offline';
return sortByName([
...prev.filter((user) => user.id !== data.user.userId),
{
...user,
activity: {
...user.activity,
socketId: data.user.socketId,
song: data.song,
status: shouldUpdateStatus ? 'playing' : user?.activity?.status,
},
},
]);
});
}, []);
const handleUserStatusChange = useCallback((data: PlayStatusChangeEvent) => {
console.log('data', data);
setActivityList((prev) => {
const user = prev.find((user) => user.id === data.user.userId);
if (!user) return prev;
console.log('data.status', data.status);
return sortByName([
...prev.filter((user) => user.id !== data.user.userId),
{
...user,
activity: {
...user.activity,
socketId: data.user.socketId,
status: data.status,
},
},
]);
});
}, []);
useEffect(() => {
if (currentSong) {
const currentSongDetails: Activity['song'] = {
album: currentSong?.album?.name,
albumArtists: currentSong?.album?.albumArtists.map((artist) => ({
id: artist.id,
name: artist.name,
})),
albumId: currentSong?.album?.id,
id: currentSong?.id,
name: currentSong?.name,
serverId: currentSong?.serverId,
};
socket.emit('user:send:change_song', {
song: currentSongDetails,
user: userDetails,
});
}
}, [currentSong, playStatus, userDetails]);
useEffect(() => {
if (playStatus === PlayerStatus.PAUSED) {
socket.emit('user:send:status_idle', { user: userDetails });
} else {
socket.emit('user:send:status_playing', { user: userDetails });
}
}, [playStatus, userDetails]);
useEffect(() => {
socket.on('user:receive:connect', (data: UserConnectionEvent) => {
handleUserConnect(data);
});
socket.on('user:receive:disconnect', (data: UserConnectionEvent) => {
handleUserDisconnect(data);
});
socket.on('user:receive:change_song', (data: SongChangeEvent) => {
handleUserSongChange(data);
});
socket.on('user:receive:status_idle', (data: PlayStatusChangeEvent) => {
handleUserStatusChange(data);
});
socket.on('user:receive:status_playing', (data: PlayStatusChangeEvent) => {
handleUserStatusChange(data);
});
socket.on('user:receive:get_online', (data: CheckOnlineEvent) => {
handleGetOnlineUsers(data);
});
return () => {
socket.off('user:receive:connect');
socket.off('user:recieve:disconnect');
socket.off('user:receive:change_song');
socket.off('user:receive:status_idle');
socket.off('user:receive:status_playing');
socket.off('user:receive:get_online');
};
}, [
handleGetOnlineUsers,
handleUserConnect,
handleUserDisconnect,
handleUserSongChange,
handleUserStatusChange,
]);
return (
<UserActivityContainer>
{activityList
.filter(
(user) => user.activity?.status && user.activity?.status !== 'offline'
)
.map((user) => (
<UserActivityItem key={`activity-${user.id}`} user={user} />
))}
</UserActivityContainer>
);
};

View File

@ -3,5 +3,7 @@ export * from './mutations/delete-user';
export * from './mutations/update-user';
export * from './components/add-user-form';
export * from './components/user-list';
export * from './components/user-activity';
export * from './components/user-activity-item';
export * from './queries/get-user-detail';
export * from './queries/get-user-list';