Image carousel (#1425)
* Add image carousel in fullscreen * Flip through all images of a post * Disable image selection in fullscreen * Keep max-width: 100vw for images * Fix missing dependency * fix merge resolve bug * better css * refactor, keypress/swipe events, remove scoll * changes after self-review * give previews their own carousel * hooks for arrow keys and swiping --------- Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com> Co-authored-by: k00b <k00b@stacker.news>
This commit is contained in:
		
							parent
							
								
									5371e1abf8
								
							
						
					
					
						commit
						9f79d588a8
					
				
							
								
								
									
										129
									
								
								components/carousel.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										129
									
								
								components/carousel.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,129 @@ | |||||||
|  | import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' | ||||||
|  | import classNames from 'classnames' | ||||||
|  | import ArrowLeft from '@/svgs/arrow-left-line.svg' | ||||||
|  | import ArrowRight from '@/svgs/arrow-right-line.svg' | ||||||
|  | import styles from './carousel.module.css' | ||||||
|  | import { useShowModal } from './modal' | ||||||
|  | import { Dropdown } from 'react-bootstrap' | ||||||
|  | 
 | ||||||
|  | function useSwiping ({ moveLeft, moveRight }) { | ||||||
|  |   const [touchStartX, setTouchStartX] = useState(null) | ||||||
|  | 
 | ||||||
|  |   const onTouchStart = useCallback((e) => { | ||||||
|  |     if (e.touches.length === 1) { | ||||||
|  |       setTouchStartX(e.touches[0].clientX) | ||||||
|  |     } | ||||||
|  |   }, []) | ||||||
|  | 
 | ||||||
|  |   const onTouchEnd = useCallback((e) => { | ||||||
|  |     if (touchStartX !== null) { | ||||||
|  |       const touchEndX = e.changedTouches[0].clientX | ||||||
|  |       const diff = touchEndX - touchStartX | ||||||
|  |       if (diff > 50) { | ||||||
|  |         moveLeft() | ||||||
|  |       } else if (diff < -50) { | ||||||
|  |         moveRight() | ||||||
|  |       } | ||||||
|  |       setTouchStartX(null) | ||||||
|  |     } | ||||||
|  |   }, [touchStartX, moveLeft, moveRight]) | ||||||
|  | 
 | ||||||
|  |   useEffect(() => { | ||||||
|  |     document.addEventListener('touchstart', onTouchStart) | ||||||
|  |     document.addEventListener('touchend', onTouchEnd) | ||||||
|  |     return () => { | ||||||
|  |       document.removeEventListener('touchstart', onTouchStart) | ||||||
|  |       document.removeEventListener('touchend', onTouchEnd) | ||||||
|  |     } | ||||||
|  |   }, [onTouchStart, onTouchEnd]) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function useArrowKeys ({ moveLeft, moveRight }) { | ||||||
|  |   const onKeyDown = useCallback((e) => { | ||||||
|  |     if (e.key === 'ArrowLeft') { | ||||||
|  |       moveLeft() | ||||||
|  |     } else if (e.key === 'ArrowRight') { | ||||||
|  |       moveRight() | ||||||
|  |     } | ||||||
|  |   }, [moveLeft, moveRight]) | ||||||
|  | 
 | ||||||
|  |   useEffect(() => { | ||||||
|  |     document.addEventListener('keydown', onKeyDown) | ||||||
|  |     return () => document.removeEventListener('keydown', onKeyDown) | ||||||
|  |   }, [onKeyDown]) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export default function Carousel ({ close, mediaArr, src, originalSrc, setOptions }) { | ||||||
|  |   const [index, setIndex] = useState(mediaArr.findIndex(([key]) => key === src)) | ||||||
|  |   const [currentSrc, canGoLeft, canGoRight] = useMemo(() => { | ||||||
|  |     return [mediaArr[index][0], index > 0, index < mediaArr.length - 1] | ||||||
|  |   }, [mediaArr, index]) | ||||||
|  | 
 | ||||||
|  |   const moveLeft = useCallback(() => { | ||||||
|  |     setIndex(i => Math.max(0, i - 1)) | ||||||
|  |   }, [setIndex]) | ||||||
|  | 
 | ||||||
|  |   const moveRight = useCallback(() => { | ||||||
|  |     setIndex(i => Math.min(mediaArr.length - 1, i + 1)) | ||||||
|  |   }, [setIndex, mediaArr.length]) | ||||||
|  | 
 | ||||||
|  |   useSwiping({ moveLeft, moveRight }) | ||||||
|  |   useArrowKeys({ moveLeft, moveRight }) | ||||||
|  | 
 | ||||||
|  |   return ( | ||||||
|  |     <div className={styles.fullScreenContainer} onClick={close}> | ||||||
|  |       <img className={styles.fullScreen} src={currentSrc} /> | ||||||
|  |       <div className={styles.fullScreenNavContainer}> | ||||||
|  |         <div | ||||||
|  |           className={classNames(styles.fullScreenNav, !canGoLeft && 'invisible', styles.left)} | ||||||
|  |           onClick={(e) => { | ||||||
|  |             e.stopPropagation() | ||||||
|  |             moveLeft() | ||||||
|  |           }} | ||||||
|  |         > | ||||||
|  |           <ArrowLeft width={34} height={34} /> | ||||||
|  |         </div> | ||||||
|  |         <div | ||||||
|  |           className={classNames(styles.fullScreenNav, !canGoRight && 'invisible', styles.right)} | ||||||
|  |           onClick={(e) => { | ||||||
|  |             e.stopPropagation() | ||||||
|  |             moveRight() | ||||||
|  |           }} | ||||||
|  |         > | ||||||
|  |           <ArrowRight width={34} height={34} /> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |   ) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | const CarouselContext = createContext() | ||||||
|  | 
 | ||||||
|  | function CarouselOverflow ({ originalSrc, rel }) { | ||||||
|  |   return <Dropdown.Item href={originalSrc} rel={rel} target='_blank'>view original</Dropdown.Item> | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function CarouselProvider ({ children }) { | ||||||
|  |   const media = useRef(new Map()) | ||||||
|  |   const showModal = useShowModal() | ||||||
|  | 
 | ||||||
|  |   const showCarousel = useCallback(({ src }) => { | ||||||
|  |     showModal((close, setOptions) => { | ||||||
|  |       return <Carousel close={close} mediaArr={Array.from(media.current.entries())} src={src} setOptions={setOptions} /> | ||||||
|  |     }, { | ||||||
|  |       fullScreen: true, | ||||||
|  |       overflow: <CarouselOverflow {...media.current.get(src)} /> | ||||||
|  |     }) | ||||||
|  |   }, [showModal, media.current]) | ||||||
|  | 
 | ||||||
|  |   const addMedia = useCallback(({ src, originalSrc, rel }) => { | ||||||
|  |     media.current.set(src, { src, originalSrc, rel }) | ||||||
|  |   }, [media.current]) | ||||||
|  | 
 | ||||||
|  |   const value = useMemo(() => ({ showCarousel, addMedia }), [showCarousel, addMedia]) | ||||||
|  |   return <CarouselContext.Provider value={value}>{children}</CarouselContext.Provider> | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function useCarousel () { | ||||||
|  |   return useContext(CarouselContext) | ||||||
|  | } | ||||||
							
								
								
									
										63
									
								
								components/carousel.module.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								components/carousel.module.css
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,63 @@ | |||||||
|  | div.fullScreenNavContainer { | ||||||
|  |     height: 100%; | ||||||
|  |     width: 100%; | ||||||
|  |     position: absolute; | ||||||
|  |     top: 0; | ||||||
|  |     left: 0; | ||||||
|  |     pointer-events: none; | ||||||
|  |     flex-direction: row; | ||||||
|  |     display: flex; | ||||||
|  |     justify-content: space-between; | ||||||
|  |     align-items: center; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | img.fullScreen { | ||||||
|  |     cursor: zoom-out !important; | ||||||
|  |     max-height: 100%; | ||||||
|  |     max-width: 100vw; | ||||||
|  |     min-width: 0; | ||||||
|  |     min-height: 0; | ||||||
|  |     align-self: center; | ||||||
|  |     justify-self: center; | ||||||
|  |     user-select: none; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .fullScreenContainer { | ||||||
|  |     --bs-columns: 1; | ||||||
|  |     --bs-rows: 1; | ||||||
|  |     display: grid; | ||||||
|  |     width: 100%; | ||||||
|  |     height: 100%; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | div.fullScreenNav:hover > svg { | ||||||
|  |     background-color: rgba(0, 0, 0, .5); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | div.fullScreenNav  { | ||||||
|  |     cursor: pointer; | ||||||
|  |     pointer-events: auto; | ||||||
|  |     width: 72px; | ||||||
|  |     height: 72px; | ||||||
|  |     display: flex; | ||||||
|  |     align-items: center; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | div.fullScreenNav.left { | ||||||
|  |     justify-content: flex-start; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | div.fullScreenNav.right { | ||||||
|  |     justify-content: flex-end; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | div.fullScreenNav > svg { | ||||||
|  |     border-radius: 50%; | ||||||
|  |     backdrop-filter: blur(4px); | ||||||
|  |     background-color: rgba(0, 0, 0, 0.7); | ||||||
|  |     fill: white; | ||||||
|  |     max-height: 34px; | ||||||
|  |     max-width: 34px; | ||||||
|  |     padding: 0.35rem; | ||||||
|  |     margin: .75rem; | ||||||
|  | } | ||||||
| @ -24,6 +24,7 @@ import { numWithUnits } from '@/lib/format' | |||||||
| import { useQuoteReply } from './use-quote-reply' | import { useQuoteReply } from './use-quote-reply' | ||||||
| import { UNKNOWN_LINK_REL } from '@/lib/constants' | import { UNKNOWN_LINK_REL } from '@/lib/constants' | ||||||
| import classNames from 'classnames' | import classNames from 'classnames' | ||||||
|  | import { CarouselProvider } from './carousel' | ||||||
| 
 | 
 | ||||||
| function BioItem ({ item, handleClick }) { | function BioItem ({ item, handleClick }) { | ||||||
|   const { me } = useMe() |   const { me } = useMe() | ||||||
| @ -156,20 +157,22 @@ export default function ItemFull ({ item, bio, rank, ...props }) { | |||||||
|           </div>) |           </div>) | ||||||
|         : <div />} |         : <div />} | ||||||
|       <RootProvider root={item.root || item}> |       <RootProvider root={item.root || item}> | ||||||
|         {item.parentId |         <CarouselProvider key={item.id}> | ||||||
|           ? <Comment topLevel item={item} replyOpen includeParent noComments {...props} /> |           {item.parentId | ||||||
|           : ( |             ? <Comment topLevel item={item} replyOpen includeParent noComments {...props} /> | ||||||
|             <div>{bio |             : ( | ||||||
|               ? <BioItem item={item} {...props} /> |               <div>{bio | ||||||
|               : <TopLevelItem item={item} {...props} />} |                 ? <BioItem item={item} {...props} /> | ||||||
|             </div>)} |                 : <TopLevelItem item={item} {...props} />} | ||||||
|         {item.comments && |               </div>)} | ||||||
|           <div className={styles.comments}> |           {item.comments && | ||||||
|             <Comments |             <div className={styles.comments}> | ||||||
|               parentId={item.id} parentCreatedAt={item.createdAt} |               <Comments | ||||||
|               pinned={item.position} bio={bio} commentSats={item.commentSats} comments={item.comments} |                 parentId={item.id} parentCreatedAt={item.createdAt} | ||||||
|             /> |                 pinned={item.position} bio={bio} commentSats={item.commentSats} comments={item.comments} | ||||||
|           </div>} |               /> | ||||||
|  |             </div>} | ||||||
|  |         </CarouselProvider> | ||||||
|       </RootProvider> |       </RootProvider> | ||||||
|     </> |     </> | ||||||
|   ) |   ) | ||||||
|  | |||||||
| @ -1,14 +1,14 @@ | |||||||
| import styles from './text.module.css' | import styles from './text.module.css' | ||||||
| import { useState, useEffect, useMemo, useCallback, memo, useRef } from 'react' | import { useState, useEffect, useMemo, useCallback, memo, useRef } from 'react' | ||||||
| import { decodeProxyUrl, IMGPROXY_URL_REGEXP, MEDIA_DOMAIN_REGEXP, parseEmbedUrl } from '@/lib/url' | import { decodeProxyUrl, IMGPROXY_URL_REGEXP, MEDIA_DOMAIN_REGEXP, parseEmbedUrl } from '@/lib/url' | ||||||
| import { useShowModal } from './modal' |  | ||||||
| import { useMe } from './me' | import { useMe } from './me' | ||||||
| import { Button, Dropdown } from 'react-bootstrap' | import { Button } from 'react-bootstrap' | ||||||
| import { UNKNOWN_LINK_REL } from '@/lib/constants' | import { UNKNOWN_LINK_REL } from '@/lib/constants' | ||||||
| import classNames from 'classnames' | import classNames from 'classnames' | ||||||
| import { TwitterTweetEmbed } from 'react-twitter-embed' | import { TwitterTweetEmbed } from 'react-twitter-embed' | ||||||
| import YouTube from 'react-youtube' | import YouTube from 'react-youtube' | ||||||
| import useDarkMode from './dark-mode' | import useDarkMode from './dark-mode' | ||||||
|  | import { useCarousel } from './carousel' | ||||||
| 
 | 
 | ||||||
| function LinkRaw ({ href, children, src, rel }) { | function LinkRaw ({ href, children, src, rel }) { | ||||||
|   const isRawURL = /^https?:\/\//.test(children?.[0]) |   const isRawURL = /^https?:\/\//.test(children?.[0]) | ||||||
| @ -52,27 +52,15 @@ const Media = memo(function Media ({ src, bestResSrc, srcSet, sizes, width, heig | |||||||
| export default function MediaOrLink ({ linkFallback = true, ...props }) { | export default function MediaOrLink ({ linkFallback = true, ...props }) { | ||||||
|   const media = useMediaHelper(props) |   const media = useMediaHelper(props) | ||||||
|   const [error, setError] = useState(false) |   const [error, setError] = useState(false) | ||||||
|   const showModal = useShowModal() |   const { showCarousel, addMedia } = useCarousel() | ||||||
| 
 | 
 | ||||||
|   const handleClick = useCallback(() => showModal(close => { |   useEffect(() => { | ||||||
|     return ( |     if (!media.image) return | ||||||
|       <div |     addMedia({ src: media.bestResSrc, originalSrc: media.originalSrc, rel: props.rel }) | ||||||
|         className={styles.fullScreenContainer} |   }, [media.image]) | ||||||
|         onClick={close} | 
 | ||||||
|       > |   const handleClick = useCallback(() => showCarousel({ src: media.bestResSrc }), | ||||||
|         <img className={styles.fullScreen} src={media.bestResSrc} /> |     [showCarousel, media.bestResSrc]) | ||||||
|       </div> |  | ||||||
|     ) |  | ||||||
|   }, { |  | ||||||
|     fullScreen: true, |  | ||||||
|     overflow: ( |  | ||||||
|       <Dropdown.Item |  | ||||||
|         href={media.originalSrc} target='_blank' |  | ||||||
|         rel={props.rel ?? UNKNOWN_LINK_REL} |  | ||||||
|       > |  | ||||||
|         open original |  | ||||||
|       </Dropdown.Item>) |  | ||||||
|   }), [showModal, media.originalSrc, styles, media.bestResSrc]) |  | ||||||
| 
 | 
 | ||||||
|   const handleError = useCallback((err) => { |   const handleError = useCallback((err) => { | ||||||
|     console.error('Error loading media', err) |     console.error('Error loading media', err) | ||||||
|  | |||||||
| @ -36,6 +36,14 @@ export default function useModal () { | |||||||
|     forceUpdate() |     forceUpdate() | ||||||
|   }, []) |   }, []) | ||||||
| 
 | 
 | ||||||
|  |   const setOptions = useCallback(options => { | ||||||
|  |     const current = getCurrentContent() | ||||||
|  |     if (current) { | ||||||
|  |       current.options = { ...current.options, ...options } | ||||||
|  |       forceUpdate() | ||||||
|  |     } | ||||||
|  |   }, [getCurrentContent, forceUpdate]) | ||||||
|  | 
 | ||||||
|   // this is called on every navigation due to below useEffect
 |   // this is called on every navigation due to below useEffect
 | ||||||
|   const onClose = useCallback(() => { |   const onClose = useCallback(() => { | ||||||
|     while (modalStack.current.length) { |     while (modalStack.current.length) { | ||||||
| @ -94,7 +102,7 @@ export default function useModal () { | |||||||
| 
 | 
 | ||||||
|   const showModal = useCallback( |   const showModal = useCallback( | ||||||
|     (getContent, options) => { |     (getContent, options) => { | ||||||
|       const ref = { node: getContent(onClose), options } |       const ref = { node: getContent(onClose, setOptions), options } | ||||||
|       if (options?.replaceModal) { |       if (options?.replaceModal) { | ||||||
|         modalStack.current = [ref] |         modalStack.current = [ref] | ||||||
|       } else { |       } else { | ||||||
|  | |||||||
| @ -23,6 +23,7 @@ import isEqual from 'lodash/isEqual' | |||||||
| import UserPopover from './user-popover' | import UserPopover from './user-popover' | ||||||
| import ItemPopover from './item-popover' | import ItemPopover from './item-popover' | ||||||
| import classNames from 'classnames' | import classNames from 'classnames' | ||||||
|  | import { CarouselProvider, useCarousel } from './carousel' | ||||||
| 
 | 
 | ||||||
| // Explicitely defined start/end tags & which CSS class from text.module.css to apply
 | // Explicitely defined start/end tags & which CSS class from text.module.css to apply
 | ||||||
| export const rehypeSuperscript = () => rehypeStyler('<sup>', '</sup>', styles.superscript) | export const rehypeSuperscript = () => rehypeStyler('<sup>', '</sup>', styles.superscript) | ||||||
| @ -148,8 +149,9 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o | |||||||
|       return url |       return url | ||||||
|     } |     } | ||||||
|     const srcSet = imgproxyUrls?.[url] |     const srcSet = imgproxyUrls?.[url] | ||||||
|  | 
 | ||||||
|     return <MediaOrLink srcSet={srcSet} tab={tab} src={src} rel={rel ?? UNKNOWN_LINK_REL} {...props} topLevel={topLevel} /> |     return <MediaOrLink srcSet={srcSet} tab={tab} src={src} rel={rel ?? UNKNOWN_LINK_REL} {...props} topLevel={topLevel} /> | ||||||
|   }, [imgproxyUrls, topLevel, tab]) |   }, [imgproxyUrls, topLevel, tab, outlawed, rel]) | ||||||
| 
 | 
 | ||||||
|   const components = useMemo(() => ({ |   const components = useMemo(() => ({ | ||||||
|     h1: Heading, |     h1: Heading, | ||||||
| @ -261,16 +263,29 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o | |||||||
| 
 | 
 | ||||||
|   const remarkPlugins = useMemo(() => [gfm, mention, sub], []) |   const remarkPlugins = useMemo(() => [gfm, mention, sub], []) | ||||||
|   const rehypePlugins = useMemo(() => [rehypeInlineCodeProperty, rehypeSuperscript, rehypeSubscript], []) |   const rehypePlugins = useMemo(() => [rehypeInlineCodeProperty, rehypeSuperscript, rehypeSubscript], []) | ||||||
|  |   const carousel = useCarousel() | ||||||
| 
 | 
 | ||||||
|   return ( |   return ( | ||||||
|     <div className={classNames(styles.text, topLevel && styles.topLevel, show ? styles.textUncontained : overflowing && styles.textContained)} ref={containerRef}> |     <div className={classNames(styles.text, topLevel && styles.topLevel, show ? styles.textUncontained : overflowing && styles.textContained)} ref={containerRef}> | ||||||
|       <ReactMarkdown |       {carousel && tab !== 'preview' | ||||||
|         components={components} |         ? ( | ||||||
|         remarkPlugins={remarkPlugins} |           <ReactMarkdown | ||||||
|         rehypePlugins={rehypePlugins} |             components={components} | ||||||
|       > |             remarkPlugins={remarkPlugins} | ||||||
|         {children} |             rehypePlugins={rehypePlugins} | ||||||
|       </ReactMarkdown> |           > | ||||||
|  |             {children} | ||||||
|  |           </ReactMarkdown>) | ||||||
|  |         : ( | ||||||
|  |           <CarouselProvider> | ||||||
|  |             <ReactMarkdown | ||||||
|  |               components={components} | ||||||
|  |               remarkPlugins={remarkPlugins} | ||||||
|  |               rehypePlugins={rehypePlugins} | ||||||
|  |             > | ||||||
|  |               {children} | ||||||
|  |             </ReactMarkdown> | ||||||
|  |           </CarouselProvider>)} | ||||||
|       {overflowing && !show && |       {overflowing && !show && | ||||||
|         <Button size='lg' variant='info' className={styles.textShowFull} onClick={() => setShow(true)}> |         <Button size='lg' variant='info' className={styles.textShowFull} onClick={() => setShow(true)}> | ||||||
|           show full text |           show full text | ||||||
|  | |||||||
| @ -233,26 +233,6 @@ | |||||||
|     max-height: 35vh; |     max-height: 35vh; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| img.fullScreen { |  | ||||||
|     cursor: zoom-out !important; |  | ||||||
|     max-height: 100%; |  | ||||||
|     max-width: 100vw; |  | ||||||
|     min-width: 0; |  | ||||||
|     min-height: 0; |  | ||||||
|     align-self: center; |  | ||||||
|     justify-self: center; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| .fullScreenContainer { |  | ||||||
|     --bs-columns: 1; |  | ||||||
|     --bs-rows: 1; |  | ||||||
|     display: grid; |  | ||||||
|     width: 100%; |  | ||||||
|     height: 100%; |  | ||||||
|     align-content: center; |  | ||||||
|     justify-content: center; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| .text table { | .text table { | ||||||
|     width: auto; |     width: auto; | ||||||
| } | } | ||||||
|  | |||||||
| @ -272,9 +272,9 @@ $zindex-sticky: 900; | |||||||
| } | } | ||||||
| 
 | 
 | ||||||
| @keyframes pulse { | @keyframes pulse { | ||||||
| 0% { |   0% { | ||||||
|   opacity: 42%; |     opacity: 42%; | ||||||
| } |   } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| svg { | svg { | ||||||
|  | |||||||
							
								
								
									
										1
									
								
								svgs/arrow-right-line.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								svgs/arrow-right-line.svg
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1 @@ | |||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M16.1716 10.9999L10.8076 5.63589L12.2218 4.22168L20 11.9999L12.2218 19.778L10.8076 18.3638L16.1716 12.9999H4V10.9999H16.1716Z"></path></svg> | ||||||
| After Width: | Height: | Size: 229 B | 
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user