mirror of
https://github.com/jeffvli/feishin.git
synced 2024-11-20 06:27:09 +01:00
Add user activity
This commit is contained in:
parent
e76267be06
commit
f9c5e6f9fb
@ -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}`));
|
||||
|
@ -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 },
|
||||
});
|
||||
});
|
||||
};
|
||||
|
@ -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>
|
||||
|
178
src/renderer/features/users/components/user-activity-item.tsx
Normal file
178
src/renderer/features/users/components/user-activity-item.tsx
Normal 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>
|
||||
);
|
||||
};
|
268
src/renderer/features/users/components/user-activity.tsx
Normal file
268
src/renderer/features/users/components/user-activity.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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';
|
||||
|
Loading…
Reference in New Issue
Block a user