@tiledev/sdk-apptile-live-selling 0.8.1 → 0.9.1

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 (196) hide show
  1. package/README.md +452 -34
  2. package/dist/auctionComments.d.ts +25 -0
  3. package/dist/auctionComments.d.ts.map +1 -0
  4. package/dist/auctionComments.js +28 -0
  5. package/dist/auctionComments.js.map +1 -0
  6. package/dist/auctionRules.d.ts +76 -0
  7. package/dist/auctionRules.d.ts.map +1 -0
  8. package/dist/auctionRules.js +106 -0
  9. package/dist/auctionRules.js.map +1 -0
  10. package/dist/auctions.d.ts.map +1 -1
  11. package/dist/auctions.js +19 -10
  12. package/dist/auctions.js.map +1 -1
  13. package/dist/client.d.ts +51 -2
  14. package/dist/client.d.ts.map +1 -1
  15. package/dist/client.js +123 -23
  16. package/dist/client.js.map +1 -1
  17. package/dist/commentHistory.d.ts +66 -0
  18. package/dist/commentHistory.d.ts.map +1 -0
  19. package/dist/commentHistory.js +125 -0
  20. package/dist/commentHistory.js.map +1 -0
  21. package/dist/engine.native.d.ts.map +1 -1
  22. package/dist/engine.native.js +17 -845
  23. package/dist/engine.native.js.map +1 -1
  24. package/dist/giveawayPrize.d.ts +13 -0
  25. package/dist/giveawayPrize.d.ts.map +1 -0
  26. package/dist/giveawayPrize.js +18 -0
  27. package/dist/giveawayPrize.js.map +1 -0
  28. package/dist/giveawayPrizeLines.d.ts +67 -0
  29. package/dist/giveawayPrizeLines.d.ts.map +1 -0
  30. package/dist/giveawayPrizeLines.js +48 -0
  31. package/dist/giveawayPrizeLines.js.map +1 -0
  32. package/dist/giveawayRoster.d.ts +53 -0
  33. package/dist/giveawayRoster.d.ts.map +1 -0
  34. package/dist/giveawayRoster.js +93 -0
  35. package/dist/giveawayRoster.js.map +1 -0
  36. package/dist/heldComments.d.ts +29 -0
  37. package/dist/heldComments.d.ts.map +1 -0
  38. package/dist/heldComments.js +61 -0
  39. package/dist/heldComments.js.map +1 -0
  40. package/dist/hiddenShows.d.ts +34 -0
  41. package/dist/hiddenShows.d.ts.map +1 -0
  42. package/dist/hiddenShows.js +42 -0
  43. package/dist/hiddenShows.js.map +1 -0
  44. package/dist/index.d.ts +19 -2
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +93 -3
  47. package/dist/index.js.map +1 -1
  48. package/dist/liveHaptics.d.ts +7 -0
  49. package/dist/liveHaptics.d.ts.map +1 -0
  50. package/dist/liveHaptics.js +11 -0
  51. package/dist/liveHaptics.js.map +1 -0
  52. package/dist/liveHaptics.native.d.ts +7 -0
  53. package/dist/liveHaptics.native.d.ts.map +1 -0
  54. package/dist/liveHaptics.native.js +12 -0
  55. package/dist/liveHaptics.native.js.map +1 -0
  56. package/dist/liveShow.d.ts +58 -0
  57. package/dist/liveShow.d.ts.map +1 -0
  58. package/dist/liveShow.js +55 -0
  59. package/dist/liveShow.js.map +1 -0
  60. package/dist/phoneTaps.d.ts +25 -0
  61. package/dist/phoneTaps.d.ts.map +1 -0
  62. package/dist/phoneTaps.js +47 -0
  63. package/dist/phoneTaps.js.map +1 -0
  64. package/dist/pipWindow.d.ts +85 -0
  65. package/dist/pipWindow.d.ts.map +1 -0
  66. package/dist/pipWindow.js +88 -0
  67. package/dist/pipWindow.js.map +1 -0
  68. package/dist/polls.d.ts.map +1 -1
  69. package/dist/polls.js +3 -0
  70. package/dist/polls.js.map +1 -1
  71. package/dist/react/LiveSellingProvider.d.ts +10 -1
  72. package/dist/react/LiveSellingProvider.d.ts.map +1 -1
  73. package/dist/react/LiveSellingProvider.js +6 -2
  74. package/dist/react/LiveSellingProvider.js.map +1 -1
  75. package/dist/react/LiveVideoTarget.d.ts.map +1 -1
  76. package/dist/react/LiveVideoTarget.js +41 -3
  77. package/dist/react/LiveVideoTarget.js.map +1 -1
  78. package/dist/react/clocks.d.ts +29 -0
  79. package/dist/react/clocks.d.ts.map +1 -0
  80. package/dist/react/clocks.js +124 -0
  81. package/dist/react/clocks.js.map +1 -0
  82. package/dist/react/index.d.ts +7 -2
  83. package/dist/react/index.d.ts.map +1 -1
  84. package/dist/react/index.js +15 -1
  85. package/dist/react/index.js.map +1 -1
  86. package/dist/react/index.web.d.ts +9 -2
  87. package/dist/react/index.web.d.ts.map +1 -1
  88. package/dist/react/index.web.js +27 -4
  89. package/dist/react/index.web.js.map +1 -1
  90. package/dist/react/startSignIn.d.ts +3 -0
  91. package/dist/react/startSignIn.d.ts.map +1 -0
  92. package/dist/react/startSignIn.js +19 -0
  93. package/dist/react/startSignIn.js.map +1 -0
  94. package/dist/react/useAuctionBidGate.d.ts +54 -4
  95. package/dist/react/useAuctionBidGate.d.ts.map +1 -1
  96. package/dist/react/useAuctionBidGate.js +169 -25
  97. package/dist/react/useAuctionBidGate.js.map +1 -1
  98. package/dist/react/useAuctionWins.d.ts +9 -1
  99. package/dist/react/useAuctionWins.d.ts.map +1 -1
  100. package/dist/react/useAuctionWins.js +9 -3
  101. package/dist/react/useAuctionWins.js.map +1 -1
  102. package/dist/react/useGiveawayEntry.d.ts +59 -0
  103. package/dist/react/useGiveawayEntry.d.ts.map +1 -0
  104. package/dist/react/useGiveawayEntry.js +170 -0
  105. package/dist/react/useGiveawayEntry.js.map +1 -0
  106. package/dist/react/useGiveawayPrize.d.ts +12 -0
  107. package/dist/react/useGiveawayPrize.d.ts.map +1 -0
  108. package/dist/react/useGiveawayPrize.js +53 -0
  109. package/dist/react/useGiveawayPrize.js.map +1 -0
  110. package/dist/react/useHiddenShows.d.ts +10 -0
  111. package/dist/react/useHiddenShows.d.ts.map +1 -0
  112. package/dist/react/useHiddenShows.js +30 -0
  113. package/dist/react/useHiddenShows.js.map +1 -0
  114. package/dist/react/useLiveSession.d.ts +7 -3
  115. package/dist/react/useLiveSession.d.ts.map +1 -1
  116. package/dist/react/useLiveSession.js +522 -157
  117. package/dist/react/useLiveSession.js.map +1 -1
  118. package/dist/react/useLiveShow.d.ts +3 -0
  119. package/dist/react/useLiveShow.d.ts.map +1 -0
  120. package/dist/react/useLiveShow.js +21 -0
  121. package/dist/react/useLiveShow.js.map +1 -0
  122. package/dist/react/useLiveStreams.d.ts.map +1 -1
  123. package/dist/react/useLiveStreams.js +6 -2
  124. package/dist/react/useLiveStreams.js.map +1 -1
  125. package/dist/react/useOngoingLive.d.ts +1 -0
  126. package/dist/react/useOngoingLive.d.ts.map +1 -1
  127. package/dist/react/useOngoingLive.js +7 -1
  128. package/dist/react/useOngoingLive.js.map +1 -1
  129. package/dist/react/useReplays.d.ts +12 -0
  130. package/dist/react/useReplays.d.ts.map +1 -1
  131. package/dist/react/useReplays.js +56 -24
  132. package/dist/react/useReplays.js.map +1 -1
  133. package/dist/react/useStreamInfo.d.ts +13 -2
  134. package/dist/react/useStreamInfo.d.ts.map +1 -1
  135. package/dist/react/useStreamInfo.js +31 -5
  136. package/dist/react/useStreamInfo.js.map +1 -1
  137. package/dist/replayPages.d.ts +31 -0
  138. package/dist/replayPages.d.ts.map +1 -0
  139. package/dist/replayPages.js +34 -0
  140. package/dist/replayPages.js.map +1 -0
  141. package/dist/replays.d.ts +7 -0
  142. package/dist/replays.d.ts.map +1 -1
  143. package/dist/replays.js +52 -22
  144. package/dist/replays.js.map +1 -1
  145. package/dist/session.d.ts +39 -6
  146. package/dist/session.d.ts.map +1 -1
  147. package/dist/session.js +209 -22
  148. package/dist/session.js.map +1 -1
  149. package/dist/sessionTypes.d.ts +190 -14
  150. package/dist/sessionTypes.d.ts.map +1 -1
  151. package/dist/sessionTypes.js +2 -1
  152. package/dist/sessionTypes.js.map +1 -1
  153. package/dist/shopifyCommerce.d.ts +149 -0
  154. package/dist/shopifyCommerce.d.ts.map +1 -0
  155. package/dist/shopifyCommerce.js +123 -0
  156. package/dist/shopifyCommerce.js.map +1 -0
  157. package/dist/shopifyIdentity.d.ts +51 -0
  158. package/dist/shopifyIdentity.d.ts.map +1 -0
  159. package/dist/shopifyIdentity.js +52 -0
  160. package/dist/shopifyIdentity.js.map +1 -0
  161. package/dist/shopifyIds.d.ts +9 -0
  162. package/dist/shopifyIds.d.ts.map +1 -0
  163. package/dist/shopifyIds.js +18 -0
  164. package/dist/shopifyIds.js.map +1 -0
  165. package/dist/showLinks.d.ts +24 -0
  166. package/dist/showLinks.d.ts.map +1 -0
  167. package/dist/showLinks.js +23 -0
  168. package/dist/showLinks.js.map +1 -0
  169. package/dist/showLots.d.ts +28 -0
  170. package/dist/showLots.d.ts.map +1 -0
  171. package/dist/showLots.js +51 -0
  172. package/dist/showLots.js.map +1 -0
  173. package/dist/showNames.d.ts +45 -0
  174. package/dist/showNames.d.ts.map +1 -0
  175. package/dist/showNames.js +74 -0
  176. package/dist/showNames.js.map +1 -0
  177. package/dist/streams.d.ts +27 -1
  178. package/dist/streams.d.ts.map +1 -1
  179. package/dist/streams.js +84 -21
  180. package/dist/streams.js.map +1 -1
  181. package/dist/types.d.ts +214 -8
  182. package/dist/types.d.ts.map +1 -1
  183. package/dist/types.js +2 -1
  184. package/dist/types.js.map +1 -1
  185. package/dist/wire.d.ts +31 -0
  186. package/dist/wire.d.ts.map +1 -1
  187. package/dist/wire.js +25 -0
  188. package/dist/wire.js.map +1 -1
  189. package/dist/zego.d.ts +3 -1
  190. package/dist/zego.d.ts.map +1 -1
  191. package/dist/zego.js +1 -1
  192. package/dist/zegoEngine.d.ts +168 -0
  193. package/dist/zegoEngine.d.ts.map +1 -0
  194. package/dist/zegoEngine.js +1052 -0
  195. package/dist/zegoEngine.js.map +1 -0
  196. package/package.json +3 -2
