@wix/himalaya-cli 0.1633.0 → 0.1634.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/members-feed-viewer/src/index.d.ts +10 -6
  2. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/members-v1/src/index.d.ts +34 -0
  3. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-members-feed-viewer.mjs +8 -7
  4. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-members.mjs +29 -0
  5. package/dist/browser-plane/assets.json +166 -0
  6. package/dist/browser-plane/host.html +156 -0
  7. package/dist/browser-plane/package-reader.js +4 -0
  8. package/dist/browser-plane/preview-deferred/368faca44fb9f7207e4af784e94750b1ee59c1098b8689e0f4a918926f244fb5.bin +0 -0
  9. package/dist/browser-plane/preview-deferred/37c179b4fefb63ea9637322b84583a2df53aa226c150294eea1675c14b1b8e29.bin +0 -0
  10. package/dist/browser-plane/preview-deferred/4ecf270136af928612c76fe4ebade91d8156975e5ea4c0cf9225501dd1788e02.bin +0 -0
  11. package/dist/browser-plane/preview-deferred/521a0e528916d678d7588b2cddf3dadd3bb959a6d62426382f61c77a82017e35.bin +1 -0
  12. package/dist/browser-plane/preview-deferred/62e2fe4718129f36ecdb0ae4de385bd6b81ecc876146447167c6c140fad699a9.bin +0 -0
  13. package/dist/browser-plane/preview-deferred/642feab8a48ddf6c7a89d7c2a7c0edfa0e6e773f671287e460e222703563191e.bin +0 -0
  14. package/dist/browser-plane/preview-deferred/97ed8f1a6772abe26e3d8af5cecfc4b02faa94497d585b0398b5053b739a70c1.bin +0 -0
  15. package/dist/browser-plane/preview-deferred/a33cfe728c584fdba4fcc1fd54bcdc2f9f2f13889ddbb5b2bd1d0f8cbe49b84e.bin +28 -0
  16. package/dist/browser-plane/preview-deferred/bf4c16a2774edd38a921edf3099469866cd74c094fd596f3fa4e81f54a04dfaa.bin +0 -0
  17. package/dist/browser-plane/preview-deferred/c3457ce58c96b65f303fec0ddf5f06733004eaf9e21e62302b155ec63a7765f7.bin +56 -0
  18. package/dist/browser-plane/preview-deferred/d7598e12c5dbef095ff8272cfc55da0250bd07fbdecbac8a530b9b277872a134.bin +0 -0
  19. package/dist/browser-plane/preview-deferred/dced97a77624bc5f8b1ae65850360908297f4588e09feb6f22792d29dc7e765b.bin +0 -0
  20. package/dist/browser-plane/preview-deferred/dee56f09fd3efc127c1ea95557edc74110c1460a46287da36e0af25efd341ef5.bin +0 -0
  21. package/dist/browser-plane/preview-deferred/ec4c7d63772d980d6e147b2df6215fd2737e4b04cebe0cf7e11dc695fb63cdcf.bin +0 -0
  22. package/dist/browser-plane/sw.js +13 -0
  23. package/dist/browser-plane/t5-frame.html +350 -0
  24. package/dist/browser-plane/t5-worker-frame.html +350 -0
  25. package/dist/cli.mjs +6412 -5067
  26. package/dist/preview-assets.generated.bin +0 -0
  27. package/fixtures/wix-canned.json +31 -1
  28. package/guides/himi-json-output.md +2 -0
  29. package/guides/one-app-to-himi-app.md +81 -5
  30. package/guides/sdk-reference.json +34 -3
  31. package/package.json +3 -3
  32. package/templates/beauty-salon/tier5-src/_shared/branded-lite-hosted.ts +1 -0
  33. package/templates/branded-lite/config.ts +7 -0
  34. package/templates/branded-lite/dev/index.ts +719 -83
  35. package/templates/branded-lite/himalaya.content.json +3 -0
  36. package/templates/branded-lite/l10n/en.json +4 -2
  37. package/templates/branded-lite/tier5-src/_shared/branded-lite-hosted.ts +1 -0
  38. package/templates/branded-lite/tier5-src/_shared/business-chat-history.ts +4 -1
  39. package/templates/branded-lite/tier5-src/_shared/business-chat-presenter.ts +2 -7
  40. package/templates/branded-lite/tier5-src/_shared/header-badges.ts +1 -1
  41. package/templates/branded-lite/tier5-src/_shared/inbox-rooms.ts +68 -0
  42. package/templates/branded-lite/tier5-src/_shared/member-activity.ts +34 -0
  43. package/templates/branded-lite/tier5-src/_shared/member-row-extras.ts +167 -0
  44. package/templates/branded-lite/tier5-src/_shared/notification-target.ts +141 -0
  45. package/templates/branded-lite/tier5-src/_shared/source-badges.ts +6 -4
  46. package/templates/branded-lite/tier5-src/_shared/wix-member-links.ts +114 -0
  47. package/templates/branded-lite/tier5-src/business-chat/index.ts +114 -59
  48. package/templates/branded-lite/tier5-src/himi-branded-lite-inbox/index.ts +83 -0
  49. package/templates/branded-lite/tier5-src/himi-branded-lite-notifications/index.ts +62 -7
  50. package/templates/branded-lite/tier5-src/wix-spaces-members/index.ts +60 -12
  51. package/templates/community-club/tier5-src/_shared/branded-lite-hosted.ts +1 -0
  52. package/templates/events-meetups/tier5-src/_shared/branded-lite-hosted.ts +1 -0
  53. package/templates/gym-fitness/tier5-src/_shared/branded-lite-hosted.ts +1 -0
  54. package/templates/nonprofit-cause/tier5-src/_shared/branded-lite-hosted.ts +1 -0
  55. package/templates/podcast-creator/tier5-src/_shared/branded-lite-hosted.ts +1 -0
  56. package/templates/portfolio-creative/tier5-src/_shared/branded-lite-hosted.ts +1 -0
  57. package/templates/restaurant/tier5-src/_shared/branded-lite-hosted.ts +1 -0
  58. package/templates/retail-store/tier5-src/_shared/branded-lite-hosted.ts +1 -0
  59. package/templates/salon-spa/dev/index.ts +112 -70
  60. package/templates/salon-spa/tier5-src/_shared/branded-lite-hosted.ts +1 -0
  61. package/templates/salon-spa/tier5-src/_shared/business-chat-history.ts +4 -1
  62. package/templates/salon-spa/tier5-src/_shared/business-chat-presenter.ts +2 -7
  63. package/templates/salon-spa/tier5-src/_shared/inbox-rooms.ts +68 -0
  64. package/templates/salon-spa/tier5-src/business-chat/index.ts +114 -59
  65. package/templates/yoga-studio/tier5-src/_shared/branded-lite-hosted.ts +1 -0
