+2
-2
src/components/composer/composer-reply-context.tsx
+2
-2
src/components/composer/composer-reply-context.tsx
···
8
8
import { useModerationOptions } from '~/lib/states/moderation';
9
9
10
10
import Avatar, { getUserAvatarType } from '../avatar';
11
-
import ImageEmbed from '../embeds/image-embed';
11
+
import ImageGridEmbed from '../embeds/image-grid-embed';
12
12
import TimeAgo from '../time-ago';
13
13
14
14
export interface ComposerReplyContextProps {
···
76
76
77
77
{image && (
78
78
<div class="grow basis-0">
79
-
<ImageEmbed embed={image} blur={shouldBlurImage()} />
79
+
<ImageGridEmbed embed={image} blur={shouldBlurImage()} />
80
80
</div>
81
81
)}
82
82
</div>
+2
-2
src/components/embeds/embed.tsx
+2
-2
src/components/embeds/embed.tsx
···
14
14
15
15
import ExternalEmbed from './external-embed';
16
16
import FeedEmbed from './feed-embed';
17
-
import ImageEmbed from './image-embed';
17
+
import ImageStandaloneEmbed from './image-standalone-embed';
18
18
import ListEmbed from './list-embed';
19
19
import QuoteEmbed from './quote-embed';
20
20
import VideoEmbed from './video-embed';
···
70
70
const type = embed.$type;
71
71
72
72
if (type === 'app.bsky.embed.images#view') {
73
-
return <ImageEmbed embed={embed} standalone />;
73
+
return <ImageStandaloneEmbed embed={embed} />;
74
74
}
75
75
76
76
if (type === 'app.bsky.embed.external#view') {
-290
src/components/embeds/image-embed.tsx
-290
src/components/embeds/image-embed.tsx
···
1
-
import type { AppBskyEmbedImages } from '@atcute/client/lexicons';
2
-
3
-
import { openModal } from '~/globals/modals';
4
-
5
-
import ImageViewerModalLazy from '~/components/images/image-viewer-modal-lazy';
6
-
7
-
export interface ImageEmbedProps {
8
-
/** Expected to be static */
9
-
embed: AppBskyEmbedImages.View;
10
-
blur?: boolean;
11
-
/** Expected to be static */
12
-
borderless?: boolean;
13
-
/** Expected to be static */
14
-
standalone?: boolean;
15
-
}
16
-
17
-
const ImageEmbed = (props: ImageEmbedProps) => {
18
-
if (props.standalone) {
19
-
return StandaloneRenderer(props);
20
-
}
21
-
22
-
return NonStandaloneRenderer(props);
23
-
};
24
-
25
-
export default ImageEmbed;
26
-
27
-
const clamp = (value: number, min: number, max: number): number => {
28
-
return Math.max(min, Math.min(max, value));
29
-
};
30
-
31
-
const getAspectRatio = (image: AppBskyEmbedImages.ViewImage): number => {
32
-
const dims = image.aspectRatio;
33
-
34
-
const width = dims ? dims.width : 1;
35
-
const height = dims ? dims.height : 1;
36
-
const ratio = width / height;
37
-
38
-
return ratio;
39
-
};
40
-
41
-
const clampBetween3_4And4_3 = (ratio: number): number => {
42
-
return clamp(ratio, 3 / 4, 4 / 3);
43
-
};
44
-
45
-
const clampBetween3_4And16_9 = (ratio: number): number => {
46
-
return clamp(ratio, 3 / 4, 16 / 9);
47
-
};
48
-
49
-
const AltIndicator = () => {
50
-
return (
51
-
<div class="pointer-events-none absolute bottom-0 right-0 p-2">
52
-
<div class="flex h-4 items-center rounded bg-p-neutral-950/60 px-1 text-[9px] font-bold tracking-wider text-white">
53
-
ALT
54
-
</div>
55
-
</div>
56
-
);
57
-
};
58
-
59
-
const StandaloneRenderer = (props: ImageEmbedProps) => {
60
-
const { embed } = props;
61
-
62
-
const images = embed.images;
63
-
const length = images.length;
64
-
65
-
const render = (index: number, img: AppBskyEmbedImages.ViewImage) => {
66
-
return (
67
-
<img
68
-
src={/* @once */ img.thumb}
69
-
alt={/* @once */ img.alt}
70
-
class="h-full w-full cursor-pointer object-cover text-[0px]"
71
-
onClick={() => {
72
-
openModal(() => <ImageViewerModalLazy images={images} active={index} />);
73
-
}}
74
-
/>
75
-
);
76
-
};
77
-
78
-
if (length === 1) {
79
-
const img = images[0];
80
-
const dims = img.aspectRatio;
81
-
82
-
const width = dims ? dims.width : 16;
83
-
const height = dims ? dims.height : 9;
84
-
const ratio = width / height;
85
-
86
-
return (
87
-
<div class="max-w-full self-start overflow-hidden rounded-md border border-outline">
88
-
<div class="relative max-h-80 min-h-16 min-w-16 max-w-full" style={{ 'aspect-ratio': ratio }}>
89
-
<img
90
-
src={/* @once */ img.thumb}
91
-
alt={/* @once */ img.alt}
92
-
class="h-full w-full cursor-pointer object-contain text-[0px]"
93
-
onClick={() => {
94
-
openModal(() => <ImageViewerModalLazy images={images} active={0} />);
95
-
}}
96
-
/>
97
-
98
-
{/* beautiful hack that ensures we're always using the maximum possible dimension */}
99
-
<div class="h-screen w-screen"></div>
100
-
101
-
{/* @once */ img.alt && <AltIndicator />}
102
-
</div>
103
-
</div>
104
-
);
105
-
}
106
-
107
-
if (length === 2) {
108
-
const rs = images.map(getAspectRatio);
109
-
const [crA, crB] = rs.map(clampBetween3_4And4_3);
110
-
111
-
const totalRatio = crA + crB;
112
-
113
-
const nodes = images.map((img, idx) => {
114
-
return (
115
-
<div class="relative overflow-hidden rounded-md border border-outline">
116
-
{/* @once */ render(idx, img)}
117
-
{/* @once */ img.alt && <AltIndicator />}
118
-
</div>
119
-
);
120
-
});
121
-
122
-
return (
123
-
<div
124
-
class="grid gap-1.5"
125
-
style={{
126
-
'aspect-ratio': totalRatio,
127
-
'grid-template-columns': `minmax(0, ${Math.floor(crA * 100)}fr) minmax(0, ${Math.floor(crB * 100)}fr)`,
128
-
}}
129
-
>
130
-
{nodes}
131
-
</div>
132
-
);
133
-
}
134
-
135
-
if (length >= 3) {
136
-
const rs = images.map(getAspectRatio);
137
-
const crs = rs.map(clampBetween3_4And4_3);
138
-
139
-
// 448px - 2px = maximum possible screen width (desktop)
140
-
// 80px = width covered by avatar and padding in timeline item (64px on the left, 16px on the right)
141
-
// 16px = random value to make it clear there's more items to the right
142
-
// 220px = reasonable height limit
143
-
const height = Math.min((1 / crs[0]) * (Math.min(window.innerWidth, 448 - 2) - 80 - 16), 220);
144
-
const widths = crs.map((ratio) => Math.floor(ratio * height));
145
-
146
-
const nodes = images.map((img, idx) => {
147
-
const h = `${height}px`;
148
-
const w = `${widths[idx]}px`;
149
-
const r = crs[idx];
150
-
151
-
return (
152
-
<div class="shrink-0" style={{ width: w, height: h, 'aspect-ratio': r }}>
153
-
<div class="relative h-full w-full overflow-hidden rounded-md border border-outline">
154
-
{/* @once */ render(idx, img)}
155
-
{/* @once */ img.alt && <AltIndicator />}
156
-
</div>
157
-
</div>
158
-
);
159
-
});
160
-
161
-
return (
162
-
<div
163
-
class="-mr-4 flex gap-1.5 overflow-x-auto pr-4 scrollbar-hide"
164
-
style={{
165
-
'margin-left': `calc(var(--embed-left-gutter, 16px) * -1)`,
166
-
'padding-left': `var(--embed-left-gutter, 16px)`,
167
-
}}
168
-
>
169
-
{nodes}
170
-
</div>
171
-
);
172
-
}
173
-
174
-
return null;
175
-
};
176
-
177
-
const NonStandaloneRenderer = (props: ImageEmbedProps) => {
178
-
const { embed, borderless } = props;
179
-
180
-
const images = embed.images;
181
-
const length = images.length;
182
-
183
-
if (length === 1) {
184
-
const img = images[0];
185
-
186
-
return (
187
-
<div class={`aspect-video` + (!borderless ? ` overflow-hidden rounded-md border border-outline` : ``)}>
188
-
<img
189
-
src={/* @once */ img.thumb}
190
-
alt={/* @once */ img.alt}
191
-
class="h-full w-full object-contain text-[0px]"
192
-
/>
193
-
</div>
194
-
);
195
-
}
196
-
197
-
if (length === 2) {
198
-
const rs = images.map(getAspectRatio);
199
-
const [crA, crB] = rs.map(clampBetween3_4And16_9);
200
-
201
-
const totalRatio = crA + crB;
202
-
203
-
const nodes = images.map((img) => {
204
-
return (
205
-
<img
206
-
src={/* @once */ img.thumb}
207
-
alt={/* @once */ img.alt}
208
-
class="h-full w-full object-cover text-[0px]"
209
-
/>
210
-
);
211
-
});
212
-
213
-
return (
214
-
<div
215
-
class={`grid gap-0.5` + (!borderless ? ` overflow-hidden rounded-md border border-outline` : ``)}
216
-
style={{
217
-
'aspect-ratio': totalRatio,
218
-
'grid-template-columns': `minmax(0, ${Math.floor(crA * 100)}fr) minmax(0, ${Math.floor(crB * 100)}fr)`,
219
-
}}
220
-
>
221
-
{nodes}
222
-
</div>
223
-
);
224
-
}
225
-
226
-
if (length === 3) {
227
-
const a = images[0];
228
-
const b = images[1];
229
-
const c = images[2];
230
-
231
-
return (
232
-
<div class={'flex gap-0.5' + (!borderless ? ` overflow-hidden rounded-md border border-outline` : ``)}>
233
-
<div class="flex aspect-square grow-2 basis-0 flex-col gap-0.5">
234
-
<img
235
-
src={/* @once */ a.thumb}
236
-
alt={/* @once */ a.alt}
237
-
class="h-full w-full object-cover text-[0px]"
238
-
/>
239
-
</div>
240
-
241
-
<div class="flex grow basis-0 flex-col gap-0.5">
242
-
<img
243
-
src={/* @once */ b.thumb}
244
-
alt={/* @once */ b.alt}
245
-
class="h-full w-full object-cover text-[0px]"
246
-
/>
247
-
<img
248
-
src={/* @once */ c.thumb}
249
-
alt={/* @once */ c.alt}
250
-
class="h-full w-full object-cover text-[0px]"
251
-
/>
252
-
</div>
253
-
</div>
254
-
);
255
-
}
256
-
257
-
if (length === 4) {
258
-
const rs = images.map(getAspectRatio);
259
-
const [crA, crB, crC, crD] = rs.map(clampBetween3_4And16_9);
260
-
261
-
const totalWidth = Math.max(crA, crC) + Math.max(crB, crD);
262
-
const totalHeight = Math.max(1 / crA, 1 / crB) + Math.max(1 / crC, 1 / crD);
263
-
const totalAspectRatio = totalWidth / totalHeight;
264
-
265
-
const nodes = images.map((img) => {
266
-
return (
267
-
<img
268
-
src={/* @once */ img.thumb}
269
-
alt={/* @once */ img.alt}
270
-
class="h-full w-full object-cover text-[0px]"
271
-
/>
272
-
);
273
-
});
274
-
275
-
return (
276
-
<div
277
-
class={`grid gap-0.5` + (!borderless ? ` overflow-hidden rounded-md border border-outline` : ``)}
278
-
style={{
279
-
'aspect-ratio': totalAspectRatio,
280
-
'grid-template-columns': `minmax(0, ${Math.floor(crA * 100)}fr) minmax(0, ${Math.floor(crB * 100)}fr)`,
281
-
'grid-template-rows': `minmax(0, ${Math.floor(crC * 100)}fr) minmax(0, ${Math.floor(crD * 100)}fr)`,
282
-
}}
283
-
>
284
-
{nodes}
285
-
</div>
286
-
);
287
-
}
288
-
289
-
return null;
290
-
};
+128
src/components/embeds/image-grid-embed.tsx
+128
src/components/embeds/image-grid-embed.tsx
···
1
+
import type { AppBskyEmbedImages } from '@atcute/client/lexicons';
2
+
3
+
import { clampBetween3_4And16_9, getAspectRatio } from './lib/image-utils';
4
+
5
+
export interface ImageGridEmbedProps {
6
+
/** Expected to be static */
7
+
embed: AppBskyEmbedImages.View;
8
+
blur?: boolean;
9
+
/** Expected to be static */
10
+
borderless?: boolean;
11
+
}
12
+
13
+
const ImageGridEmbed = (props: ImageGridEmbedProps) => {
14
+
const { embed, borderless } = props;
15
+
16
+
const images = embed.images;
17
+
const length = images.length;
18
+
19
+
if (length === 1) {
20
+
const img = images[0];
21
+
22
+
return (
23
+
<div class={`aspect-video` + (!borderless ? ` overflow-hidden rounded-md border border-outline` : ``)}>
24
+
<img
25
+
src={/* @once */ img.thumb}
26
+
alt={/* @once */ img.alt}
27
+
class="h-full w-full object-contain text-[0px]"
28
+
/>
29
+
</div>
30
+
);
31
+
}
32
+
33
+
if (length === 2) {
34
+
const rs = images.map(getAspectRatio);
35
+
const [crA, crB] = rs.map(clampBetween3_4And16_9);
36
+
37
+
const totalRatio = crA + crB;
38
+
39
+
const nodes = images.map((img) => {
40
+
return (
41
+
<img
42
+
src={/* @once */ img.thumb}
43
+
alt={/* @once */ img.alt}
44
+
class="h-full w-full object-cover text-[0px]"
45
+
/>
46
+
);
47
+
});
48
+
49
+
return (
50
+
<div
51
+
class={`grid gap-0.5` + (!borderless ? ` overflow-hidden rounded-md border border-outline` : ``)}
52
+
style={{
53
+
'aspect-ratio': totalRatio,
54
+
'grid-template-columns': `minmax(0, ${Math.floor(crA * 100)}fr) minmax(0, ${Math.floor(crB * 100)}fr)`,
55
+
}}
56
+
>
57
+
{nodes}
58
+
</div>
59
+
);
60
+
}
61
+
62
+
if (length === 3) {
63
+
const a = images[0];
64
+
const b = images[1];
65
+
const c = images[2];
66
+
67
+
return (
68
+
<div class={'flex gap-0.5' + (!borderless ? ` overflow-hidden rounded-md border border-outline` : ``)}>
69
+
<div class="flex aspect-square grow-2 basis-0 flex-col gap-0.5">
70
+
<img
71
+
src={/* @once */ a.thumb}
72
+
alt={/* @once */ a.alt}
73
+
class="h-full w-full object-cover text-[0px]"
74
+
/>
75
+
</div>
76
+
77
+
<div class="flex grow basis-0 flex-col gap-0.5">
78
+
<img
79
+
src={/* @once */ b.thumb}
80
+
alt={/* @once */ b.alt}
81
+
class="h-full w-full object-cover text-[0px]"
82
+
/>
83
+
<img
84
+
src={/* @once */ c.thumb}
85
+
alt={/* @once */ c.alt}
86
+
class="h-full w-full object-cover text-[0px]"
87
+
/>
88
+
</div>
89
+
</div>
90
+
);
91
+
}
92
+
93
+
if (length === 4) {
94
+
const rs = images.map(getAspectRatio);
95
+
const [crA, crB, crC, crD] = rs.map(clampBetween3_4And16_9);
96
+
97
+
const totalWidth = Math.max(crA, crC) + Math.max(crB, crD);
98
+
const totalHeight = Math.max(1 / crA, 1 / crB) + Math.max(1 / crC, 1 / crD);
99
+
const totalAspectRatio = totalWidth / totalHeight;
100
+
101
+
const nodes = images.map((img) => {
102
+
return (
103
+
<img
104
+
src={/* @once */ img.thumb}
105
+
alt={/* @once */ img.alt}
106
+
class="h-full w-full object-cover text-[0px]"
107
+
/>
108
+
);
109
+
});
110
+
111
+
return (
112
+
<div
113
+
class={`grid gap-0.5` + (!borderless ? ` overflow-hidden rounded-md border border-outline` : ``)}
114
+
style={{
115
+
'aspect-ratio': totalAspectRatio,
116
+
'grid-template-columns': `minmax(0, ${Math.floor(crA * 100)}fr) minmax(0, ${Math.floor(crB * 100)}fr)`,
117
+
'grid-template-rows': `minmax(0, ${Math.floor(crC * 100)}fr) minmax(0, ${Math.floor(crD * 100)}fr)`,
118
+
}}
119
+
>
120
+
{nodes}
121
+
</div>
122
+
);
123
+
}
124
+
125
+
return null;
126
+
};
127
+
128
+
export default ImageGridEmbed;
+140
src/components/embeds/image-standalone-embed.tsx
+140
src/components/embeds/image-standalone-embed.tsx
···
1
+
import type { AppBskyEmbedImages } from '@atcute/client/lexicons';
2
+
3
+
import { openModal } from '~/globals/modals';
4
+
5
+
import ImageViewerModalLazy from '~/components/images/image-viewer-modal-lazy';
6
+
7
+
import { clampBetween3_4And4_3, getAspectRatio } from './lib/image-utils';
8
+
9
+
export interface ImageStandaloneEmbedProps {
10
+
/** Expected to be static */
11
+
embed: AppBskyEmbedImages.View;
12
+
}
13
+
14
+
const ImageStandaloneEmbed = ({ embed }: ImageStandaloneEmbedProps) => {
15
+
const images = embed.images;
16
+
const length = images.length;
17
+
18
+
const render = (index: number, img: AppBskyEmbedImages.ViewImage) => {
19
+
return (
20
+
<img
21
+
src={/* @once */ img.thumb}
22
+
alt={/* @once */ img.alt}
23
+
class="h-full w-full cursor-pointer object-cover text-[0px]"
24
+
onClick={() => {
25
+
openModal(() => <ImageViewerModalLazy images={images} active={index} />);
26
+
}}
27
+
/>
28
+
);
29
+
};
30
+
31
+
if (length === 1) {
32
+
const img = images[0];
33
+
const dims = img.aspectRatio;
34
+
35
+
const width = dims ? dims.width : 16;
36
+
const height = dims ? dims.height : 9;
37
+
const ratio = width / height;
38
+
39
+
return (
40
+
<div class="max-w-full self-start overflow-hidden rounded-md border border-outline">
41
+
<div class="relative max-h-80 min-h-16 min-w-16 max-w-full" style={{ 'aspect-ratio': ratio }}>
42
+
<img
43
+
src={/* @once */ img.thumb}
44
+
alt={/* @once */ img.alt}
45
+
class="h-full w-full cursor-pointer object-contain text-[0px]"
46
+
onClick={() => {
47
+
openModal(() => <ImageViewerModalLazy images={images} active={0} />);
48
+
}}
49
+
/>
50
+
51
+
{/* beautiful hack that ensures we're always using the maximum possible dimension */}
52
+
<div class="h-screen w-screen"></div>
53
+
54
+
{/* @once */ img.alt && <AltIndicator />}
55
+
</div>
56
+
</div>
57
+
);
58
+
}
59
+
60
+
if (length === 2) {
61
+
const rs = images.map(getAspectRatio);
62
+
const [crA, crB] = rs.map(clampBetween3_4And4_3);
63
+
64
+
const totalRatio = crA + crB;
65
+
66
+
const nodes = images.map((img, idx) => {
67
+
return (
68
+
<div class="relative overflow-hidden rounded-md border border-outline">
69
+
{/* @once */ render(idx, img)}
70
+
{/* @once */ img.alt && <AltIndicator />}
71
+
</div>
72
+
);
73
+
});
74
+
75
+
return (
76
+
<div
77
+
class="grid gap-1.5"
78
+
style={{
79
+
'aspect-ratio': totalRatio,
80
+
'grid-template-columns': `minmax(0, ${Math.floor(crA * 100)}fr) minmax(0, ${Math.floor(crB * 100)}fr)`,
81
+
}}
82
+
>
83
+
{nodes}
84
+
</div>
85
+
);
86
+
}
87
+
88
+
if (length >= 3) {
89
+
const rs = images.map(getAspectRatio);
90
+
const crs = rs.map(clampBetween3_4And4_3);
91
+
92
+
// 448px - 2px = maximum possible screen width (desktop)
93
+
// 80px = width covered by avatar and padding in timeline item (64px on the left, 16px on the right)
94
+
// 16px = random value to make it clear there's more items to the right
95
+
// 220px = reasonable height limit
96
+
const height = Math.min((1 / crs[0]) * (Math.min(window.innerWidth, 448 - 2) - 80 - 16), 220);
97
+
const widths = crs.map((ratio) => Math.floor(ratio * height));
98
+
99
+
const nodes = images.map((img, idx) => {
100
+
const h = `${height}px`;
101
+
const w = `${widths[idx]}px`;
102
+
const r = crs[idx];
103
+
104
+
return (
105
+
<div class="shrink-0" style={{ width: w, height: h, 'aspect-ratio': r }}>
106
+
<div class="relative h-full w-full overflow-hidden rounded-md border border-outline">
107
+
{/* @once */ render(idx, img)}
108
+
{/* @once */ img.alt && <AltIndicator />}
109
+
</div>
110
+
</div>
111
+
);
112
+
});
113
+
114
+
return (
115
+
<div
116
+
class="-mr-4 flex gap-1.5 overflow-x-auto pr-4 scrollbar-hide"
117
+
style={{
118
+
'margin-left': `calc(var(--embed-left-gutter, 16px) * -1)`,
119
+
'padding-left': `var(--embed-left-gutter, 16px)`,
120
+
}}
121
+
>
122
+
{nodes}
123
+
</div>
124
+
);
125
+
}
126
+
127
+
return null;
128
+
};
129
+
130
+
export default ImageStandaloneEmbed;
131
+
132
+
const AltIndicator = () => {
133
+
return (
134
+
<div class="pointer-events-none absolute bottom-0 right-0 p-2">
135
+
<div class="flex h-4 items-center rounded bg-p-neutral-950/60 px-1 text-[9px] font-bold tracking-wider text-white">
136
+
ALT
137
+
</div>
138
+
</div>
139
+
);
140
+
};
+23
src/components/embeds/lib/image-utils.ts
+23
src/components/embeds/lib/image-utils.ts
···
1
+
import type { AppBskyEmbedImages } from '@atcute/client/lexicons';
2
+
3
+
const clamp = (value: number, min: number, max: number): number => {
4
+
return Math.max(min, Math.min(max, value));
5
+
};
6
+
7
+
export const getAspectRatio = (image: AppBskyEmbedImages.ViewImage): number => {
8
+
const dims = image.aspectRatio;
9
+
10
+
const width = dims ? dims.width : 1;
11
+
const height = dims ? dims.height : 1;
12
+
const ratio = width / height;
13
+
14
+
return ratio;
15
+
};
16
+
17
+
export const clampBetween3_4And4_3 = (ratio: number): number => {
18
+
return clamp(ratio, 3 / 4, 4 / 3);
19
+
};
20
+
21
+
export const clampBetween3_4And16_9 = (ratio: number): number => {
22
+
return clamp(ratio, 3 / 4, 16 / 9);
23
+
};
+3
-3
src/components/embeds/quote-embed.tsx
+3
-3
src/components/embeds/quote-embed.tsx
···
17
17
import Avatar, { getUserAvatarType } from '../avatar';
18
18
import TimeAgo from '../time-ago';
19
19
20
-
import ImageEmbed from './image-embed';
20
+
import ImageGridEmbed from './image-grid-embed';
21
21
import VideoEmbed from './video-embed';
22
22
23
23
export interface QuoteEmbedProps {
···
86
86
{!large ? (
87
87
image ? (
88
88
<div class="mb-3 ml-3 mt-2 grow basis-0">
89
-
<ImageEmbed embed={image} blur={shouldBlurMedia()} />
89
+
<ImageGridEmbed embed={image} blur={shouldBlurMedia()} />
90
90
</div>
91
91
) : video ? (
92
92
<div class="mb-3 ml-3 mt-2 grow basis-0">
···
105
105
106
106
{large || !text ? (
107
107
image ? (
108
-
<ImageEmbed embed={image} borderless blur={shouldBlurMedia()} />
108
+
<ImageGridEmbed embed={image} borderless blur={shouldBlurMedia()} />
109
109
) : video ? (
110
110
<VideoEmbed embed={video} borderless blur={shouldBlurMedia()} />
111
111
) : null