Skip to content

Commit a906a5e

Browse files
author
Gillian
authored
Add/remove user from private channel
Wires up the server handlers added in #60 (and actually changes them slightly so that they take arrays of users in the request body, rather than one user as a param) Now, if you are in a private channel, you can add someone from clack_all who is not already in the channel, and/or remove someone already in the channel (this is a bit more permissive than Slack's model, which only lets the 'channel manager' remove people, but I think that's ok) Note that the current backend of the org members API doesn't handle subscriptions, so it seems like nothing happens on add/delete, but if you refresh you will see the updated list. Work is in progress to fix that (with the new live query) - I could do some optimistic rendering stuff here so it doesn't look broken for now, but I don't think it's worth the effort Test Plan: https://github.com/getcord/clack/assets/28454190/24469793-a674-43f9-99d8-5216c5b9b69e Reviewers: jwatzman Reviewed By: jwatzman Pull Request: #67
1 parent 38bf1f7 commit a906a5e

File tree

4 files changed

+293
-36
lines changed

4 files changed

+293
-36
lines changed

src/client/components/UsersInChannelModal.tsx

Lines changed: 278 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,27 @@
1-
import React from 'react';
1+
import React, { useCallback, useEffect, useMemo, useState } from 'react';
22
import styled from 'styled-components';
33
import {
44
CordContext,
55
Avatar as DefaultAvatar,
66
presence,
7+
user,
78
} from '@cord-sdk/react';
9+
import { UserPlusIcon } from '@heroicons/react/24/outline';
10+
import { LockClosedIcon, HashtagIcon } from '@heroicons/react/24/solid';
811
import type { ClientUserData } from '@cord-sdk/types';
912
import type { Channel } from 'src/client/consts/Channel';
1013
import { Colors } from 'src/client/consts/Colors';
1114
import { ActiveBadge } from 'src/client/components/ActiveBadge';
1215
import { Name } from 'src/client/components/Name';
1316
import { XIcon } from 'src/client/components/Buttons';
17+
import { useAPIUpdateFetch } from 'src/client/hooks/useAPIFetch';
18+
import { EVERYONE_ORG_ID } from 'src/client/consts/consts';
1419

15-
interface UsersInChannelModalProps {
20+
type UsersInChannelModalProps = {
1621
onClose: () => void;
1722
channel: Channel;
1823
users: ClientUserData[];
19-
}
24+
};
2025

