mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-10-08 20:09:17 +02:00
simple files view
This commit is contained in:
@@ -17,6 +17,7 @@
|
|||||||
"@emotion/react": "^11.11.0",
|
"@emotion/react": "^11.11.0",
|
||||||
"@emotion/styled": "^11.11.0",
|
"@emotion/styled": "^11.11.0",
|
||||||
"bech32": "^2.0.0",
|
"bech32": "^2.0.0",
|
||||||
|
"blurhash": "^2.0.5",
|
||||||
"cheerio": "^1.0.0-rc.12",
|
"cheerio": "^1.0.0-rc.12",
|
||||||
"dayjs": "^1.11.9",
|
"dayjs": "^1.11.9",
|
||||||
"debug": "^4.3.4",
|
"debug": "^4.3.4",
|
||||||
|
@@ -42,6 +42,7 @@ const StreamsView = React.lazy(() => import("./views/streams"));
|
|||||||
const StreamView = React.lazy(() => import("./views/streams/stream"));
|
const StreamView = React.lazy(() => import("./views/streams/stream"));
|
||||||
const SearchView = React.lazy(() => import("./views/search"));
|
const SearchView = React.lazy(() => import("./views/search"));
|
||||||
const MapView = React.lazy(() => import("./views/map"));
|
const MapView = React.lazy(() => import("./views/map"));
|
||||||
|
const FilesView = React.lazy(() => import("./views/files"));
|
||||||
|
|
||||||
const RootPage = () => {
|
const RootPage = () => {
|
||||||
useSetColorMode();
|
useSetColorMode();
|
||||||
@@ -106,6 +107,7 @@ const router = createHashRouter([
|
|||||||
element: <NoteView />,
|
element: <NoteView />,
|
||||||
},
|
},
|
||||||
{ path: "settings", element: <SettingsView /> },
|
{ path: "settings", element: <SettingsView /> },
|
||||||
|
{ path: "files", element: <FilesView /> },
|
||||||
{ path: "relays/reviews", element: <RelayReviewsView /> },
|
{ path: "relays/reviews", element: <RelayReviewsView /> },
|
||||||
{ path: "relays", element: <RelaysView /> },
|
{ path: "relays", element: <RelaysView /> },
|
||||||
{ path: "r/:relay", element: <RelayView /> },
|
{ path: "r/:relay", element: <RelayView /> },
|
||||||
|
23
src/components/blured-image.tsx
Normal file
23
src/components/blured-image.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { Box, Image, ImageProps, useDisclosure } from "@chakra-ui/react";
|
||||||
|
|
||||||
|
export default function BlurredImage(props: ImageProps) {
|
||||||
|
const { isOpen, onOpen } = useDisclosure();
|
||||||
|
return (
|
||||||
|
<Box overflow="hidden">
|
||||||
|
<Image
|
||||||
|
onClick={
|
||||||
|
!isOpen
|
||||||
|
? (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
onOpen();
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
cursor="pointer"
|
||||||
|
filter={isOpen ? "" : "blur(1.5rem)"}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
29
src/components/blurhash-image.tsx
Normal file
29
src/components/blurhash-image.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { Box, BoxProps } from "@chakra-ui/react";
|
||||||
|
import { decode } from "blurhash";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
export type BlurhashImageProps = {
|
||||||
|
blurhash: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
} & Omit<BoxProps, "width" | "height" | "children">;
|
||||||
|
|
||||||
|
export default function BlurhashImage({ blurhash, width, height, ...props }: BlurhashImageProps) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canvasRef.current) return;
|
||||||
|
const ctx = canvasRef.current.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
ctx.canvas.width = width;
|
||||||
|
ctx.canvas.height = height;
|
||||||
|
|
||||||
|
const imageData = ctx.createImageData(width, height);
|
||||||
|
const pixels = decode(blurhash, width, height);
|
||||||
|
imageData.data.set(pixels);
|
||||||
|
ctx.putImageData(imageData, 0, 0);
|
||||||
|
}, [blurhash, width, height]);
|
||||||
|
|
||||||
|
return <Box as="canvas" ref={canvasRef} {...props} />;
|
||||||
|
}
|
@@ -3,28 +3,7 @@ import appSettings from "../../services/settings/app-settings";
|
|||||||
import { ImageGalleryLink } from "../image-gallery";
|
import { ImageGalleryLink } from "../image-gallery";
|
||||||
import { useTrusted } from "../../providers/trust";
|
import { useTrusted } from "../../providers/trust";
|
||||||
import OpenGraphCard from "../open-graph-card";
|
import OpenGraphCard from "../open-graph-card";
|
||||||
|
import BlurredImage from "../blured-image";
|
||||||
const BlurredImage = (props: ImageProps) => {
|
|
||||||
const { isOpen, onOpen } = useDisclosure();
|
|
||||||
return (
|
|
||||||
<Box overflow="hidden">
|
|
||||||
<Image
|
|
||||||
onClick={
|
|
||||||
!isOpen
|
|
||||||
? (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
e.preventDefault();
|
|
||||||
onOpen();
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
cursor="pointer"
|
|
||||||
filter={isOpen ? "" : "blur(1.5rem)"}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const EmbeddedImage = ({ src }: { src: string }) => {
|
const EmbeddedImage = ({ src }: { src: string }) => {
|
||||||
const trusted = useTrusted();
|
const trusted = useTrusted();
|
||||||
|
@@ -301,3 +301,9 @@ export const StarHalfIcon = createIcon({
|
|||||||
d: "M12.0006 15.968L16.2473 18.3451L15.2988 13.5717L18.8719 10.2674L14.039 9.69434L12.0006 5.27502V15.968ZM12.0006 18.26L4.94715 22.2082L6.52248 14.2799L0.587891 8.7918L8.61493 7.84006L12.0006 0.5L15.3862 7.84006L23.4132 8.7918L17.4787 14.2799L19.054 22.2082L12.0006 18.26Z",
|
d: "M12.0006 15.968L16.2473 18.3451L15.2988 13.5717L18.8719 10.2674L14.039 9.69434L12.0006 5.27502V15.968ZM12.0006 18.26L4.94715 22.2082L6.52248 14.2799L0.587891 8.7918L8.61493 7.84006L12.0006 0.5L15.3862 7.84006L23.4132 8.7918L17.4787 14.2799L19.054 22.2082L12.0006 18.26Z",
|
||||||
defaultProps,
|
defaultProps,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const FileIcon = createIcon({
|
||||||
|
displayName: "FileIcon",
|
||||||
|
d: "M9 2.00318V2H19.9978C20.5513 2 21 2.45531 21 2.9918V21.0082C21 21.556 20.5551 22 20.0066 22H3.9934C3.44476 22 3 21.5501 3 20.9932V8L9 2.00318ZM5.82918 8H9V4.83086L5.82918 8ZM11 4V9C11 9.55228 10.5523 10 10 10H5V20H19V4H11Z",
|
||||||
|
defaultProps,
|
||||||
|
});
|
||||||
|
@@ -8,6 +8,7 @@ import {
|
|||||||
ChatIcon,
|
ChatIcon,
|
||||||
EditIcon,
|
EditIcon,
|
||||||
FeedIcon,
|
FeedIcon,
|
||||||
|
FileIcon,
|
||||||
LiveStreamIcon,
|
LiveStreamIcon,
|
||||||
LogoutIcon,
|
LogoutIcon,
|
||||||
MapIcon,
|
MapIcon,
|
||||||
@@ -50,6 +51,9 @@ export default function DesktopSideNav(props: Omit<FlexProps, "children">) {
|
|||||||
<Button onClick={() => navigate("/streams")} leftIcon={<LiveStreamIcon />}>
|
<Button onClick={() => navigate("/streams")} leftIcon={<LiveStreamIcon />}>
|
||||||
Streams
|
Streams
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button onClick={() => navigate("/files")} leftIcon={<FileIcon />}>
|
||||||
|
Files
|
||||||
|
</Button>
|
||||||
<Button onClick={() => navigate("/map")} leftIcon={<MapIcon />}>
|
<Button onClick={() => navigate("/map")} leftIcon={<MapIcon />}>
|
||||||
Map
|
Map
|
||||||
</Button>
|
</Button>
|
||||||
|
@@ -13,7 +13,17 @@ import {
|
|||||||
} from "@chakra-ui/react";
|
} from "@chakra-ui/react";
|
||||||
import { Link as RouterLink, useNavigate } from "react-router-dom";
|
import { Link as RouterLink, useNavigate } from "react-router-dom";
|
||||||
import { ConnectedRelays } from "../connected-relays";
|
import { ConnectedRelays } from "../connected-relays";
|
||||||
import { HomeIcon, LiveStreamIcon, LogoutIcon, ProfileIcon, RelayIcon, SearchIcon, SettingsIcon } from "../icons";
|
import {
|
||||||
|
FileIcon,
|
||||||
|
HomeIcon,
|
||||||
|
LiveStreamIcon,
|
||||||
|
LogoutIcon,
|
||||||
|
MapIcon,
|
||||||
|
ProfileIcon,
|
||||||
|
RelayIcon,
|
||||||
|
SearchIcon,
|
||||||
|
SettingsIcon,
|
||||||
|
} from "../icons";
|
||||||
import { UserAvatar } from "../user-avatar";
|
import { UserAvatar } from "../user-avatar";
|
||||||
import { UserLink } from "../user-link";
|
import { UserLink } from "../user-link";
|
||||||
import AccountSwitcher from "./account-switcher";
|
import AccountSwitcher from "./account-switcher";
|
||||||
@@ -57,6 +67,12 @@ export default function MobileSideDrawer({ ...props }: Omit<DrawerProps, "childr
|
|||||||
<Button onClick={() => navigate("/streams")} leftIcon={<LiveStreamIcon />}>
|
<Button onClick={() => navigate("/streams")} leftIcon={<LiveStreamIcon />}>
|
||||||
Streams
|
Streams
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button onClick={() => navigate("/files")} leftIcon={<FileIcon />}>
|
||||||
|
Files
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => navigate("/map")} leftIcon={<MapIcon />}>
|
||||||
|
Map
|
||||||
|
</Button>
|
||||||
<Button onClick={() => navigate("/relays")} leftIcon={<RelayIcon />}>
|
<Button onClick={() => navigate("/relays")} leftIcon={<RelayIcon />}>
|
||||||
Relays
|
Relays
|
||||||
</Button>
|
</Button>
|
||||||
|
44
src/helpers/nostr/files.ts
Normal file
44
src/helpers/nostr/files.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { NostrEvent } from "../../types/nostr-event";
|
||||||
|
|
||||||
|
export type ParsedImageFile = {
|
||||||
|
url: string;
|
||||||
|
mimeType: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
size?: number;
|
||||||
|
magnet?: string;
|
||||||
|
sha256Hash?: string;
|
||||||
|
infoHash?: string;
|
||||||
|
blurhash?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseImageFile(event: NostrEvent): ParsedImageFile {
|
||||||
|
const url = event.tags.find((t) => t[0] === "url" && t[1])?.[1];
|
||||||
|
const mimeType = event.tags.find((t) => t[0] === "m" && t[1])?.[1];
|
||||||
|
const magnet = event.tags.find((t) => t[0] === "magnet" && t[1])?.[1];
|
||||||
|
const infoHash = event.tags.find((t) => t[0] === "i" && t[1])?.[1];
|
||||||
|
const size = event.tags.find((t) => t[0] === "i" && t[1])?.[1];
|
||||||
|
const sha256Hash = event.tags.find((t) => t[0] === "x" && t[1])?.[1];
|
||||||
|
const blurhash = event.tags.find((t) => t[0] === "blurhash" && t[1])?.[1];
|
||||||
|
|
||||||
|
const dimensions = event.tags.find((t) => t[0] === "dim" && t[1])?.[1];
|
||||||
|
const [width, height] = dimensions?.split("x").map((v) => parseInt(v)) ?? [];
|
||||||
|
|
||||||
|
if (!url) throw new Error("missing url");
|
||||||
|
if (!mimeType) throw new Error("missing MIME Type");
|
||||||
|
if (width !== undefined && height !== undefined) {
|
||||||
|
if (!Number.isFinite(width) || !Number.isFinite(height)) throw new Error("bad dimensions");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
mimeType,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
magnet,
|
||||||
|
infoHash,
|
||||||
|
sha256Hash,
|
||||||
|
blurhash,
|
||||||
|
size: size ? parseInt(size) : undefined,
|
||||||
|
};
|
||||||
|
}
|
@@ -18,6 +18,7 @@ export type NostrQuery = {
|
|||||||
"#r"?: string[];
|
"#r"?: string[];
|
||||||
"#l"?: string[];
|
"#l"?: string[];
|
||||||
"#g"?: string[];
|
"#g"?: string[];
|
||||||
|
"#m"?: string[];
|
||||||
since?: number;
|
since?: number;
|
||||||
until?: number;
|
until?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
160
src/views/files/index.tsx
Normal file
160
src/views/files/index.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Code,
|
||||||
|
Divider,
|
||||||
|
Flex,
|
||||||
|
Image,
|
||||||
|
Popover,
|
||||||
|
PopoverArrow,
|
||||||
|
PopoverBody,
|
||||||
|
PopoverCloseButton,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
SimpleGrid,
|
||||||
|
Text,
|
||||||
|
useDisclosure,
|
||||||
|
} from "@chakra-ui/react";
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import useTimelineLoader from "../../hooks/use-timeline-loader";
|
||||||
|
import { useReadRelayUrls } from "../../hooks/use-client-relays";
|
||||||
|
import useSubject from "../../hooks/use-subject";
|
||||||
|
import { NostrEvent } from "../../types/nostr-event";
|
||||||
|
import { parseImageFile } from "../../helpers/nostr/files";
|
||||||
|
import BlurhashImage from "../../components/blurhash-image";
|
||||||
|
import { ErrorBoundary } from "../../components/error-boundary";
|
||||||
|
import useAppSettings from "../../hooks/use-app-settings";
|
||||||
|
import { useTrusted } from "../../providers/trust";
|
||||||
|
import BlurredImage from "../../components/blured-image";
|
||||||
|
|
||||||
|
const FILE_KIND = 1063;
|
||||||
|
const VIDEO_TYPES = ["video/mp4", "video/webm"];
|
||||||
|
const IMAGE_TYPES = ["image/png", "image/jpeg", "image/svg+xml", "image/webp", "image/gif"];
|
||||||
|
const AUDIO_TYPES = ["audio/webm", "audio/wav", "audio/ogg"];
|
||||||
|
const TEXT_TYPES = ["text/plain"];
|
||||||
|
|
||||||
|
function ImageFile({ event }: { event: NostrEvent }) {
|
||||||
|
const parsed = parseImageFile(event);
|
||||||
|
const settings = useAppSettings();
|
||||||
|
const trust = useTrusted();
|
||||||
|
|
||||||
|
const shouldBlur = settings.blurImages && !trust;
|
||||||
|
|
||||||
|
const showImage = useDisclosure();
|
||||||
|
if (shouldBlur && parsed.blurhash && parsed.width && parsed.height && !showImage.isOpen) {
|
||||||
|
const aspect = parsed.width / parsed.height;
|
||||||
|
return (
|
||||||
|
<BlurhashImage
|
||||||
|
blurhash={parsed.blurhash}
|
||||||
|
width={64 * aspect}
|
||||||
|
height={64}
|
||||||
|
onClick={showImage.onOpen}
|
||||||
|
cursor="pointer"
|
||||||
|
w="full"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ImageComponent = shouldBlur ? BlurredImage : Image;
|
||||||
|
return <ImageComponent src={parsed.url} w="full" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FileType({ event }: { event: NostrEvent }) {
|
||||||
|
const mimeType = event.tags.find((t) => t[0] === "m" && t[1])?.[1];
|
||||||
|
|
||||||
|
if (!mimeType) throw new Error("missing MIME type");
|
||||||
|
|
||||||
|
if (IMAGE_TYPES.includes(mimeType)) {
|
||||||
|
return <ImageFile event={event} />;
|
||||||
|
}
|
||||||
|
return <Text>Unknown mine type {mimeType}</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FilesView() {
|
||||||
|
const [selectedTypes, setSelectedTypes] = useState<string[]>(IMAGE_TYPES);
|
||||||
|
const toggleType = useCallback(
|
||||||
|
(type: string) => {
|
||||||
|
setSelectedTypes((arr) => {
|
||||||
|
if (arr.includes(type)) {
|
||||||
|
return arr.filter((t) => t !== type);
|
||||||
|
} else return arr.concat(type);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[setSelectedTypes]
|
||||||
|
);
|
||||||
|
|
||||||
|
const relays = useReadRelayUrls();
|
||||||
|
const timeline = useTimelineLoader(
|
||||||
|
"files",
|
||||||
|
relays,
|
||||||
|
{ kinds: [FILE_KIND], "#m": selectedTypes },
|
||||||
|
{ enabled: selectedTypes.length > 0 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const events = useSubject(timeline.timeline);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex direction="column" gap="2" p="2">
|
||||||
|
<Flex>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger>
|
||||||
|
<Button>{selectedTypes.length} Selected types</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent w="xl">
|
||||||
|
<PopoverArrow />
|
||||||
|
<PopoverCloseButton />
|
||||||
|
<PopoverBody>
|
||||||
|
<Flex gap="4">
|
||||||
|
<Flex gap="2" direction="column">
|
||||||
|
<Checkbox isDisabled>Images</Checkbox>
|
||||||
|
<Divider />
|
||||||
|
{IMAGE_TYPES.map((type) => (
|
||||||
|
<Checkbox key={type} isChecked={selectedTypes.includes(type)} onChange={() => toggleType(type)}>
|
||||||
|
{type}
|
||||||
|
</Checkbox>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
<Flex gap="2" direction="column">
|
||||||
|
<Checkbox isDisabled>Videos</Checkbox>
|
||||||
|
<Divider />
|
||||||
|
{VIDEO_TYPES.map((type) => (
|
||||||
|
<Checkbox key={type} isChecked={selectedTypes.includes(type)} onChange={() => toggleType(type)}>
|
||||||
|
{type}
|
||||||
|
</Checkbox>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
<Flex gap="2" direction="column">
|
||||||
|
<Checkbox isDisabled>Audio</Checkbox>
|
||||||
|
<Divider />
|
||||||
|
{AUDIO_TYPES.map((type) => (
|
||||||
|
<Checkbox key={type} isChecked={selectedTypes.includes(type)} onChange={() => toggleType(type)}>
|
||||||
|
{type}
|
||||||
|
</Checkbox>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
<Flex gap="2" direction="column">
|
||||||
|
<Checkbox isDisabled>Text</Checkbox>
|
||||||
|
<Divider />
|
||||||
|
{TEXT_TYPES.map((type) => (
|
||||||
|
<Checkbox key={type} isChecked={selectedTypes.includes(type)} onChange={() => toggleType(type)}>
|
||||||
|
{type}
|
||||||
|
</Checkbox>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
</PopoverBody>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<SimpleGrid minChildWidth="20rem" spacing="2">
|
||||||
|
{events.map((event) => (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<FileType key={event.id} event={event} />
|
||||||
|
{/* <Code whiteSpace="pre">{JSON.stringify(event, null, 2)}</Code> */}
|
||||||
|
</ErrorBoundary>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
@@ -2964,6 +2964,11 @@ bluebird@^3.7.2:
|
|||||||
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f"
|
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f"
|
||||||
integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==
|
integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==
|
||||||
|
|
||||||
|
blurhash@^2.0.5:
|
||||||
|
version "2.0.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/blurhash/-/blurhash-2.0.5.tgz#efde729fc14a2f03571a6aa91b49cba80d1abe4b"
|
||||||
|
integrity sha512-cRygWd7kGBQO3VEhPiTgq4Wc43ctsM+o46urrmPOiuAe+07fzlSB9OJVdpgDL0jPqXUVQ9ht7aq7kxOeJHRK+w==
|
||||||
|
|
||||||
boolbase@^1.0.0:
|
boolbase@^1.0.0:
|
||||||
version "1.0.0"
|
version "1.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e"
|
resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e"
|
||||||
|
Reference in New Issue
Block a user