update scrolling and bleeding edges
This commit is contained in:
@@ -308,6 +308,7 @@ export function QuoteEmbed({
|
|||||||
<Embed
|
<Embed
|
||||||
embed={quote.embed}
|
embed={quote.embed}
|
||||||
moderation={moderation}
|
moderation={moderation}
|
||||||
|
viewContext={PostEmbedViewContext.FeedEmbedRecordWithMedia}
|
||||||
isWithinQuote={parentIsWithinQuote ?? true}
|
isWithinQuote={parentIsWithinQuote ?? true}
|
||||||
// already within quote? override nested
|
// already within quote? override nested
|
||||||
allowNestedQuotes={
|
allowNestedQuotes={
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {useContext, useMemo, useRef, useState} from 'react'
|
import {useContext, useMemo, useRef, useState} from 'react'
|
||||||
import {FlatList, Pressable, View} from 'react-native'
|
import {FlatList, Pressable, useWindowDimensions, View} from 'react-native'
|
||||||
import {DrawerGestureContext} from 'react-native-drawer-layout'
|
import {DrawerGestureContext} from 'react-native-drawer-layout'
|
||||||
import {Gesture, GestureDetector} from 'react-native-gesture-handler'
|
import {Gesture, GestureDetector} from 'react-native-gesture-handler'
|
||||||
import {type AnimatedRef, useAnimatedRef} from 'react-native-reanimated'
|
import {type AnimatedRef, useAnimatedRef} from 'react-native-reanimated'
|
||||||
@@ -20,8 +20,7 @@ import {Text} from '#/components/Typography'
|
|||||||
import {useAnalytics} from '#/analytics'
|
import {useAnalytics} from '#/analytics'
|
||||||
|
|
||||||
const CONTAINER_ASPECT_RATIO = 4 / 3
|
const CONTAINER_ASPECT_RATIO = 4 / 3
|
||||||
const PEEK_WIDTH = 40
|
const ITEM_GAP = 8 // tokens.space.sm
|
||||||
const ITEM_GAP = 6
|
|
||||||
|
|
||||||
interface GalleryProps {
|
interface GalleryProps {
|
||||||
images: AppBskyEmbedImages.ViewImage[]
|
images: AppBskyEmbedImages.ViewImage[]
|
||||||
@@ -45,8 +44,9 @@ export function Gallery({
|
|||||||
const ax = useAnalytics()
|
const ax = useAnalytics()
|
||||||
const {screenReaderEnabled} = useA11y()
|
const {screenReaderEnabled} = useA11y()
|
||||||
const largeAltBadge = useLargeAltBadgeEnabled()
|
const largeAltBadge = useLargeAltBadgeEnabled()
|
||||||
const [currentPage, setCurrentPage] = useState(0)
|
|
||||||
const currentPageRef = useRef(0)
|
const currentPageRef = useRef(0)
|
||||||
|
const {width: windowWidth} = useWindowDimensions()
|
||||||
|
const [leftOffset, setLeftOffset] = useState(0)
|
||||||
const [containerWidth, setContainerWidth] = useState(0)
|
const [containerWidth, setContainerWidth] = useState(0)
|
||||||
|
|
||||||
const containerRefs = useRef<AnimatedRef<any>[]>([]).current
|
const containerRefs = useRef<AnimatedRef<any>[]>([]).current
|
||||||
@@ -61,11 +61,34 @@ export function Gallery({
|
|||||||
containerRefs[i] = refs[i]
|
containerRefs[i] = refs[i]
|
||||||
}
|
}
|
||||||
|
|
||||||
const hideBadges =
|
const isWithinQuote =
|
||||||
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
|
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
|
||||||
|
const hideBadges = isWithinQuote
|
||||||
|
|
||||||
const itemWidth = containerWidth > 0 ? containerWidth - PEEK_WIDTH : 0
|
const containerHeight =
|
||||||
const snapInterval = itemWidth + ITEM_GAP
|
containerWidth > 0 ? containerWidth / CONTAINER_ASPECT_RATIO : 0
|
||||||
|
// Bleed: full-width carousel that extends to screen edges
|
||||||
|
// In quotes: small bleed to the quote card border (p_md = 12px)
|
||||||
|
const QUOTE_PADDING = 12
|
||||||
|
const bleed = !isWithinQuote
|
||||||
|
const insetLeft = bleed
|
||||||
|
? leftOffset || windowWidth - containerWidth
|
||||||
|
: QUOTE_PADDING
|
||||||
|
const insetRight = bleed
|
||||||
|
? windowWidth - insetLeft - containerWidth
|
||||||
|
: QUOTE_PADDING
|
||||||
|
|
||||||
|
const getItemWidth = (image: AppBskyEmbedImages.ViewImage) => {
|
||||||
|
const ar = image.aspectRatio
|
||||||
|
if (ar && ar.width > 0 && ar.height > 0) {
|
||||||
|
const ratio = ar.width / ar.height
|
||||||
|
// Width derived from image's own aspect ratio at the fixed container height
|
||||||
|
const w = containerHeight * ratio
|
||||||
|
// Clamp: at least 40% of content width, at most the full content width
|
||||||
|
return Math.max(containerWidth * 0.4, Math.min(w, containerWidth))
|
||||||
|
}
|
||||||
|
return containerWidth
|
||||||
|
}
|
||||||
|
|
||||||
if (screenReaderEnabled) {
|
if (screenReaderEnabled) {
|
||||||
return (
|
return (
|
||||||
@@ -125,8 +148,22 @@ export function Gallery({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={[a.rounded_md, a.overflow_hidden]}
|
style={
|
||||||
onLayout={e => setContainerWidth(e.nativeEvent.layout.width)}>
|
containerWidth > 0
|
||||||
|
? {height: containerHeight, overflow: 'visible'}
|
||||||
|
: {aspectRatio: CONTAINER_ASPECT_RATIO}
|
||||||
|
}
|
||||||
|
onLayout={e => {
|
||||||
|
const w = e.nativeEvent.layout.width
|
||||||
|
if (w > 0) {
|
||||||
|
setContainerWidth(w)
|
||||||
|
}
|
||||||
|
e.target.measureInWindow((x: number) => {
|
||||||
|
if (x > 0) {
|
||||||
|
setLeftOffset(x)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}>
|
||||||
{containerWidth > 0 && (
|
{containerWidth > 0 && (
|
||||||
<DrawerGestureBlocker>
|
<DrawerGestureBlocker>
|
||||||
<FlatList
|
<FlatList
|
||||||
@@ -134,14 +171,31 @@ export function Gallery({
|
|||||||
horizontal
|
horizontal
|
||||||
pagingEnabled={false}
|
pagingEnabled={false}
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
snapToOffsets={images.map((_, i) => i * snapInterval)}
|
|
||||||
decelerationRate="normal"
|
decelerationRate="normal"
|
||||||
disableIntervalMomentum
|
style={{
|
||||||
contentContainerStyle={{gap: ITEM_GAP}}
|
width: bleed ? windowWidth : containerWidth + QUOTE_PADDING * 2,
|
||||||
|
height: containerHeight,
|
||||||
|
marginLeft: -insetLeft,
|
||||||
|
}}
|
||||||
|
contentContainerStyle={{
|
||||||
|
gap: ITEM_GAP,
|
||||||
|
paddingLeft: insetLeft,
|
||||||
|
paddingRight: insetRight,
|
||||||
|
}}
|
||||||
onScroll={e => {
|
onScroll={e => {
|
||||||
const offsetX = e.nativeEvent.contentOffset.x
|
const offsetX = e.nativeEvent.contentOffset.x
|
||||||
if (snapInterval > 0) {
|
// Determine which item is most visible based on scroll position
|
||||||
const page = Math.round(offsetX / snapInterval)
|
let accumulated = insetLeft // account for left content padding
|
||||||
|
let page = 0
|
||||||
|
for (let i = 0; i < images.length; i++) {
|
||||||
|
const w = getItemWidth(images[i]) + ITEM_GAP
|
||||||
|
if (offsetX < accumulated + w / 2) {
|
||||||
|
page = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
accumulated += w
|
||||||
|
page = i
|
||||||
|
}
|
||||||
if (page !== currentPageRef.current) {
|
if (page !== currentPageRef.current) {
|
||||||
ax.metric('post:gallery:swipe', {
|
ax.metric('post:gallery:swipe', {
|
||||||
fromIndex: currentPageRef.current,
|
fromIndex: currentPageRef.current,
|
||||||
@@ -149,8 +203,6 @@ export function Gallery({
|
|||||||
totalImages: images.length,
|
totalImages: images.length,
|
||||||
})
|
})
|
||||||
currentPageRef.current = page
|
currentPageRef.current = page
|
||||||
setCurrentPage(page)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
scrollEventThrottle={16}
|
scrollEventThrottle={16}
|
||||||
@@ -161,8 +213,8 @@ export function Gallery({
|
|||||||
collapsable={false}
|
collapsable={false}
|
||||||
style={[
|
style={[
|
||||||
{
|
{
|
||||||
width: itemWidth,
|
width: getItemWidth(image),
|
||||||
aspectRatio: CONTAINER_ASPECT_RATIO,
|
height: containerHeight,
|
||||||
},
|
},
|
||||||
]}>
|
]}>
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -250,30 +302,6 @@ export function Gallery({
|
|||||||
/>
|
/>
|
||||||
</DrawerGestureBlocker>
|
</DrawerGestureBlocker>
|
||||||
)}
|
)}
|
||||||
{images.length > 1 && (
|
|
||||||
<View
|
|
||||||
accessible={false}
|
|
||||||
style={[
|
|
||||||
a.absolute,
|
|
||||||
a.rounded_full,
|
|
||||||
t.atoms.bg_contrast_975,
|
|
||||||
{
|
|
||||||
bottom: a.p_xs.padding,
|
|
||||||
left: a.p_xs.padding,
|
|
||||||
paddingHorizontal: 8,
|
|
||||||
paddingVertical: 4,
|
|
||||||
opacity: 0.75,
|
|
||||||
},
|
|
||||||
]}>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
a.font_bold,
|
|
||||||
{fontSize: 11, color: t.atoms.text_inverted.color},
|
|
||||||
]}>
|
|
||||||
{currentPage + 1}/{images.length}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import {memo, useMemo, useState} from 'react'
|
import {memo, useState} from 'react'
|
||||||
import {
|
import {
|
||||||
findNodeHandle,
|
findNodeHandle,
|
||||||
type ImageStyle,
|
|
||||||
Keyboard,
|
Keyboard,
|
||||||
type LayoutChangeEvent,
|
type LayoutChangeEvent,
|
||||||
Platform,
|
Platform,
|
||||||
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
type ViewStyle,
|
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import {Image} from 'expo-image'
|
import {Image} from 'expo-image'
|
||||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||||
@@ -16,7 +15,6 @@ import {msg} from '@lingui/core/macro'
|
|||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {Trans} from '@lingui/react/macro'
|
import {Trans} from '@lingui/react/macro'
|
||||||
|
|
||||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
|
||||||
import {type Dimensions} from '#/lib/media/types'
|
import {type Dimensions} from '#/lib/media/types'
|
||||||
import {colors} from '#/lib/styles'
|
import {colors} from '#/lib/styles'
|
||||||
import {type ComposerImage, cropImage} from '#/state/gallery'
|
import {type ComposerImage, cropImage} from '#/state/gallery'
|
||||||
@@ -32,6 +30,7 @@ import {EditImageDialog} from './EditImageDialog'
|
|||||||
import {ImageAltTextDialog} from './ImageAltTextDialog'
|
import {ImageAltTextDialog} from './ImageAltTextDialog'
|
||||||
|
|
||||||
const IMAGE_GAP = 8
|
const IMAGE_GAP = 8
|
||||||
|
const CONTAINER_HEIGHT = 200
|
||||||
|
|
||||||
interface GalleryProps {
|
interface GalleryProps {
|
||||||
images: ComposerImage[]
|
images: ComposerImage[]
|
||||||
@@ -63,53 +62,33 @@ interface GalleryInnerProps extends GalleryProps {
|
|||||||
containerInfo: Dimensions
|
containerInfo: Dimensions
|
||||||
}
|
}
|
||||||
|
|
||||||
const GalleryInner = ({images, containerInfo, dispatch}: GalleryInnerProps) => {
|
const getItemWidth = (image: ComposerImage, height: number) => {
|
||||||
const {isMobile} = useWebMediaQueries()
|
const source = image.transformed ?? image.source
|
||||||
|
if (source.width > 0 && source.height > 0) {
|
||||||
const {altTextControlStyle, imageControlsStyle, imageStyle} = useMemo(() => {
|
const ratio = source.width / source.height
|
||||||
const side =
|
const w = height * ratio
|
||||||
images.length === 1
|
// Clamp: at least 60% of height, at most 1.5x height
|
||||||
? 250
|
return Math.max(height * 0.6, Math.min(w, height * 1.5))
|
||||||
: (containerInfo.width - IMAGE_GAP * (images.length - 1)) /
|
|
||||||
images.length
|
|
||||||
|
|
||||||
const isOverflow = isMobile && images.length > 2
|
|
||||||
|
|
||||||
return {
|
|
||||||
altTextControlStyle: isOverflow
|
|
||||||
? {left: 4, bottom: 4}
|
|
||||||
: !isMobile && images.length < 3
|
|
||||||
? {left: 8, top: 8}
|
|
||||||
: {left: 4, top: 4},
|
|
||||||
imageControlsStyle: {
|
|
||||||
display: 'flex' as const,
|
|
||||||
flexDirection: 'row' as const,
|
|
||||||
position: 'absolute' as const,
|
|
||||||
...(isOverflow
|
|
||||||
? {top: 4, right: 4, gap: 4}
|
|
||||||
: !isMobile && images.length < 3
|
|
||||||
? {top: 8, right: 8, gap: 8}
|
|
||||||
: {top: 4, right: 4, gap: 4}),
|
|
||||||
zIndex: 1,
|
|
||||||
},
|
|
||||||
imageStyle: {
|
|
||||||
height: side,
|
|
||||||
width: side,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}, [images.length, containerInfo, isMobile])
|
return height
|
||||||
|
}
|
||||||
|
|
||||||
|
const GalleryInner = ({images, dispatch}: GalleryInnerProps) => {
|
||||||
return images.length !== 0 ? (
|
return images.length !== 0 ? (
|
||||||
<>
|
<>
|
||||||
<View testID="selectedPhotosView" style={styles.gallery}>
|
<ScrollView
|
||||||
|
testID="selectedPhotosView"
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={{gap: IMAGE_GAP, paddingTop: 16}}
|
||||||
|
style={{height: CONTAINER_HEIGHT + 16}}>
|
||||||
{images.map(image => {
|
{images.map(image => {
|
||||||
return (
|
return (
|
||||||
<GalleryItem
|
<GalleryItem
|
||||||
key={image.source.id}
|
key={image.source.id}
|
||||||
image={image}
|
image={image}
|
||||||
altTextControlStyle={altTextControlStyle}
|
itemWidth={getItemWidth(image, CONTAINER_HEIGHT)}
|
||||||
imageControlsStyle={imageControlsStyle}
|
itemHeight={CONTAINER_HEIGHT}
|
||||||
imageStyle={imageStyle}
|
|
||||||
onChange={next => {
|
onChange={next => {
|
||||||
dispatch({type: 'embed_update_image', image: next})
|
dispatch({type: 'embed_update_image', image: next})
|
||||||
}}
|
}}
|
||||||
@@ -119,7 +98,7 @@ const GalleryInner = ({images, containerInfo, dispatch}: GalleryInnerProps) => {
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</View>
|
</ScrollView>
|
||||||
<Admonition type="tip" style={[a.mt_sm]}>
|
<Admonition type="tip" style={[a.mt_sm]}>
|
||||||
<Trans>
|
<Trans>
|
||||||
Alt text describes images for blind and low-vision users, and helps
|
Alt text describes images for blind and low-vision users, and helps
|
||||||
@@ -132,18 +111,16 @@ const GalleryInner = ({images, containerInfo, dispatch}: GalleryInnerProps) => {
|
|||||||
|
|
||||||
type GalleryItemProps = {
|
type GalleryItemProps = {
|
||||||
image: ComposerImage
|
image: ComposerImage
|
||||||
altTextControlStyle?: ViewStyle
|
itemWidth: number
|
||||||
imageControlsStyle?: ViewStyle
|
itemHeight: number
|
||||||
imageStyle?: ImageStyle
|
|
||||||
onChange: (next: ComposerImage) => void
|
onChange: (next: ComposerImage) => void
|
||||||
onRemove: () => void
|
onRemove: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const GalleryItem = ({
|
const GalleryItem = ({
|
||||||
image,
|
image,
|
||||||
altTextControlStyle,
|
itemWidth,
|
||||||
imageControlsStyle,
|
itemHeight,
|
||||||
imageStyle,
|
|
||||||
onChange,
|
onChange,
|
||||||
onRemove,
|
onRemove,
|
||||||
}: GalleryItemProps): React.ReactNode => {
|
}: GalleryItemProps): React.ReactNode => {
|
||||||
@@ -156,7 +133,6 @@ const GalleryItem = ({
|
|||||||
const [altBtnViewTag, setAltBtnViewTag] = useState<number>()
|
const [altBtnViewTag, setAltBtnViewTag] = useState<number>()
|
||||||
|
|
||||||
const altBtnRef = (node: View | null) => {
|
const altBtnRef = (node: View | null) => {
|
||||||
// for iOS 26 fluid transition
|
|
||||||
if (IS_IOS && node) {
|
if (IS_IOS && node) {
|
||||||
const tag = findNodeHandle(node)
|
const tag = findNodeHandle(node)
|
||||||
if (tag != null) setAltBtnViewTag(tag)
|
if (tag != null) setAltBtnViewTag(tag)
|
||||||
@@ -185,33 +161,8 @@ const GalleryItem = ({
|
|||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
ref={altBtnRef}
|
ref={altBtnRef}
|
||||||
style={imageStyle as ViewStyle}
|
style={{width: itemWidth, height: itemHeight}}
|
||||||
// Fixes ALT and icons appearing with half opacity when the post is inactive
|
|
||||||
renderToHardwareTextureAndroid>
|
renderToHardwareTextureAndroid>
|
||||||
<TouchableOpacity
|
|
||||||
testID="altTextButton"
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={_(msg`Add alt text`)}
|
|
||||||
accessibilityHint=""
|
|
||||||
onPress={onAltTextEdit}
|
|
||||||
style={[styles.altTextControl, altTextControlStyle]}>
|
|
||||||
{image.alt.length !== 0 ? (
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon="check"
|
|
||||||
size={10}
|
|
||||||
style={{color: t.palette.white}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon="plus"
|
|
||||||
size={10}
|
|
||||||
style={{color: t.palette.white}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Text style={styles.altTextControlLabel} accessible={false}>
|
|
||||||
<Trans>ALT</Trans>
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<View style={imageControlsStyle}>
|
<View style={imageControlsStyle}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
testID="editPhotoButton"
|
testID="editPhotoButton"
|
||||||
@@ -236,6 +187,32 @@ const GalleryItem = ({
|
|||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
testID="altTextButton"
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={_(msg`Add alt text`)}
|
||||||
|
accessibilityHint=""
|
||||||
|
onPress={onAltTextEdit}
|
||||||
|
style={[styles.altTextControl, {left: 4, bottom: 4}]}>
|
||||||
|
{image.alt.length !== 0 ? (
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon="check"
|
||||||
|
size={10}
|
||||||
|
style={{color: t.palette.white}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon="plus"
|
||||||
|
size={10}
|
||||||
|
style={{color: t.palette.white}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Text style={styles.altTextControlLabel} accessible={false}>
|
||||||
|
<Trans>ALT</Trans>
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={_(msg`Add alt text`)}
|
accessibilityLabel={_(msg`Add alt text`)}
|
||||||
@@ -246,7 +223,7 @@ const GalleryItem = ({
|
|||||||
|
|
||||||
<Image
|
<Image
|
||||||
testID="selectedPhotoImage"
|
testID="selectedPhotoImage"
|
||||||
style={[styles.image, imageStyle]}
|
style={[styles.image, {width: itemWidth, height: itemHeight}]}
|
||||||
source={{
|
source={{
|
||||||
uri: (image.transformed ?? image.source).path,
|
uri: (image.transformed ?? image.source).path,
|
||||||
}}
|
}}
|
||||||
@@ -275,13 +252,17 @@ const GalleryItem = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const imageControlsStyle = {
|
||||||
|
display: 'flex' as const,
|
||||||
|
flexDirection: 'row' as const,
|
||||||
|
position: 'absolute' as const,
|
||||||
|
top: 4,
|
||||||
|
right: 4,
|
||||||
|
gap: 4,
|
||||||
|
zIndex: 1,
|
||||||
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
gallery: {
|
|
||||||
flex: 1,
|
|
||||||
flexDirection: 'row',
|
|
||||||
gap: IMAGE_GAP,
|
|
||||||
marginTop: 16,
|
|
||||||
},
|
|
||||||
image: {
|
image: {
|
||||||
borderRadius: tokens.borderRadius.md,
|
borderRadius: tokens.borderRadius.md,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user