mirror of
https://github.com/urbit/shrub.git
synced 2025-01-01 17:16:47 +03:00
groups: virtualize participant list and add search
This commit is contained in:
parent
ae059bfb4b
commit
5e7f4db67d
8
pkg/interface/package-lock.json
generated
8
pkg/interface/package-lock.json
generated
@ -8034,6 +8034,14 @@
|
||||
"tslib": "^1.11.1"
|
||||
}
|
||||
},
|
||||
"react-visibility-sensor": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/react-visibility-sensor/-/react-visibility-sensor-5.1.1.tgz",
|
||||
"integrity": "sha512-cTUHqIK+zDYpeK19rzW6zF9YfT4486TIgizZW53wEZ+/GPBbK7cNS0EHyJVyHYacwFEvvHLEKfgJndbemWhB/w==",
|
||||
"requires": {
|
||||
"prop-types": "^15.7.2"
|
||||
}
|
||||
},
|
||||
"readable-stream": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",
|
||||
|
@ -35,6 +35,7 @@
|
||||
"react-oembed-container": "^1.0.0",
|
||||
"react-router-dom": "^5.0.0",
|
||||
"react-virtuoso": "^0.20.0",
|
||||
"react-visibility-sensor": "^5.1.1",
|
||||
"remark-disable-tokenizers": "^1.0.24",
|
||||
"style-loader": "^1.2.1",
|
||||
"styled-components": "^5.1.0",
|
||||
|
55
pkg/interface/src/views/components/StatelessAsyncAction.tsx
Normal file
55
pkg/interface/src/views/components/StatelessAsyncAction.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import React, { ReactNode, useState, useEffect, useCallback } from "react";
|
||||
|
||||
import { Button, LoadingSpinner, Action } from "@tlon/indigo-react";
|
||||
|
||||
import { useFormikContext } from "formik";
|
||||
|
||||
interface AsyncActionProps {
|
||||
children: ReactNode;
|
||||
onClick: (e: React.MouseEvent) => Promise<void>;
|
||||
}
|
||||
|
||||
type ButtonState = "waiting" | "error" | "loading" | "success";
|
||||
|
||||
export function StatelessAsyncAction({
|
||||
loadingText,
|
||||
children,
|
||||
onClick,
|
||||
...rest
|
||||
}: AsyncActionProps & Parameters<typeof Action>[0]) {
|
||||
const [state, setState] = useState<ButtonState>("waiting");
|
||||
const handleClick = useCallback(
|
||||
async (e: React.MouseEvent) => {
|
||||
try {
|
||||
setState("loading");
|
||||
await onClick(e);
|
||||
setState("success");
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setState("error");
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
setState("waiting");
|
||||
}, 3000);
|
||||
}
|
||||
},
|
||||
[onClick, setState]
|
||||
);
|
||||
|
||||
return (
|
||||
<Action onClick={handleClick} {...rest}>
|
||||
{state === "error" ? (
|
||||
"Error"
|
||||
) : state === "loading" ? (
|
||||
<LoadingSpinner
|
||||
foreground={rest.primary ? "white" : "black"}
|
||||
background="transparent"
|
||||
/>
|
||||
) : state === "success" ? (
|
||||
"Done"
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</Action>
|
||||
);
|
||||
}
|
@ -1,4 +1,10 @@
|
||||
import React, { useState, useMemo, SyntheticEvent, ChangeEvent } from "react";
|
||||
import React, {
|
||||
useState,
|
||||
useMemo,
|
||||
useCallback,
|
||||
SyntheticEvent,
|
||||
ChangeEvent,
|
||||
} from "react";
|
||||
import {
|
||||
Col,
|
||||
Box,
|
||||
@ -8,16 +14,21 @@ import {
|
||||
Center,
|
||||
Button,
|
||||
Action,
|
||||
StatelessTextInput as Input,
|
||||
} from "@tlon/indigo-react";
|
||||
import _ from "lodash";
|
||||
import VisibilitySensor from "react-visibility-sensor";
|
||||
|
||||
import { Contact, Contacts } from "~/types/contact-update";
|
||||
import { Sigil } from "~/logic/lib/sigil";
|
||||
import { cite, uxToHex } from "~/logic/lib/util";
|
||||
import { Group, RoleTags } from "~/types/group-update";
|
||||
import { roleForShip } from "~/logic/lib/group";
|
||||
import { roleForShip, resourceFromPath } from "~/logic/lib/group";
|
||||
import { Association } from "~/types/metadata-update";
|
||||
import { useHistory, Link } from "react-router-dom";
|
||||
import { Dropdown } from "~/views/components/Dropdown";
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import { StatelessAsyncAction } from "~/views/components/StatelessAsyncAction";
|
||||
|
||||
type Participant = Contact & { patp: string; pending: boolean };
|
||||
type ParticipantsTabId = "total" | "pending" | "admin";
|
||||
@ -30,6 +41,30 @@ const searchParticipant = (search: string) => (p: Participant) => {
|
||||
return p.patp.includes(s) || p.nickname.toLowerCase().includes(search);
|
||||
};
|
||||
|
||||
function getParticipants(cs: Contacts, group: Group) {
|
||||
const contacts: Participant[] = _.map(cs, (c, patp) => ({
|
||||
...c,
|
||||
patp,
|
||||
pending: false,
|
||||
}));
|
||||
const members: Participant[] = _.map(Array.from(group.members), (m) =>
|
||||
emptyContact(m, false)
|
||||
);
|
||||
const allMembers = _.unionBy(contacts, members, "patp");
|
||||
const pending: Participant[] =
|
||||
"invite" in group.policy
|
||||
? _.map(Array.from(group.policy.invite.pending), (m) =>
|
||||
emptyContact(m, true)
|
||||
)
|
||||
: [];
|
||||
|
||||
return [
|
||||
_.unionBy(allMembers, pending, "patp"),
|
||||
pending.length,
|
||||
allMembers.length,
|
||||
] as const;
|
||||
}
|
||||
|
||||
const emptyContact = (patp: string, pending: boolean): Participant => ({
|
||||
nickname: "",
|
||||
email: "",
|
||||
@ -58,7 +93,9 @@ export function Participants(props: {
|
||||
contacts: Contacts;
|
||||
group: Group;
|
||||
association: Association;
|
||||
api: GlobalApi;
|
||||
}) {
|
||||
const { api } = props;
|
||||
const tabFilters: Record<
|
||||
ParticipantsTabId,
|
||||
(p: Participant) => boolean
|
||||
@ -74,37 +111,31 @@ export function Participants(props: {
|
||||
const [filter, setFilter] = useState<ParticipantsTabId>("total");
|
||||
|
||||
const [search, _setSearch] = useState("");
|
||||
const setSearch = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
_setSearch(e.target.value);
|
||||
};
|
||||
const contacts: Participant[] = useMemo(
|
||||
() =>
|
||||
_.map(props.contacts, (c, patp) => ({
|
||||
...c,
|
||||
patp,
|
||||
pending: false,
|
||||
})),
|
||||
[props.contacts]
|
||||
const setSearch = useMemo(() => _.debounce(_setSearch, 200), [_setSearch]);
|
||||
const onSearchChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
setSearch(e.target.value);
|
||||
},
|
||||
[setSearch]
|
||||
);
|
||||
const members: Participant[] = _.map(Array.from(props.group.members), (m) =>
|
||||
emptyContact(m, false)
|
||||
);
|
||||
const allMembers = _.unionBy(contacts, members, "patp");
|
||||
const isInvite = "invite" in props.group.policy;
|
||||
const pending: Participant[] =
|
||||
"invite" in props.group.policy
|
||||
? _.map(Array.from(props.group.policy.invite.pending), (m) =>
|
||||
emptyContact(m, true)
|
||||
)
|
||||
: [];
|
||||
|
||||
const adminCount = props.group.tags?.role?.admin?.size || 0;
|
||||
const isInvite = "invite" in props.group.policy;
|
||||
|
||||
const allSundry = _.unionBy(allMembers, pending, "patp");
|
||||
const [participants, pendingCount, memberCount] = getParticipants(
|
||||
props.contacts,
|
||||
props.group
|
||||
);
|
||||
|
||||
const filtered = _.chain(allSundry)
|
||||
.filter(tabFilters[filter])
|
||||
.filter(searchParticipant(search))
|
||||
.value();
|
||||
const filtered = useMemo(
|
||||
() =>
|
||||
_.chain(participants)
|
||||
.filter(tabFilters[filter])
|
||||
.filter(searchParticipant(search))
|
||||
.chunk(8)
|
||||
.value(),
|
||||
[search, filter, participants]
|
||||
);
|
||||
|
||||
return (
|
||||
<Col height="100%" overflowY="auto" p={2} position="relative">
|
||||
@ -119,19 +150,19 @@ export function Participants(props: {
|
||||
px={2}
|
||||
zIndex={1}
|
||||
>
|
||||
<Row>
|
||||
<Row mr="4" flexShrink="0">
|
||||
<Tab
|
||||
selected={filter}
|
||||
setSelected={setFilter}
|
||||
id="total"
|
||||
label={`${allMembers.length} total`}
|
||||
label={`${memberCount} total`}
|
||||
/>
|
||||
{isInvite && (
|
||||
<Tab
|
||||
selected={filter}
|
||||
setSelected={setFilter}
|
||||
id="pending"
|
||||
label={`${pending.length} pending`}
|
||||
label={`${pendingCount} pending`}
|
||||
/>
|
||||
)}
|
||||
<Tab
|
||||
@ -149,14 +180,52 @@ export function Participants(props: {
|
||||
gridRowGap={2}
|
||||
alignItems="center"
|
||||
>
|
||||
{filtered.map((c) => (
|
||||
<Participant
|
||||
key={c.patp}
|
||||
role="admin"
|
||||
group={props.group}
|
||||
contact={c}
|
||||
association={props.association}
|
||||
<Row
|
||||
alignItems="center"
|
||||
gridColumn={["1 / 3", "1 / 4"]}
|
||||
bg="washedGray"
|
||||
borderRadius="1"
|
||||
px="2"
|
||||
>
|
||||
<Icon stroke="gray" icon="MagnifyingGlass" />
|
||||
<Input
|
||||
maxWidth="256px"
|
||||
color="gray"
|
||||
bg="transparent"
|
||||
border="0"
|
||||
placeholder="Search Participants"
|
||||
onChange={onSearchChange}
|
||||
/>
|
||||
</Row>
|
||||
<Box
|
||||
borderBottom={1}
|
||||
borderBottomColor="washedGray"
|
||||
gridColumn={["1 / 3", "1 / 4"]}
|
||||
/>
|
||||
{filtered.map((cs, idx) => (
|
||||
<VisibilitySensor
|
||||
key={idx}
|
||||
offset={{ top: -800, bottom: -800 }}
|
||||
partialVisibility
|
||||
scrollDelay={150}
|
||||
>
|
||||
{({ isVisible }) =>
|
||||
isVisible ? (
|
||||
cs.map((c) => (
|
||||
<Participant
|
||||
api={api}
|
||||
key={c.patp}
|
||||
role="admin"
|
||||
group={props.group}
|
||||
contact={c}
|
||||
association={props.association}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<BlankParticipant length={cs.length} />
|
||||
)
|
||||
}
|
||||
</VisibilitySensor>
|
||||
))}
|
||||
</Box>
|
||||
</Col>
|
||||
@ -167,29 +236,50 @@ function Participant(props: {
|
||||
contact: Participant;
|
||||
association: Association;
|
||||
group: Group;
|
||||
role: RoleTags;
|
||||
role?: RoleTags;
|
||||
api: GlobalApi;
|
||||
}) {
|
||||
const history = useHistory();
|
||||
const { contact, association, group } = props;
|
||||
const { contact, association, group, api } = props;
|
||||
const { title } = association.metadata;
|
||||
|
||||
const color = uxToHex(contact.color);
|
||||
const isInvite = "invite" in group.policy;
|
||||
|
||||
const role = contact.pending
|
||||
? "pending"
|
||||
: roleForShip(group, contact.patp) || "member";
|
||||
const sendMessage = () => {
|
||||
history.push(`/~chat/new/dm/${contact.patp}`);
|
||||
};
|
||||
const role = useMemo(
|
||||
() =>
|
||||
contact.pending
|
||||
? "pending"
|
||||
: roleForShip(group, contact.patp) || "member",
|
||||
[contact, group]
|
||||
);
|
||||
|
||||
const onPromote = useCallback(async () => {
|
||||
const resource = resourceFromPath(association["group-path"]);
|
||||
await api.groups.addTag(resource, { tag: "admin" }, [`~${contact.patp}`]);
|
||||
}, [api, association]);
|
||||
|
||||
const onDemote = useCallback(async () => {
|
||||
const resource = resourceFromPath(association["group-path"]);
|
||||
await api.groups.removeTag(resource, { tag: "admin" }, [
|
||||
`~${contact.patp}`,
|
||||
]);
|
||||
}, [api, association]);
|
||||
|
||||
const onBan = useCallback(async () => {
|
||||
const resource = resourceFromPath(association["group-path"]);
|
||||
await api.groups.changePolicy(resource, {
|
||||
open: { banShips: [`~${contact.patp}`] },
|
||||
});
|
||||
}, [api, association]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box>
|
||||
<Sigil ship={contact.patp} size={32} color={`#${color}`} />
|
||||
</Box>
|
||||
<Col>
|
||||
<Text>{contact.nickname}</Text>
|
||||
<Col justifyContent="center" gapY="1" height="100%">
|
||||
{contact.nickname && <Text>{contact.nickname}</Text>}
|
||||
<Text color="gray" fontFamily="mono">
|
||||
{cite(contact.patp)}
|
||||
</Text>
|
||||
@ -198,7 +288,8 @@ function Participant(props: {
|
||||
width="100%"
|
||||
justifyContent="space-between"
|
||||
gridColumn={["1 / 3", "auto"]}
|
||||
alignItems="center">
|
||||
alignItems="center"
|
||||
>
|
||||
<Col>
|
||||
<Text mb={1} color="lightGray">
|
||||
Role
|
||||
@ -209,16 +300,37 @@ function Participant(props: {
|
||||
alignX="right"
|
||||
alignY="top"
|
||||
options={
|
||||
<Col gapY={1} p={2}>
|
||||
<Action onClick={sendMessage}>
|
||||
<Text color="green">Send Message</Text>
|
||||
<Col
|
||||
bg="white"
|
||||
border="1"
|
||||
borderRadius="1"
|
||||
borderColor="lightGray"
|
||||
gapY={2}
|
||||
p={2}
|
||||
>
|
||||
<Action>
|
||||
<Link to={`/~chat/new/dm/${contact.patp}`}>
|
||||
<Text color="green">Send Message</Text>
|
||||
</Link>
|
||||
</Action>
|
||||
{isInvite && (
|
||||
<Action onClick={() => {}}>
|
||||
<Text color="red">Ban from {title}</Text>
|
||||
</Action>
|
||||
{props.role === "admin" && (
|
||||
<>
|
||||
{!isInvite && (
|
||||
<StatelessAsyncAction onClick={onBan}>
|
||||
<Text color="red">Ban from {title}</Text>
|
||||
</StatelessAsyncAction>
|
||||
)}
|
||||
{role === "admin" ? (
|
||||
<StatelessAsyncAction onClick={onDemote}>
|
||||
Demote from Admin
|
||||
</StatelessAsyncAction>
|
||||
) : (
|
||||
<StatelessAsyncAction onClick={onPromote}>
|
||||
Promote to Admin
|
||||
</StatelessAsyncAction>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Action onSelect={() => {}}>Promote to Admin</Action>
|
||||
</Col>
|
||||
}
|
||||
>
|
||||
@ -234,11 +346,12 @@ function Participant(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function ParticipantMenu(props: {
|
||||
ourRole?: RoleTags;
|
||||
theirRole?: RoleTags;
|
||||
them: string;
|
||||
}) {
|
||||
const { ourRole, theirRole } = props;
|
||||
let options = [];
|
||||
function BlankParticipant({ length }) {
|
||||
return (
|
||||
<Box
|
||||
gridRow={[`auto / span ${3 * length}`, `auto / span ${2 * length}`]}
|
||||
gridColumn={["1 / 3", "1 / 4"]}
|
||||
height="100%"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user