@syra.fm/sdk 0.5.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.
Files changed (55) hide show
  1. package/README.md +33 -4
  2. package/lib/commonjs/live/colors.js +54 -0
  3. package/lib/commonjs/live/colors.js.map +1 -0
  4. package/lib/commonjs/live/components/LiveRoomSheet.js +9 -8
  5. package/lib/commonjs/live/components/LiveRoomSheet.js.map +1 -1
  6. package/lib/commonjs/live/components/MiniRoomBar.js +3 -2
  7. package/lib/commonjs/live/components/MiniRoomBar.js.map +1 -1
  8. package/lib/commonjs/live/components/PodcastStreamPicker.js +1 -1
  9. package/lib/commonjs/live/components/RecordingsPanel.js +3 -2
  10. package/lib/commonjs/live/components/RecordingsPanel.js.map +1 -1
  11. package/lib/commonjs/live/components/RoomCard.js +283 -404
  12. package/lib/commonjs/live/components/RoomCard.js.map +1 -1
  13. package/lib/commonjs/live/index.js +31 -0
  14. package/lib/commonjs/live/index.js.map +1 -1
  15. package/lib/module/live/colors.js +49 -0
  16. package/lib/module/live/colors.js.map +1 -0
  17. package/lib/module/live/components/LiveRoomSheet.js +9 -8
  18. package/lib/module/live/components/LiveRoomSheet.js.map +1 -1
  19. package/lib/module/live/components/MiniRoomBar.js +3 -2
  20. package/lib/module/live/components/MiniRoomBar.js.map +1 -1
  21. package/lib/module/live/components/PodcastStreamPicker.js +1 -1
  22. package/lib/module/live/components/RecordingsPanel.js +3 -2
  23. package/lib/module/live/components/RecordingsPanel.js.map +1 -1
  24. package/lib/module/live/components/RoomCard.js +284 -407
  25. package/lib/module/live/components/RoomCard.js.map +1 -1
  26. package/lib/module/live/index.js +4 -0
  27. package/lib/module/live/index.js.map +1 -1
  28. package/lib/typescript/commonjs/live/colors.d.ts +35 -0
  29. package/lib/typescript/commonjs/live/colors.d.ts.map +1 -0
  30. package/lib/typescript/commonjs/live/components/LiveRoomSheet.d.ts.map +1 -1
  31. package/lib/typescript/commonjs/live/components/MiniRoomBar.d.ts.map +1 -1
  32. package/lib/typescript/commonjs/live/components/PodcastStreamPicker.d.ts +1 -1
  33. package/lib/typescript/commonjs/live/components/RecordingsPanel.d.ts.map +1 -1
  34. package/lib/typescript/commonjs/live/components/RoomCard.d.ts +18 -0
  35. package/lib/typescript/commonjs/live/components/RoomCard.d.ts.map +1 -1
  36. package/lib/typescript/commonjs/live/index.d.ts +1 -0
  37. package/lib/typescript/commonjs/live/index.d.ts.map +1 -1
  38. package/lib/typescript/module/live/colors.d.ts +35 -0
  39. package/lib/typescript/module/live/colors.d.ts.map +1 -0
  40. package/lib/typescript/module/live/components/LiveRoomSheet.d.ts.map +1 -1
  41. package/lib/typescript/module/live/components/MiniRoomBar.d.ts.map +1 -1
  42. package/lib/typescript/module/live/components/PodcastStreamPicker.d.ts +1 -1
  43. package/lib/typescript/module/live/components/RecordingsPanel.d.ts.map +1 -1
  44. package/lib/typescript/module/live/components/RoomCard.d.ts +18 -0
  45. package/lib/typescript/module/live/components/RoomCard.d.ts.map +1 -1
  46. package/lib/typescript/module/live/index.d.ts +1 -0
  47. package/lib/typescript/module/live/index.d.ts.map +1 -1
  48. package/package.json +3 -13
  49. package/src/live/colors.ts +57 -0
  50. package/src/live/components/LiveRoomSheet.tsx +9 -8
  51. package/src/live/components/MiniRoomBar.tsx +3 -2
  52. package/src/live/components/PodcastStreamPicker.tsx +1 -1
  53. package/src/live/components/RecordingsPanel.tsx +3 -2
  54. package/src/live/components/RoomCard.tsx +245 -323
  55. package/src/live/index.ts +12 -0
@@ -1,11 +1,13 @@
1
1
  import React, { useMemo } from 'react';
