diff --git a/README.md b/README.md index 40f029d..2ba3be4 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ⚡ A moderation tool for [Lemmy](https://github.com/LemmyNet/lemmy) community moderators and site admins. ⚡ - > Currently only compatible with 0.18.x instances _(and not 0.19.x)_, as the [Lemmy SDK is not backwards-compatible.](https://github.com/LemmyNet/lemmy-js-client/issues/194) ☹ + > 🎉🎉 We now have cross-version support for 0.19! 🎉🙌 ~~Currently only compatible with 0.18.x instances _(and not 0.19.x)_, as the [Lemmy SDK is not backwards-compatible.](https://github.com/LemmyNet/lemmy-js-client/issues/194) ☹~~ ## Screenshots | | | | @@ -34,7 +34,6 @@ There are 3 types of users in Lemmy Modder: user, mod and `admin` This is determined based on the amount of moderated communities you manage. - ## Hosting Options You can either use the hosted option at https://modder.lemmyverse.net/ or host your own instance. diff --git a/package-lock.json b/package-lock.json index f397e14..9d51b77 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "react-number-format": "^5.3.1", "react-redux": "^8.1.1", "react-router-dom": "^6.20.1", + "react-window": "^1.8.10", "redux": "^4.2.1", "redux-persist": "^6.0.0", "remove-markdown": "^0.5.0", @@ -5840,6 +5841,11 @@ "node": ">= 4.0.0" } }, + "node_modules/memoize-one": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", + "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==" + }, "node_modules/merge-descriptors": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", @@ -7104,6 +7110,22 @@ "react-dom": ">=16.6.0" } }, + "node_modules/react-window": { + "version": "1.8.10", + "resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.10.tgz", + "integrity": "sha512-Y0Cx+dnU6NLa5/EvoHukUD0BklJ8qITCtVEPY1C/nL8wwoZ0b5aEw8Ff1dOVHw7fCzMt55XfJDd8S8W8LCaUCg==", + "dependencies": { + "@babel/runtime": "^7.0.0", + "memoize-one": ">=3.1.1 <6" + }, + "engines": { + "node": ">8.0.0" + }, + "peerDependencies": { + "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", diff --git a/package.json b/package.json index bb4a097..480112c 100644 --- a/package.json +++ b/package.json @@ -34,8 +34,8 @@ "css-minimizer-webpack-plugin": "^5.0.1", "file-loader": "^6.2.0", "html-webpack-plugin": "^5.5.3", - "lemmy-js-client18": "npm:lemmy-js-client@^0.18.1", "lemmy-js-client": "npm:lemmy-js-client@^0.19.0-rc.19", + "lemmy-js-client18": "npm:lemmy-js-client@^0.18.1", "moment": "^2.29.4", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -45,9 +45,10 @@ "react-number-format": "^5.3.1", "react-redux": "^8.1.1", "react-router-dom": "^6.20.1", + "react-window": "^1.8.10", "redux": "^4.2.1", - "remove-markdown": "^0.5.0", "redux-persist": "^6.0.0", + "remove-markdown": "^0.5.0", "sass": "^1.69.5", "sass-loader": "^13.3.2", "sonner": "^1.2.4", diff --git a/src/components/Filters.jsx b/src/components/Filters.jsx index 7c75d79..c302026 100644 --- a/src/components/Filters.jsx +++ b/src/components/Filters.jsx @@ -2,15 +2,33 @@ import React from "react"; import { useDispatch, useSelector } from "react-redux"; +import Autocomplete from "@mui/joy/Autocomplete"; +import CircularProgress from "@mui/joy/CircularProgress"; +import AutocompleteOption from "@mui/joy/AutocompleteOption"; +import ListItemDecorator from "@mui/joy/ListItemDecorator"; +import ListItemContent from "@mui/joy/ListItemContent"; import Box from "@mui/joy/Box"; import Select from "@mui/joy/Select"; import Option from "@mui/joy/Option"; import Typography from "@mui/joy/Typography"; import Checkbox from "@mui/joy/Checkbox"; - import Chip from "@mui/joy/Chip"; -import { selectFilterCommunity, selectFilterType, selectHideReadApprovals, selectModLogType, selectShowRemoved, selectShowResolved, setConfigItem } from "../redux/reducer/configReducer"; +import PersonSearchIcon from "@mui/icons-material/PersonSearch"; +import SupervisedUserCircleIcon from "@mui/icons-material/SupervisedUserCircle"; + +import { useLemmyHttpAction } from "../hooks/useLemmyHttp.js"; + +import { UserAvatar } from "./Display.jsx"; +import { + selectFilterCommunity, + selectFilterType, + selectHideReadApprovals, + selectModLogType, + selectShowRemoved, + selectShowResolved, + setConfigItem, +} from "../redux/reducer/configReducer"; import { getSiteData } from "../hooks/getSiteData"; import { getModLogTypeNames } from "../utils"; @@ -193,3 +211,264 @@ export function FilterRemoved() { /> ); } + +export function FilterUserAutocomplete({ value, onChange }) { + const [open, setOpen] = React.useState(false); + const [options, setOptions] = React.useState([]); + const [userSelected, setUserSelected] = React.useState(null); + const [managedInputValue, setManagedInputValue] = React.useState(null); + + const { + data: localUserData, + callAction: getLocalUserData, + isSuccess: localUserIsSuccess, + isLoading: localUserIsLoading, + } = useLemmyHttpAction("getPersonDetails"); + + // lookup the users data if we are loading a value + React.useEffect(() => { + if (value && !localUserIsLoading) { + getLocalUserData({ person_id: value }); + } + }, [value]); + + // if the pre-load user is loaded + React.useEffect(() => { + if (localUserIsSuccess) { + console.log("localUserIsSuccess", localUserData.person_view); + + const thePerson = localUserData.person_view.person; + const personFQUN = thePerson.name + "@" + thePerson.actor_id.split("/")[2]; + + setUserSelected(localUserData.person_view); + setOptions([ + { + id: localUserData.person_view.person.id, + title: personFQUN, + person: localUserData.person_view.person, + }, + ]); + setManagedInputValue({ + title: personFQUN, + }); + } + }, [localUserIsSuccess, localUserData]); + + // need to show an autocomplete, and then call the search api for results + const { data, callAction, isSuccess, isLoading } = useLemmyHttpAction("search"); + + const searchUsers = (searchTerm) => { + console.log("searchUsers", searchTerm); + + if (!searchTerm || isLoading) { + return; + } + + if (searchTerm.length < 2) { + return; + } + + callAction({ q: searchTerm, listing_type: "Local", type_: "Users" }); + }; + + const onUserSelected = (user) => { + console.log("onUserSelected", user); + setUserSelected(user); + onChange && onChange(user); + }; + + React.useEffect(() => { + if (isLoading) { + return; + } + + if (data) { + const opts = data.users.map((user) => { + return { + id: user.person.id, + title: user.person.name + "@" + user.person.actor_id.split("/")[2], + person: user.person, + }; + }); + setOptions(opts); + } + }, [isLoading, data]); + + return ( + { + setOpen(true); + }} + onClose={() => { + setOptions([]); + setOpen(false); + }} + isOptionEqualToValue={(option, value) => option.title === value.title} + getOptionLabel={(option) => option.title} + options={options} + startDecorator={ + userSelected ? : + } + // defaultValue={ + // userSelected + // ? { + // id: userSelected.person.id, + // title: userSelected.person.name + "@" + userSelected.person.actor_id.split("/")[2], + // person: userSelected.person, + // } + // : null + // } + loading={localUserIsLoading || isLoading} + noOptionsText={data ? "Nothing Found" : "Search Users"} + onInputChange={(e, newValue) => { + searchUsers(newValue); + }} + onChange={(e, newValue) => { + console.log("onChange", newValue); + onUserSelected(newValue); + }} + renderOption={(props, option) => ( + + + + + + {option.person.display_name ? option.person.display_name : option.person.name} + {option.person.actor_id} + + + )} + endDecorator={isLoading ? : null} + /> + ); +} + +export function FilterCommunityAutocomplete({ value, onChange }) { + const [open, setOpen] = React.useState(false); + const [options, setOptions] = React.useState([]); + const [selected, setSelected] = React.useState(null); + const [managedInputValue, setManagedInputValue] = React.useState(null); + + const { + data: communityData, + callAction: getCommunityData, + isSuccess: getCommunityIsSuccess, + isLoading: getCommunityIsLoading, + } = useLemmyHttpAction("getCommunity"); + + // lookup the Community data if we are loading a value + React.useEffect(() => { + if (value) { + getCommunityData({ id: value }); + } + }, [value]); + + // if the pre-load Community is loaded + React.useEffect(() => { + if (getCommunityIsSuccess) { + console.log("getCommunityIsSuccess", communityData); + + const theCommunity = communityData.community_view; + const communityFQUN = theCommunity.community.name + "@" + theCommunity.community.actor_id.split("/")[2]; + + setSelected(communityData.community_view); + setOptions([ + { + id: theCommunity.community.id, + title: communityFQUN, + community: theCommunity.community, + }, + ]); + setManagedInputValue({ + title: communityFQUN, + }); + } + }, [getCommunityIsSuccess, communityData]); + + // need to show an autocomplete, and then call the search api for results + const { data, callAction, isSuccess, isLoading } = useLemmyHttpAction("search"); + + const searchUsers = (searchTerm) => { + console.log("searchcommunity", searchTerm); + + if (!searchTerm || isLoading) { + return; + } + + if (searchTerm.length < 2) { + return; + } + + callAction({ q: searchTerm, listing_type: "Local", type_: "Communities" }); + }; + + const onSelected = (community) => { + console.log("onSelected", community); + setSelected(community); + onChange && onChange(community); + }; + + React.useEffect(() => { + if (isLoading) { + return; + } + + if (data) { + const opts = data.communities.map((item) => { + return { + id: item.community.id, + title: item.community.name + "@" + item.community.actor_id.split("/")[2], + community: item.community, + }; + }); + setOptions(opts); + } + }, [isLoading, data]); + + return ( + { + setOpen(true); + }} + onClose={() => { + setOptions([]); + setOpen(false); + }} + isOptionEqualToValue={(option, value) => option.title === value.title} + getOptionLabel={(option) => option.title} + options={options} + startDecorator={ + selected ? : + } + loading={getCommunityIsLoading || isLoading} + noOptionsText={data ? "Nothing Found" : "Search Communities"} + onInputChange={(e, newValue) => { + searchUsers(newValue); + }} + onChange={(e, newValue) => { + console.log("onChange", newValue); + onSelected(newValue); + }} + renderOption={(props, option) => ( + + + + + + {option.community.title ? option.community.title : option.community.name} + {option.community.actor_id} + + + )} + endDecorator={isLoading ? : null} + /> + ); +} diff --git a/src/components/InstanceSelect.jsx b/src/components/InstanceSelect.jsx new file mode 100644 index 0000000..1ba159b --- /dev/null +++ b/src/components/InstanceSelect.jsx @@ -0,0 +1,195 @@ +import React from "react"; +// import { connect } from "react-redux"; + +// import useQueryCache from "../../hooks/useQueryCache"; + +import { FixedSizeList } from "react-window"; + +import { Popper } from "@mui/base/Popper"; +import Autocomplete, { createFilterOptions } from "@mui/joy/Autocomplete"; +import AutocompleteListbox from "@mui/joy/AutocompleteListbox"; +import AutocompleteOption from "@mui/joy/AutocompleteOption"; +import FormControl from "@mui/joy/FormControl"; +import ListItemDecorator from "@mui/joy/ListItemDecorator"; + +import Add from "@mui/icons-material/Add"; + +// import { setHomeInstance } from "../../reducers/configReducer"; + +import useLVQueryCache from "../hooks/useLVQueryCache"; +/** + * This component renders a button that allows the user to select a home instance. + * + * It uses a react-window Virtualized List to render the list of instances. + */ + +const filterOptions = createFilterOptions({ + // matchFrom: "start", + stringify: (option) => option.base, + trim: true, + ignoreCase: true, +}); + +const LISTBOX_PADDING = 6; // px + +function renderRow(props) { + const { data, index, style } = props; + const dataSet = data[index]; + const inlineStyle = { + ...style, + top: style.top + LISTBOX_PADDING, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + }; + + return ( + + {dataSet[1].name?.startsWith('Add "') && ( + + + + )} + {typeof dataSet[1] == "string" && dataSet[1]} + {dataSet[1].base && ( + <> + {dataSet[1].name} ({dataSet[1].base}) + + )} + + ); +} + +const OuterElementContext = React.createContext({}); + +const OuterElementType = React.forwardRef((props, ref) => { + const outerProps = React.useContext(OuterElementContext); + return ( + + ); +}); + +// Adapter for react-window +const ListboxComponent = React.forwardRef(function ListboxComponent(props, ref) { + const { children, anchorEl, open, modifiers, ...other } = props; + const itemData = []; + + children[0].forEach((item) => { + if (item) { + itemData.push(item); + itemData.push(...(item.children || [])); + } + }); + + const itemCount = itemData.length; + const itemSize = 40; + + return ( + + + ({ + zIndex: theme.zIndex.modal + 1000, + })} + > + {renderRow} + + + + ); +}); + +export default function InstanceSelect({ + placeholder, + value, + onChange, + sx, + variant, + color, + disabled, + ...props +}) { + const { isLoading, error, data } = useLVQueryCache("instanceMinData", "instance.min"); + + const handleChange = (event, newValue) => { + console.log("onChange", newValue); + + onChange(newValue ? newValue.base : ""); + }; + + return ( + option.code === value.code} + renderOption={(props, option) => [props, option]} + // TODO: Post React 18 update - validate this conversion, look like a hidden bug + // renderGroup={(params) => params} + filterOptions={(options, params) => { + const filtered = filterOptions(options, params); + + const { inputValue } = params; + + // Suggest the creation of a new value + const isExisting = options.some((option) => inputValue === option.base); + if (inputValue !== "" && !isExisting) { + const cleanedUrl = inputValue + .replace("http:", "") + .replace("https:", "") + .replace("//", "") + .replace("/", ""); + filtered.push({ + name: `Other`, + base: cleanedUrl, + }); + } + + return filtered; + }} + getOptionLabel={(option) => { + // console.log("getOptionLabel", option); + // Value selected with enter, right from the input + if (typeof option === "string") { + return option; + } + + // Regular option + return option.base; + }} + /> + ); +} diff --git a/src/pages/Actions.jsx b/src/pages/Actions.jsx index f9c36d5..6953564 100644 --- a/src/pages/Actions.jsx +++ b/src/pages/Actions.jsx @@ -11,7 +11,7 @@ import AccordionGroup from "@mui/joy/AccordionGroup"; import { accordionSummaryClasses } from "@mui/joy/AccordionSummary"; import Checkbox from "@mui/joy/Checkbox"; -import { FilterModLogType } from "../components/Filters"; +import { FilterModLogType, FilterUserAutocomplete, FilterCommunityAutocomplete } from "../components/Filters"; import useLemmyInfinite from "../hooks/useLemmyInfinite"; import { getSiteData } from "../hooks/getSiteData"; @@ -37,16 +37,16 @@ export default function Actions() { const [limitModId, setLimitModId] = React.useState(null); const [actedOnId, setActedOnID] = React.useState(null); + // when search params changes useEffect(() => { if (searchParams.get("community_id")) { setLimitCommunityId(searchParams.get("community_id")); } if (searchParams.get("mod_log_type")) { - dispatch(setConfigItem("modLogType",searchParams.get("mod_log_type"))); + dispatch(setConfigItem("modLogType", searchParams.get("mod_log_type"))); } - if (searchParams.get("mod_id")) { setLimitModId(searchParams.get("mod_id")); } @@ -55,16 +55,17 @@ export default function Actions() { setActedOnID(searchParams.get("acted_on_id")); } - if(searchParams.get("local_instance")) { + if (searchParams.get("local_instance")) { setLimitLocalInstance(searchParams.get("local_instance") == "true"); } - }, []); + }, [searchParams]); + // when type changes useEffect(() => { if (modLogType !== null) { setSearchParams({ mod_log_type: modLogType }); } - + if (limitLocalInstance !== null && limitLocalInstance !== true) { setSearchParams({ local_instance: limitLocalInstance }); } else { @@ -72,7 +73,7 @@ export default function Actions() { setSearchParams(searchParams); } }, [modLogType, limitLocalInstance]); - + const { ref, inView, entry } = useInView({ threshold: 0, }); @@ -314,6 +315,17 @@ export default function Actions() { > + (newPerson ? setActedOnID(newPerson.id) : setActedOnID(null))} + /> + + newCommunity ? setLimitCommunityId(newCommunity.id) : setLimitCommunityId(null) + } + /> + - (domainLock ? null : setInstanceBase(newValue))} + sx={{ mb: 1, width: "100%" }} + disabled={domainLock || accountIsLoading} + variant="outlined" + color="neutral" + /> + + {/* (domainLock ? null : setInstanceBase(e.target.value))} @@ -162,7 +174,7 @@ export default function LoginForm() { color="neutral" sx={{ mb: 1, width: "100%" }} disabled={domainLock || accountIsLoading} - /> + /> */}