package/README.md CHANGED
@@ -8,7 +8,9 @@ its own config plugin.
8
8
  native). Your header, comments list, bid bar and sheets stay yours.
9
9
 
10
10
  **Storefront-agnostic.** Products, cart writes and the auction checkout come from an adapter you
11
- supply, so there is no Shopify dependency.
11
+ supply. An app on `@tiledev/sdk-shopify` can take that adapter ready-made (§4, "On Shopify"), along
12
+ with the cart's giveaway-prize helpers. They are built on the app's own sdk-shopify client, so this
13
+ package loads none of sdk-shopify.
12
14
 
13
15
  ```bash
14
16
  npm i @tiledev/sdk-apptile-live-selling @tiledev/sdk-apptile-live-selling-native
@@ -69,13 +71,16 @@ mistake here regardless of how the ranges are written.
69
71
 
70
72
  Peers: `react` and `react-native` are needed by the main entry, which re-exports the React layer;
71
73
  `zego-express-engine-reactnative` stays optional — off-device the engine resolves to a no-op, as does
72
- picture-in-picture in the native package.
74
+ picture-in-picture in the native package. `@tiledev/sdk-shopify` (`>=0.10.0`) is required since 0.9.0:
75
+ the Shopify adapter and prize helpers (§4) are built for it, though the app passes its own client in
76
+ and nothing of sdk-shopify is loaded from here.
73
77
 
74
- **Everything ships from the package root**, including the providers, the hooks and `LiveVideoTarget`.
75
- There is no `./react` subpath — a subpath is reachable only through the `exports` map, which a
76
- resolver that ignores it (node10, some test runners and bundlers) cannot see at all. What remains
77
- beside the root is `./types` (types alone) and a wildcard for any module in `dist` — `/session`,
78
- `/streams`, `/polls` — the escape hatch below.
78
+ **Everything ships from the package root**, including the providers, the hooks, `LiveVideoTarget` and
79
+ the Shopify helpers. There is no `./react` or `./shopify` subpath — a subpath is reachable only through
80
+ the `exports` map, which a resolver that ignores it (node10, some test runners and bundlers) cannot see
81
+ at all, and an app testing a local build would need its Metro and Jest redirects changed for one
82
+ (decided 2026-10-06, Head of Engineering). What remains beside the root is `./types` (types alone) and
83
+ a wildcard for any module in `dist` — `/session`, `/streams`, `/polls` — the escape hatch below.
79
84
 
80
85
  > The root entry pulls in `react-native`, so it no longer loads in a plain Node runtime. Driving the
81
86
  > pure data layer from Node — `sessionReducer`, the wire mappers — goes through the module subpaths
@@ -121,11 +126,13 @@ Every viewer endpoint is **anonymous**: the brand `companyId` is the only creden
121
126
  `x-company-id`. It is not the Shopify store id and not the Tile app id.
122
127
 
123
128
  ```ts
129
+ import AsyncStorage from '@react-native-async-storage/async-storage';
124
130
  import { liveSelling } from '@tiledev/sdk-apptile-live-selling';
125
131
 