2
- import { View, Text, StyleSheet, TouchableOpacity, StyleProp, ViewStyle } from 'react-native';
2
+ import { View, Text, TouchableOpacity, StyleProp, ViewStyle } from 'react-native';
3
3
  import MaterialCommunityIcons from '@expo/vector-icons/MaterialCommunityIcons';
4
4
  import { useAuth } from '@oxyhq/services';
5
5
 
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 } from '../colors';
10
+ import type { LiveTheme } from '../types';
9
11
 
10
12
  // --- Utility helpers ---
11
13
 
@@ -24,38 +26,55 @@ function formatDuration(startedAt: string, endedAt: string): string {
24
26
  return m > 0 ? `${h}h ${m}m` : `${h}h`;
25
27
  }
26
28
 
27
- const MONTH_SHORT = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
29
+ const DAY_MS = 86_400_000;
28
30
 
29
- function formatDate(dateStr: string): string {
30
- const d = new Date(dateStr);
31
- 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();
32
33
  }
33
34
 
34
- function getTimeLabel(room: RoomCardProps['room']): string {
35
- if (room.status === 'ended' && room.startedAt && room.endedAt) {
36
- return `${formatDuration(room.startedAt, room.endedAt)} · ${formatDate(room.endedAt)}`;
37
- }
38
- if (room.status === 'live' && room.startedAt) {
39
- return `Live · Started ${formatDate(room.startedAt)}`;
40
- }
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 {
41
57
  if (room.status === 'scheduled' && room.scheduledStart) {
42
- return formatDate(room.scheduledStart);
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;
43
64
  }
44
65
  if (room.createdAt) {
45
- return formatDate(room.createdAt);
66
+ return formatDay(new Date(room.createdAt), now);
46
67
  }
47
68
  return '';
48
69
  }
49
70
 
50
71
  // --- Constants ---
51
72
 
52
- const ROOM_TYPE_META: Record<string, { icon: 'account-voice' | 'broadcast'; label: string; color: string }> = {
53
- stage: { icon: 'account-voice', label: 'Stage', color: '#3B82F6' },
54
- broadcast: { icon: 'broadcast', label: 'Broadcast', color: '#FF6B35' },
55
- };
56
-
57
- const MAX_SPEAKER_AVATARS = 4;
73
+ const MAX_SPEAKER_AVATARS = 3;
58
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 };
59
78
 
60
79
  // --- Types ---
61
80
 