@@ -13,7 +13,7 @@ import { hostSiteFromInput } from '../_shared/branded-lite-hosted.js';
13
13
  import { productImage, searchStoreProducts } from '../_shared/himi-store.js';
14
14
  import { queryServices } from '@himalaya/wix-bookings';
15
15
 
16
- type PendingIntent = BusinessChatIntent;
16
+ type PendingIntent = BusinessChatIntent & { suggestionId?:string; answerTo?:string };
17
17
  // Only what the screen reads. This state crosses into the worker on every keystroke, so a row carries no raw
18
18
  // parts, plain text or text runs: they would be re-sent each time for nothing.
19
19
  type BusinessMessageRow = Pick<MessageRow,'id'|'sender'|'when'|'createdDate'|'mine'> & {
@@ -23,7 +23,7 @@ type BusinessMessageRow = Pick<MessageRow,'id'|'sender'|'when'|'createdDate'|'mi
23
23
  /** Only the member's own row that the server has not returned yet. */
24
24
  pending?:boolean;
25
25
  /** Confirmed local delivery, retained until the authoritative history returns it. */
26
- acceptedIntent?:BusinessChatIntent;
26
+ acceptedIntent?:PendingIntent;
27
27
  };
28
28
  type State = {
29
29
  phase:'loading'|'ready'|'error'|'guest'; error:string; notice:string; member:boolean;
@@ -115,12 +115,31 @@ async function sendBusinessVisitorOption(options:BusinessVisitorOptions|null|und
115
115
  function liveDescription(state:string,reason:string):string{return businessRealtimeStatusLabel(state,reason);}
116
116
  /** The member's own message as it shows the instant they tap Send, before the server has answered. It carries the
117
117
  * id sent to the server, so the real message replaces it once history returns it. */
118
- function pendingRow(intent:BusinessChatIntent):BusinessMessageRow{
118
+ function pendingRow(intent:PendingIntent):BusinessMessageRow{
119
119
  const when=intent.delivery==='sending'?businessChatCopy('sending'):intent.delivery==='unconfirmed'?businessChatCopy('unconfirmed'):messageTimeLabel(intent.createdDate);
120
120
  return {id:intent.id,sender:'',when,createdDate:intent.createdDate,mine:true,
121
121
  senderAvatar:'',senderName:intent.senderName??'',senderInitials:'',senderBadge:'',imageUrl:'',imageDescription:'',noImage:true,bodyText:intent.text,bodyMarkdown:intent.text,
122
122
  suggestions:[],articleActions:[],couponCode:'',noCoupon:true,notMine:false,isCard:false,notCard:true,noSuggestions:true,noActions:true,cardTitle:'',cardDetail:'',noCardText:true,dayLabel:'',dayHidden:true,startsDay:false,scrollAnchor:`himi_business_chat.message_${intent.id}`,pending:true,acceptedIntent:intent.delivery==='sent'?intent:undefined};
123
123
  }
124
+ /** Keep a selected option in its turn while Wix omits its visible text from history. */
125
+ /** Selected options whose exact record history has returned. A state built from an older snapshot has lost
126
+ * the anchor that matched them by text, so every path asks this before it adds a local row. */
127
+ const seenSelections=new Set<string>();
128
+ function placeLocalRow(messages:BusinessMessageRow[],intent:PendingIntent,source:State['transcriptSource']):BusinessMessageRow[]{
129
+ const after=intent.afterId?messages.findIndex(row=>row.id===intent.afterId):-1;
130
+ const beforeAll=intent.fromStart||!!intent.suggestionId&&source==='smart-chat';
131
+ const at=after>=0?after+1:beforeAll?0:messages.length;
132
+ return [...messages.slice(0,at),pendingRow(intent),...messages.slice(at)];
133
+ }
134
+ /** The selection is visible only when the exact answer's selected record produced a row. */
135
+ function selectedOptionVisible(aiMessages:unknown[],messages:BusinessMessageRow[],intent:PendingIntent):boolean{
136
+ if(!intent.answerTo)return false;
137
+ return aiMessages.some(value=>{
138
+ const message=value as {id?:unknown;sender?:unknown;messageType?:unknown;answerTo?:unknown}|null;
139
+ return message?.sender==='SITE_VISITOR'&&message.messageType==='VISITOR_OPTION_SELECTED'&&message.answerTo===intent.answerTo&&
140
+ messages.some(row=>row.id===`smart-chat:${message.id}`&&row.mine&&row.bodyText.trim()===intent.text.trim());
141
+ });
142
+ }
124
143
  function shape(s:State):State{const used=new Set(s.usedSuggestionIds);const messages=s.messages.map(row=>({...row,noSuggestions:s.phase!=='ready'||!(row.suggestions||[]).length,suggestions:(row.suggestions||[]).map(button=>used.has(button.id)?{...button,enabled:false,disabled:true}:button)}));const quickReplies=(s.quickReplies||[]).map(button=>used.has(button.id)?{...button,enabled:false,disabled:true}:button);const quickEnabled=s.phase==='ready'&&quickReplies.some(button=>button.enabled);return {...s,messages,quickReplies,hasQuickReplies:quickEnabled,noQuickReplies:!quickEnabled,scrollAnchor:messages[messages.length-1]?.scrollAnchor||'',liveLabel:liveDescription(s.liveState,s.liveReason),hasActiveRoom:!!s.roomId,noActiveRoom:!s.roomId,hasHostedName:!!s.hostedName,noHostedName:!s.hostedName,isGuest:s.phase==='guest',notGuest:s.phase!=='guest',realtimeDiagnostic:`state=${s.liveState}; reason=${s.liveReason||'none'}; events=${s.realtimeEventCount}; lastType=${/^(message|messagesent)$/i.test(s.lastRealtimeEventType)?s.lastRealtimeEventType:'none'}`,errorText:s.error,loadingHidden:s.phase!=='loading'||!!s.error,noError:!s.error,noNotice:!s.notice,noMessages:!messages.length,hasMessages:!!messages.length,noEmpty:s.phase!=='ready'||!!messages.length,
125
144
  notLoading:s.phase!=='loading',sendDisabled:!canSend(s,s.draft),sendEnabled:canSend(s,s.draft),noLive:!s.roomId||s.liveState==='connected'};}
126
145
  /** Smart Chat resolves the conversation from the member identity, including the first message.
@@ -276,7 +295,7 @@ function safeError(error:unknown,operation:'load'|'send'|'init'):string{
276
295
  }
277
296
  async function endSubscription():Promise<void>{const id=subscriptionId;subscriptionId='';if(id)try{await unsubscribeRealtime({subscriptionId:id});}catch{}}
278
297
  function clearPrivate(s:State,phase:'guest'|'error'='guest'):State{
279
- attachmentUrls.clear();productImages.clear();productImageLookups.clear();serviceIds.clear();serviceIdLookups.clear();currentIdentity=null;businessParticipantId='';epoch++;void endSubscription();
298
+ attachmentUrls.clear();seenSelections.clear();productImages.clear();productImageLookups.clear();serviceIds.clear();serviceIdLookups.clear();currentIdentity=null;businessParticipantId='';epoch++;void endSubscription();
280
299
  return shape({...initial,phase,error:phase==='guest'?businessChatCopy('signInPrompt'):s.error});
281
300
  }
282
301
  let refreshTail:Promise<unknown>=Promise.resolve();
@@ -327,15 +346,20 @@ async function refreshHistory(s:State):Promise<State|undefined>{
327
346
  // What was typed while this ran is kept even on the first load, when the live state is still empty.
328
347
  const liveDraft=draftRevision!==startDraftRevision?latest.draft:s.draft;
329
348
  const base:State=same?withLiveFields({...s,roomId:latest.roomId||roomId},latest,liveDraft):{...s,draft:liveDraft};
330
- const intentObserved=intentObservedIn(messages,base.pendingIntent,historySource);
331
- const draft=intentObserved&&base.pendingIntent&&base.draft.trim()===base.pendingIntent.text?'':base.draft;
349
+ const observed=(visible:BusinessMessageRow[],intent:PendingIntent|null)=>{
350
+ if(intent&&selectedOptionVisible(aiRead.messages,visible,intent))seenSelections.add(intent.id);
351
+ return intentObservedIn(visible,intent,historySource);
352
+ };
353
+ const intentObserved=observed(messages,base.pendingIntent);
354
+ // A selected option never sits in the composer, so its text must not clear a draft that happens to match.
355
+ const draft=intentObserved&&base.pendingIntent&&!base.pendingIntent.suggestionId&&base.draft.trim()===base.pendingIntent.text?'':base.draft;
332
356
  // A send the server has not returned yet stays on screen until history does. Seeing it in history only replaces the
333
357
  // placeholder: a send still in flight stays pending until its own request settles it, so its outcome is never lost.
334
358
  // An unconfirmed one (its request is over) no longer holds Send once its words show up; its notice stays.
335
- const shown=!intentObserved&&base.pendingIntent?[...messages,pendingRow(base.pendingIntent)]:messages;
359
+ const shown=!intentObserved&&base.pendingIntent?base.pendingIntent.suggestionId?placeLocalRow(messages,base.pendingIntent,historySource):[...messages,pendingRow(base.pendingIntent)]:messages;
336
360
  const pendingIntent=intentObserved&&base.pendingIntent?.delivery==='unconfirmed'?null:base.pendingIntent;
337
361
  const loaded:State={...base,historyRevision:++historyRevision,historySource,transcriptSource:historySource,smartChatEmpty:!aiRead.error&&aiRead.messages.length===0,messages:shown,pendingIntent,visitorOptions,quickReplies:businessVisitorOptionSuggestions(visitorOptions),draft,phase:'ready',error:'',loadFailure:''};
338
- return shape(same?withAcceptedRows(loaded,latest):loaded);
362
+ return shape(same?withAcceptedRows(loaded,latest,observed):loaded);
339
363
  }catch(error){
340
364
  const wrapped=error instanceof Error?error:new Error('load_failed');
341
365
  Object.assign(wrapped,{businessLoadFailure:safeLoadFailure(error)});
@@ -347,25 +371,27 @@ async function refreshHistory(s:State):Promise<State|undefined>{
347
371
  function withLiveFields(st:State,latest:State,draft:string):State{
348
372
  // The pending intent is always the live one: a send settled meanwhile (null now) must not come back from a snapshot.
349
373
  return {...st,draft,pendingIntent:latest.pendingIntent,
350
- usedSuggestionIds:[...new Set([...st.usedSuggestionIds,...latest.usedSuggestionIds])],
374
+ // A rejected send removes its ID from the live state. Unioning an older snapshot would disable it again.
375
+ usedSuggestionIds:latest.usedSuggestionIds,
351
376
  liveState:latest.liveState,liveReason:latest.liveReason,realtimeEventCount:Math.max(st.realtimeEventCount,latest.realtimeEventCount),lastRealtimeEventType:latest.lastRealtimeEventType,
352
377
  scrollSend:Math.max(st.scrollSend,latest.scrollSend)};
353
378
  }
354
379
  /** A successful empty pre-send read anchors accepted Smart Chat rows, never an
355
380
  * older Members payload retained after a failed read. Keep both contexts intact. */
356
381
  function intentObservedIn(messages:BusinessMessageRow[],intent:BusinessChatIntent|null,source:State['transcriptSource']):boolean{
382
+ if(intent&&seenSelections.has(intent.id))return true;
357
383
  return pendingIntentWasObserved(messages,source==='smart-chat'&&intent?.delivery==='sent'&&intent.smartChatFromStart
358
384
  ?{...intent,afterId:undefined,fromStart:true}:intent);
359
385
  }
360
386
  /** Keep confirmed local deliveries independently of in-flight intent and history revision.
361
387
  * Reconcile by the existing stored-id/anchor contract, never by ownership or text alone. */
362
- function withAcceptedRows(st:State,latest:State):State{
388
+ function withAcceptedRows(st:State,latest:State,observed:(messages:BusinessMessageRow[],intent:PendingIntent)=>boolean=(messages,intent)=>intentObservedIn(messages,intent,st.transcriptSource)):State{
363
389
  let messages=st.messages;
364
390
  for(const row of [...st.messages,...latest.messages]){
365
391
  const intent=row.acceptedIntent;
366
392
  if(!intent)continue;
367
- if(intentObservedIn(messages,intent,st.transcriptSource))messages=messages.filter(candidate=>!(candidate.id===intent.id&&candidate.pending));
368
- else if(!messages.some(candidate=>candidate.id===intent.id))messages=[...messages,row];
393
+ if(observed(messages,intent))messages=messages.filter(candidate=>!(candidate.id===intent.id&&candidate.pending));
394
+ else if(!messages.some(candidate=>candidate.id===intent.id))messages=intent.suggestionId?placeLocalRow(messages,intent,st.transcriptSource):[...messages,row];
369
395
  }
370
396
  return {...st,messages,historySource:smartChatAcceptedEpoch===epoch?'smart-chat':st.historySource};
371
397
  }
@@ -384,6 +410,7 @@ function withLiveDraft(st:State):State{
384
410
  return intent&&!intentObservedIn(merged.messages,intent,merged.transcriptSource)&&!merged.messages.some(row=>row.id===intent.id)
385
411
  ?{...merged,messages:[...merged.messages,pendingRow(intent)]}:merged;
386
412
  }
413
+ type SuggestionSession={member:boolean;roomId:string;principal:string;currentPrincipal:string;scope:string;currentScope:string};
387
414
  type SendResult={id:string;outcome:'sent'|'rejected'|'unconfirmed'|'signed_out';draft:string;storedId?:string;error?:unknown};
388
415
  /** Posts one message and reports how it ended; never throws. The caller already showed the message. The call lasts
389
416
  * until the AI has answered: history usually shows the stored copy first, which replaces the placeholder, while Send
@@ -400,6 +427,35 @@ async function deliver(pending:BusinessChatIntent,draft:string):Promise<void>{
400
427
  }
401
428
  if(sendEpoch===epoch)void svc.settleSend(result);
402
429
  }
430
+ /** An AI visitor option uses the same visible pending row as a typed send. Its Wix request can take until the AI finishes;
431
+ * the row and disabled chip are already painted while it runs. */
432
+ async function deliverSuggestion(pending:PendingIntent,rows:{suggestions?:BusinessSuggestion[]}[],options:BusinessVisitorOptions|null,session:SuggestionSession):Promise<void>{
433
+ const sendEpoch=epoch;
434
+ let result:SendResult={id:pending.id,outcome:'sent',draft:''};
435
+ try{
436
+ await Promise.resolve();
437
+ const sent=await dispatchBusinessSuggestion(rows,pending.suggestionId!,session,suggestion=>sendBusinessVisitorOption(options,suggestion.label));
438
+ if(!sent)result={id:pending.id,outcome:'rejected',draft:'',error:new Error('suggestion_unavailable')};
439
+ }catch(error){
440
+ const lost=/member_sign_in_required|MemberAccessChanged|\b401\b|\b403\b/i.test(String(error));
441
+ result={id:pending.id,draft:'',error,outcome:lost?'signed_out':businessChatSendRejected(error)?'rejected':'unconfirmed'};
442
+ }
443
+ if(sendEpoch===epoch)void svc.settleSend(result);
444
+ }
445
+ /** A Wix Chat postback is silent: it disables its chip immediately but does not invent an outgoing text message. */
446
+ async function deliverButtonSuggestion(id:string,rows:{suggestions?:BusinessSuggestion[]}[],session:SuggestionSession):Promise<void>{
447
+ const sendEpoch=epoch;
448
+ let result:SendResult={id,outcome:'sent',draft:''};
449
+ try{
450
+ await Promise.resolve();
451
+ const sent=await dispatchBusinessSuggestion(rows,id,session,suggestion=>chat.sendButton(me(),session.roomId,{id:newBusinessChatIntent(suggestion.label,session.roomId,session.principal).id,text:suggestion.label,appId:suggestion.appId!,interactionId:suggestion.interactionId!}));
452
+ if(!sent)result={id,outcome:'rejected',draft:'',error:new Error('suggestion_unavailable')};
453
+ }catch(error){
454
+ const lost=/member_sign_in_required|MemberAccessChanged|\b401\b|\b403\b/i.test(String(error));
455
+ result={id,draft:'',error,outcome:lost?'signed_out':businessChatSendRejected(error)?'rejected':'unconfirmed'};
456
+ }
457
+ if(sendEpoch===epoch)void svc.settleButtonSuggestion(result);
458
+ }
403
459
  const svc=defineService<State,Record<string,(s:State,a?:unknown)=>State|undefined|Promise<State|undefined>>>('businessChat',{
404
460
  state:shape(initial),notHydrated:Object.keys(initial) as (keyof State)[],
405
461
  actions:{
@@ -510,6 +566,12 @@ const svc=defineService<State,Record<string,(s:State,a?:unknown)=>State|undefine
510
566
  if(result.outcome==='signed_out')return clearPrivate(s,'guest');
511
567
  if(result.outcome==='rejected'){
512
568
  // The row goes, and the text comes back unless the member has already started something new.
569
+ if(pending.suggestionId){
570
+ // A selected option has no draft to restore: its chip is offered again.
571
+ const restore=(buttons:BusinessSuggestion[])=>buttons.map(button=>button.id===pending.suggestionId?{...button,enabled:true,disabled:false}:button);
572
+ return shape({...s,messages:s.messages.filter(row=>row.id!==pending.id).map(row=>({...row,suggestions:restore(row.suggestions)})),quickReplies:restore(s.quickReplies),
573
+ pendingIntent:null,usedSuggestionIds:s.usedSuggestionIds.filter(id=>id!==pending.suggestionId),error:safeError(result.error,'send'),notice:businessChatCopy('suggestionRejected')});
574
+ }
513
575
  return shape({...s,messages:s.messages.filter(row=>row.id!==pending.id),pendingIntent:null,draft:s.draft.trim()?s.draft:result.draft,error:safeError(result.error,'send'),notice:businessChatCopy('rejected')});
514
576
  }
515
577
  // History cannot tell this send from the same words written elsewhere (the website), so an indeterminate failure is
@@ -517,20 +579,22 @@ const svc=defineService<State,Record<string,(s:State,a?:unknown)=>State|undefine
517
579
  if(result.outcome==='unconfirmed'){
518
580
  const unconfirmed={...pending,delivery:'unconfirmed' as const};
519
581
  const seen=intentObservedIn(s.messages,pending,s.transcriptSource);
520
- return shape({...s,pendingIntent:seen?null:unconfirmed,messages:replaceRow(unconfirmed),error:safeError(result.error,'send'),notice:businessChatCopy('checkDelivery')});
582
+ return shape({...s,pendingIntent:seen?null:unconfirmed,messages:replaceRow(unconfirmed),error:safeError(result.error,'send'),notice:businessChatCopy(pending.suggestionId?'suggestionUnconfirmed':'checkDelivery')});
521
583
  }
522
584
  const sent={...pending,storedId:result.storedId,delivery:'sent' as const};
585
+ // A selected option keeps the notice it always had; a typed message shows none once accepted.
586
+ const sentNotice=sent.suggestionId?businessChatCopy('suggestionSent'):'';
523
587
  // Every typed send uses Smart Chat, even when a Members room already exists.
524
588
  smartChatAcceptedEpoch=epoch;
525
- const accepted=shape({...s,historySource:'smart-chat',pendingIntent:sent,messages:replaceRow(sent),error:'',notice:''});
589
+ const accepted=shape({...s,historySource:'smart-chat',pendingIntent:sent,messages:replaceRow(sent),error:'',notice:sentNotice});
526
590
  const sendEpoch=epoch;
527
591
  // The server accepted it, so Send is free again whatever history says; the row stays until history returns it.
528
592
  // A refresh that ran while the committed intent still said sending leaves a 'Sending…' row: swap it for the accepted one.
529
593
  // History that already holds the message under the server's id needs no placeholder for it. Only this send's intent
530
594
  // is cleared: a newer one the live state carried in is that send's to settle.
531
595
  const ownIntent=(st:State)=>st.pendingIntent&&st.pendingIntent.id!==sent.id?st.pendingIntent:null;
532
- const settled=(st:State):State=>intentObservedIn(st.messages,sent,st.transcriptSource)?shape({...st,pendingIntent:ownIntent(st),notice:'',messages:st.messages.filter(row=>!(row.id===sent.id&&row.pending))}):
533
- shape({...st,pendingIntent:ownIntent(st),notice:'',messages:st.messages.some(row=>row.id===sent.id)?st.messages.map(row=>row.id===sent.id&&row.pending?pendingRow(sent):row):[...st.messages,pendingRow(sent)]});
596
+ const settled=(st:State):State=>intentObservedIn(st.messages,sent,st.transcriptSource)?shape({...st,pendingIntent:ownIntent(st),notice:sentNotice,messages:st.messages.filter(row=>!(row.id===sent.id&&row.pending))}):
597
+ shape({...st,pendingIntent:ownIntent(st),notice:sentNotice,messages:st.messages.some(row=>row.id===sent.id)?st.messages.map(row=>row.id===sent.id&&row.pending?pendingRow(sent):row):sent.suggestionId?st.messages:[...st.messages,pendingRow(sent)]});
534
598
  // The stream finishes after the AI has persisted its answer. Reload even while connected:
535
599
  // the Members-room subscription may never see the separate Smart Chat room's event.
536
600
  // An existing-room refresh owns the next read. A first acceptance must wait for
@@ -569,53 +633,44 @@ const svc=defineService<State,Record<string,(s:State,a?:unknown)=>State|undefine
569
633
  return settled(latestResolved());
570
634
  }
571
635
  },
572
- sendSuggestion:async(s,a)=>{
636
+ settleButtonSuggestion:async(s,a)=>{
637
+ const result=a as SendResult|undefined;
638
+ if(!result||!s.usedSuggestionIds.includes(result.id))return undefined;
639
+ if(result.outcome==='signed_out')return clearPrivate(s,'guest');
640
+ if(result.outcome==='rejected'){
641
+ const restore=(buttons:BusinessSuggestion[])=>buttons.map(button=>button.id===result.id?{...button,enabled:true,disabled:false}:button);
642
+ return shape({...s,usedSuggestionIds:s.usedSuggestionIds.filter(id=>id!==result.id),quickReplies:restore(s.quickReplies),
643
+ messages:s.messages.map(row=>({...row,suggestions:restore(row.suggestions)})),error:safeError(result.error,'send'),notice:businessChatCopy('suggestionRejected')});
644
+ }
645
+ if(result.outcome==='unconfirmed')return shape({...s,error:safeError(result.error,'send'),notice:businessChatCopy('suggestionUnconfirmed')});
646
+ const accepted=shape({...s,error:'',notice:businessChatCopy('suggestionSent')});
647
+ try{
648
+ const loaded=await loadLatest(accepted,true);
649
+ return loaded?shape({...loaded,notice:businessChatCopy('suggestionSent')}):undefined;
650
+ }catch{return shape({...withLiveDraft(accepted),notice:businessChatCopy('suggestionSent')});}
651
+ },
652
+ sendSuggestion:(s,a)=>{
573
653
  if(suggestionFlight||!s.member||s.phase!=='ready'||!!s.pendingIntent)return s;
574
654
  const id=param(a,'id');
575
655
  if(!id||s.usedSuggestionIds.includes(id))return s;
576
656
  const rows=[{suggestions:s.quickReplies} as {suggestions?:BusinessSuggestion[]},...s.messages];
577
- if(!s.roomId&&findBusinessSuggestion(rows,id)?.kind!=='visitor_option')return s;
578
- const options=s.visitorOptions;
579
- const intent=newBusinessChatIntent('suggestion',s.roomId,s.principal);
580
- const sendEpoch=epoch;
657
+ const suggestion=findBusinessSuggestion(rows,id);
658
+ // A first conversation has no room yet: only an AI option can start it.
659
+ if(!suggestion||!s.roomId&&suggestion.kind!=='visitor_option')return s;
660
+ const currentPrincipal=currentIdentity?.userId||currentIdentity?.email||'';
661
+ if(!s.principal||s.principal!==currentPrincipal||s.scope!==currentIdentity?.scope)return s;
662
+ const session:SuggestionSession={member:s.member,roomId:s.roomId,principal:s.principal,currentPrincipal,scope:s.scope,currentScope:currentIdentity?.scope||''};
581
663
  suggestionFlight=true;
582
- s=shape({...s,usedSuggestionIds:[...s.usedSuggestionIds,id],error:'',notice:''});
583
- let accepted:State|undefined;
584
- const keptAccepted=():State=>{
585
- const latest=withLiveDraft(accepted!);
586
- return shape({...latest,historySource:accepted!.historySource==='smart-chat'?'smart-chat':latest.historySource,error:'',notice:businessChatCopy('suggestionSent')});
587
- };
588
- try{
589
- const sent=await dispatchBusinessSuggestion(rows,id,{member:s.member,roomId:s.roomId,principal:s.principal,currentPrincipal:currentIdentity?.userId||currentIdentity?.email||'',scope:s.scope,currentScope:currentIdentity?.scope||''},suggestion=>suggestion.kind==='visitor_option'
590
- ? sendBusinessVisitorOption(options,suggestion.label)
591
- : chat.sendButton(me(),s.roomId,{id:intent.id,text:suggestion.label,appId:suggestion.appId!,interactionId:suggestion.interactionId!}));
592
- if(sendEpoch!==epoch)return undefined;
593
- if(!sent)return withLiveDraft(s);
594
- const latest=readServiceState();
595
- if(latest.principal!==s.principal||latest.scope!==s.scope||(latest.roomId!==s.roomId&&(!!s.roomId||findBusinessSuggestion(rows,id)?.kind!=='visitor_option')))return undefined;
596
- if(findBusinessSuggestion(rows,id)?.kind==='visitor_option')smartChatAcceptedEpoch=epoch;
597
- accepted=shape({...latest,historySource:smartChatAcceptedEpoch===epoch?'smart-chat':latest.historySource,usedSuggestionIds:[...new Set([...s.usedSuggestionIds,...latest.usedSuggestionIds])],scrollSend:latest.scrollSend+1,error:'',notice:businessChatCopy('suggestionSent')});
598
- const loaded=await loadLatest(keptAccepted(),true);
599
- if(!loaded||sendEpoch!==epoch)return undefined;
600
- accepted=loaded;
601
- if(!accepted.roomId){
602
- const room=await resolveExistingBusinessRoom(sendEpoch,s.businessName);
603
- if(sendEpoch!==epoch)return undefined;
604
- const current=keptAccepted();
605
- if(room&&!current.roomId)businessParticipantId=room.participantId;
606
- accepted={...current,roomId:current.roomId||room?.id||'',historySource:'smart-chat'};
607
- }
608
- return keptAccepted();
609
- }catch(error){
610
- if(sendEpoch!==epoch)return undefined;
611
- if(/member_sign_in_required|MemberAccessChanged|\b401\b|\b403\b/i.test(String(error)))return clearPrivate(s,'guest');
612
- const latest=readServiceState();
613
- if(latest.principal!==s.principal||latest.scope!==s.scope)return undefined;
614
- if(accepted)return keptAccepted();
615
- s=shape({...latest,usedSuggestionIds:[...new Set([...s.usedSuggestionIds,...latest.usedSuggestionIds])]});
616
- const definitive=businessChatSendRejected(error);
617
- return shape({...withLiveDraft(s),usedSuggestionIds:definitive?s.usedSuggestionIds.filter(value=>value!==id):s.usedSuggestionIds,error:safeError(error,'send'),notice:definitive?businessChatCopy('suggestionRejected'):businessChatCopy('suggestionUnconfirmed')});
618
- }finally{suggestionFlight=false;}
664
+ if(suggestion.kind!=='visitor_option'){
665
+ void deliverButtonSuggestion(id,rows,session).finally(()=>{suggestionFlight=false;});
666
+ return shape({...s,usedSuggestionIds:[...s.usedSuggestionIds,id],error:'',notice:''});
667
+ }
668
+ const ownName=[...s.messages].reverse().find(row=>row.mine&&row.senderName)?.senderName??'';
669
+ const intent:PendingIntent={...newBusinessChatIntent(suggestion.label,s.roomId,s.principal),suggestionId:id,answerTo:suggestion.messageId,
670
+ senderName:ownName,...sendAnchor(s.messages,suggestion.label,true),smartChatFromStart:s.transcriptSource==='members'&&s.smartChatEmpty};
671
+ void deliverSuggestion(intent,rows,s.visitorOptions,session).finally(()=>{suggestionFlight=false;});
672
+ return shape({...s,usedSuggestionIds:[...s.usedSuggestionIds,id],pendingIntent:intent,
673
+ messages:[...s.messages,pendingRow(intent)],scrollSend:s.scrollSend+1,error:'',notice:''});
619
674
  },
620
675
  onEvent:async(s,a)=>{
621
676
  if(initFlight){eventDuringInit=true;return undefined;}
@@ -649,7 +704,7 @@ const svc=defineService<State,Record<string,(s:State,a?:unknown)=>State|undefine
649
704
  }
650
705
  catch(error){if(startEpoch!==epoch)return undefined;const lost=/member_sign_in_required|MemberAccessChanged|\b401\b|\b403\b/i.test(String(error));return lost?clearPrivate(next,'guest'):shape({...withLiveDraft(next),error:safeError(error,'load'),loadFailure:typeof (error as {businessLoadFailure?:unknown})?.businessLoadFailure==='string'?(error as {businessLoadFailure:string}).businessLoadFailure:safeLoadFailure(error)});}
651
706
  },
652
- dispose:async s=>{epoch++;attachmentUrls.clear();productImages.clear();productImageLookups.clear();serviceIds.clear();serviceIdLookups.clear();currentIdentity=null;businessParticipantId='';await endSubscription();return shape({...initial,phase:'guest'});},
707
+ dispose:async s=>{epoch++;attachmentUrls.clear();seenSelections.clear();productImages.clear();productImageLookups.clear();serviceIds.clear();serviceIdLookups.clear();currentIdentity=null;businessParticipantId='';await endSubscription();return shape({...initial,phase:'guest'});},
653
708
  }
654
709
  });
655
710
  readServiceState=()=>svc.getState();
@@ -0,0 +1,83 @@
1
+ // himi-branded-lite-inbox — the member's Inbox: every conversation the member-role chat list returns, the
2
+ // business's and other members'. Only the member-role list is used (`GET inbox/v2/conversations`, the call the
3
+ // first Inbox made, is an OWNER endpoint that answers a member with 404). A failed query is an error with Retry
4
+ // (or Sign in for a visitor) and an empty one is a real empty state — never sample rows presented as the
5
+ // customer's own. Samples appear only in the host's explicit sample preview (_shared/branded-load-state.ts).
6
+ import { defineService } from "../_shared/collection-service.js";
7
+ import { LOADING, loaded, loadFailed, type LoadCopy, type LoadFlags } from "../_shared/branded-load-state.js";
8
+ import { getChatToken, listChatroomsPage } from "@himalaya/wix-chat-web";
9
+ import { assertMemberAccess, memberAccess } from "../_shared/member-access.js";
10
+ import { inboxRows, type InboxRow } from "../_shared/inbox-rooms.js";
11
+ import { copyParam, stashCopy } from "../_shared/himi-copy.js";
12
+
13
+ type Row = Omit<InboxRow, "at">;
14
+ interface S extends LoadFlags {
15
+ conversations: Row[]; emptyText: string;
16
+ isMock: boolean; notMock: boolean; /** #2849 — hiddenWhen() hides on TRUE, so these are inverted: the empty-state banner
17
+ * shows only when the site really has none, and the unavailable one only when the query failed. A failed
18
+ * query must never render the sentence that claims the customer has no conversations. */
19
+ notMockEmpty: boolean; notMockUnavailable: boolean;
20
+ }
21
+
22
+ const MEMBER = { role: "member" } as const;
23
+ const PAGE_SIZE = 50;
24
+ // 2000 conversations: far beyond a real member. The cap only stops a runaway cursor from looping, and reaching it is an
25
+ // error, never a quietly shorter list.
26
+ const MAX_PAGES = 40;
27
+
28
+ const MOCK: Row[] = [
29
+ { id: "sample-support", title: "Support", subtitle: "Hi! Let us know how we can help.", screenId: "himi_business_chat", kind: "", photo: "", initial: "S", unread: false, unreadDot: "" },
30
+ { id: "sample-member", title: "A member", subtitle: "Welcome 👋 Thanks for joining us!", screenId: "himi_chat_thread", kind: "chatroom", photo: "", initial: "A", unread: false, unreadDot: "" },
31
+ ];
32
+
33
+ /** Sample rows reach the screen ONLY in the host's explicit sample preview; a failed or empty query is an
34
+ * honest error / empty state (branded-load-state.ts). */
35
+ const COPY: LoadCopy<Row> = {
36
+ prefix: "inbox",
37
+ empty: "No messages yet.",
38
+ error: "Your messages couldn't be loaded.",
39
+ signIn: "Sign in to see your messages.",
40
+ sample: () => MOCK.map((r) => ({ ...r })),
41
+ };
42
+
43
+ const signInRequired = () => new Error("Unauthenticated: sign in to see your messages");
44
+
45
+ async function listAllRooms(): Promise<unknown[]> {
46
+ // One token for every page; a refused one fails the whole load rather than showing half a list.
47
+ const { chatToken } = await getChatToken(MEMBER);
48
+ const rooms: unknown[] = [];
49
+ let nextPointer: number | string | undefined;
50
+ for (let page = 0; page < MAX_PAGES; page++) {
51
+ const result = await listChatroomsPage({ ...MEMBER, chatToken, pageSize: PAGE_SIZE, ...(nextPointer !== undefined ? { nextPointer } : {}) });
52
+ rooms.push(...result.items);
53
+ nextPointer = result.nextPointer;
54
+ if (nextPointer === undefined) return rooms;
55
+ }
56
+ throw new Error("Conversation list is longer than the pages read");
57
+ }
58
+
59
+ const svc = defineService<S, { load: (s: S, arg?: unknown) => Promise<S> }>("himiBrandedInbox", {
60
+ state: { ...LOADING, conversations: [], emptyText: "" },
61
+ actions: {
62
+ load: async (s, arg) => {
63
+ // The screen's onAppear carries the copy pack (Hebrew, an owner's wording). This service is not a collection, so
64
+ // nothing else stashes it; a retry sends no params and keeps the pack already stashed.
65
+ stashCopy(arg);
66
+ try {
67
+ const member = await memberAccess();
68
+ if (!member.email) throw signInRequired();
69
+ const rows = inboxRows(await listAllRooms(), copyParam("inbox.conversationFallback", "Conversation")).map(({ at: _at, ...row }) => row);
70
+ // A sign-out or account switch while the list loaded must not paint the previous member's rooms.
71
+ await assertMemberAccess(member);
72
+ return { ...s, ...loaded("conversations", rows, COPY) };
73
+ } catch (err) {
74
+ const failed = loadFailed("conversations", (await memberAccess()).email ? err : signInRequired(), COPY);
75
+ // The failure sentence is the screen banner's stock key, not a second key for the same English (the extractor
76
+ // keeps one key per extracted row); loadFailed would read `inbox.errorText`, which does not exist.
77
+ return { ...s, ...failed, ...(failed.signInRequired ? {} : { error: copyParam("inbox.unavailableBody", COPY.error) }) };
78
+ }
79
+ },
80
+ },
81
+ });
82
+ svc.load();
83
+ export { svc };
@@ -10,6 +10,10 @@ import { defineService as registerService } from "../_shared/collection-service.
10
10
  import { LOADING, loaded, loadFailed, type LoadCopy, type LoadFlags } from "../_shared/branded-load-state.js";
11
11
  import { queryNotifications, markRead, markAllRead, type MfvNotification } from "@himalaya/wix-members-feed-viewer";
12
12
  import { assertMemberAccess, memberAccess, type MemberAccess } from "../_shared/member-access.js";
13
+ import { navigate } from "@himalaya/state";
14
+ import { notificationOpen, urlHost, type NotificationOpen } from "../_shared/notification-target.js";
15
+ import { openRichLink } from "../_shared/rich-content-link.js";
16
+ import { copyParam, copyTemplate } from "../_shared/himi-copy.js";
13
17
 
14
18
  interface Row {
15
19
  id: string;
@@ -22,9 +26,13 @@ interface Row {
22
26
  photo: string;
23
27
  initial: string;
24
28
  dest: string;
29
+ /** What a tap does (a screen, a web link or nothing). Absent on the preview samples, which open nothing. */
30
+ open?: NotificationOpen;
25
31
  }
26
32
  interface S extends LoadFlags {
27
33
  items: Row[]; emptyText: string; actionMessage: string;
34
+ /** The web link the last tap named and is waiting for a second tap to open (see `open`). */
35
+ pendingUrl: string;
28
36
  /** Ownership travels with the rows, including a state captured by an async write. */
29
37
  inboxMember: MemberAccess | null;
30
38
  isMock: boolean; notMock: boolean;
@@ -67,7 +75,17 @@ function photo(n: MfvNotification): string {
67
75
  return n.icon?.personAvatarData?.data || n.icon?.webUrlData?.webUrl || "";
68
76
  }
69
77
 
70
- function norm(n: MfvNotification): Row {
78
+ /** The screens the Notifications screen says its app serves, or undefined when it did not say. */
79
+ function servedScreens(arg: unknown): ReadonlySet<string> | undefined {
80
+ const served = (arg as { served?: unknown } | null | undefined)?.served;
81
+ // A Retry reaches `load` with no params (the collection layer re-dispatches the action bare), so the list the screen
82
+ // last named is kept: dropping it would let every catalog link be tried again.
83
+ if (typeof served === "string") lastServed = new Set(served.split(",").filter(Boolean));
84
+ return lastServed;
85
+ }
86
+ let lastServed: ReadonlySet<string> | undefined;
87
+
88
+ function norm(n: MfvNotification, served?: ReadonlySet<string>): Row {
71
89
  const name = (n.title || n.message || "Notification").trim() || "Notification";
72
90
  const rel = relativeTime(n.sentDate);
73
91
  const lead = n.title && n.message ? n.message.trim() : "";
@@ -83,6 +101,7 @@ function norm(n: MfvNotification): Row {
83
101
  photo: p,
84
102
  initial: (name[0] || "#").toUpperCase(),
85
103
  dest: dest(n),
104
+ open: notificationOpen(n.deeplink, served),
86
105
  };
87
106
  }
88
107
 
@@ -128,12 +147,13 @@ function currentInbox(s: S): S | undefined {
128
147
  }
129
148
 
130
149
  const defineService = withOnlineWrites(registerService, ["markOneRead","markAll"], { messageFields: ["actionMessage"] });
131
- const INITIAL = { ...LOADING, items: [] as Row[], emptyText: "", actionMessage: "", inboxMember: null };
150
+ const INITIAL = { ...LOADING, items: [] as Row[], emptyText: "", actionMessage: "", pendingUrl: "", inboxMember: null };
132
151
  const svc = defineService<S, {
133
152
  prepare: (s: S) => S;
134
- load: (s: S) => Promise<S>;
153
+ load: (s: S, arg?: unknown) => Promise<S>;
135
154
  markOneRead: (s: S, id: string) => Promise<S | undefined>;
136
155
  markAll: (s: S) => Promise<S | undefined>;
156
+ open: (s: S, arg?: unknown) => Promise<S | undefined>;
137
157
  }>("himiBrandedNotifications", {
138
158
  state: INITIAL,
139
159
  actions: {
@@ -141,14 +161,16 @@ const svc = defineService<S, {
141
161
  // Native hosts may already cold-paint their account-scoped snapshot; this
142
162
  // reducer does not control that earlier host phase.
143
163
  prepare: (s) => ({ ...s, ...INITIAL }),
144
- load: async (s) => {
164
+ load: async (s, arg) => {
165
+ // The screen names which of the link targets this app serves (a comma list); none given means any may be tried.
166
+ const served = servedScreens(arg);
145
167
  try {
146
168
  const member = await requireMember();
147
- const items = (await queryNotifications(25)).map(norm);
169
+ const items = (await queryNotifications(25)).map((n) => norm(n, served));
148
170
  await assertMemberAccess(member);
149
- return { ...s, ...loaded("items", items, COPY), inboxMember: member, actionMessage: "" };
171
+ return { ...s, ...loaded("items", items, COPY), inboxMember: member, actionMessage: "", pendingUrl: "" };
150
172
  } catch (err) {
151
- return { ...s, ...loadFailed("items", (await memberAccess()).email ? err : signInRequired(), COPY), inboxMember: null };
173
+ return { ...s, ...loadFailed("items", (await memberAccess()).email ? err : signInRequired(), COPY), inboxMember: null, actionMessage: "", pendingUrl: "" };
152
174
  }
153
175
  },
154
176
  // Mark one read; optimistically clear its dot. Real write is best-effort and
@@ -174,6 +196,39 @@ const svc = defineService<S, {
174
196
  const current = currentInbox(s);
175
197
  return current ? { ...current, items: current.items.map((r) => (r.id === id ? { ...r, unread: false, unreadDot: "" } : r)) } : undefined;
176
198
  },
199
+ // A tap on a row opens what the notification points at, like the One App: a Himi screen when its link names
200
+ // one of ours (see _shared/notification-target.ts), else its https link, else a visible "can't open".
201
+ // A web link is written by the site, so it needs a SECOND tap on the same row, after the first tap has
202
+ // shown where it goes. It never replaces the inbox, and an unknown link never guesses a screen.
203
+ open: async (s, arg): Promise<S | undefined> => {
204
+ const id = String((arg as { id?: unknown } | null | undefined)?.id ?? "");
205
+ const row = s.items.find((r) => r.id === id);
206
+ if (!row || s.busy) return undefined;
207
+ if (!s.isMock) {
208
+ try { await requireInboxMember(s); } catch (err) {
209
+ const current = currentInbox(s);
210
+ return current ? { ...current, ...loadFailed("items", err, COPY), inboxMember: null } : undefined;
211
+ }
212
+ }
213
+ const current = currentInbox(s);
214
+ if (!current) return undefined;
215
+ const action = row.open ?? { kind: "none" as const };
216
+ if (action.kind === "url") {
217
+ if (current.pendingUrl !== action.url) {
218
+ return { ...current, pendingUrl: action.url, actionMessage: copyTemplate("notifications.openLinkText", "Tap again to open {host}.", { host: urlHost(action.url) }) };
219
+ }
220
+ const opened = await openRichLink(action.url);
221
+ const now = currentInbox(s);
222
+ return now ? { ...now, pendingUrl: "", actionMessage: opened ? "" : copyParam("notifications.cantOpenText", "This notification can't be opened here.") } : undefined;
223
+ }
224
+ if (action.kind === "none") {
225
+ return { ...current, pendingUrl: "", actionMessage: copyParam("notifications.cantOpenText", "This notification can't be opened here.") };
226
+ }
227
+ // Read on open: the dot clears at once, the write is best effort and never holds the navigation.
228
+ if (!id.startsWith("sample-") && row.unread) markRead([id]).catch(() => undefined);
229
+ const read = { ...current, pendingUrl: "", actionMessage: "", items: current.items.map((r) => (r.id === id ? { ...r, unread: false, unreadDot: "" } : r)) };
230
+ return navigate(read, { screenId: action.target.screenId, args: action.target.args });
231
+ },
177
232
  // Mark everything read; optimistically clear all dots.
178
233
  markAll: async (s): Promise<S | undefined> => {
179
234
  if (s.busy) return undefined;