Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
301 changes: 278 additions & 23 deletions src/client/components/UsersInChannelModal.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,27 @@
import React from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import styled from 'styled-components';
import {
CordContext,
Avatar as DefaultAvatar,
presence,
user,
} from '@cord-sdk/react';
import { UserPlusIcon } from '@heroicons/react/24/outline';
import { LockClosedIcon, HashtagIcon } from '@heroicons/react/24/solid';
import type { ClientUserData } from '@cord-sdk/types';
import type { Channel } from 'src/client/consts/Channel';
import { Colors } from 'src/client/consts/Colors';
import { ActiveBadge } from 'src/client/components/ActiveBadge';
import { Name } from 'src/client/components/Name';
import { XIcon } from 'src/client/components/Buttons';
import { useAPIUpdateFetch } from 'src/client/hooks/useAPIFetch';
import { EVERYONE_ORG_ID } from 'src/client/consts/consts';

interface UsersInChannelModalProps {
type UsersInChannelModalProps = {
onClose: () => void;
channel: Channel;
users: ClientUserData[];
}
};

export function UsersInChannelModal({
onClose,
Expand All @@ -27,41 +32,289 @@ export function UsersInChannelModal({
{ page: 'clack' },
{ exclude_durable: true, partial_match: true },
);
const [showAddUsersModal, setShowAddUsersModal] = React.useState(false);

return (
<>
<Modal $order={1}>
<Box>
<Header>
<Heading>
{channel.org ? (
<LockClosedIcon
width={20}
style={{ padding: '1px', marginRight: '2px' }}
/>
) : (
<HashtagIcon width={20} style={{ padding: '1px' }} />
)}
{channel.id}
</Heading>
<CloseButton onClick={onClose}>
<XIcon />
</CloseButton>
</Header>
<UsersList>
{/* Show the Add People modal option if this is a private org
(public channels have an undefined channel.org, and are visible to
everyone in the clack_all org) */}
{channel.org && (
<UserDetails onClick={() => setShowAddUsersModal(true)}>
<AddPeopleIconWrapper>
<UserPlusIcon
width={32}
height={32}
style={{
backgroundColor: '#e8f5fa',
color: 'rgba(18,100,163,1)',
}}
/>
</AddPeopleIconWrapper>
<Name $variant="main">Add people</Name>
</UserDetails>
)}
{users.map((user) => {
const isUserPresent = usersPresent?.some(
(presence) => presence.id === user.id,
);
return (
<UserRow
key={user.id}
org={channel.org}
isUserPresent={!!isUserPresent}
user={user}
/>
);
})}
</UsersList>
</Box>
</Modal>
{showAddUsersModal && (
<AddUsersToChannelModal
channel={channel}
onClose={() => setShowAddUsersModal(false)}
existingUsers={users.map((u) => u.id)}
/>
)}
</>
);
}

function UserRow({
org,
isUserPresent,
user,
}: {
org: string | undefined;
isUserPresent: boolean;
user: ClientUserData;
}) {
const { userID: cordUserID } = React.useContext(CordContext);
const [showDelete, setShowDelete] = useState(false);

const update = useAPIUpdateFetch();

return (
<Modal>
<>
<UserDetails
key={user.id}
onMouseEnter={() => setShowDelete(true)}
onMouseLeave={() => setShowDelete(false)}
>
<Avatar userId={user.id} enableTooltip />
{/* //todo: fill short name values in db console? */}
<Name $variant="main">
{user.shortName || user.name}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe @flooey is adding displayName to the API here? I guess it's not in the npm package yet.

{cordUserID === user.id ? ' (you)' : ''}
</Name>
<ActiveBadge $isActive={isUserPresent} />
<Name $variant="simple">{user?.name}</Name>
{showDelete && org && (
// TODO: the org members API currently doesn't have subscriptions, so
// it looks like nothing's happened in the FE atm
<DeleteButton
onClick={() => {
void update(`/channels/${org}`, 'DELETE', {
userIDs: [user.id],
});
}}
>
Remove
</DeleteButton>
)}
</UserDetails>
</>
);
}

type AddUsersToChannelModalProps = {
onClose: () => void;
channel: Channel;
existingUsers: string[];
};

function AddUsersToChannelModal({
onClose,
existingUsers,
channel,
}: AddUsersToChannelModalProps) {
const {
orgMembers: allOrgMembers,
loading,
hasMore,
fetchMore,
} = user.useOrgMembers({
organizationID: EVERYONE_ORG_ID,
});

useEffect(() => {
if (!loading && hasMore) {
void fetchMore(50);
}
}, [hasMore, loading, fetchMore]);
Comment on lines +170 to +174
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use a PaginationTrigger instead?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm I know I just approved your other PR but I think I'm getting a bit confused here. This code expects to know all the existing users in the channel, so that it can filter them out from a list of clack_all members. I'm not sure how this will work if we can't be sure we loaded all the channel members at the previous step. I think pagination could still work here, although I guess it might be a bit awkward because the org members you load might then get filtered away and so you might not see new results as you'd expect. Although I guess in that case it would load more again?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You mean we need existingUsers to be a complete list, even if allOrgMembers doesn't need to be, right? Yeah I guess if there is one specific org we need everyone loaded in high up, that's fine -- also Clack doesn't need to be the cleanest code anyway :P

I'll hold off on landing #65 until you're all done in here, it's not an urgent one at all.


const addableUsers = useMemo(() => {
return allOrgMembers
.filter((om) => !existingUsers.includes(om.id))
.sort(
(a, b) =>
(a.shortName ?? a.name ?? 'Unknown')?.localeCompare(
b.shortName ?? b.name ?? 'Unknown',
Comment on lines +181 to +182
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another nice case for displayName :)

),
);
}, [allOrgMembers, existingUsers]);

const [usersToAdd, setUsersToAdd] = useState<string[]>([]);

const update = useAPIUpdateFetch();

// TODO: the org members API currently doesn't have subscriptions, so
// it looks like nothing's happened in the FE atm
const addUsers = useCallback(() => {
void update(`/channels/${channel.org}`, 'PUT', {
userIDs: usersToAdd,
}).then(() => onClose());
}, [channel.org, onClose, update, usersToAdd]);

return (
// This is a modal stacked on top of another modal
<Modal $order={2}>
<Box>
<Header>
<Heading># {channel.id}</Heading>
<Heading>
Add people to{' '}
{channel.org ? (
<LockClosedIcon
width={20}
style={{ padding: '1px', marginRight: '2px' }}
/>
) : (
<HashtagIcon width={20} style={{ padding: '1px' }} />
)}{' '}
{channel.id}
</Heading>
<CloseButton onClick={onClose}>
<XIcon />
</CloseButton>
</Header>

<UsersList>
{users.map((user) => {
const isUserPresent = usersPresent?.some(
(presence) => presence.id === user.id,
);
{addableUsers.map((user) => {
return (
<UserDetails key={user.id}>
<Avatar userId={user.id} enableTooltip />
{/* //todo: fill short name values in db console? */}
<Name $variant="main">
{user.shortName || user.name}
{cordUserID === user.id ? ' (you)' : ''}
</Name>
<ActiveBadge $isActive={!!isUserPresent} />
<Name $variant="simple">{user?.name}</Name>
</UserDetails>
<Label key={user.id}>
<UserDetails>
<Checkbox
id={`add-${user.id}`}
type="checkbox"
value={user.id}
onChange={(e) => {
if (e.target.checked) {
setUsersToAdd((prevState) => [...prevState, user.id]);
} else {
setUsersToAdd((prevState) =>
prevState.filter((u) => u !== user.id),
);
}
}}
/>
<Avatar userId={user.id} enableTooltip />
<Name $variant="main">{user.shortName || user.name}</Name>
<Name $variant="simple">{user.name}</Name>
</UserDetails>
</Label>
);
})}
</UsersList>
<Footer>
<AddButton onClick={addUsers}>Add</AddButton>
</Footer>
</Box>
</Modal>
);
}
const AddButton = styled.button({
border: 'none',
borderRadius: '4px',
backgroundColor: '#007a5a',
color: '#ffffff',
padding: '0 12px 1px',
fontSize: '15px',
height: '36px',
minWidth: '80px',
boxShadow: 'none',
fontWeight: '700',
transition: 'all 80ms linear',
cursor: 'pointer',
'&:hover': {
background: '#148567',
boxShadow: '0 1px 4px #0000004d',
},
});

const DeleteButton = styled.button({
backgroundColor: 'rgba(224,30,90)',
border: 'none',
borderRadius: '4px',
boxShadow: 'none',
color: '#ffffff',
cursor: 'pointer',
fontSize: '15px',
fontWeight: '700',
height: '36px',
marginLeft: 'auto',
minWidth: '80px',
padding: '0 12px 1px',
transition: 'all 80ms linear',
'&:hover': {
background: '#e23067',
boxShadow: '0 1px 4px #0000004d',
},
});

const AddPeopleIconWrapper = styled.div({
alignItems: 'center',
backgroundColor: '#e8f5fa',
display: 'flex',
height: '36px',
justifyContent: 'center',
width: '36px',
});

const Checkbox = styled.input({
marginRight: '16px',
cursor: 'pointer',
});

const Label = styled.label({
cursor: 'pointer',
});

const Footer = styled.div({
display: 'flex',
justifyContent: 'space-between',
backgroundColor: 'transparent',
padding: '24px 24px',
borderTop: `1px solid ${Colors.gray_light}`,
});

const Avatar = styled(DefaultAvatar)`
grid-area: avatar;
Expand All @@ -71,16 +324,16 @@ const Avatar = styled(DefaultAvatar)`
}
`;

const Modal = styled.div({
const Modal = styled.div<{ $order: number }>(({ $order }) => ({
position: 'absolute',
height: '100vh',
inset: 0,
backgroundColor: 'rgba(0, 0, 0, 0.4)',
zIndex: 999,
zIndex: $order * 999,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
});
}));

const Box = styled.div({
backgroundColor: 'white',
Expand All @@ -101,6 +354,7 @@ const Header = styled.div({
});

const Heading = styled.h2({
display: 'flex',
marginTop: 0,
});

Expand All @@ -120,6 +374,7 @@ const UserDetails = styled.div({
},
// todo: update once we have profile details like role
alignItems: 'center',
cursor: 'pointer',
});

const CloseButton = styled.button({
Expand Down
4 changes: 1 addition & 3 deletions src/client/hooks/useAPIFetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,12 @@ import { API_HOST } from 'src/client/consts/consts';

export function useAPIFetch<T extends object = object>(
path: string,
method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET',
Copy link
Copy Markdown
Author

@yeo-yeo yeo-yeo Sep 27, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove these changes which I made in #60

but then I realised there is already useAPIUpdateFetch below to use for these types of request (and passes body etc)

): T | undefined {
const [data, setData] = useState<T | undefined>(undefined);

useEffect(() => {
fetch(`${API_HOST}${path}`, {
credentials: 'include',
method,
})
.then((resp) =>
resp.ok
Expand All @@ -23,7 +21,7 @@ export function useAPIFetch<T extends object = object>(
setData(data);
})
.catch((error) => console.error('useAPIFetch error', error));
}, [method, path]);
}, [path]);

return data;
}
Expand Down
Loading