@@ -89,6 +108,24 @@ interface RoomCardProps {
89
108
 
90
109
  // --- Component ---
91
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
+ */
92
129
  export const RoomCard: React.FC<RoomCardProps> = ({
93
130
  room,
94
131
  onPress,
@@ -109,6 +146,7 @@ export const RoomCard: React.FC<RoomCardProps> = ({
109
146
  const isLive = room.status === 'live';
110
147
  const isScheduled = room.status === 'scheduled';
111
148
  const isCompact = variant === 'compact';
149
+ const isFeatured = isLive && !isCompact;
112
150
 
113
151
  // Resolve host display: an explicit prop wins, then the @handle, then the
114
152
  // canonical API-owned `name.displayName` from the Oxy user DTO (never a
@@ -122,187 +160,238 @@ export const RoomCard: React.FC<RoomCardProps> = ({
122
160
  || 'Unknown');
123
161
  const hostAvatarUri = hostAvatarUriProp ?? getAvatarUrl(hostProfile, oxyServices, getCachedFileDownloadUrlSync);
124
162
 
125
- // Resolve speaker avatars (default variant only)
163
+ // Speaker faces only earn their space on the featured (live) card.
126
164
  const speakerIds = useMemo(() => {
127
- if (isCompact) return [];
165
+ if (!isFeatured) return [];
128
166
  const ids = room.speakers?.length ? room.speakers : [room.host];
129
167
  return ids.slice(0, MAX_SPEAKER_AVATARS);
130
- }, [room.speakers, room.host, isCompact]);
168
+ }, [isFeatured, room.speakers, room.host]);
169
+ const hiddenSpeakerCount = Math.max(0, (room.speakers?.length ?? 0) - speakerIds.length);
131
170
 
132
171
  useRoomUsers(speakerIds);
133
172
 
134
- const typeMeta = room.type && room.type !== 'talk' ? ROOM_TYPE_META[room.type] : null;
135
173
  const listenerCount = room.participants?.length || room.stats?.totalJoined || 0;
136
- const timeLabel = getTimeLabel(room);
137
-
138
- // --- Compact variant (kept simple) ---
174
+ const scheduleLabel = isLive ? '' : getScheduleLabel(room, new Date());
175
+ const byline = house ? `by ${hostName} · ${house.name}` : `by ${hostName}`;
176
+
177
+ // --- Compact variant (post attachments, composer preview) ---
178
+ // It lives inside HORIZONTAL carousels, where a percentage width has no parent
179
+ // to resolve against — hence the intrinsic width, capped at the parent so a
180
+ // constrained host box (the composer preview) can still shrink it. Hosts
181
+ // override any of this through `style`, which wins over the className.
139
182
  if (isCompact) {
140
183
  return (
141
184
  <TouchableOpacity
142
- style={[styles.compactCard, { backgroundColor: theme.colors.card, borderColor: theme.colors.border }, style]}
185
+ className="w-[200px] max-w-full min-h-[140px] justify-between rounded-2xl border border-border bg-surface p-3"
186
+ style={style}
143
187
  onPress={onPress}
144
188
  activeOpacity={onPress ? 0.7 : 1}
145
189
  disabled={!onPress}
146
190
  >
147
- <View style={styles.compactHeader}>
148
- {house && (
149
- <Text style={[styles.compactHouseName, { color: theme.colors.textSecondary }]} numberOfLines={1}>
150
- {house.name.toUpperCase()}
151
- </Text>
191
+ <View className="flex-1">
192
+ {isLive && (
193
+ <View className="mb-1.5 flex-row">
194
+ <LiveBadge />
195
+ </View>
152
196
  )}
153
- <Text style={[styles.compactTitle, { color: theme.colors.text }]} numberOfLines={2}>
197
+ <Text className="text-sm font-semibold leading-[18px] text-foreground" numberOfLines={2}>
154
198
  {room.title}
155
199
  </Text>
156
- {(isLive || isScheduled || typeMeta) && (
157
- <View style={styles.compactBadgeRow}>
158
- {isLive && (
159
- <View style={[styles.liveBadge, { backgroundColor: '#FF4458' }]}>
160
- <AnimatedPulse size={6} />
161
- <Text style={styles.liveText}>LIVE</Text>
162
- </View>
163
- )}
164
- {isScheduled && (
165
- <View style={[styles.scheduledBadge, { backgroundColor: theme.colors.backgroundSecondary }]}>
166
- <MaterialCommunityIcons name="calendar" size={10} color={theme.colors.textSecondary} />
167
- <Text style={[styles.scheduledText, { color: theme.colors.textSecondary }]}>SCHEDULED</Text>
168
- </View>
169
- )}
170
- {typeMeta && (
171
- <View style={[styles.typeBadge, { backgroundColor: typeMeta.color + '20' }]}>
172
- <MaterialCommunityIcons name={typeMeta.icon} size={10} color={typeMeta.color} />
173
- <Text style={[styles.typeText, { color: typeMeta.color }]}>{typeMeta.label}</Text>
174
- </View>
175
- )}
176
- </View>
177
- )}
178
200
  </View>
179
- <View style={styles.compactFooter}>
180
- <MaterialCommunityIcons name="account-group" size={14} color={theme.colors.textSecondary} />
181
- <Text style={[styles.compactCount, { color: theme.colors.textSecondary }]}>
182
- {listenerCount} listening
183
- </Text>
184
- <Text style={{ color: theme.colors.textSecondary, fontSize: 10 }}>•</Text>
185
- {hostAvatarUri && (
186
- <AvatarComponent size={14} source={hostAvatarUri} shape="squircle" style={{ marginRight: 2 }} />
187
- )}
188
- <Text style={[styles.compactHost, { color: theme.colors.textSecondary }]} numberOfLines={1}>
189
- {hostName}
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}
190
210
  </Text>
191
211
  </View>
192
212
  </TouchableOpacity>
193
213
  );
194
214
  }
195
215
 
196
- // --- Default variant ---
197
- return (
198
- <TouchableOpacity
199
- style={[styles.card, { backgroundColor: theme.colors.card, borderColor: theme.colors.border }, style]}
200
- onPress={onPress}
201
- activeOpacity={onPress ? 0.7 : 1}
202
- disabled={!onPress}
203
- >
204
- {/* Section 1: House header + menu */}
205
- {(house || onMenuPress) && (
206
- <View style={styles.houseRow}>
207
- {house && (
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 */}
227
+ <View className="flex-row items-center gap-1.5">
228
+ <LiveBadge />
229
+ {listenerCount > 0 && (
208
230
  <>
209
- <MaterialCommunityIcons name="home" size={16} color={theme.colors.primary} />
210
- <Text style={[styles.houseName, { color: theme.colors.textSecondary }]} numberOfLines={1}>
211
- {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
212
234
  </Text>
213
235
  </>
214
236
  )}
215
- <View style={{ flex: 1 }} />
216
- {onMenuPress && (
217
- <TouchableOpacity onPress={onMenuPress} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
218
- <MaterialCommunityIcons name="dots-horizontal" size={20} color={theme.colors.textSecondary} />
219
- </TouchableOpacity>
237
+ <View className="flex-1" />
238
+ {onSave && (
239
+ <SaveAction onSave={onSave} isSaved={isSaved} isScheduled={false} theme={theme} />
220
240
  )}
241
+ {onMenuPress && <MenuAction onMenuPress={onMenuPress} theme={theme} />}
221
242
  </View>
222
- )}
223
243
 
224
- {/* Section 2: Title + status badges */}
225
- <View>
226
- <Text style={[styles.title, { color: theme.colors.text }]} numberOfLines={2}>
244
+ <Text className="text-[17px] font-bold leading-[22px] text-foreground" numberOfLines={2}>
227
245
  {room.title}
228
246
  </Text>
229
- {(isLive || isScheduled || typeMeta) && (
230
- <View style={styles.badgeRow}>
231
- {isLive && (
232
- <View style={[styles.liveBadge, { backgroundColor: '#FF4458' }]}>
233
- <AnimatedPulse size={6} />
234
- <Text style={styles.liveText}>LIVE</Text>
235
- </View>
236
- )}
237
- {isScheduled && (
238
- <View style={[styles.scheduledBadge, { backgroundColor: theme.colors.backgroundSecondary }]}>
239
- <MaterialCommunityIcons name="calendar" size={12} color={theme.colors.textSecondary} />
240
- <Text style={[styles.scheduledText, { color: theme.colors.textSecondary }]}>SCHEDULED</Text>
241
- </View>
242
- )}
243
- {typeMeta && (
244
- <View style={[styles.typeBadge, { backgroundColor: typeMeta.color + '20' }]}>
245
- <MaterialCommunityIcons name={typeMeta.icon} size={10} color={typeMeta.color} />
246
- <Text style={[styles.typeText, { color: typeMeta.color }]}>{typeMeta.label}</Text>
247
- </View>
248
- )}
249
- </View>
250
- )}
251
- </View>
252
247
 
253
- {/* Section 3: Speaker avatars + listener count */}
254
- <SpeakerRow
255
- speakerIds={speakerIds}
256
- listenerCount={listenerCount}
257
- theme={theme}
258
- />
248
+ <SpeakerStack speakerIds={speakerIds} hiddenCount={hiddenSpeakerCount} />
259
249
 
260
- {/* Section 4: Metadata + save */}
261
- {(timeLabel || onSave) && (
262
- <View style={styles.metaRow}>
263
- {timeLabel ? (
264
- <Text style={[styles.metaText, { color: theme.colors.textSecondary }]}>{timeLabel}</Text>
265
- ) : (
266
- <View />
267
- )}
268
- {onSave && (
269
- <TouchableOpacity onPress={onSave} style={styles.saveButton} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
270
- <MaterialCommunityIcons
271
- name={isSaved ? 'bookmark' : 'bookmark-outline'}
272
- size={18}
273
- color={isSaved ? theme.colors.primary : theme.colors.textSecondary}
274
- />
275
- <Text style={[styles.saveText, { color: isSaved ? theme.colors.primary : theme.colors.textSecondary }]}>
276
- Save
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 && (
257
+ <TouchableOpacity
258
+ className="rounded-full px-4 py-1.5"
259
+ style={{ backgroundColor: LIVE_COLOR }}
260
+ onPress={onPress}
261
+ accessibilityRole="button"
262
+ accessibilityLabel="Join room"
263
+ >
264
+ <Text className="text-[13px] font-bold" style={{ color: LIVE_FOREGROUND_COLOR }}>
265
+ Join
277
266
  </Text>
278
267
  </TouchableOpacity>
279
268
  )}
280
269
  </View>
281
- )}
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} />}
282
303
  </TouchableOpacity>
283
304
  );
284
305
  };
285
306
 
286
- // --- Speaker row sub-component ---
307
+ // --- Badges ---
287
308
 
288
- function SpeakerRow({
289
- speakerIds,
290
- listenerCount,
309
+ // The LIVE badge is the one surface that must NOT theme with the host: it is the
310
+ // brand's "on air" signal, so its red comes from the shared live palette and its
311
+ // content sits on white regardless of light/dark mode.
312
+ function LiveBadge() {
313
+ return (
314
+ <View
315
+ className="flex-row items-center gap-1 rounded-[4px] px-2 py-1"
316
+ style={{ backgroundColor: LIVE_COLOR }}
317
+ >
318
+ <AnimatedPulse size={6} color={LIVE_FOREGROUND_COLOR} />
319
+ <Text className="text-[10px] font-bold text-white">LIVE</Text>
320
+ </View>
321
+ );
322
+ }
323
+
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,
291
335
  theme,
292
336
  }: {
293
- speakerIds: string[];
294
- listenerCount: number;
295
- theme: ReturnType<ReturnType<typeof useLiveConfig>['useTheme']>;
337
+ onSave: () => void;
338
+ isSaved?: boolean;
339
+ isScheduled: boolean;
340
+ theme: LiveTheme;
296
341
  }) {
342
+ const icon = isScheduled
343
+ ? (isSaved ? 'bell' : 'bell-outline')
344
+ : (isSaved ? 'bookmark' : 'bookmark-outline');
345
+
346
+ return (
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>
360
+ );
361
+ }
362
+
363
+ function MenuAction({ onMenuPress, theme }: { onMenuPress: () => void; theme: LiveTheme }) {
297
364
  return (
298
- <View style={styles.avatarRow}>
299
- {speakerIds.map((id) => (
300
- <SpeakerAvatar key={id} userId={id} />
365
+ <TouchableOpacity
366
+ onPress={onMenuPress}
367
+ hitSlop={ACTION_HIT_SLOP}
368
+ accessibilityRole="button"
369
+ accessibilityLabel="Room options"
370
+ >
371
+ <MaterialCommunityIcons name="dots-horizontal" size={20} color={theme.colors.textSecondary} />
372
+ </TouchableOpacity>
373
+ );
374
+ }
375
+
376
+ // --- Speaker stack (featured card only) ---
377
+
378
+ function SpeakerStack({ speakerIds, hiddenCount }: { speakerIds: string[]; hiddenCount: number }) {
379
+ if (speakerIds.length === 0) return null;
380
+
381
+ return (
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>
301
387
  ))}
302
- {listenerCount > 0 && (
303
- <View style={[styles.countTile, { backgroundColor: theme.colors.backgroundSecondary, borderColor: theme.colors.border }]}>
304
- <Text style={[styles.countTileText, { color: theme.colors.textSecondary }]}>
305
- +{formatCompact(listenerCount)}
388
+ {hiddenCount > 0 && (
389
+ <View
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 }}
392
+ >
393
+ <Text className="text-[13px] font-bold text-muted-foreground">
394
+ +{formatCompact(hiddenCount)}
306
395
  </Text>
307
396
  </View>
308
397
  )}
@@ -311,184 +400,17 @@ function SpeakerRow({
311
400
  }
312
401
 
313
402
  function SpeakerAvatar({ userId }: { userId: string }) {
314
- const { useUserById, AvatarComponent, getCachedFileDownloadUrlSync, useTheme } = useLiveConfig();
315
- const theme = useTheme();
403
+ const { useUserById, AvatarComponent, getCachedFileDownloadUrlSync } = useLiveConfig();
316
404
  const { oxyServices } = useAuth();
317
405
  const profile = useUserById(userId);
318
406
  const avatarUri = getAvatarUrl(profile, oxyServices, getCachedFileDownloadUrlSync);
319
407
 
408
+ // The ring is the card's own surface color, so overlapping faces stay separable.
320
409
  return (
321
- <View style={[styles.avatarRing, { borderColor: theme.colors.border }]}>
322
- <AvatarComponent
323
- size={SPEAKER_AVATAR_SIZE}
324
- source={avatarUri}
325
- shape="squircle"
326
- />
410
+ <View className="rounded-full border-2 border-surface">
411
+ <AvatarComponent size={SPEAKER_AVATAR_SIZE} source={avatarUri} shape="squircle" />
327
412
  </View>
328
413
  );
329
414
  }
330
415
 
331
- // --- Styles ---
332
-
333
- const styles = StyleSheet.create({
334
- card: {
335
- borderRadius: 12,
336
- borderWidth: 1,
337
- padding: 16,
338
- marginBottom: 12,
339
- gap: 12,
340
- },
341
- compactCard: {
342
- width: 200,
343
- minHeight: 140,
344
- borderRadius: 14,
345
- borderWidth: 1,
346
- padding: 12,
347
- justifyContent: 'space-between',
348
- },
349
-
350
- // House header
351
- houseRow: {
352
- flexDirection: 'row',
353
- alignItems: 'center',
354
- gap: 6,
355
- },
356
- houseName: {
357
- fontSize: 11,
358
- fontWeight: '700',
359
- letterSpacing: 0.5,
360
- },
361
-
362
- // Title
363
- title: {
364
- fontSize: 32,
365
- fontWeight: '700',
366
- lineHeight: 38,
367
- },
368
-
369
- // Badges
370
- badgeRow: {
371
- flexDirection: 'row',
372
- alignItems: 'center',
373
- gap: 6,
374
- marginTop: 6,
375
- },
376
- liveBadge: {
377
- flexDirection: 'row',
378
- alignItems: 'center',
379
- paddingHorizontal: 8,
380
- paddingVertical: 4,
381
- borderRadius: 4,
382
- gap: 4,
383
- },
384
- liveText: {
385
- fontSize: 10,
386
- fontWeight: '700',
387
- color: '#FFFFFF',
388
- },
389
- scheduledBadge: {
390
- flexDirection: 'row',
391
- alignItems: 'center',
392
- paddingHorizontal: 8,
393
- paddingVertical: 4,
394
- borderRadius: 4,
395
- gap: 4,
396
- },
397
- scheduledText: {
398
- fontSize: 10,
399
- fontWeight: '700',
400
- },
401
- typeBadge: {
402
- flexDirection: 'row',
403
- alignItems: 'center',
404
- paddingHorizontal: 6,
405
- paddingVertical: 3,
406
- borderRadius: 4,
407
- gap: 3,
408
- },
409
- typeText: {
410
- fontSize: 9,
411
- fontWeight: '700',
412
- },
413
-
414
- // Speaker avatars
415
- avatarRow: {
416
- flexDirection: 'row',
417
- alignItems: 'center',
418
- gap: 8,
419
- },
420
- avatarRing: {
421
- borderRadius: 14,
422
- padding: 2,
423
- borderWidth: 2,
424
- },
425
- countTile: {
426
- width: SPEAKER_AVATAR_SIZE + 4,
427
- height: SPEAKER_AVATAR_SIZE + 4,
428
- borderRadius: 14,
429
- borderWidth: 2,
430
- alignItems: 'center',
431
- justifyContent: 'center',
432
- gap: 2,
433
- },
434
- countTileText: {
435
- fontSize: 14,
436
- fontWeight: '700',
437
- },
438
-
439
- // Metadata
440
- metaRow: {
441
- flexDirection: 'row',
442
- alignItems: 'center',
443
- justifyContent: 'space-between',
444
- },
445
- metaText: {
446
- fontSize: 13,
447
- },
448
- saveButton: {
449
- flexDirection: 'row',
450
- alignItems: 'center',
451
- gap: 4,
452
- },
453
- saveText: {
454
- fontSize: 13,
455
- fontWeight: '500',
456
- },
457
-
458
- // Compact variant
459
- compactHeader: {
460
- flex: 1,
461
- },
462
- compactHouseName: {
463
- fontSize: 9,
464
- fontWeight: '700',
465
- letterSpacing: 0.5,
466
- marginBottom: 4,
467
- },
468
- compactTitle: {
469
- fontSize: 14,
470
- fontWeight: '600',
471
- lineHeight: 18,
472
- },
473
- compactBadgeRow: {
474
- flexDirection: 'row',
475
- marginTop: 4,
476
- gap: 4,
477
- flexWrap: 'wrap',
478
- },
479
- compactFooter: {
480
- flexDirection: 'row',
481
- alignItems: 'center',
482
- gap: 4,
483
- marginTop: 8,
484
- },
485
- compactCount: {
486
- fontSize: 11,
487
- },
488
- compactHost: {
489
- fontSize: 11,
490
- flex: 1,
491
- },
492
- });
493
-
494
416
  export default RoomCard;