@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.
- package/README.md +33 -4
- package/lib/commonjs/live/colors.js +54 -0
- package/lib/commonjs/live/colors.js.map +1 -0
- package/lib/commonjs/live/components/LiveRoomSheet.js +9 -8
- package/lib/commonjs/live/components/LiveRoomSheet.js.map +1 -1
- package/lib/commonjs/live/components/MiniRoomBar.js +3 -2
- package/lib/commonjs/live/components/MiniRoomBar.js.map +1 -1
- package/lib/commonjs/live/components/PodcastStreamPicker.js +1 -1
- package/lib/commonjs/live/components/RecordingsPanel.js +3 -2
- package/lib/commonjs/live/components/RecordingsPanel.js.map +1 -1
- package/lib/commonjs/live/components/RoomCard.js +283 -404
- package/lib/commonjs/live/components/RoomCard.js.map +1 -1
- package/lib/commonjs/live/index.js +31 -0
- package/lib/commonjs/live/index.js.map +1 -1
- package/lib/module/live/colors.js +49 -0
- package/lib/module/live/colors.js.map +1 -0
- package/lib/module/live/components/LiveRoomSheet.js +9 -8
- package/lib/module/live/components/LiveRoomSheet.js.map +1 -1
- package/lib/module/live/components/MiniRoomBar.js +3 -2
- package/lib/module/live/components/MiniRoomBar.js.map +1 -1
- package/lib/module/live/components/PodcastStreamPicker.js +1 -1
- package/lib/module/live/components/RecordingsPanel.js +3 -2
- package/lib/module/live/components/RecordingsPanel.js.map +1 -1
- package/lib/module/live/components/RoomCard.js +284 -407
- package/lib/module/live/components/RoomCard.js.map +1 -1
- package/lib/module/live/index.js +4 -0
- package/lib/module/live/index.js.map +1 -1
- package/lib/typescript/commonjs/live/colors.d.ts +35 -0
- package/lib/typescript/commonjs/live/colors.d.ts.map +1 -0
- package/lib/typescript/commonjs/live/components/LiveRoomSheet.d.ts.map +1 -1
- package/lib/typescript/commonjs/live/components/MiniRoomBar.d.ts.map +1 -1
- package/lib/typescript/commonjs/live/components/PodcastStreamPicker.d.ts +1 -1
- package/lib/typescript/commonjs/live/components/RecordingsPanel.d.ts.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/commonjs/live/index.d.ts +1 -0
- package/lib/typescript/commonjs/live/index.d.ts.map +1 -1
- package/lib/typescript/module/live/colors.d.ts +35 -0
- package/lib/typescript/module/live/colors.d.ts.map +1 -0
- package/lib/typescript/module/live/components/LiveRoomSheet.d.ts.map +1 -1
- package/lib/typescript/module/live/components/MiniRoomBar.d.ts.map +1 -1
- package/lib/typescript/module/live/components/PodcastStreamPicker.d.ts +1 -1
- package/lib/typescript/module/live/components/RecordingsPanel.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/lib/typescript/module/live/index.d.ts +1 -0
- package/lib/typescript/module/live/index.d.ts.map +1 -1
- package/package.json +3 -13
- package/src/live/colors.ts +57 -0
- package/src/live/components/LiveRoomSheet.tsx +9 -8
- package/src/live/components/MiniRoomBar.tsx +3 -2
- package/src/live/components/PodcastStreamPicker.tsx +1 -1
- package/src/live/components/RecordingsPanel.tsx +3 -2
- package/src/live/components/RoomCard.tsx +245 -323
- package/src/live/index.ts +12 -0
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import React, { useMemo } from 'react';
|
|
2
|
-
import { View, Text,
|
|
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
|
|
29
|
+
const DAY_MS = 86_400_000;
|
|
28
30
|
|
|
29
|
-
function
|
|
30
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
if (
|
|
39
|
-
|
|
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
|
-
|
|
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
|
|
66
|
+
return formatDay(new Date(room.createdAt), now);
|
|
46
67
|
}
|
|
47
68
|
return '';
|
|
48
69
|
}
|
|
49
70
|
|
|
50
71
|
// --- Constants ---
|
|
51
72
|
|
|
52
|
-
const
|
|
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
|
-
//
|
|
163
|
+
// Speaker faces only earn their space on the featured (live) card.
|
|
126
164
|
const speakerIds = useMemo(() => {
|
|
127
|
-
if (
|
|
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
|
|
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
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
|
148
|
-
{
|
|
149
|
-
<
|
|
150
|
-
|
|
151
|
-
</
|
|
191
|
+
<View className="flex-1">
|
|
192
|
+
{isLive && (
|
|
193
|
+
<View className="mb-1.5 flex-row">
|
|
194
|
+
<LiveBadge />
|
|
195
|
+
</View>
|
|
152
196
|
)}
|
|
153
|
-
<Text
|
|
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
|
|
180
|
-
<
|
|
181
|
-
|
|
182
|
-
{
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
// ---
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
<
|
|
210
|
-
<Text
|
|
211
|
-
{
|
|
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
|
|
216
|
-
{
|
|
217
|
-
<
|
|
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
|
-
|
|
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
|
-
|
|
254
|
-
<SpeakerRow
|
|
255
|
-
speakerIds={speakerIds}
|
|
256
|
-
listenerCount={listenerCount}
|
|
257
|
-
theme={theme}
|
|
258
|
-
/>
|
|
248
|
+
<SpeakerStack speakerIds={speakerIds} hiddenCount={hiddenSpeakerCount} />
|
|
259
249
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
// ---
|
|
307
|
+
// --- Badges ---
|
|
287
308
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
<
|
|
299
|
-
{
|
|
300
|
-
|
|
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
|
-
{
|
|
303
|
-
<View
|
|
304
|
-
|
|
305
|
-
|
|
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
|
|
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
|
|
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;
|