@syra.fm/sdk 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/commonjs/live/components/RoomCard.js +241 -158
- package/lib/commonjs/live/components/RoomCard.js.map +1 -1
- package/lib/module/live/components/RoomCard.js +243 -161
- package/lib/module/live/components/RoomCard.js.map +1 -1
- package/lib/typescript/commonjs/live/components/RoomCard.d.ts +18 -0
- package/lib/typescript/commonjs/live/components/RoomCard.d.ts.map +1 -1
- package/lib/typescript/module/live/components/RoomCard.d.ts +18 -0
- package/lib/typescript/module/live/components/RoomCard.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/live/components/RoomCard.tsx +202 -129
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syra.fm/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Syra SDK — headless, isomorphic catalog client (Node/React-free, public reads + 30s previews) that also ships the Syra live-rooms engine (audio rooms over LiveKit) on React Native and web.",
|
|
5
5
|
"source": "src/index.ts",
|
|
6
6
|
"main": "lib/commonjs/index.js",
|
|
@@ -6,7 +6,8 @@ import { useAuth } from '@oxyhq/services';
|
|
|
6
6
|
import { useLiveConfig } from '../context/LiveConfigContext';
|
|
7
7
|
import { AnimatedPulse } from './AnimatedPulse';
|
|
8
8
|
import { useRoomUsers, getAvatarUrl } from '../hooks/useRoomUsers';
|
|
9
|
-
import { LIVE_COLOR, LIVE_FOREGROUND_COLOR
|
|
9
|
+
import { LIVE_COLOR, LIVE_FOREGROUND_COLOR } from '../colors';
|
|
10
|
+
import type { LiveTheme } from '../types';
|
|
10
11
|
|
|
11
12
|
// --- Utility helpers ---
|
|
12
13
|
|
|
@@ -25,33 +26,55 @@ function formatDuration(startedAt: string, endedAt: string): string {
|
|
|
25
26
|
return m > 0 ? `${h}h ${m}m` : `${h}h`;
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
const
|
|
29
|
+
const DAY_MS = 86_400_000;
|
|
29
30
|
|
|
30
|
-
function
|
|
31
|
-
|
|
32
|
-
return `${d.getDate()} ${MONTH_SHORT[d.getMonth()]} ${d.getFullYear()}`;
|
|
31
|
+
function startOfDay(date: Date): number {
|
|
32
|
+
return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
if (
|
|
40
|
-
|
|
41
|
-
|
|
35
|
+
/** `Today` / `Tomorrow` / `Yesterday`, else a locale short date (year only when it differs). */
|
|
36
|
+
function formatDay(date: Date, now: Date): string {
|
|
37
|
+
const dayDelta = Math.round((startOfDay(date) - startOfDay(now)) / DAY_MS);
|
|
38
|
+
if (dayDelta === 0) return 'Today';
|
|
39
|
+
if (dayDelta === 1) return 'Tomorrow';
|
|
40
|
+
if (dayDelta === -1) return 'Yesterday';
|
|
41
|
+
|
|
42
|
+
const options: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'short' };
|
|
43
|
+
if (date.getFullYear() !== now.getFullYear()) options.year = 'numeric';
|
|
44
|
+
return date.toLocaleDateString(undefined, options);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function formatTime(date: Date): string {
|
|
48
|
+
return date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The when-label for a room that is NOT live: the start time for a scheduled room
|
|
53
|
+
* (`Tomorrow 18:00`), the day + duration for an ended one (`Yesterday · 45 min`).
|
|
54
|
+
* A live room never uses this — its lead metric is the listener count.
|
|
55
|
+
*/
|
|
56
|
+
function getScheduleLabel(room: RoomCardProps['room'], now: Date): string {
|
|
42
57
|
if (room.status === 'scheduled' && room.scheduledStart) {
|
|
43
|
-
|
|
58
|
+
const start = new Date(room.scheduledStart);
|
|
59
|
+
return `${formatDay(start, now)} ${formatTime(start)}`;
|
|
60
|
+
}
|
|
61
|
+
if (room.status === 'ended' && room.endedAt) {
|
|
62
|
+
const day = formatDay(new Date(room.endedAt), now);
|
|
63
|
+
return room.startedAt ? `${day} · ${formatDuration(room.startedAt, room.endedAt)}` : day;
|
|
44
64
|
}
|
|
45
65
|
if (room.createdAt) {
|
|
46
|
-
return
|
|
66
|
+
return formatDay(new Date(room.createdAt), now);
|
|
47
67
|
}
|
|
48
68
|
return '';
|
|
49
69
|
}
|
|
50
70
|
|
|
51
71
|
// --- Constants ---
|
|
52
72
|
|
|
53
|
-
const MAX_SPEAKER_AVATARS =
|
|
73
|
+
const MAX_SPEAKER_AVATARS = 3;
|
|
54
74
|
const SPEAKER_AVATAR_SIZE = 44;
|
|
75
|
+
const ROW_AVATAR_SIZE = 40;
|
|
76
|
+
const BYLINE_AVATAR_SIZE = 20;
|
|
77
|
+
const ACTION_HIT_SLOP = { top: 8, bottom: 8, left: 8, right: 8 };
|
|
55
78
|
|
|
56
79
|
// --- Types ---
|
|
57
80
|
|
|
@@ -85,6 +108,24 @@ interface RoomCardProps {
|
|
|
85
108
|
|
|
86
109
|
// --- Component ---
|
|
87
110
|
|
|
111
|
+
/**
|
|
112
|
+
* A room in a list. The `default` variant renders ONE of two presentations,
|
|
113
|
+
* selected by `room.status`, because a live room and a listing of one are
|
|
114
|
+
* different objects:
|
|
115
|
+
*
|
|
116
|
+
* - **live** → a featured card: the LIVE chip + the listener count (the only
|
|
117
|
+
* number that matters while a room is on air), the speaker faces, the host,
|
|
118
|
+
* and an explicit Join CTA.
|
|
119
|
+
* - **scheduled / ended** → a flush full-width row: host avatar, title + when,
|
|
120
|
+
* byline, and one subtle right-side action. No card chrome, no CTA — there is
|
|
121
|
+
* nothing to join yet (or ever again).
|
|
122
|
+
*
|
|
123
|
+
* At most ONE status chip is ever shown (a live room is not a scheduled one, and
|
|
124
|
+
* the date already says "scheduled"); the room type carries no badge at all.
|
|
125
|
+
*
|
|
126
|
+
* The `compact` variant is the small fixed-width card used by post attachments
|
|
127
|
+
* and the composer preview, and follows the same rules in miniature.
|
|
128
|
+
*/
|
|
88
129
|
export const RoomCard: React.FC<RoomCardProps> = ({
|
|
89
130
|
room,
|
|
90
131
|
onPress,
|
|
@@ -105,6 +146,7 @@ export const RoomCard: React.FC<RoomCardProps> = ({
|
|
|
105
146
|
const isLive = room.status === 'live';
|
|
106
147
|
const isScheduled = room.status === 'scheduled';
|
|
107
148
|
const isCompact = variant === 'compact';
|
|
149
|
+
const isFeatured = isLive && !isCompact;
|
|
108
150
|
|
|
109
151
|
// Resolve host display: an explicit prop wins, then the @handle, then the
|
|
110
152
|
// canonical API-owned `name.displayName` from the Oxy user DTO (never a
|
|
@@ -118,18 +160,19 @@ export const RoomCard: React.FC<RoomCardProps> = ({
|
|
|
118
160
|
|| 'Unknown');
|
|
119
161
|
const hostAvatarUri = hostAvatarUriProp ?? getAvatarUrl(hostProfile, oxyServices, getCachedFileDownloadUrlSync);
|
|
120
162
|
|
|
121
|
-
//
|
|
163
|
+
// Speaker faces only earn their space on the featured (live) card.
|
|
122
164
|
const speakerIds = useMemo(() => {
|
|
123
|
-
if (
|
|
165
|
+
if (!isFeatured) return [];
|
|
124
166
|
const ids = room.speakers?.length ? room.speakers : [room.host];
|
|
125
167
|
return ids.slice(0, MAX_SPEAKER_AVATARS);
|
|
126
|
-
}, [room.speakers, room.host
|
|
168
|
+
}, [isFeatured, room.speakers, room.host]);
|
|
169
|
+
const hiddenSpeakerCount = Math.max(0, (room.speakers?.length ?? 0) - speakerIds.length);
|
|
127
170
|
|
|
128
171
|
useRoomUsers(speakerIds);
|
|
129
172
|
|
|
130
|
-
const typeMeta = getRoomTypeMeta(room.type);
|
|
131
173
|
const listenerCount = room.participants?.length || room.stats?.totalJoined || 0;
|
|
132
|
-
const
|
|
174
|
+
const scheduleLabel = isLive ? '' : getScheduleLabel(room, new Date());
|
|
175
|
+
const byline = house ? `by ${hostName} · ${house.name}` : `by ${hostName}`;
|
|
133
176
|
|
|
134
177
|
// --- Compact variant (post attachments, composer preview) ---
|
|
135
178
|
// It lives inside HORIZONTAL carousels, where a percentage width has no parent
|
|
@@ -146,121 +189,117 @@ export const RoomCard: React.FC<RoomCardProps> = ({
|
|
|
146
189
|
disabled={!onPress}
|
|
147
190
|
>
|
|
148
191
|
<View className="flex-1">
|
|
149
|
-
{
|
|
150
|
-
<
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
>
|
|
154
|
-
{house.name.toUpperCase()}
|
|
155
|
-
</Text>
|
|
192
|
+
{isLive && (
|
|
193
|
+
<View className="mb-1.5 flex-row">
|
|
194
|
+
<LiveBadge />
|
|
195
|
+
</View>
|
|
156
196
|
)}
|
|
157
197
|
<Text className="text-sm font-semibold leading-[18px] text-foreground" numberOfLines={2}>
|
|
158
198
|
{room.title}
|
|
159
199
|
</Text>
|
|
160
|
-
{(isLive || isScheduled || typeMeta) && (
|
|
161
|
-
<View className="mt-1 flex-row flex-wrap gap-1">
|
|
162
|
-
{isLive && <LiveBadge />}
|
|
163
|
-
{isScheduled && <ScheduledBadge iconSize={10} iconColor={theme.colors.textSecondary} />}
|
|
164
|
-
{typeMeta && <TypeBadge meta={typeMeta} />}
|
|
165
|
-
</View>
|
|
166
|
-
)}
|
|
167
200
|
</View>
|
|
168
|
-
<View className="mt-2
|
|
169
|
-
<
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
<Text className="
|
|
176
|
-
{
|
|
201
|
+
<View className="mt-2 gap-1">
|
|
202
|
+
<View className="flex-row items-center gap-1">
|
|
203
|
+
<AvatarComponent size={14} source={hostAvatarUri} shape="squircle" />
|
|
204
|
+
<Text className="flex-1 text-[11px] text-muted-foreground" numberOfLines={1}>
|
|
205
|
+
{byline}
|
|
206
|
+
</Text>
|
|
207
|
+
</View>
|
|
208
|
+
<Text className="text-[11px] text-muted-foreground" numberOfLines={1}>
|
|
209
|
+
{isLive ? `${formatCompact(listenerCount)} listening` : scheduleLabel}
|
|
177
210
|
</Text>
|
|
178
211
|
</View>
|
|
179
212
|
</TouchableOpacity>
|
|
180
213
|
);
|
|
181
214
|
}
|
|
182
215
|
|
|
183
|
-
// ---
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
216
|
+
// --- Featured card: a LIVE room, with presence and an explicit CTA ---
|
|
217
|
+
if (isFeatured) {
|
|
218
|
+
return (
|
|
219
|
+
<TouchableOpacity
|
|
220
|
+
className="mb-3 w-full gap-2.5 rounded-2xl border border-border bg-surface p-3"
|
|
221
|
+
style={style}
|
|
222
|
+
onPress={onPress}
|
|
223
|
+
activeOpacity={onPress ? 0.7 : 1}
|
|
224
|
+
disabled={!onPress}
|
|
225
|
+
>
|
|
226
|
+
{/* Status + the only number that matters on air, then icon-only actions */}
|
|
194
227
|
<View className="flex-row items-center gap-1.5">
|
|
195
|
-
|
|
228
|
+
<LiveBadge />
|
|
229
|
+
{listenerCount > 0 && (
|
|
196
230
|
<>
|
|
197
|
-
<
|
|
198
|
-
<Text
|
|
199
|
-
|
|
200
|
-
numberOfLines={1}
|
|
201
|
-
>
|
|
202
|
-
{house.name.toUpperCase()}
|
|
231
|
+
<Text className="text-[13px] text-muted-foreground">·</Text>
|
|
232
|
+
<Text className="text-[13px] font-semibold text-muted-foreground" numberOfLines={1}>
|
|
233
|
+
{formatCompact(listenerCount)} listening
|
|
203
234
|
</Text>
|
|
204
235
|
</>
|
|
205
236
|
)}
|
|
206
237
|
<View className="flex-1" />
|
|
207
|
-
{
|
|
208
|
-
<
|
|
209
|
-
<MaterialCommunityIcons name="dots-horizontal" size={20} color={theme.colors.textSecondary} />
|
|
210
|
-
</TouchableOpacity>
|
|
238
|
+
{onSave && (
|
|
239
|
+
<SaveAction onSave={onSave} isSaved={isSaved} isScheduled={false} theme={theme} />
|
|
211
240
|
)}
|
|
241
|
+
{onMenuPress && <MenuAction onMenuPress={onMenuPress} theme={theme} />}
|
|
212
242
|
</View>
|
|
213
|
-
)}
|
|
214
243
|
|
|
215
|
-
{/* Section 2: Title + status badges */}
|
|
216
|
-
<View>
|
|
217
244
|
<Text className="text-[17px] font-bold leading-[22px] text-foreground" numberOfLines={2}>
|
|
218
245
|
{room.title}
|
|
219
246
|
</Text>
|
|
220
|
-
{(isLive || isScheduled || typeMeta) && (
|
|
221
|
-
<View className="mt-1.5 flex-row flex-wrap items-center gap-1.5">
|
|
222
|
-
{isLive && <LiveBadge />}
|
|
223
|
-
{isScheduled && <ScheduledBadge iconColor={theme.colors.textSecondary} />}
|
|
224
|
-
{typeMeta && <TypeBadge meta={typeMeta} />}
|
|
225
|
-
</View>
|
|
226
|
-
)}
|
|
227
|
-
</View>
|
|
228
247
|
|
|
229
|
-
|
|
230
|
-
<SpeakerRow speakerIds={speakerIds} listenerCount={listenerCount} />
|
|
248
|
+
<SpeakerStack speakerIds={speakerIds} hiddenCount={hiddenSpeakerCount} />
|
|
231
249
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
)}
|
|
240
|
-
{onSave && (
|
|
250
|
+
{/* Host — a room's primary identity — and the join affordance */}
|
|
251
|
+
<View className="flex-row items-center gap-2">
|
|
252
|
+
<AvatarComponent size={BYLINE_AVATAR_SIZE} source={hostAvatarUri} shape="squircle" />
|
|
253
|
+
<Text className="flex-1 text-[13px] text-muted-foreground" numberOfLines={1}>
|
|
254
|
+
{byline}
|
|
255
|
+
</Text>
|
|
256
|
+
{onPress && (
|
|
241
257
|
<TouchableOpacity
|
|
242
|
-
className="
|
|
243
|
-
|
|
244
|
-
|
|
258
|
+
className="rounded-full px-4 py-1.5"
|
|
259
|
+
style={{ backgroundColor: LIVE_COLOR }}
|
|
260
|
+
onPress={onPress}
|
|
261
|
+
accessibilityRole="button"
|
|
262
|
+
accessibilityLabel="Join room"
|
|
245
263
|
>
|
|
246
|
-
<
|
|
247
|
-
|
|
248
|
-
size={18}
|
|
249
|
-
color={isSaved ? theme.colors.primary : theme.colors.textSecondary}
|
|
250
|
-
/>
|
|
251
|
-
<Text
|
|
252
|
-
className={
|
|
253
|
-
isSaved
|
|
254
|
-
? 'text-[13px] font-medium text-primary'
|
|
255
|
-
: 'text-[13px] font-medium text-muted-foreground'
|
|
256
|
-
}
|
|
257
|
-
>
|
|
258
|
-
Save
|
|
264
|
+
<Text className="text-[13px] font-bold" style={{ color: LIVE_FOREGROUND_COLOR }}>
|
|
265
|
+
Join
|
|
259
266
|
</Text>
|
|
260
267
|
</TouchableOpacity>
|
|
261
268
|
)}
|
|
262
269
|
</View>
|
|
263
|
-
|
|
270
|
+
</TouchableOpacity>
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// --- Compact row: a SCHEDULED or ENDED room, flush in the feed ---
|
|
275
|
+
return (
|
|
276
|
+
<TouchableOpacity
|
|
277
|
+
className="w-full flex-row items-center gap-3 border-b border-border px-3 py-3"
|
|
278
|
+
style={style}
|
|
279
|
+
onPress={onPress}
|
|
280
|
+
activeOpacity={onPress ? 0.7 : 1}
|
|
281
|
+
disabled={!onPress}
|
|
282
|
+
>
|
|
283
|
+
<AvatarComponent size={ROW_AVATAR_SIZE} source={hostAvatarUri} shape="squircle" />
|
|
284
|
+
|
|
285
|
+
<View className="flex-1 gap-0.5">
|
|
286
|
+
<View className="flex-row items-baseline gap-1.5">
|
|
287
|
+
<Text className="shrink text-[15px] font-semibold text-foreground" numberOfLines={1}>
|
|
288
|
+
{room.title}
|
|
289
|
+
</Text>
|
|
290
|
+
{scheduleLabel !== '' && (
|
|
291
|
+
<Text className="shrink-0 text-[13px] text-muted-foreground" numberOfLines={1}>
|
|
292
|
+
· {scheduleLabel}
|
|
293
|
+
</Text>
|
|
294
|
+
)}
|
|
295
|
+
</View>
|
|
296
|
+
<Text className="text-[13px] text-muted-foreground" numberOfLines={1}>
|
|
297
|
+
{byline}
|
|
298
|
+
</Text>
|
|
299
|
+
</View>
|
|
300
|
+
|
|
301
|
+
{onSave && <SaveAction onSave={onSave} isSaved={isSaved} isScheduled={isScheduled} theme={theme} />}
|
|
302
|
+
{onMenuPress && <MenuAction onMenuPress={onMenuPress} theme={theme} />}
|
|
264
303
|
</TouchableOpacity>
|
|
265
304
|
);
|
|
266
305
|
};
|
|
@@ -282,44 +321,77 @@ function LiveBadge() {
|
|
|
282
321
|
);
|
|
283
322
|
}
|
|
284
323
|
|
|
285
|
-
|
|
324
|
+
// --- Actions (icon-only, like the surrounding feed) ---
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* The saved-state toggle. On a SCHEDULED room, saving it is how you ask to be
|
|
328
|
+
* pulled back when it starts, so the icon reads as a reminder bell; anywhere
|
|
329
|
+
* else it is a bookmark.
|
|
330
|
+
*/
|
|
331
|
+
function SaveAction({
|
|
332
|
+
onSave,
|
|
333
|
+
isSaved,
|
|
334
|
+
isScheduled,
|
|
335
|
+
theme,
|
|
336
|
+
}: {
|
|
337
|
+
onSave: () => void;
|
|
338
|
+
isSaved?: boolean;
|
|
339
|
+
isScheduled: boolean;
|
|
340
|
+
theme: LiveTheme;
|
|
341
|
+
}) {
|
|
342
|
+
const icon = isScheduled
|
|
343
|
+
? (isSaved ? 'bell' : 'bell-outline')
|
|
344
|
+
: (isSaved ? 'bookmark' : 'bookmark-outline');
|
|
345
|
+
|
|
286
346
|
return (
|
|
287
|
-
<
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
347
|
+
<TouchableOpacity
|
|
348
|
+
onPress={onSave}
|
|
349
|
+
hitSlop={ACTION_HIT_SLOP}
|
|
350
|
+
accessibilityRole="button"
|
|
351
|
+
accessibilityLabel={isScheduled ? 'Remind me about this room' : 'Save room'}
|
|
352
|
+
accessibilityState={{ selected: Boolean(isSaved) }}
|
|
353
|
+
>
|
|
354
|
+
<MaterialCommunityIcons
|
|
355
|
+
name={icon}
|
|
356
|
+
size={20}
|
|
357
|
+
color={isSaved ? theme.colors.primary : theme.colors.textSecondary}
|
|
358
|
+
/>
|
|
359
|
+
</TouchableOpacity>
|
|
291
360
|
);
|
|
292
361
|
}
|
|
293
362
|
|
|
294
|
-
function
|
|
363
|
+
function MenuAction({ onMenuPress, theme }: { onMenuPress: () => void; theme: LiveTheme }) {
|
|
295
364
|
return (
|
|
296
|
-
<
|
|
297
|
-
|
|
298
|
-
|
|
365
|
+
<TouchableOpacity
|
|
366
|
+
onPress={onMenuPress}
|
|
367
|
+
hitSlop={ACTION_HIT_SLOP}
|
|
368
|
+
accessibilityRole="button"
|
|
369
|
+
accessibilityLabel="Room options"
|
|
299
370
|
>
|
|
300
|
-
<MaterialCommunityIcons name=
|
|
301
|
-
|
|
302
|
-
{meta.label}
|
|
303
|
-
</Text>
|
|
304
|
-
</View>
|
|
371
|
+
<MaterialCommunityIcons name="dots-horizontal" size={20} color={theme.colors.textSecondary} />
|
|
372
|
+
</TouchableOpacity>
|
|
305
373
|
);
|
|
306
374
|
}
|
|
307
375
|
|
|
308
|
-
// --- Speaker
|
|
376
|
+
// --- Speaker stack (featured card only) ---
|
|
377
|
+
|
|
378
|
+
function SpeakerStack({ speakerIds, hiddenCount }: { speakerIds: string[]; hiddenCount: number }) {
|
|
379
|
+
if (speakerIds.length === 0) return null;
|
|
309
380
|
|
|
310
|
-
function SpeakerRow({ speakerIds, listenerCount }: { speakerIds: string[]; listenerCount: number }) {
|
|
311
381
|
return (
|
|
312
|
-
<View className="flex-row items-center
|
|
313
|
-
{speakerIds.map((id) => (
|
|
314
|
-
<
|
|
382
|
+
<View className="flex-row items-center">
|
|
383
|
+
{speakerIds.map((id, index) => (
|
|
384
|
+
<View key={id} className={index === 0 ? '' : '-ml-3'}>
|
|
385
|
+
<SpeakerAvatar userId={id} />
|
|
386
|
+
</View>
|
|
315
387
|
))}
|
|
316
|
-
{
|
|
388
|
+
{hiddenCount > 0 && (
|
|
317
389
|
<View
|
|
318
|
-
className="items-center justify-center rounded-
|
|
319
|
-
style={{ width: SPEAKER_AVATAR_SIZE
|
|
390
|
+
className="-ml-3 items-center justify-center rounded-full border-2 border-surface bg-muted"
|
|
391
|
+
style={{ width: SPEAKER_AVATAR_SIZE, height: SPEAKER_AVATAR_SIZE }}
|
|
320
392
|
>
|
|
321
|
-
<Text className="text-
|
|
322
|
-
+{formatCompact(
|
|
393
|
+
<Text className="text-[13px] font-bold text-muted-foreground">
|
|
394
|
+
+{formatCompact(hiddenCount)}
|
|
323
395
|
</Text>
|
|
324
396
|
</View>
|
|
325
397
|
)}
|
|
@@ -333,8 +405,9 @@ function SpeakerAvatar({ userId }: { userId: string }) {
|
|
|
333
405
|
const profile = useUserById(userId);
|
|
334
406
|
const avatarUri = getAvatarUrl(profile, oxyServices, getCachedFileDownloadUrlSync);
|
|
335
407
|
|
|
408
|
+
// The ring is the card's own surface color, so overlapping faces stay separable.
|
|
336
409
|
return (
|
|
337
|
-
<View className="rounded-
|
|
410
|
+
<View className="rounded-full border-2 border-surface">
|
|
338
411
|
<AvatarComponent size={SPEAKER_AVATAR_SIZE} source={avatarUri} shape="squircle" />
|
|
339
412
|
</View>
|
|
340
413
|
);
|