mirror of
https://github.com/rowyio/rowy.git
synced 2025-12-28 16:06:41 +01:00
add update check badge
This commit is contained in:
@@ -25,6 +25,7 @@
|
||||
"algoliasearch": "^4.8.6",
|
||||
"ansi-to-react": "^6.1.5",
|
||||
"colord": "^2.7.0",
|
||||
"compare-versions": "^4.1.1",
|
||||
"craco-swc": "^0.1.3",
|
||||
"csv-parse": "^4.15.3",
|
||||
"date-fns": "^2.19.0",
|
||||
|
||||
@@ -23,6 +23,7 @@ import { APP_BAR_HEIGHT } from ".";
|
||||
import Logo from "@src/assets/Logo";
|
||||
import NavItem from "./NavItem";
|
||||
import NavTableSection from "./NavTableSection";
|
||||
import UpdateCheckBadge from "./UpdateCheckBadge";
|
||||
|
||||
import { useAppContext } from "@src/contexts/AppContext";
|
||||
import { useProjectContext } from "@src/contexts/ProjectContext";
|
||||
@@ -157,6 +158,7 @@ export default function NavDrawer({
|
||||
<ProjectSettingsIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Project Settings" />
|
||||
<UpdateCheckBadge sx={{ mr: 1.5 }} />
|
||||
</NavItem>
|
||||
</li>
|
||||
)}
|
||||
|
||||
22
src/components/Navigation/UpdateCheckBadge.tsx
Normal file
22
src/components/Navigation/UpdateCheckBadge.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Badge, BadgeProps } from "@mui/material";
|
||||
import useUpdateCheck from "@src/hooks/useUpdateCheck";
|
||||
|
||||
export default function UpdateCheckBadge(props: Partial<BadgeProps>) {
|
||||
const [latestUpdate] = useUpdateCheck();
|
||||
|
||||
if (!latestUpdate.rowy && !latestUpdate.rowyRun) return <>{props.children}</>;
|
||||
|
||||
return (
|
||||
<Badge
|
||||
badgeContent=" "
|
||||
color="error"
|
||||
variant="dot"
|
||||
aria-label="Update available"
|
||||
{...props}
|
||||
sx={{
|
||||
"& .MuiBadge-badge": { bgcolor: "#f00" },
|
||||
...props.sx,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import NavDrawer, { NAV_DRAWER_WIDTH } from "./NavDrawer";
|
||||
import UserMenu from "./UserMenu";
|
||||
import ErrorBoundary from "@src/components/ErrorBoundary";
|
||||
import Loading from "@src/components/Loading";
|
||||
import UpdateCheckBadge from "./UpdateCheckBadge";
|
||||
|
||||
import { useAppContext } from "@src/contexts/AppContext";
|
||||
import useDocumentTitle from "@src/hooks/useDocumentTitle";
|
||||
@@ -43,7 +44,7 @@ export default function Navigation({
|
||||
currentSection,
|
||||
titleTransitionProps,
|
||||
}: INavigationProps) {
|
||||
const { projectId } = useAppContext();
|
||||
const { projectId, userRoles } = useAppContext();
|
||||
useDocumentTitle(projectId, title);
|
||||
|
||||
const [open, setOpen] = useOpenState(false);
|
||||
@@ -114,7 +115,13 @@ export default function Navigation({
|
||||
size="large"
|
||||
edge="start"
|
||||
>
|
||||
<MenuIcon />
|
||||
{userRoles.includes("ADMIN") ? (
|
||||
<UpdateCheckBadge>
|
||||
<MenuIcon />
|
||||
</UpdateCheckBadge>
|
||||
) : (
|
||||
<MenuIcon />
|
||||
)}
|
||||
</IconButton>
|
||||
</Grow>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import createPersistedState from "use-persisted-state";
|
||||
import { differenceInDays } from "date-fns";
|
||||
|
||||
import { Grid, Typography, Button, Link, Divider } from "@mui/material";
|
||||
import LoadingButton from "@mui/lab/LoadingButton";
|
||||
import GitHubIcon from "@mui/icons-material/GitHub";
|
||||
@@ -11,72 +7,15 @@ import TwitterIcon from "@mui/icons-material/Twitter";
|
||||
import Logo from "@src/assets/Logo";
|
||||
import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon";
|
||||
|
||||
import { name, version, repository } from "@root/package.json";
|
||||
import { name, version } from "@root/package.json";
|
||||
import { useAppContext } from "@src/contexts/AppContext";
|
||||
import useUpdateCheck from "@src/hooks/useUpdateCheck";
|
||||
import { EXTERNAL_LINKS, WIKI_LINKS } from "@src/constants/externalLinks";
|
||||
|
||||
const useLastCheckedUpdateState = createPersistedState(
|
||||
"__ROWY__LAST_CHECKED_UPDATE"
|
||||
);
|
||||
export const useLatestUpdateState = createPersistedState(
|
||||
"__ROWY__LATEST_UPDATE"
|
||||
);
|
||||
|
||||
export default function About() {
|
||||
const { projectId } = useAppContext();
|
||||
|
||||
const [lastCheckedUpdate, setLastCheckedUpdate] =
|
||||
useLastCheckedUpdateState<string>();
|
||||
const [latestUpdate, setLatestUpdate] = useLatestUpdateState<null | Record<
|
||||
string,
|
||||
any
|
||||
>>(null);
|
||||
|
||||
const [checkState, setCheckState] = useState<null | "LOADING" | "NO_UPDATE">(
|
||||
null
|
||||
);
|
||||
|
||||
const checkForUpdate = useCallback(async () => {
|
||||
setCheckState("LOADING");
|
||||
|
||||
// https://docs.github.com/en/rest/reference/repos#get-the-latest-release
|
||||
const endpoint = repository.url
|
||||
.replace("github.com", "api.github.com/repos")
|
||||
.replace(/.git$/, "/releases/latest");
|
||||
try {
|
||||
const req = await fetch(endpoint, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
},
|
||||
});
|
||||
const res = await req.json();
|
||||
|
||||
if (res.tag_name > "v" + version) {
|
||||
setLatestUpdate(res);
|
||||
setCheckState(null);
|
||||
} else {
|
||||
setCheckState("NO_UPDATE");
|
||||
}
|
||||
|
||||
setLastCheckedUpdate(new Date().toISOString());
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setLatestUpdate(null);
|
||||
setCheckState("NO_UPDATE");
|
||||
}
|
||||
}, [setLastCheckedUpdate, setLatestUpdate]);
|
||||
|
||||
// Check for new updates on page load, if last check was more than 7 days ago
|
||||
useEffect(() => {
|
||||
if (!lastCheckedUpdate) checkForUpdate();
|
||||
else if (differenceInDays(new Date(), new Date(lastCheckedUpdate)) > 7)
|
||||
checkForUpdate();
|
||||
}, [lastCheckedUpdate, checkForUpdate]);
|
||||
|
||||
// Verify latest update is not installed yet
|
||||
useEffect(() => {
|
||||
if (latestUpdate?.tag_name <= "v" + version) setLatestUpdate(null);
|
||||
}, [latestUpdate, setLatestUpdate]);
|
||||
const [latestUpdate, checkForUpdates, loading] = useUpdateCheck();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -132,19 +71,29 @@ export default function About() {
|
||||
<div>
|
||||
<Grid container spacing={1} alignItems="center" direction="row">
|
||||
<Grid item xs>
|
||||
{checkState === "LOADING" ? (
|
||||
{loading ? (
|
||||
<Typography display="block">Checking for updates…</Typography>
|
||||
) : latestUpdate === null ? (
|
||||
) : latestUpdate.rowy === null ? (
|
||||
<Typography display="block">Up to date</Typography>
|
||||
) : (
|
||||
<Typography display="block">
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
backgroundColor: "#f00",
|
||||
borderRadius: "50%",
|
||||
width: 10,
|
||||
height: 10,
|
||||
marginRight: 4,
|
||||
}}
|
||||
/>
|
||||
Update available:{" "}
|
||||
<Link
|
||||
href={latestUpdate.html_url}
|
||||
href={latestUpdate.rowy.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{latestUpdate.tag_name}
|
||||
{latestUpdate.rowy.tag_name}
|
||||
<InlineOpenInNewIcon />
|
||||
</Link>
|
||||
</Typography>
|
||||
@@ -156,11 +105,8 @@ export default function About() {
|
||||
</Grid>
|
||||
|
||||
<Grid item>
|
||||
{latestUpdate === null ? (
|
||||
<LoadingButton
|
||||
onClick={checkForUpdate}
|
||||
loading={checkState === "LOADING"}
|
||||
>
|
||||
{latestUpdate.rowy === null ? (
|
||||
<LoadingButton onClick={checkForUpdates} loading={loading}>
|
||||
Check for updates
|
||||
</LoadingButton>
|
||||
) : (
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import createPersistedState from "use-persisted-state";
|
||||
import { differenceInDays } from "date-fns";
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
Typography,
|
||||
@@ -12,19 +10,14 @@ import {
|
||||
} from "@mui/material";
|
||||
import LoadingButton from "@mui/lab/LoadingButton";
|
||||
import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon";
|
||||
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
||||
|
||||
import { IProjectSettingsChildProps } from "@src/pages/Settings/ProjectSettings";
|
||||
import { EXTERNAL_LINKS, WIKI_LINKS } from "@src/constants/externalLinks";
|
||||
import useUpdateCheck from "@src/hooks/useUpdateCheck";
|
||||
import { name } from "@root/package.json";
|
||||
import { runRoutes } from "@src/constants/runRoutes";
|
||||
|
||||
const useLastCheckedUpdateState = createPersistedState(
|
||||
"__ROWY__RUN_LAST_CHECKED_UPDATE"
|
||||
);
|
||||
export const useLatestUpdateState = createPersistedState(
|
||||
"__ROWY__RUN_LATEST_UPDATE"
|
||||
);
|
||||
|
||||
export default function RowyRun({
|
||||
settings,
|
||||
updateSettings,
|
||||
@@ -41,7 +34,12 @@ export default function RowyRun({
|
||||
if (!versionReq.version) throw new Error("No version found");
|
||||
else {
|
||||
setVerified(true);
|
||||
setVersion(versionReq.version);
|
||||
|
||||
// If the deployed version is different from the last update check,
|
||||
// check for updates again to clear update
|
||||
if (versionReq.version !== latestUpdate.deployedRowyRun)
|
||||
checkForUpdates();
|
||||
|
||||
updateSettings({ rowyRunUrl: inputRowyRunUrl });
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -50,76 +48,7 @@ export default function RowyRun({
|
||||
}
|
||||
};
|
||||
|
||||
const [lastCheckedUpdate, setLastCheckedUpdate] =
|
||||
useLastCheckedUpdateState<string>();
|
||||
const [latestUpdate, setLatestUpdate] = useLatestUpdateState<null | Record<
|
||||
string,
|
||||
any
|
||||
>>(null);
|
||||
|
||||
const [checkState, setCheckState] = useState<null | "LOADING" | "NO_UPDATE">(
|
||||
null
|
||||
);
|
||||
const [version, setVersion] = useState("");
|
||||
useEffect(() => {
|
||||
fetch(settings.rowyRunUrl + runRoutes.version.path, {
|
||||
method: runRoutes.version.method,
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => setVersion(data.version));
|
||||
}, [settings.rowyRunUrl]);
|
||||
|
||||
const checkForUpdate = useCallback(async () => {
|
||||
setCheckState("LOADING");
|
||||
|
||||
// https://docs.github.com/en/rest/reference/repos#get-the-latest-release
|
||||
const endpoint =
|
||||
EXTERNAL_LINKS.rowyRunGitHub.replace(
|
||||
"github.com",
|
||||
"api.github.com/repos"
|
||||
) + "/releases/latest";
|
||||
try {
|
||||
const versionReq = await fetch(
|
||||
settings.rowyRunUrl + runRoutes.version.path,
|
||||
{ method: runRoutes.version.method }
|
||||
).then((res) => res.json());
|
||||
const version = versionReq.version;
|
||||
setVersion(version);
|
||||
|
||||
const req = await fetch(endpoint, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
},
|
||||
});
|
||||
const res = await req.json();
|
||||
|
||||
if (res.tag_name > "v" + version) {
|
||||
setLatestUpdate(res);
|
||||
setCheckState(null);
|
||||
} else {
|
||||
setCheckState("NO_UPDATE");
|
||||
}
|
||||
|
||||
setLastCheckedUpdate(new Date().toISOString());
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setLatestUpdate(null);
|
||||
setCheckState("NO_UPDATE");
|
||||
}
|
||||
}, [setLastCheckedUpdate, setLatestUpdate, settings.rowyRunUrl]);
|
||||
|
||||
// Check for new updates on page load, if last check was more than 7 days ago
|
||||
useEffect(() => {
|
||||
if (!lastCheckedUpdate) checkForUpdate();
|
||||
else if (differenceInDays(new Date(), new Date(lastCheckedUpdate)) > 7)
|
||||
checkForUpdate();
|
||||
}, [lastCheckedUpdate, checkForUpdate]);
|
||||
|
||||
// Verify latest update is not installed yet
|
||||
useEffect(() => {
|
||||
if (version && latestUpdate?.tag_name <= "v" + version)
|
||||
setLatestUpdate(null);
|
||||
}, [latestUpdate, setLatestUpdate, version]);
|
||||
const [latestUpdate, checkForUpdates, loading] = useUpdateCheck();
|
||||
|
||||
const deployButton = window.location.hostname.includes(
|
||||
EXTERNAL_LINKS.rowyAppHostName
|
||||
@@ -179,35 +108,42 @@ export default function RowyRun({
|
||||
<div>
|
||||
<Grid container spacing={1} alignItems="center" direction="row">
|
||||
<Grid item xs>
|
||||
{checkState === "LOADING" ? (
|
||||
{loading ? (
|
||||
<Typography display="block">Checking for updates…</Typography>
|
||||
) : latestUpdate === null ? (
|
||||
) : latestUpdate.rowyRun === null ? (
|
||||
<Typography display="block">Up to date</Typography>
|
||||
) : (
|
||||
<Typography display="block">
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
backgroundColor: "#f00",
|
||||
borderRadius: "50%",
|
||||
width: 10,
|
||||
height: 10,
|
||||
marginRight: 4,
|
||||
}}
|
||||
/>
|
||||
Update available:{" "}
|
||||
<Link
|
||||
href={latestUpdate.html_url}
|
||||
href={latestUpdate.rowyRun.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{latestUpdate.tag_name}
|
||||
{latestUpdate.rowyRun.tag_name}
|
||||
<InlineOpenInNewIcon />
|
||||
</Link>
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Typography display="block" color="textSecondary">
|
||||
{name} Run v{version}
|
||||
{name} Run v{latestUpdate.deployedRowyRun}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item>
|
||||
{latestUpdate === null ? (
|
||||
<LoadingButton
|
||||
onClick={checkForUpdate}
|
||||
loading={checkState === "LOADING"}
|
||||
>
|
||||
{latestUpdate.rowyRun === null ? (
|
||||
<LoadingButton onClick={checkForUpdates} loading={loading}>
|
||||
Check for updates
|
||||
</LoadingButton>
|
||||
) : (
|
||||
@@ -254,11 +190,20 @@ export default function RowyRun({
|
||||
autoComplete="url"
|
||||
error={verified === false}
|
||||
helperText={
|
||||
verified === true
|
||||
? `${name} Run is set up correctly`
|
||||
: verified === false
|
||||
? `${name} Run is not set up correctly`
|
||||
: " "
|
||||
verified === true ? (
|
||||
<>
|
||||
<CheckCircleIcon
|
||||
color="success"
|
||||
style={{ fontSize: "1rem", verticalAlign: "text-top" }}
|
||||
/>
|
||||
|
||||
{name} Run is set up correctly
|
||||
</>
|
||||
) : verified === false ? (
|
||||
`${name} Run is not set up correctly`
|
||||
) : (
|
||||
" "
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
102
src/hooks/useUpdateCheck.ts
Normal file
102
src/hooks/useUpdateCheck.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import createPersistedState from "use-persisted-state";
|
||||
import { differenceInDays } from "date-fns";
|
||||
import { compare } from "compare-versions";
|
||||
|
||||
import { useProjectContext } from "@src/contexts/ProjectContext";
|
||||
import { repository, version } from "@root/package.json";
|
||||
import { EXTERNAL_LINKS } from "@src/constants/externalLinks";
|
||||
import { runRoutes } from "@src/constants/runRoutes";
|
||||
|
||||
// https://docs.github.com/en/rest/reference/repos#get-the-latest-release
|
||||
const UPDATE_ENDPOINTS = {
|
||||
rowy: repository.url
|
||||
.replace("github.com", "api.github.com/repos")
|
||||
.replace(/.git$/, "/releases/latest"),
|
||||
|
||||
rowyRun:
|
||||
EXTERNAL_LINKS.rowyRunGitHub.replace("github.com", "api.github.com/repos") +
|
||||
"/releases/latest",
|
||||
};
|
||||
|
||||
export const useLatestUpdateState = createPersistedState(
|
||||
"__ROWY__UPDATE_CHECK"
|
||||
);
|
||||
type LatestUpdateState = {
|
||||
lastChecked: string;
|
||||
rowy: null | Record<string, any>;
|
||||
rowyRun: null | Record<string, any>;
|
||||
deployedRowy: string;
|
||||
deployedRowyRun: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the latest version of Rowy and Rowy Run from GitHub releases,
|
||||
* and the currently deployed versions
|
||||
* @returns [latestUpdate, checkForUpdates, loading]
|
||||
*/
|
||||
export default function useUpdateCheck() {
|
||||
const { rowyRun } = useProjectContext();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Store latest release from GitHub
|
||||
const [latestUpdate, setLatestUpdate] =
|
||||
useLatestUpdateState<LatestUpdateState>({
|
||||
lastChecked: "",
|
||||
rowy: null,
|
||||
rowyRun: null,
|
||||
deployedRowy: version,
|
||||
deployedRowyRun: "",
|
||||
});
|
||||
|
||||
// Check for updates using latest releases from GitHub
|
||||
const checkForUpdates = useCallback(async () => {
|
||||
if (!rowyRun) return;
|
||||
setLoading(true);
|
||||
|
||||
const newState = {
|
||||
lastChecked: new Date().toISOString(),
|
||||
rowy: null,
|
||||
rowyRun: null,
|
||||
deployedRowy: version,
|
||||
deployedRowyRun: "",
|
||||
};
|
||||
|
||||
// Make all requests simultaneously
|
||||
const [resRowy, resRowyRun, deployedRowyRun] = await Promise.all([
|
||||
fetch(UPDATE_ENDPOINTS.rowy, {
|
||||
headers: { Accept: "application/vnd.github.v3+json" },
|
||||
}).then((r) => r.json()),
|
||||
fetch(UPDATE_ENDPOINTS.rowyRun, {
|
||||
headers: { Accept: "application/vnd.github.v3+json" },
|
||||
}).then((r) => r.json()),
|
||||
rowyRun({ route: runRoutes.version }),
|
||||
]);
|
||||
|
||||
// Only store the latest release
|
||||
if (compare(resRowy.tag_name, version, ">")) newState.rowy = resRowy;
|
||||
if (compare(resRowyRun.tag_name, deployedRowyRun.version, ">"))
|
||||
newState.rowyRun = resRowyRun;
|
||||
|
||||
// Save deployed version
|
||||
newState.deployedRowyRun = deployedRowyRun.version;
|
||||
|
||||
setLatestUpdate(newState);
|
||||
setLoading(false);
|
||||
}, [setLoading, setLatestUpdate, rowyRun]);
|
||||
|
||||
// Check for new updates on page load if last check was more than 7 days ago
|
||||
// or if deployed version has changed
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
|
||||
if (
|
||||
!latestUpdate.lastChecked ||
|
||||
differenceInDays(new Date(), new Date(latestUpdate.lastChecked)) > 7 ||
|
||||
latestUpdate.deployedRowy !== version
|
||||
)
|
||||
checkForUpdates();
|
||||
}, [latestUpdate, loading, checkForUpdates]);
|
||||
|
||||
return [latestUpdate, checkForUpdates, loading] as const;
|
||||
}
|
||||
@@ -86,7 +86,11 @@ export default function ProjectSettingsPage() {
|
||||
) : (
|
||||
<Stack spacing={4}>
|
||||
{sections.map(({ title, Component, props }, i) => (
|
||||
<SettingsSection title={title} transitionTimeout={(i + 1) * 100}>
|
||||
<SettingsSection
|
||||
key={title}
|
||||
title={title}
|
||||
transitionTimeout={(i + 1) * 100}
|
||||
>
|
||||
<Component {...(props as any)} />
|
||||
</SettingsSection>
|
||||
))}
|
||||
|
||||
@@ -65,7 +65,11 @@ export default function UserSettingsPage() {
|
||||
) : (
|
||||
<Stack spacing={4}>
|
||||
{sections.map(({ title, Component, props }, i) => (
|
||||
<SettingsSection title={title} transitionTimeout={(i + 1) * 100}>
|
||||
<SettingsSection
|
||||
key={title}
|
||||
title={title}
|
||||
transitionTimeout={(i + 1) * 100}
|
||||
>
|
||||
<Component {...(props as any)} />
|
||||
</SettingsSection>
|
||||
))}
|
||||
|
||||
@@ -5426,6 +5426,11 @@ compare-versions@^3.6.0:
|
||||
resolved "https://registry.yarnpkg.com/compare-versions/-/compare-versions-3.6.0.tgz#1a5689913685e5a87637b8d3ffca75514ec41d62"
|
||||
integrity sha512-W6Af2Iw1z4CB7q4uU4hv646dW9GQuBM+YpC0UvUCWSD8w90SJjp+ujJuXaEMtAXBtSqGfMPuFOVn4/+FlaqfBA==
|
||||
|
||||
compare-versions@^4.1.1:
|
||||
version "4.1.1"
|
||||
resolved "https://registry.yarnpkg.com/compare-versions/-/compare-versions-4.1.1.tgz#d881fc9f47d6eb2b8f63109dc5e82dae39c3680c"
|
||||
integrity sha512-jHQA7zMUpbO+FhPz/kADChZVSk3edtD7c3WkEAjleBtwgAl0ji6wGrYxryaBhViGgq0A+Pb6JPhjhg9jpth4mQ==
|
||||
|
||||
component-emitter@^1.2.1:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0"
|
||||
|
||||
Reference in New Issue
Block a user