@@ -10,8 +10,16 @@ interface CounterState {
1010 stale : boolean ;
1111}
1212
13+ type StoredReactionState = CounterState & {
14+ viewer : ViewerVote ;
15+ viewerKnown ?: boolean ;
16+ starred ?: boolean ;
17+ } ;
18+
1319export interface ReactionState extends CounterState {
1420 viewer : ViewerVote ;
21+ viewerKnown : boolean ;
22+ starred : boolean ;
1523}
1624
1725interface ReactionEnvelope {
@@ -42,6 +50,7 @@ export interface AccessToken {
4250 value : string ;
4351 expiresAt : number ;
4452 creationGrant : string ;
53+ viewerId ?: string ;
4554}
4655
4756export interface EnsuredDiscussion {
@@ -91,13 +100,57 @@ export class FeedbackClient {
91100 for ( const key of normalized ) {
92101 const state = payload . items [ key ] ?? emptyState ( ) ;
93102 if ( ! isCounterState ( state ) ) throw new TypeError ( "Invalid feedback service response" ) ;
94- const combined = { ...state , viewer : this . #cachedReaction( key ) ?. viewer ?? "none" } ;
103+ const cached = this . #cachedReaction( key ) ;
104+ const combined = {
105+ ...state ,
106+ viewer : cached ?. viewer ?? "none" ,
107+ viewerKnown : cached ?. viewerKnown ?? false ,
108+ starred : cached ?. starred ?? false ,
109+ } ;
95110 result . set ( key , combined ) ;
96111 this . #storeReaction( key , combined ) ;
97112 }
98113 return result ;
99114 }
100115
116+ async syncViewerReactions (
117+ keys : Iterable < string > ,
118+ token : AccessToken ,
119+ signal ?: AbortSignal ,
120+ ) : Promise < ReadonlyMap < string , ReactionState > > {
121+ const normalized = [ ...new Set ( Array . from ( keys , validateResourceId ) ) ] . sort ( ) ;
122+ if ( normalized . length === 0 ) throw new TypeError ( "At least one resource key is required" ) ;
123+ const pending = normalized . filter ( ( key ) => this . #viewerNeedsSync( key , token . viewerId ) ) ;
124+ if ( pending . length > 0 ) {
125+ const url = new URL ( `/v1/sites/${ encodeURIComponent ( this . #site) } /viewer-reactions` , this . #apiOrigin) ;
126+ url . searchParams . set ( "keys" , pending . join ( "," ) ) ;
127+ const headers = { Accept : "application/json" , Authorization : `Bearer ${ token . value } ` } ;
128+ const init : RequestInit = { headers } ;
129+ if ( signal ) init . signal = signal ;
130+ const response = await this . #fetch( url , init ) ;
131+ if ( ! response . ok ) throw new FeedbackError ( response . status , await errorCode ( response ) ) ;
132+ const payload : unknown = await response . json ( ) ;
133+ if ( ! isViewerEnvelope ( payload , this . #site, pending ) ) {
134+ throw new TypeError ( "Invalid feedback service response" ) ;
135+ }
136+ for ( const key of pending ) {
137+ const cached = this . #cachedReaction( key ) ?? emptyState ( ) ;
138+ this . #storeReaction(
139+ key ,
140+ {
141+ ...cached ,
142+ viewer : payload . items [ key ] ?. vote ?? "none" ,
143+ starred : payload . items [ key ] ?. starred ?? false ,
144+ viewerKnown : true ,
145+ } ,
146+ true ,
147+ token . viewerId ,
148+ ) ;
149+ }
150+ }
151+ return this . cachedReactions ( normalized ) ;
152+ }
153+
101154 async authorize ( challenge : string , nonce : string , signal ?: AbortSignal ) : Promise < Authorization > {
102155 const payload = await this . #post( "oauth/authorize" , { challenge, nonce } , signal ) ;
103156 const url = payload . authorization_url ;
@@ -174,10 +227,26 @@ export class FeedbackClient {
174227 age : 0 ,
175228 stale : true ,
176229 viewer : result . viewer ,
177- } ) ;
230+ viewerKnown : true ,
231+ starred : cached ?. starred ?? false ,
232+ } , true , token . viewerId ) ;
178233 return result ;
179234 }
180235
236+ async toggleStar ( key : string , token : AccessToken , signal ?: AbortSignal ) : Promise < boolean > {
237+ validateResourceId ( key ) ;
238+ const payload = await this . #post( "stars" , { key } , signal , token . value ) ;
239+ if ( typeof payload . starred !== "boolean" ) throw new TypeError ( "Invalid star response" ) ;
240+ const cached = this . #cachedReaction( key ) ?? emptyState ( ) ;
241+ this . #storeReaction(
242+ key ,
243+ { ...cached , starred : payload . starred , viewerKnown : true } ,
244+ true ,
245+ token . viewerId ,
246+ ) ;
247+ return payload . starred ;
248+ }
249+
181250 async #post(
182251 path : string ,
183252 body : Record < string , unknown > ,
@@ -214,28 +283,73 @@ export class FeedbackClient {
214283 if (
215284 ! Number . isSafeInteger ( value . savedAt ) ||
216285 Date . now ( ) - ( value . savedAt as number ) > 7 * 24 * 60 * 60 * 1000 ||
217- ! isReactionState ( value . state )
286+ ! isStoredReactionState ( value . state )
218287 ) {
219288 this . #counterStorage. removeItem ( counterKey ( this . #site, key ) ) ;
220289 return null ;
221290 }
222291 const elapsed = Math . max ( 0 , Math . floor ( ( Date . now ( ) - ( value . savedAt as number ) ) / 1000 ) ) ;
223- return { ...value . state , age : value . state . age + elapsed , stale : true } ;
292+ return {
293+ ...value . state ,
294+ viewerKnown : value . state . viewerKnown ?? false ,
295+ starred : value . state . starred ?? false ,
296+ age : value . state . age + elapsed ,
297+ stale : true ,
298+ } ;
224299 } catch {
225300 return null ;
226301 }
227302 }
228303
229- #storeReaction( key : string , state : ReactionState ) : void {
304+ #storeReaction(
305+ key : string ,
306+ state : ReactionState ,
307+ viewerConfirmed = false ,
308+ viewerId ?: string ,
309+ ) : void {
230310 try {
311+ let viewerCheckedAt : number | null = null ;
312+ let existingViewerId : string | undefined ;
313+ const existing = this . #counterStorage?. getItem ( counterKey ( this . #site, key ) ) ;
314+ if ( existing !== null && existing !== undefined ) {
315+ const parsed = JSON . parse ( existing ) as { viewerCheckedAt ?: unknown ; viewerId ?: unknown } ;
316+ if ( Number . isSafeInteger ( parsed . viewerCheckedAt ) ) viewerCheckedAt = parsed . viewerCheckedAt as number ;
317+ if ( typeof parsed . viewerId === "string" ) existingViewerId = parsed . viewerId ;
318+ }
231319 this . #counterStorage?. setItem (
232320 counterKey ( this . #site, key ) ,
233- JSON . stringify ( { savedAt : Date . now ( ) , state } ) ,
321+ JSON . stringify ( {
322+ savedAt : Date . now ( ) ,
323+ viewerCheckedAt : viewerConfirmed ? Date . now ( ) : viewerCheckedAt ,
324+ viewerId : viewerConfirmed ? viewerId : existingViewerId ,
325+ state,
326+ } ) ,
234327 ) ;
235328 } catch {
236329 // Storage may be disabled or full; counters still work from the network.
237330 }
238331 }
332+
333+
334+ #viewerNeedsSync( key : string , viewerId ?: string ) : boolean {
335+ if ( this . #counterStorage === undefined ) return true ;
336+ try {
337+ const raw = this . #counterStorage. getItem ( counterKey ( this . #site, key ) ) ;
338+ if ( raw === null ) return true ;
339+ const value = JSON . parse ( raw ) as {
340+ viewerCheckedAt ?: unknown ;
341+ viewerId ?: unknown ;
342+ state ?: unknown ;
343+ } ;
344+ return ! isStoredReactionState ( value . state ) ||
345+ ! value . state . viewerKnown ||
346+ ( viewerId !== undefined && value . viewerId !== viewerId ) ||
347+ ! Number . isSafeInteger ( value . viewerCheckedAt ) ||
348+ Date . now ( ) - ( value . viewerCheckedAt as number ) >= 5 * 60 * 1000 ;
349+ } catch {
350+ return true ;
351+ }
352+ }
239353}
240354
241355export class FeedbackError extends Error {
@@ -245,7 +359,16 @@ export class FeedbackError extends Error {
245359}
246360
247361function emptyState ( ) : ReactionState {
248- return { id : null , up : 0 , down : 0 , age : 0 , stale : false , viewer : "none" } ;
362+ return {
363+ id : null ,
364+ up : 0 ,
365+ down : 0 ,
366+ age : 0 ,
367+ stale : false ,
368+ viewer : "none" ,
369+ viewerKnown : false ,
370+ starred : false ,
371+ } ;
249372}
250373
251374function isEnvelope ( value : unknown , site : string ) : value is ReactionEnvelope {
@@ -266,14 +389,42 @@ function isCounterState(value: unknown): value is CounterState {
266389 ) ;
267390}
268391
269- function isReactionState ( value : unknown ) : value is ReactionState {
270- return isCounterState ( value ) && isViewerVote ( ( value as Partial < ReactionState > ) . viewer ) ;
392+ function isStoredReactionState ( value : unknown ) : value is StoredReactionState {
393+ return isCounterState ( value ) &&
394+ isViewerVote ( ( value as Partial < ReactionState > ) . viewer ) &&
395+ ( ( value as Partial < ReactionState > ) . viewerKnown === undefined ||
396+ typeof ( value as Partial < ReactionState > ) . viewerKnown === "boolean" ) &&
397+ ( ( value as Partial < ReactionState > ) . starred === undefined ||
398+ typeof ( value as Partial < ReactionState > ) . starred === "boolean" ) ;
271399}
272400
273401function isViewerVote ( value : unknown ) : value is ViewerVote {
274402 return value === "up" || value === "down" || value === "both" || value === "none" ;
275403}
276404
405+ function isViewerEnvelope (
406+ value : unknown ,
407+ site : string ,
408+ keys : readonly string [ ] ,
409+ ) : value is {
410+ v : 1 ;
411+ site : string ;
412+ items : Record < string , { vote : ViewerVote ; starred : boolean } > ;
413+ } {
414+ if ( ! value || typeof value !== "object" ) return false ;
415+ const candidate = value as { v ?: unknown ; site ?: unknown ; items ?: unknown } ;
416+ if ( candidate . v !== 1 || candidate . site !== site || ! candidate . items || typeof candidate . items !== "object" ) {
417+ return false ;
418+ }
419+ const items = candidate . items as Record < string , unknown > ;
420+ return Object . keys ( items ) . length === keys . length && keys . every ( ( key ) => {
421+ const item = items [ key ] ;
422+ return ! ! item && typeof item === "object" &&
423+ isViewerVote ( ( item as { vote ?: unknown } ) . vote ) &&
424+ typeof ( item as { starred ?: unknown } ) . starred === "boolean" ;
425+ } ) ;
426+ }
427+
277428function counterKey ( site : string , resource : string ) : string {
278429 return `cppsocial.feedback.v1.${ site } .reaction.${ resource } ` ;
279430}
0 commit comments