From 5e7f4db67db09f8eeb591ee4b645d972e3aa27c1 Mon Sep 17 00:00:00 2001 From: Liam Fitzgerald Date: Tue, 6 Oct 2020 11:54:41 +1000 Subject: [PATCH] groups: virtualize participant list and add search --- pkg/interface/package-lock.json | 8 + pkg/interface/package.json | 1 + .../views/components/StatelessAsyncAction.tsx | 55 ++++ .../landscape/components/Participants.tsx | 243 +++++++++++++----- 4 files changed, 242 insertions(+), 65 deletions(-) create mode 100644 pkg/interface/src/views/components/StatelessAsyncAction.tsx diff --git a/pkg/interface/package-lock.json b/pkg/interface/package-lock.json index 4a86835eb2..9ef5025b61 100644 --- a/pkg/interface/package-lock.json +++ b/pkg/interface/package-lock.json @@ -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", diff --git a/pkg/interface/package.json b/pkg/interface/package.json index 226978431b..5b46d06e73 100644 --- a/pkg/interface/package.json +++ b/pkg/interface/package.json @@ -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", diff --git a/pkg/interface/src/views/components/StatelessAsyncAction.tsx b/pkg/interface/src/views/components/StatelessAsyncAction.tsx new file mode 100644 index 0000000000..998a3c846b --- /dev/null +++ b/pkg/interface/src/views/components/StatelessAsyncAction.tsx @@ -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; +} + +type ButtonState = "waiting" | "error" | "loading" | "success"; + +export function StatelessAsyncAction({ + loadingText, + children, + onClick, + ...rest +}: AsyncActionProps & Parameters[0]) { + const [state, setState] = useState("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 ( + + {state === "error" ? ( + "Error" + ) : state === "loading" ? ( + + ) : state === "success" ? ( + "Done" + ) : ( + children + )} + + ); +} diff --git a/pkg/interface/src/views/landscape/components/Participants.tsx b/pkg/interface/src/views/landscape/components/Participants.tsx index cb4ad48b63..864818843b 100644 --- a/pkg/interface/src/views/landscape/components/Participants.tsx +++ b/pkg/interface/src/views/landscape/components/Participants.tsx @@ -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("total"); const [search, _setSearch] = useState(""); - const setSearch = (e: ChangeEvent) => { - _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) => { + 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 ( @@ -119,19 +150,19 @@ export function Participants(props: { px={2} zIndex={1} > - + {isInvite && ( )} - {filtered.map((c) => ( - + + + + + {filtered.map((cs, idx) => ( + + {({ isVisible }) => + isVisible ? ( + cs.map((c) => ( + + )) + ) : ( + + ) + } + ))} @@ -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 ( <> - - {contact.nickname} + + {contact.nickname && {contact.nickname}} {cite(contact.patp)} @@ -198,7 +288,8 @@ function Participant(props: { width="100%" justifyContent="space-between" gridColumn={["1 / 3", "auto"]} - alignItems="center"> + alignItems="center" + > Role @@ -209,16 +300,37 @@ function Participant(props: { alignX="right" alignY="top" options={ - - - Send Message + + + + Send Message + - {isInvite && ( - {}}> - Ban from {title} - + {props.role === "admin" && ( + <> + {!isInvite && ( + + Ban from {title} + + )} + {role === "admin" ? ( + + Demote from Admin + + ) : ( + + Promote to Admin + + )} + )} - {}}>Promote to Admin } > @@ -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 ( + + ); }