126
132
  liveSelling.configure({
127
133
  apiUrl: 'https://live-selling.apptile.io',
128
134
  companyId: '<brand-id>',
135
+ storage: AsyncStorage, // required since 0.9.0; `window.localStorage` on the web
129
136
  zegoAppId: 361108744, // platform-level: the gateway signs room tokens with one Zego app
130
137
  commerce, // see §4
131
138
  identity,
@@ -136,18 +143,48 @@ liveSelling.configure({
136
143
  |---|---|---|
137
144
  | `apiUrl` | yes | Gateway base URL, no trailing slash |
138
145
  | `companyId` | yes | Apptile "brand id" — the only credential |
146
+ | `storage` | yes | Since 0.9.0. AsyncStorage-shaped: `AsyncStorage` on a phone, `window.localStorage` on the web. `configure` throws without it |
139
147
  | `zegoAppId` | for video | Matches the gateway, not the brand. Native engine only |
140
148
  | `commerce` | for shopping | Resolves lots to products and sells them |
141
149
  | `identity` | for bidding | The customer a win is attributed to |
142
- | `storage` | no | AsyncStorage-shaped. Defaults to `localStorage`, else in-memory |
143
150
  | `videoCollectionId` | no | The clip reel. **Not** scoped by the company header, so a stale id serves another tenant's clips — unset ⇒ `clips.list()` is `[]` |
144
151
  | `requestSource` | no | `X-Request-Source`. Defaults to `"APPTILE"` |
145
- | `logger` | no | `{ error }`. Defaults to silent |
152
+ | `logger` | no | `{ error, warn? }`. Defaults to silent. `warn` gets handled-data reports; without it they go to `error` |
146
153
  | `customerAccountRequest` | no | The customer's bearer transport. Only the unpaid-wins read uses it |
154
+ | `requestTimeoutMs` | no | Since 0.9.0. How long any gateway (or replay-comment CDN) request may take before it fails with a `LiveSellingTimeoutError`. Defaults to `10000`; zero, negative or non-numbers mean the default |
155
+ | `offAirGraceMs` | no | Since 0.9.0. How long the session waits, with no live record and not reconnecting, before `status` becomes `ended`. Defaults to `4000` |
156
+ | `hiddenShows` | no | Since 0.9.0. `{ titleIncludes, visibleToEmailDomains }`: shows to keep from shoppers, such as a team's rehearsals. Below |
147
157
 
148
- Pass real `storage` in anything you ship. Three things are lost on relaunch without it: the name a
149
- signed-out shopper gave, the accepted auction terms, and which polls this install voted in. The last one
158
+ **Timeouts (0.9.0).** Every request is time-boxed — the fetch and reading its body — and a request
159
+ that runs out fails with `LiveSellingTimeoutError` (`url`, `timeoutMs`), never a bare `AbortError`.
160
+ An HTTP failure is still a `LiveSellingError` with its `status`. Before 0.9.0 a request on a dead
161
+ connection never settled, so a join waiting on its room token hung for as long as the OS kept the
162
+ socket open.
163
+
164
+ **Hidden shows (0.9.0).** A team rehearses on the store's real gateway, which has no "draft" setting.
165
+ Name the rehearsals and who may see them, and pass the signed-in viewer's email to the provider:
166
+
167
+ ```tsx
168
+ hiddenShows: { titleIncludes: ['apptiletest'], visibleToEmailDomains: ['apptile.io'] },
169
+
170
+ <LiveSellingProvider config={config} viewerEmail={customer?.email ?? null}>
171
+ ```
172
+
173
+ A title containing any word, in any case, is hidden from everyone but an email ending `@` + a domain
174
+ (`host@apptile.io.example.com` is not `apptile.io`). For a viewer who may not see it, the show doesn't
175
+ exist: `useReplays`, `useOngoingLive`, `useLiveStreams` and `useStreamInfo` leave it out, the session
176
+ never holds its record **or joins its room**, and `streams.destinationFor` sends its link to the list
177
+ of shows. The imperative reads (`streams.list`, `ongoing`, `byId`, `replays.list`) stay the gateway's.
178
+ `viewerEmail` is a prop so a sign-in reaches every hook at once; `useHiddenShows()` gives the rule for
179
+ an app's own lists, and the pure `isHiddenFrom` / `withoutHiddenShows` take it as arguments. It hides
180
+ shows in the app only: anyone with the company id can still ask the gateway for them.
181
+
182
+ **`storage` is required (0.9.0).** It keeps three things across relaunches: the name a signed-out
183
+ shopper gave, the accepted auction terms, and which polls this install voted in. Losing the last one
150
184
  does damage — **the gateway does not dedupe votes**, so a forgotten vote lets the same device vote again.
185
+ Until 0.9.0 it was optional, with an in-memory store on a phone, and two of the three apps on this
186
+ package shipped without it (each found on a phone, not in review), so it is now a compile error to
187
+ leave it out. A test that wants nothing kept passes an in-memory store of its own.
151
188
 
152
189
  ## 3. Mount and render
153
190
 
@@ -194,23 +231,168 @@ Exactly one mounted target should have it set.
194
231
  | `status` | `loading` · `connecting` · `waitingForHost` · `playing` · `ended` · `error` |
195
232
  | `stream` | the record: title, host, products, giveaways, `bidCap`, `reactionStyle` |
196
233
  | `activeProduct` `products` | resolved through your commerce adapter |
197
- | `comments` `reactions` `sold` `poll` | the live feed; reactions and sold cards expire on their own |
198
- | `auction` | `biddingOpen`, `highestBid`, `nextBid`, `timeLeftMs`, `isLeading`, `leaderName`, `extended`, `lastWin` |
199
- | `giveaway` | `active`, `entryCount`, `registered`, `result` |
234
+ | `comments` `reactions` `sold` `cartAnnouncements` `poll` | the live feed; reactions, sold cards and cart cards expire on their own (each at its lifetime, by one timeout) |
235
+ | `auction` | `biddingOpen`, `highestBid`, `nextBid`, `closesAt`, `isLeading`, `leaderName`, `extended`, `lastWin` (with `isYou`), `wonLotIds` |
236
+ | `giveaway` | `active`, `entryCount`, `registered`, `result` (`winnerName`, `entrantNames`, `entrantNamesReady`, `youEntered`, `isYou`) |
237
+ | `pinnedComment` | `{ id, text }` the host pinned, or null; always null once `status` is `ended` |
200
238
  | `guest` | co-host: `mode`, `status`, `slot`, `mutedByHost`, `mixerLayout` |
201
- | `viewerCount` `elapsedMs` `muted` `pipMode` `viewerName` | header material |
202
- | `actions` | `sendComment`, `sendReaction`, `addToCart`, `voteInPoll`, `placeBid`, `enterGiveaway`, `enterPip`, `joinAsGuest`, … |
239
+ | `viewerCount` `muted` `pipMode` `viewerName` | header material |
240
+ | `actions` | `sendComment`, `sendReaction`, `addToCart`, `voteInPoll`, `placeBid`, `enterGiveaway`, `enterPip`, `joinAsGuest`, … `placeBid` resolves `{ sent }` and `enterGiveaway` `{ entered }`, with a `reason` when not (0.9.0) |
203
241
 
204
242
  `actions` is identity-stable — safe in a dependency array, and deliberately so: one of the effects that
205
243
  depends on it rebinds the video, which is a real `startPlayingStream`.
206
244
 
245
+ ### The session never ticks; the clocks are hooks (0.9.0)
246
+
247
+ The session changes only when something happens (a room event, a record read, an action's answer) and
248
+ is the same object until one of its fields does. It holds **no clock**: a session that ticks draws every
249
+ component that reads it on every tick, and the session is app-wide (before 0.9.0 that was every reader,
250
+ 5 times a second during an auction round). It holds the moments instead:
251
+
252
+ - `auction.closesAt`: when the round closes, **on this phone's clock** (ms, like `Date.now()`); null
253
+ with no round open. It moves only when the host moves the deadline.
254
+ - `stream.startedAtMs`: when the show started.
255
+
256
+ A component that shows a clock counts to one of them itself, and only that component is drawn as it
257
+ ticks:
258
+
259
+ ```tsx
260
+ function RoundClock({ closesAt }: { closesAt: number | null }) {
261
+ const timeLeftMs = useAuctionCountdown(closesAt); // ms left, ≥ 0
262
+ return <Text>{Math.ceil(timeLeftMs / 1000)}s</Text>;
263
+ }
264
+
265
+ function ShowClock({ startedAt }: { startedAt: number | null }) {
266
+ const elapsedMs = useShowClock(startedAt); // ms since the start, ≥ 0
267
+ return <Text>{Math.floor(elapsedMs / 60_000)} min</Text>;
268
+ }
269
+
270
+ // The screen passes the moments down and is never drawn on a clock:
271
+ <RoundClock closesAt={session.auction.closesAt} />
272
+ <ShowClock startedAt={session.stream?.startedAtMs ?? null} />
273
+ ```
274
+
275
+ Both wake once a second, a few milliseconds after the second they show changes, so every second is
276
+ shown once (a free-running 1 s timer shows one twice and skips another). Both stop with nothing to
277
+ count: the countdown at zero or with no round, the show's clock with no start time. They take a number
278
+ rather than reading the session, so a tile can call them with its props, and they work outside the
279
+ provider and on the web.
280
+
207
281
  `viewerName` is `null` when nobody has said who they are. Ask before the first comment goes out rather
208
282
  than publishing "Anonymous" to a room; `actions.chooseViewerName(raw)` remembers the answer.
209
283
 
284
+ ### The show a screen is about (0.9.0)
285
+
286
+ The session is app-wide and follows one show. A screen opened for a named show pins it
287
+ (`holdStream(id)`), and for a moment after that the session still holds the last show's record. Ask
288
+ `useLiveShow` rather than reading `session.stream`:
289
+
290
+ ```ts
291
+ const { show, status, onAir, capabilities } = useLiveShow(route.params.streamId);
292
+ // show: the session's record once it is this show's, else null
293
+ // status: 'connecting' until then, then the session's ('loading' reads as 'connecting');
294
+ // 'ended' at once when the session has settled on nothing
295
+ // onAir: playing, or waiting for the host: when the room's controls show
296
+ ```
297
+
298
+ `liveShowFor(session, streamingId)` is the same, pure. On the web it is always `ended`, with no show.
299
+
300
+ **A card that plays the show** (a muted preview on a home rail or a list of shows) claims the picture
301
+ only when `canPlayInCard({ session, show, screenOnShow: useIsFocused() })`: the session is in that
302
+ show's room and playing, no bubble or window has the picture, and the card's screen is on show.
303
+
304
+ **Where a link goes:** `await liveSelling.streams.destinationFor(streamingId, { viewerEmail })` reads the
305
+ record once and answers `{ destination, show, readError }`. `destination` is `{ to: 'livePlayer' }` (on
306
+ air), `{ to: 'replay', feedId }` (ended with a recording) or `{ to: 'showList' }` (anything else, a
307
+ hidden show included); map them to your screens. Most pushes about a show are opened after it, so this
308
+ is how one lands on the recording. A recording is remembered for the app's run; never throws; a failed
309
+ read comes back in `readError`, for you to report.
310
+
311
+ **The lots:** `product.sellId` (the lot number) and `product.baseBidPrice` (an auction's opening bid)
312
+ are joined on from the record; `featuredFirst(products, featuredId)` lifts the featured one to the front
313
+ of a list, the rest in the host's order.
314
+
315
+ ### Names are never emails (0.9.0)
316
+
317
+ The room shows each viewer's name to everyone, so an email must never reach a screen. Every name the
318
+ SDK hands out has been through `publicName`: comments and the room's history, cart cards, reactions,
319
+ the auction's leader and winner, the giveaway's winner and entrants, a replay's chat, and the events
320
+ themselves. An email keeps its part before the @, an "@handle" stays whole, a real name is unchanged.
321
+ So does the name it publishes for this viewer.
322
+
323
+ - `viewerNameFor(customer)` is the name to pass as `viewerName`: first and last name, else the first
324
+ name, else the email's handle; null when signed out. `createShopifyLiveIdentity`'s `customerName`
325
+ uses it.
326
+ - `firstNameOf(name)` is the first word, for a card with no room for more.
327
+
328
+ ### What the session does on its own (0.9.0)
329
+
330
+ These used to be workarounds in each app's live screen (Freckled Poppy's, mostly). They are the
331
+ session's job now; delete the app-side copies when you upgrade.
332
+
333
+ - **Comment history on join.** The last 40 lines the room said before this device arrived
334
+ (`/live-streaming/comments?streaming_id=`) are put in front of `comments`, once per show. The
335
+ gateway's duplicate rows are collapsed; anything the room has already delivered is not repeated;
336
+ the host's stored `"You"` is shown as the host (`stream.hostName`); this device's earlier lines are
337
+ marked `isOwn` by the name it comments under. Muted lines are left out.
338
+ - **The first comment carries the right name (L6).** `chooseViewerName` gives the name to the engine
339
+ at once, so the comment that prompted for it goes out under it, not as "Anonymous". A comment a
340
+ signed-in shopper sends before their name has loaded is **held** and sent the moment it lands —
341
+ `sendComment` resolves then — or after 10 s at the latest. Don't hold it yourself.
342
+ - **Pinned comment.** `session.pinnedComment` joins the record's `pinned_comment` (arrival), every
343
+ `PINNED_COMMENT_CHANGED` broadcast (now the typed `pinnedCommentChanged` event, not `unknown`), and
344
+ a fresh record read on every reconnect. Hidden once the show has ended.
345
+ - **Lot numbers.** When the host features a lot (`PRODUCT_CHANGE`) the record is read again, so a
346
+ `sellId` the gateway issues at show time reaches `product.sellId` without an app-side
347
+ `refreshStreamRecords()`. Skipped when the record already has the lot's number.
348
+ - **Nothing on air.** With a read finished, nothing live in it, and the room not reconnecting,
349
+ `status` becomes `ended` after `offAirGraceMs` (4 s). It used to stay `loading` for ever. A forced
350
+ read (`refreshStreamRecords()`, e.g. on screen focus) restarts the wait, so a stale record cannot end
351
+ a show that has just started.
352
+ - **The giveaway reveal's roster (ENG-705).** `giveaway_end` arrives before the gateway writes the
353
+ entrants onto the record, so the session asks the record again every 700 ms for up to 5 s and keeps
354
+ the first list with names. Wait for `giveaway.result.entrantNamesReady` before rolling the reveal;
355
+ the list never changes after it turns true.
356
+ - **A giveaway or poll that has ended stays ended (0.9.0).** A giveaway this phone watched end isn't
357
+ opened again by a record read taken before the gateway wrote the end (known by its lot and its start,
358
+ so the same product given away again later still shows). A poll on the record is raised only while
359
+ its `status` is `"active"` (or absent). A giveaway's `variantPrice` and `variantImageUrl` are the
360
+ record's `variant_price` and `variant_image`; before 0.9.0 the price was never read.
361
+ - **Android picture-in-picture.** A window the session armed becomes `pipMode: 'system'` even when the
362
+ player let go of the handover in the same instant, so the window's close button stops playback and
363
+ its expand button raises `onPipRestore`. A device with no picture-in-picture is reported once, as a
364
+ warning, and never armed again (L21).
365
+ - **The system window gives the picture back where it took it from.** A window opened from your
366
+ in-app bubble goes back to the bubble (`pipMode` `'inapp'`, no `onPipRestore`); one opened from a
367
+ full-screen player goes back to `'none'` and raises `onPipRestore`. Either way in one render, so a
368
+ surface that claims on `'none'` never grabs the picture on the way back to the bubble. On iOS, coming
369
+ back to the app with the window still up (by the app's icon rather than the window's expand) does the
370
+ same and takes the window down. A window is offered only while the show is `playing`, and once up it
371
+ stays armed whatever the status does. **Closing** it stops the show, which then stays stopped until
372
+ the app is back in the foreground: a surface that claims in between (the full-screen player still
373
+ focused behind the window) no longer starts it again behind the app. The same holds after the
374
+ `PIP_GRANT_MS` stop when no window came.
375
+ - **`LiveVideoTarget`** restates its mute on every status change (a card mounted before the engine
376
+ existed used to leave the host audible) and takes the picture again when the app returns to the
377
+ foreground (ENG-580, a blank surface after a comment link) — no app-side "reclaim" toggle needed.
378
+ - **`holdStream(streamingId)`** pins the session to a named show at once, replacing any earlier hold.
379
+ Pass the id your route carries (a push, a deep link): one call replaces the release-then-hold pair
380
+ that re-pinned the old show when both ran in one commit (DEL-2882). `holdStream()` with no id still
381
+ pins whatever is on screen.
382
+
210
383
  `capabilitiesFor(stream)` returns the flag set a stream calls for (`canBid`, `canEnterGiveaway`,
211
384
  `showLiveBadge`, …), so one screen serves an ordinary live show and an auction. An auction is a live show
212
385
  plus bidding — the flags are derived, not spelled out twice.
213
386
 
387
+ ### Taps on the phone (0.9.1)
388
+
389
+ `liveHaptics.tick()` as each name rolls past in a giveaway draw, `liveHaptics.knock()` as a bidding war
390
+ starts, `liveHaptics.success()` when the shopper wins the draw or the auction. Pass expo-haptics to
391
+ `configure` (`haptics: Haptics`, from `import * as Haptics from 'expo-haptics'`, a native module) and call
392
+ them where your tiles draw those moments. Without it, and always on the web, they do nothing; a failed tap
393
+ is dropped; ticks are capped at 20 a second. The package doesn't depend on expo-haptics (an optional
394
+ `require` wouldn't bundle in an app without it).
395
+
214
396
  ## 4. Adapters
215
397
 
216
398
  The gateway only ever speaks in bare numeric product ids, because that is what the host dashboard
@@ -265,20 +447,133 @@ what the host mutes by.
265
447
  Build a **disposable** cart, not the shopping cart: an auction win is already a commitment at a settled
266
448
  price, and mixing those lines into a browsing cart lets a quantity stepper change it.
267
449
 
268
- On Shopify each line carries a hidden `_winningBid` (or `_giveawayItem`) attribute that a **cart
269
- transform** function on the store reads to reprice the line server-side. Each line also needs something
270
- unique in its attributes — Shopify merges lines sharing the same merchandise *and* attributes, so two
271
- identical wins collapse into one quantity-2 line priced as a single lot, and the second win is free.
450
+ On Shopify each line carries a hidden `_winningBid` attribute (a prize: the store's prize key, such as
451
+ `_isGiveawayItem` or `_giveawayItem`) that a **cart transform** function on the store reads to reprice
452
+ the line server-side. Each line also needs something unique in its attributes (`_winIndex`) — Shopify
453
+ merges lines sharing the same merchandise *and* attributes, so two identical wins collapse into one
454
+ quantity-2 line priced as a single lot, and the second win is free. The cart itself carries
455
+ `source_name`, `source_type: "auction"` and `auction_id`, which the order webhook matches;
456
+ `createShopifyLiveCommerce` (below) builds exactly this.
457
+
458
+ **The wins can come from more than one show.** The bid gate stops a customer bidding on a second stream
459
+ while the first is unpaid, but nothing gates a giveaway, so a shopper can win one on a later show. Since
460
+ 0.9.0 `auctions.wins()` and `useAuctionWins` return every unpaid stream's lots, and each item names its
461
+ own `streamingId`. `auctionId` is still one stream, the first with something payable: put it on the cart
462
+ as `auction_id`, as before, and build one cart for every item, because a checkout opens one cart. The
463
+ host's order webhook matches that one `auction_id`, so lots from another stream may stay listed as
464
+ unpaid until the host clears them too.
272
465
  </details>
273
466
 
467
+ ### On Shopify (0.9.0)
468
+
469
+ For an app whose storefront is `@tiledev/sdk-shopify` (`>=0.10.0`, a required peer). These build the
470
+ adapters above from the app's own sdk-shopify client and customer, and give the cart its
471
+ giveaway-prize helpers. They are plain functions that load nothing of sdk-shopify: the app passes its
472
+ own `shopify` client and customer in, so the copy of sdk-shopify the app configured is the one used.
473
+ (The Tile preview, or an app testing an unpublished build, can hold two copies, and the other one has
474
+ no store and no customer.) The few fields they read are declared here (`ShopifyProductsAndCart`,
475
+ `ShopifyProductForLive`, `ShopifyCustomerSession`, …), and the app's compiler checks sdk-shopify's real
476
+ objects against them where it passes them in.
477
+
478
+ ```tsx
479
+ import AsyncStorage from '@react-native-async-storage/async-storage';
480
+ import {
481
+ LiveSellingProvider, createShopifyLiveCommerce, createShopifyLiveIdentity,
482
+ } from '@tiledev/sdk-apptile-live-selling';
483
+ import { shopify, useCustomer } from '@tiledev/sdk-shopify';
484
+
485
+ function AppLiveSellingProvider({ children }) {
486
+ const customer = useCustomer();
487
+ const latestCustomer = useRef(customer);
488
+ latestCustomer.current = customer;
489
+
490
+ const config = useMemo(() => {
491
+ const signedIn = createShopifyLiveIdentity(() => latestCustomer.current);
492
+ return {
493
+ apiUrl, companyId, zegoAppId,
494
+ storage: AsyncStorage,
495
+ identity: signedIn.identity,
496
+ customerAccountRequest: signedIn.customerAccountRequest,
497
+ commerce: createShopifyLiveCommerce({
498
+ shopify,
499
+ cartSourceName: 'apptile-mobile-app-builder',
500
+ prizeWriteKey: '_isGiveawayItem', // the key YOUR store's cart transform reads; no default
501
+ buyer: signedIn.buyer,
502
+ imageTransform: { maxWidth: 600 },
503
+ }),
504
+ };
505
+ }, []);
506
+ return <LiveSellingProvider config={config}>{children}</LiveSellingProvider>;
507
+ }
508
+ ```
509
+
510
+ **`createShopifyLiveCommerce(options)`** returns `{ productsByIds, createAuctionCheckout }`. Add your
511
+ own `addLine` beside them. Each product's `raw` is the sdk-shopify `Product` it was read as; read it
512
+ typed with `useAuctionWins<Product>()` / `useLiveSession<Product>()`.
513
+
514
+ | Option | Required | What it is |
515
+ |---|---|---|
516
+ | `shopify` | yes | The app's sdk-shopify client (`shopify`): its `products.byIds` and `cart.create` |
517
+ | `cartSourceName` | yes | `source_name` on the wins cart |
518
+ | `prizeWriteKey` | yes | The line attribute this store's cart transform reads for a prize. No default: a wrong one charges a winner full price |
519
+ | `buyer` | yes | Who pays: `{ customerAccessToken, email? }`, or null for a guest cart. `createShopifyLiveIdentity(...).buyer` |
520
+ | `imageTransform` | no | The products' photo size, passed to `products.byIds` |
521
+ | `extraLineAttributes` | no | `(win) => ShopifyAttribute[]`: more attributes on each win's line, after the adapter's own (a final-sale stamp, say) |
522
+ | `extraCartAttributes` | no | `(lines) => ShopifyAttribute[]`: more attributes on the cart, after the adapter's own (an attribution record, say) |
523
+
524
+ - `productsByIds(ids)`: one `products.byIds` read. A bare id becomes a product GID, a missing product is
525
+ left out, and each `LiveProduct` keeps the Shopify `Product` on `raw`. Each variant carries its own
526
+ photo, when it has one (`imageUrl`, 0.9.0).
527
+ - `createAuctionCheckout(wins, auctionId)`: the contract above. A cart of its own; each win a line of
528
+ one stamped `_winningBid` (or `prizeWriteKey: "true"`) and `_winIndex`; the giveaway discount codes;
529
+ `source_name`, `source_type: "auction"`, `auction_id`; the buyer when there is one (a guest cart when
530
+ there isn't, or reading it fails). Each win is charged on the variant `useAuctionWins` shows: the
531
+ recorded one when it belongs to the product, else the first that can be sold, else the first. Throws
532
+ when there is nothing to check out or none of the wins can be sold.
533
+
534
+ **`createShopifyLiveIdentity(readCustomer)`** takes a function that returns sdk-shopify's
535
+ `useCustomer()` result as it is now (keep it in a ref, as above), and calls it each time the SDK asks.
536
+ It returns:
537
+
538
+ - `identity`: `bidderId`, the customer's bare numeric id, and `customerRef`, their GID, both only for a
539
+ Shopify sign-in (the winner's record is on the Customer Account API, so an email-and-password
540
+ session can't bid); `customerName`, first and last name; `customerEmail`.
541
+ - `customerAccountRequest`: the customer's own `request`.
542
+ - `buyer`: their access token and email, for `createShopifyLiveCommerce`; null when signed out.
543
+
544
+ **The cart's giveaway prizes.** The live-selling service adds a won prize to the winner's shopping cart,
545
+ stamped, and the store's cart transform makes it free.
546
+
547
+ ```ts
548
+ import { isGiveawayPrizeLine, prizeLinePrice, useAuctionWins } from '@tiledev/sdk-apptile-live-selling';
549
+
550
+ const { giveawayVariantIds } = useAuctionWins(); // prizes the host put in with no stamp
551
+ const prize = isGiveawayPrizeLine(line, { keys: ['_isGiveawayItem'] }) ||
552
+ giveawayVariantIds.has(line.merchandise.id);
553
+ const { nowPerUnit, costsNothing, repriced } = prizeLinePrice(line);
554
+ ```
555
+
556
+ - `isGiveawayPrizeLine(line, { keys? })`: stamped with any of `keys`, with any value but `"false"`.
557
+ `keys` defaults to `GIVEAWAY_PRIZE_KEYS`, both spellings in use (`_isGiveawayItem`, which the service
558
+ writes, and `_giveawayItem`, which some auction carts write). A store that reads one passes it.
559
+ Reading a line as a prize takes its stepper away, so a prize line can't be removed or changed.
560
+ - **The stamp proves a prize, never a price.** The store's repricing may not have run (lines charged
561
+ in full have been seen), so show what the line costs. `prizeLinePrice(line)`: the line's total
562
+ shared over its quantity (`nowPerUnit`), because Shopify zeroes the total and leaves
563
+ `amountPerQuantity` at the full price; `costsNothing` when the total is 0; `repriced` when it is less
564
+ than the unit price, for the unit price struck through.
565
+ - `useAuctionWins().giveawayVariantIds`: the variants this customer won as prizes (empty on the web).
566
+
274
567
  ## 5. Auctions — the two rules that matter
275
568
 
276
569
  **Nothing is optimistic.** A viewer *offers* a bid to the round's moderator and waits; only the host's
277
570
  broadcast sets the high bid, the leader and the deadline. That is what makes two phones agree.
278
571
 
279
572
  **Deadlines are on the host's clock**, carried as `serverNowMs` on every event that moves them. A phone
280
- minutes out of sync would otherwise close a round that is still open. `auction.timeLeftMs` is already
281
- corrected for the offset.
573
+ minutes out of sync would otherwise close a round that is still open. `auction.closesAt` is already
574
+ moved onto the phone's clock (`endsAtMs − serverOffsetMs`), so `useAuctionCountdown(auction.closesAt)`
575
+ counts the host's time. If the host goes quiet, the session closes the round itself 1 s past the
576
+ deadline: a slow last-instant bid, which also extends the clock, still lands first.
282
577
 
283
578
  `useAuctionBidGate` owns the four ordered conditions in front of a bid and reports which one is current;
284
579
  you render the panels. The order is the whole point:
@@ -289,21 +584,89 @@ const gate = useAuctionBidGate({
289
584
  isLoggedIn, biddingOpen: auction.biddingOpen,
290
585
  variantCount: biddingProduct?.variants.length ?? 0,
291
586
  placeBid: actions.placeBid,
587
+ // 0.9.0, all optional:
588
+ atAmountShown: true, round: auction, // a bid goes out at the amount shown, or not at all
589
+ startSignIn, // the gate signs the shopper in, then comes back to the bid
590
+ signInLoading, // signed in, the bidder still loading: hold, start no sign-in
591
+ screenOnShow: isFocused, // back on show still signed out: the held bid is dropped
292
592
  });
293
593
 
294
594
  // gate.blocker: 'signIn' | 'unpaidWins' | 'terms' | 'variant' | null
295
595
  gate.requestBid(); // the round's minimum — the slide gesture
296
596
  gate.requestBid(250); // a custom bid
597
+ gate.refusal; // { id, reason: 'outbid' | 'overCap', amount } | null (atAmountShown)
598
+ gate.bidOnItsWay; // true for BID_ON_ITS_WAY_MS (1.2 s) after a bid goes
297
599
  ```
298
600
 
299
601
  Signed in → nothing outstanding on another stream → rules accepted once per install → a variant chosen
300
602
  (once per round, not per bid). The requested bid is held while you clear the blockers and then placed, so
301
- finishing the last panel completes the bid they originally asked for. Call `gate.refreshUnpaidWins()`
302
- from your own focus effect, so a customer who leaves to settle a tab is unblocked on return.
603
+ finishing the last panel completes the bid they originally asked for. A held bid belongs to its round:
604
+ when bidding closes it is dropped and its panel closed. Call `gate.refreshUnpaidWins()` from your own
605
+ focus effect, so a customer who leaves to settle a tab is unblocked on return.
606
+
607
+ - **`atAmountShown`** (with `round: session.auction`): `requestBid()` holds the next bid as it is at the
608
+ tap, not as it will be once the panels close, and the bid gets a last look at the round as it goes
609
+ (`bidRefusal`: open with time left, at most the ceiling, at least the next bid). A closed round sends
610
+ nothing; outbid or over the ceiling sends nothing and sets `refusal` (`amount`: the next bid to beat,
611
+ or the ceiling) for you to show.
612
+ - **`startSignIn`**: the gate never stops at `signIn`. It holds the bid and starts your sign-in; once
613
+ `isLoggedIn`, if bidding on that lot is still open, it reads the unpaid wins fresh and goes through the
614
+ gate again from there. Dropped when the lot closes, when another bid is asked for, or when
615
+ `screenOnShow` turns true again with the shopper signed out (your Sign In page closed). A sign-in that
616
+ never leaves the screen keeps it until the lot closes. Without `startSignIn` the gate stops at `signIn`.
617
+ - **`actions.placeBid`** says whether the bid went: `{ sent: true }`, or `{ sent: false, reason }`
618
+ (`noModerator` is L3, below).
303
619
 
304
620
  If you render the panels as React Native `Modal`s, honour `SHEET_HANDOFF_MS` between them — presenting
305
621
  one while another dismisses is a native iOS exception, not a catchable error.
306
622
 
623
+ **The round's other rules (0.9.0), pure:**
624
+
625
+ - `auction.lastWin.isYou`: the shopper led the round as it closed and the winning bid is theirs
626
+ (`isYourWin`). The room names the winner only by name; this is the best a phone can tell.
627
+ - `lotStateFor(storeProductId, auction, show)`: `'open'` while a round is open on the lot, `'closed'` once
628
+ won (the record's `winnerCount`, or `auction.wonLotIds`, the lots this phone saw won this show), else
629
+ `'upcoming'`.
630
+ - The custom bid sheet: `quickBids(nextBid, bidCap)` (the next bid rounded up, plus 5, 20, 60 and 100,
631
+ none over the ceiling) and `winChance(amount, nextBid)` (`'veryHigh'`: 50 over or double; `'good'`: 20
632
+ over or half again; `'medium'`: anything over; else `'tooLow'`).
633
+ - The chat: `classifyComment(text)` → `{ kind: 'winner' }` | `{ kind: 'bid', label: 'Bid ', amount:
634
+ '$64.00' }` | `{ kind: 'plain' }`, for the room's bid and winner lines, which arrive as plain text.
635
+
636
+ ### Giveaways (0.9.0)
637
+
638
+ ```ts
639
+ const entry = useGiveawayEntry({
640
+ session, // useLiveSession()
641
+ canEnter: onAir && capabilities.canEnterGiveaway, // useLiveShow(streamId)
642
+ isLoggedIn, startSignIn,
643
+ screenOnShow: isFocused && !signInSheetUp,
644
+ cartId,
645
+ });
646
+ // entry.open, entry.sheetOpen, entry.openSheet(), entry.closeSheet()
647
+ // entry.enter(typedName?), entry.entering, entry.entryFailed
648
+ // entry.signInToEnter() back here signed in, the sheet opens again
649
+ // entry.draw, entry.youWon, entry.drawDone() the draw once its roster is ready; never one from before
650
+ // this screen opened
651
+
652
+ const { product, variant, readError } = useGiveawayPrize<Product>(entry.open);
653
+ ```
654
+
655
+ - **Signing in to enter:** `signInToEnter()` starts your sign-in (close the sheet first: it is a
656
+ `Modal`). Once `isLoggedIn` and `screenOnShow`, the sheet opens again if the giveaway is still open and
657
+ not drawn; the shopper taps Enter themselves. Back on show still signed out, nothing reopens.
658
+ - **The entry:** `actions.enterGiveaway` resolves `{ entered: true }` once the host took it, else
659
+ `{ entered: false, reason }`; `entryFailed` says so. With a typed name (`enter(name)`), the name is
660
+ saved first and the entry goes once the session holds it.
661
+ - **"You won":** `giveaway.result.isYou`, by the customer id the record names on this draw's giveaway
662
+ (`LiveGiveaway.winnerId`) when a read has it, else among those who entered by name, spaces and capitals
663
+ aside (`sameRoomName`). `youEntered` keeps the entry past the draw.
664
+ - **The prize:** one `productsByIds` read through your adapter when the prize changes, cleared first;
665
+ `variant` is the one given away. The record's name, variant, price and photo stand in until it
666
+ answers. `readError` is yours to report.
667
+ - Both hooks take values rather than reading the session, so they run on a stand-in session in your
668
+ tests, and on the web, where the session is idle.
669
+
307
670
  ## 6. Platform boundaries
308
671
 
309
672
  Two pairs of files here, and Metro picks by platform. TypeScript resolves the non-native one, which
@@ -312,7 +675,7 @@ lives in the native package.)
312
675
 
313
676
  | Native (iOS/Android) | Everywhere else |
314
677
  |---|---|
315
- | `engine.native.ts` — the real Zego engine; owns the room, plays into a rebindable view, publishes a guest's camera, translates every Zego channel into a `LiveEvent` | `engine.ts` — no-op, `isNoop: true` |
678
+ | `engine.native.ts` — hands the real Zego module, `Platform.OS`, picture-in-picture and the token call to `ZegoLiveEngine` (`zegoEngine.ts`), which owns the room, plays into a rebindable view, publishes a guest's camera, and translates every Zego channel into a `LiveEvent` | `engine.ts` — no-op, `isNoop: true` |
316
679
  | `react/index.ts` — the real providers, hooks and video target | `react/index.web.ts` — real client and read hooks, idle session, `View` in place of the texture view |
317
680
 
318
681
  The web file is not only about behaviour. `LiveVideoTarget` requires `zego-express-engine-reactnative`
@@ -328,8 +691,14 @@ entry module.
328
691
 
329
692
  **Picture-in-picture** is two stages. `actions.enterPip()` shrinks to a surface *you* draw; the session
330
693
  arms the OS handover in advance, so minimising the app opens a real system window with no JavaScript
331
- involved at that moment. Coming back raises `onPipRestore` — navigate to your live screen, which the SDK
332
- can't do itself from above the navigator.
694
+ involved at that moment. Coming back puts the picture where the window took it from: your bubble again
695
+ (`'inapp'`), or, for a window your full-screen player opened (`autoPip`), `'none'` and `onPipRestore` —
696
+ navigate to your live screen, which the SDK can't do itself from above the navigator.
697
+
698
+ To come back to the same picture with no black frame, keep the bubble's `LiveVideoTarget` mounted and
699
+ active while an iOS window that came from it is up (`pipMode` `'system'`): the engine goes on drawing
700
+ into that view, so the bubble needs no new binding when the window hands it back. Keep the full-screen
701
+ player's view mounted too (its `active` off), as the README's usual `active` expression already does.
333
702
 
334
703
  ## 7. Imperative client
335
704
 
@@ -338,7 +707,7 @@ report state.
338
707
 
339
708
  | Namespace | Methods |
340
709
  |---|---|
341
- | `streams` | `list` `ongoing` `byId` `zegoToken` |
710
+ | `streams` | `list` `ongoing` `byId` `zegoToken` `commentHistory` `destinationFor` |
342
711
  | `auctions` | `recordBid` `enterGiveaway` `hasAcceptedTerms` `acceptTerms` `unpaidStreamIds` `wins` `isBiddingBlocked` |
343
712
  | `polls` | `vote` `loadVotedOption` `rememberVote` `forgetVote` |
344
713
  | `replays` | `list` `segments` `comments` `incrementViews` |
@@ -349,7 +718,7 @@ report state.
349
718
  | `engine` | the Zego engine (above) |
350
719
 
351
720
  Read hooks for screens that only need data: `useOngoingLive`, `useLiveStreams`, `useStreamInfo`,
352
- `useReplays`, `useClips`, `useAuction`.
721
+ `useReplays`, `useClips`, `useAuction`; and `useLiveShow`, the session's record for one show (§3).
353
722
 
354
723
  **`useOngoingLive` is the one hook that polls, on purpose, to make the app experience better.** A
355
724
  show that starts while the shopper already has the app open is worth catching, and no event reaches
@@ -368,12 +737,60 @@ turn it off in the config: `ongoingLivePolling: { whenIdleMs, whileLiveMs }` or
368
737
  interval. 10,000 apps open at 2 minutes is about 83 requests a second; 100,000 is about 830. Raise
369
738
  `whenIdleMs` for a large audience, or send a "show started" push and call `refreshOngoingLive()`.
370
739
 
371
- `useReplays({ playableOnly: true })` keeps only replays with a playable video.
740
+ `useReplays({ playableOnly: true })` keeps only replays with a playable video. `hasRead` (0.9.0) turns
741
+ true when its first read has finished, successfully or not, and stays true: before it, an empty list
742
+ is still loading, not "no shows". Shows hidden from the viewer are left out, so a page can show nothing
743
+ while the archive goes on; read on while the list is empty, `hasRead` and `hasMore`.
744
+
745
+ **Replays, since 0.9.0:**
746
+ - `useReplays` pages by what the gateway **sent** (`ReplayFeed.receivedCount`), not by what it kept
747
+ after dropping entries without a playlist. Until 0.9.0 a page with an unplayable entry made the next
748
+ page overlap it, and a page with nothing playable on it was asked for again forever. An empty page
749
+ is the end (`hasMore: false`), whatever the gateway's flag says. One read at a time: `loadMore`
750
+ does nothing while another read or a `refresh` is running.
751
+ - `replays.comments(streamingId)` reads the show's chat file,
752
+ `https://cdn.apptile.io/assets/live-replay-comments/<streamingId>.json` (no company header). The
753
+ gateway's `/live-streaming/comments/{id}`, which it read until 0.9.0, answers 404 for every
754
+ recording. No file (403) means nobody talked: an empty list. The host's own rows keep the name the
755
+ file gives them, `"You"`; show your brand's name there instead.
372
756
 
373
757
  `LiveEvent` is the union the engine emits — `comment`, `reaction`, `sold`, `viewers`, `productChange`,
374
- `poll*`, `biddingStarted`/`bidInfo`/`biddingClosed`/`auctionWon`, `giveaway*`, the `guest*` co-host
375
- signals, and an `unknown` fallthrough so a new host-side message is visible rather than dropped.
376
- Subscribe via `LiveSellingProvider`'s `onEvent` or `liveSelling.engine.subscribe`.
758
+ `poll*`, `biddingStarted`/`bidInfo`/`biddingClosed`/`auctionWon`, `giveaway*`, `pinnedCommentChanged`,
759
+ the `guest*` co-host signals, and an `unknown` fallthrough so a new host-side message is visible rather
760
+ than dropped. Subscribe via `LiveSellingProvider`'s `onEvent` or `liveSelling.engine.subscribe`.
761
+
762
+ **The engine, since 0.9.0:**
763
+ - Zego hands messages over in **batches**; every entry of a barrage, broadcast or stream-extra-info
764
+ batch is read. Until 0.9.0 only the first was, and the rest vanished.
765
+ - A room counts as joined only once `loginRoom` succeeds. A refused login is tried 3 times (waiting
766
+ 1 s, then 2 s), and a join that failed can simply be asked for again — before, a failed login left
767
+ the engine believing it was in the room, and every later join for that show returned early.
768
+ - `roomTokenWillExpire` fetches a fresh token (`streams.zegoToken`) and calls `renewToken`, so a long
769
+ watch is no longer dropped when the token runs out.
770
+ - A subscriber that throws is logged and skipped; leaving never throws; and only this package's own
771
+ Zego handlers are ever detached (L16 — `playerQualityUpdate` was attached twice).
772
+ - `bidInfo` carries `moderatorId` when the gateway sends one, and the session remembers the last
773
+ moderator of the show, so a viewer who missed a round's opening can still bid when one is known.
774
+ When none is, `placeBid` logs a warning and sends nothing — not even the REST ledger record — and
775
+ resolves `{ sent: false, reason: 'noModerator' }`, so an app can tell the shopper.
776
+
777
+ ### Needs the backend
778
+
779
+ The SDK reads these the moment they exist; until then the gaps above remain.
780
+
781
+ - **The moderator on bid info (L3).** Add `moderatorInCharge` (the same `{ userID, userName }` that
782
+ `START_BIDDING` carries) to every `BROADCAST_BID_INFO` `data`, and to the stream record while a round
783
+ is open. Without it, a viewer who joined after the round opened — and has not heard any earlier
784
+ round's opening this show — has nobody to address `BID_PLACED` to.
785
+ - **A winner id.** The SDK reads only a display name from `BID_WINNER_ANNOUNCEMENT`
786
+ (`bidWinner.meta.user.userName`) and from `giveaway_end` (`winner.displayName`). So an auction's
787
+ "you won" is inferred (the shopper led as the round closed, at the winning amount), and a giveaway's
788
+ is matched by name unless a record read after the draw carries the drawn giveaway's `winner` (a
789
+ customer GID, read since 0.9.0). Send the winner's customer id on both events — the same bare id
790
+ `BROADCAST_BID_INFO.user.userId` uses — and the SDK can compare it with `identity.bidderId()`.
791
+ - **Entrant names on `giveaway_end`.** The reveal's roster is read back off the record because the end
792
+ event names only the winner, and the record lags it (ENG-705). Send the entrant display names (or a
793
+ sample) on the event and the 5-second wait goes away.
377
794
 
378
795
  ## Releasing
379
796
 
@@ -419,9 +836,10 @@ two copies of the same Expo module, and identical pod and module names collide a
419
836
  | `npm run build` | Cleans and compiles `src` → `dist` |
420
837
  | `npm run lint` | Typechecks, emitting nothing |
421
838
  | `npm run clean` | Removes `dist` |
839
+ | `npm test` | Compiles `src` + `test` to `.test-dist` and runs `node --test` — the reducer, the engine (against a stand-in Zego), the transport and the mappers |
422
840
  | `npm pack` | The publish tarball — `dist`, `types`, `session` and nothing native |
423
841
 
424
- The reducer is exported on its own (`sessionReducer`, `deriveStatus`, `timeLeftMs`) and is pure, so it
842
+ The reducer is exported on its own (`sessionReducer`, `deriveStatus`, `roundClosesAt`, `nextExpiryAt`) and is pure, so it
425
843
  can be driven with no room, socket or React — from Node, import it as
426
844
  `@tiledev/sdk-apptile-live-selling/session` rather than off the root.
427
845