2126
export function UsersInChannelModal({
2227
onClose,
@@ -27,41 +32,289 @@ export function UsersInChannelModal({
2732
{ page: 'clack' },
2833
{ exclude_durable: true, partial_match: true },
2934
);
35+
const [showAddUsersModal, setShowAddUsersModal] = React.useState(false);
36+
37+
return (
38+
<>
39+
<Modal $order={1}>
40+
<Box>
41+
<Header>
42+
<Heading>
43+
{channel.org ? (
44+
<LockClosedIcon
45+
width={20}
46+
style={{ padding: '1px', marginRight: '2px' }}
47+
/>
48+
) : (
49+
<HashtagIcon width={20} style={{ padding: '1px' }} />
50+
)}
51+
{channel.id}
52+
</Heading>
53+
<CloseButton onClick={onClose}>
54+
<XIcon />
55+
</CloseButton>
56+
</Header>
57+
<UsersList>
58+
{/* Show the Add People modal option if this is a private org
59+
(public channels have an undefined channel.org, and are visible to
60+
everyone in the clack_all org) */}
61+
{channel.org && (
62+
<UserDetails onClick={() => setShowAddUsersModal(true)}>
63+
<AddPeopleIconWrapper>
64+
<UserPlusIcon
65+
width={32}
66+
height={32}
67+
style={{
68+
backgroundColor: '#e8f5fa',
69+
color: 'rgba(18,100,163,1)',
70+
}}
71+
/>
72+
</AddPeopleIconWrapper>
73+
<Name $variant="main">Add people</Name>
74+
</UserDetails>
75+
)}
76+
{users.map((user) => {
77+
const isUserPresent = usersPresent?.some(
78+
(presence) => presence.id === user.id,
79+
);
80+
return (
81+
<UserRow
82+
key={user.id}
83+
org={channel.org}
84+
isUserPresent={!!isUserPresent}
85+
user={user}
86+
/>
87+
);
88+
})}
89+
</UsersList>
90+
</Box>
91+
</Modal>
92+
{showAddUsersModal && (
93+
<AddUsersToChannelModal
94+
channel={channel}
95+
onClose={() => setShowAddUsersModal(false)}
96+
existingUsers={users.map((u) => u.id)}
97+
/>
98+
)}
99+
</>
100+
);
101+
}
102+
103+
function UserRow({
104+
org,
105+
isUserPresent,
106+
user,
107+
}: {
108+
org: string | undefined;
109+
isUserPresent: boolean;
110+
user: ClientUserData;
111+
}) {
30112
const { userID: cordUserID } = React.useContext(CordContext);
113+
const [showDelete, setShowDelete] = useState(false);
114+
115+
const update = useAPIUpdateFetch();
31116

32117
return (
33-
<Modal>
118+
<>
119+
<UserDetails
120+
key={user.id}
121+
onMouseEnter={() => setShowDelete(true)}
122+
onMouseLeave={() => setShowDelete(false)}
123+
>
124+
<Avatar userId={user.id} enableTooltip />
125+
{/* //todo: fill short name values in db console? */}
126+
<Name $variant="main">
127+
{user.shortName || user.name}
128+
{cordUserID === user.id ? ' (you)' : ''}
129+
</Name>
130+
<ActiveBadge $isActive={isUserPresent} />
131+
<Name $variant="simple">{user?.name}</Name>
132+
{showDelete && org && (
133+
// TODO: the org members API currently doesn't have subscriptions, so
134+
// it looks like nothing's happened in the FE atm
135+
<DeleteButton
136+
onClick={() => {
137+
void update(`/channels/${org}`, 'DELETE', {
138+
userIDs: [user.id],
139+
});
140+
}}
141+
>
142+
Remove
143+
</DeleteButton>
144+
)}
145+
</UserDetails>
146+
</>
147+
);
148+
}
149+
150+
type AddUsersToChannelModalProps = {
151+
onClose: () => void;
152+
channel: Channel;
153+
existingUsers: string[];
154+
};
155+
156+
function AddUsersToChannelModal({
157+
onClose,
158+
existingUsers,
159+
channel,
160+
}: AddUsersToChannelModalProps) {
161+
const {
162+
orgMembers: allOrgMembers,
163+
loading,
164+
hasMore,
165+
fetchMore,
166+
} = user.useOrgMembers({
167+
organizationID: EVERYONE_ORG_ID,
168+
});
169+
170+
useEffect(() => {
171+
if (!loading && hasMore) {
172+
void fetchMore(50);
173+
}
174+
}, [hasMore, loading, fetchMore]);
175+
176+
const addableUsers = useMemo(() => {
177+
return allOrgMembers
178+
.filter((om) => !existingUsers.includes(om.id))
179+
.sort(
180+
(a, b) =>
181+
(a.shortName ?? a.name ?? 'Unknown')?.localeCompare(
182+
b.shortName ?? b.name ?? 'Unknown',
183+
),
184+
);
185+
}, [allOrgMembers, existingUsers]);
186+
187+
const [usersToAdd, setUsersToAdd] = useState<string[]>([]);
188+
189+
const update = useAPIUpdateFetch();
190+
191+
// TODO: the org members API currently doesn't have subscriptions, so
192+
// it looks like nothing's happened in the FE atm
193+
const addUsers = useCallback(() => {
194+
void update(`/channels/${channel.org}`, 'PUT', {
195+
userIDs: usersToAdd,
196+
}).then(() => onClose());
197+
}, [channel.org, onClose, update, usersToAdd]);
198+
199+
return (
200+
// This is a modal stacked on top of another modal
201+
<Modal $order={2}>
34202
<Box>
35203
<Header>
36-
<Heading># {channel.id}</Heading>
204+
<Heading>
205+
Add people to{' '}
206+
{channel.org ? (
207+
<LockClosedIcon
208+
width={20}
209+
style={{ padding: '1px', marginRight: '2px' }}
210+
/>
211+
) : (
212+
<HashtagIcon width={20} style={{ padding: '1px' }} />
213+
)}{' '}
214+
{channel.id}
215+
</Heading>
37216
<CloseButton onClick={onClose}>
38217
<XIcon />
39218
</CloseButton>
40219
</Header>
41-
42220
<UsersList>
43-
{users.map((user) => {
44-
const isUserPresent = usersPresent?.some(
45-
(presence) => presence.id === user.id,
46-
);
221+
{addableUsers.map((user) => {
47222
return (
48-
<UserDetails key={user.id}>
49-
<Avatar userId={user.id} enableTooltip />
50-
{/* //todo: fill short name values in db console? */}
51-
<Name $variant="main">
52-
{user.shortName || user.name}
53-
{cordUserID === user.id ? ' (you)' : ''}
54-
</Name>
55-
<ActiveBadge $isActive={!!isUserPresent} />
56-
<Name $variant="simple">{user?.name}</Name>
57-
</UserDetails>
223+
<Label key={user.id}>
224+
<UserDetails>
225+
<Checkbox
226+
id={`add-${user.id}`}
227+
type="checkbox"
228+
value={user.id}
229+
onChange={(e) => {
230+
if (e.target.checked) {
231+
setUsersToAdd((prevState) => [...prevState, user.id]);
232+
} else {
233+
setUsersToAdd((prevState) =>
234+
prevState.filter((u) => u !== user.id),
235+
);
236+
}
237+
}}
238+
/>
239+
<Avatar userId={user.id} enableTooltip />
240+
<Name $variant="main">{user.shortName || user.name}</Name>
241+
<Name $variant="simple">{user.name}</Name>
242+
</UserDetails>
243+
</Label>
58244
);
59245
})}
60246
</UsersList>
247+
<Footer>
248+
<AddButton onClick={addUsers}>Add</AddButton>
249+
</Footer>
61250
</Box>
62251
</Modal>
63252
);
64253
}
254+
const AddButton = styled.button({
255+
border: 'none',
256+
borderRadius: '4px',
257+
backgroundColor: '#007a5a',
258+
color: '#ffffff',
259+
padding: '0 12px 1px',
260+
fontSize: '15px',
261+
height: '36px',
262+
minWidth: '80px',
263+
boxShadow: 'none',
264+
fontWeight: '700',
265+
transition: 'all 80ms linear',
266+
cursor: 'pointer',
267+
'&:hover': {
268+
background: '#148567',
269+
boxShadow: '0 1px 4px #0000004d',
270+
},
271+
});
272+
273+
const DeleteButton = styled.button({
274+
backgroundColor: 'rgba(224,30,90)',
275+
border: 'none',
276+
borderRadius: '4px',
277+
boxShadow: 'none',
278+
color: '#ffffff',
279+
cursor: 'pointer',
280+
fontSize: '15px',
281+
fontWeight: '700',
282+
height: '36px',
283+
marginLeft: 'auto',
284+
minWidth: '80px',
285+
padding: '0 12px 1px',
286+
transition: 'all 80ms linear',
287+
'&:hover': {
288+
background: '#e23067',
289+
boxShadow: '0 1px 4px #0000004d',
290+
},
291+
});
292+
293+
const AddPeopleIconWrapper = styled.div({
294+
alignItems: 'center',
295+
backgroundColor: '#e8f5fa',
296+
display: 'flex',
297+
height: '36px',
298+
justifyContent: 'center',
299+
width: '36px',
300+
});
301+
302+
const Checkbox = styled.input({
303+
marginRight: '16px',
304+
cursor: 'pointer',
305+
});
306+
307+
const Label = styled.label({
308+
cursor: 'pointer',
309+
});
310+
311+
const Footer = styled.div({
312+
display: 'flex',
313+
justifyContent: 'space-between',
314+
backgroundColor: 'transparent',
315+
padding: '24px 24px',
316+
borderTop: `1px solid ${Colors.gray_light}`,
317+
});
65318

66319
const Avatar = styled(DefaultAvatar)`
67320
grid-area: avatar;
@@ -71,16 +324,16 @@ const Avatar = styled(DefaultAvatar)`
71324
}
72325
`;
73326

74-
const Modal = styled.div({
327+
const Modal = styled.div<{ $order: number }>(({ $order }) => ({
75328
position: 'absolute',
76329
height: '100vh',
77330
inset: 0,
78331
backgroundColor: 'rgba(0, 0, 0, 0.4)',
79-
zIndex: 999,
332+
zIndex: $order * 999,
80333
display: 'flex',
81334
alignItems: 'center',
82335
justifyContent: 'center',
83-
});
336+
}));
84337

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

103356
const Heading = styled.h2({
357+
display: 'flex',
104358
marginTop: 0,
105359
});
106360

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

125380
const CloseButton = styled.button({

src/client/hooks/useAPIFetch.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,12 @@ import { API_HOST } from 'src/client/consts/consts';
33

44
export function useAPIFetch<T extends object = object>(
55
path: string,
6-
method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET',
76
): T | undefined {
87
const [data, setData] = useState<T | undefined>(undefined);
98

109
useEffect(() => {
1110
fetch(`${API_HOST}${path}`, {
1211
credentials: 'include',
13-
method,
1412
})
1513
.then((resp) =>
1614
resp.ok
@@ -23,7 +21,7 @@ export function useAPIFetch<T extends object = object>(
2321
setData(data);
2422
})
2523
.catch((error) => console.error('useAPIFetch error', error));
26-
}, [method, path]);
24+
}, [path]);
2725

2826
return data;
2927
}

0 commit comments

Comments
 (0)