@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syra.fm/sdk",
3
- "version": "0.6.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, getRoomTypeMeta, type RoomTypeMeta } from '../colors';
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 MONTH_SHORT = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
29
+ const DAY_MS = 86_400_000;
29
30
 
30
- function formatDate(dateStr: string): string {
31
- const d = new Date(dateStr);
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
- function getTimeLabel(room: RoomCardProps['room']): string {
36
- if (room.status === 'ended' && room.startedAt && room.endedAt) {
37
- return `${formatDuration(room.startedAt, room.endedAt)} · ${formatDate(room.endedAt)}`;
38
- }
39
- if (room.status === 'live' && room.startedAt) {
40
- return `Live · Started ${formatDate(room.startedAt)}`;
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
- 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;
44
64
  }
45
65
  if (room.createdAt) {
46
- return formatDate(room.createdAt);
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 = 4;
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
- // Resolve speaker avatars (default variant only)
163
+ // Speaker faces only earn their space on the featured (live) card.
122
164
  const speakerIds = useMemo(() => {
123
- if (isCompact) return [];
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, isCompact]);
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 timeLabel = getTimeLabel(room);
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
- {house && (
150
- <Text
151
- className="mb-1 text-[9px] font-bold tracking-[0.5px] text-muted-foreground"
152
- numberOfLines={1}
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 flex-row items-center gap-1">
169
- <MaterialCommunityIcons name="account-group" size={14} color={theme.colors.textSecondary} />
170
- <Text className="text-[11px] text-muted-foreground">{listenerCount} listening</Text>
171
- <Text className="text-[10px] text-muted-foreground">•</Text>
172
- {hostAvatarUri && (
173
- <AvatarComponent size={14} source={hostAvatarUri} shape="squircle" style={{ marginRight: 2 }} />
174
- )}
175
- <Text className="flex-1 text-[11px] text-muted-foreground" numberOfLines={1}>
176
- {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}
177
210
  </Text>
178
211
  </View>
179
212
  </TouchableOpacity>
180
213
  );
181
214
  }
182
215
 
183
- // --- Default variant: a full-width, flush feed row (hairline divider, no card chrome) ---
184
- return (
185
- <TouchableOpacity
186
- className="w-full gap-2 border-b border-border px-3 py-3"
187
- style={style}
188
- onPress={onPress}
189
- activeOpacity={onPress ? 0.7 : 1}
190
- disabled={!onPress}
191
- >
192
- {/* Section 1: House header + menu */}
193
- {(house || onMenuPress) && (
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
- {house && (
228
+ <LiveBadge />
229
+ {listenerCount > 0 && (
196
230
  <>
197
- <MaterialCommunityIcons name="home" size={14} color={theme.colors.primary} />
198
- <Text
199
- className="text-[11px] font-bold tracking-[0.5px] text-muted-foreground"
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
- {onMenuPress && (
208
- <TouchableOpacity onPress={onMenuPress} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
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
- {/* Section 3: Speaker avatars + listener count */}
230
- <SpeakerRow speakerIds={speakerIds} listenerCount={listenerCount} />
248
+ <SpeakerStack speakerIds={speakerIds} hiddenCount={hiddenSpeakerCount} />
231
249
 
232
- {/* Section 4: Metadata + save */}
233
- {(timeLabel || onSave) && (
234
- <View className="flex-row items-center justify-between">
235
- {timeLabel ? (
236
- <Text className="text-[13px] text-muted-foreground">{timeLabel}</Text>
237
- ) : (
238
- <View />
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="flex-row items-center gap-1"
243
- onPress={onSave}
244
- hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
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
- <MaterialCommunityIcons
247
- name={isSaved ? 'bookmark' : 'bookmark-outline'}
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
- function ScheduledBadge({ iconSize = 12, iconColor }: { iconSize?: number; iconColor: string }) {
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
- <View className="flex-row items-center gap-1 rounded-[4px] bg-muted px-2 py-1">
288
- <MaterialCommunityIcons name="calendar" size={iconSize} color={iconColor} />
289
- <Text className="text-[10px] font-bold text-muted-foreground">SCHEDULED</Text>
290
- </View>
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 TypeBadge({ meta }: { meta: RoomTypeMeta }) {
363
+ function MenuAction({ onMenuPress, theme }: { onMenuPress: () => void; theme: LiveTheme }) {
295
364
  return (
296
- <View
297
- className="flex-row items-center gap-0.5 rounded-[4px] px-1.5 py-0.5"
298
- style={{ backgroundColor: meta.tintColor }}
365
+ <TouchableOpacity
366
+ onPress={onMenuPress}
367
+ hitSlop={ACTION_HIT_SLOP}
368
+ accessibilityRole="button"
369
+ accessibilityLabel="Room options"
299
370
  >
300
- <MaterialCommunityIcons name={meta.icon} size={10} color={meta.color} />
301
- <Text className="text-[9px] font-bold" style={{ color: meta.color }}>
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 row sub-component ---
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 gap-2">
313
- {speakerIds.map((id) => (
314
- <SpeakerAvatar key={id} userId={id} />
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
- {listenerCount > 0 && (
388
+ {hiddenCount > 0 && (
317
389
  <View
318
- className="items-center justify-center rounded-[14px] border-2 border-border bg-muted"
319
- style={{ width: SPEAKER_AVATAR_SIZE + 4, height: SPEAKER_AVATAR_SIZE + 4 }}
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-sm font-bold text-muted-foreground">
322
- +{formatCompact(listenerCount)}
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-[14px] border-2 border-border p-0.5">
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
  );