Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/improsocial/impro
An extensible Bluesky client for web impro.social
Something went wrong. Try again.
JavaScript
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653365436553656365736583659366036613662366336643665366636673668366936703671367236733674367536763677367836793680368136823683368436853686368736883689369036913692369336943695369636973698369937003701370237033704370537063707370837093710371137123713371437153716371737183719372037213722372337243725372637273728372937303731373237333734373537363737373837393740374137423743374437453746374737483749375037513752375337543755375637573758375937603761376237633764376537663767376837693770377137723773377437753776377737783779378037813782378337843785378637873788378937903791379237933794379537963797379837993800380138023803380438053806380738083809381038113812381338143815381638173818381938203821382238233824382538263827382838293830383138323833383438353836383738383839384038413842384338443845384638473848384938503851385238533854385538563857385838593860386138623863386438653866386738683869387038713872387338743875387638773878387938803881388238833884388538863887388838893890389138923893389438953896389738983899390039013902390339043905390639073908390939103911391239133914391539163917391839193920392139223923392439253926392739283929393039313932393339343935393639373938393939403941394239433944394539463947394839493950395139523953395439553956395739583959396039613962396339643965396639673968396939703971397239733974397539763977397839793980398139823983398439853986398739883989399039913992399339943995399639973998399940004001400240034004400540064007400840094010401140124013401440154016401740184019402040214022402340244025402640274028402940304031403240334034403540364037403840394040404140424043404440454046404740484049405040514052405340544055405640574058405940604061406240634064406540664067406840694070407140724073407440754076407740784079408040814082408340844085408640874088408940904091409240934094409540964097409840994100410141024103410441054106410741084109411041114112411341144115411641174118411941204121412241234124412541264127412841294130413141324133413441354136413741384139414041414142414341444145414641474148414941504151415241534154415541564157415841594160416141624163416441654166416741684169417041714172417341744175417641774178417941804181418241834184418541864187418841894190419141924193419441954196419741984199420042014202420342044205420642074208420942104211421242134214421542164217421842194220422142224223422442254226422742284229423042314232423342344235423642374238423942404241424242434244424542464247424842494250425142524253425442554256425742584259426042614262426342644265426642674268426942704271427242734274427542764277427842794280428142824283428442854286428742884289429042914292429342944295429642974298429943004301430243034304430543064307430843094310431143124313431443154316431743184319432043214322432343244325432643274328432943304331433243334334433543364337433843394340434143424343434443454346434743484349435043514352435343544355435643574358435943604361436243634364436543664367436843694370437143724373437443754376437743784379438043814382438343844385438643874388438943904391439243934394439543964397439843994400440144024403440444054406440744084409441044114412441344144415import { describe, it, beforeEach } from "node:test";import assert from "node:assert/strict";import { Requests } from "/js/dataLayer/requests.js";import { DataStore } from "/js/dataLayer/dataStore.js";import { createSessionState } from "/js/dataLayer/sessionState.js";import { DraftMediaStore } from "/js/drafts.js";import { Preferences } from "/js/preferences.js";import { ApiError } from "/js/api.js";import { EventEmitter } from "/js/eventEmitter.js";
const stubConstellation = { getLinks: async () => [] };
function createRequests(api, dataStore, preferencesProvider, events = null) { return new Requests( api, dataStore, preferencesProvider, new DraftMediaStore("test-media"), events ?? new EventEmitter(), stubConstellation, );}
describe("loadPostThread", () => { const postURI = "at://did:test/app.bsky.feed.post/thread";
it("should load and store post thread", async () => { const mockPostThread = { post: { uri: postURI, content: "Main post" }, replies: [ { $type: "app.bsky.feed.defs#threadViewPost", post: { uri: "reply1", content: "Reply 1" }, }, ], };
const mockPostThreadOther = [{ uri: "reply1" }];
const normalizedPosts = [ { uri: postURI, content: "Main post" }, { uri: "reply1", content: "Reply 1" }, ];
const mockApi = { getPostThread: async () => mockPostThread, getPostThreadOther: async () => mockPostThreadOther, };
const dataStore = new DataStore(createSessionState(null)); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; const requests = createRequests( mockApi, dataStore, mockPreferencesProvider, );
await requests.loadPostThread(postURI);
// Check thread was stored assert.deepEqual(dataStore.$postThreads.get(postURI), mockPostThread);
// Check postThreadOther was stored assert.deepEqual( dataStore.$postThreadOthers.get(postURI), mockPostThreadOther, );
// Check posts were stored assert.deepEqual(dataStore.$posts.get(postURI), normalizedPosts[0]); assert.deepEqual(dataStore.$posts.get("reply1"), normalizedPosts[1]); });
it("should handle empty post thread", async () => { const emptyPostThread = { post: { uri: postURI, content: "Lonely post" }, replies: [], };
const normalizedPosts = [{ uri: postURI, content: "Lonely post" }];
const mockApi = { getPostThread: async () => emptyPostThread, getPostThreadOther: async () => [], };
const dataStore = new DataStore(createSessionState(null)); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; const requests = createRequests( mockApi, dataStore, mockPreferencesProvider, );
await requests.loadPostThread(postURI);
assert.deepEqual(dataStore.$postThreads.get(postURI), emptyPostThread); assert.deepEqual(dataStore.$postThreadOthers.get(postURI), []); assert.deepEqual(dataStore.$posts.get(postURI), normalizedPosts[0]); });});
describe("loadNextFeedPage", () => { const feedURI = "at://did:test/app.bsky.feed.generator/test";
it("should load initial feed page", async () => { const mockFeed = { feed: [{ post: { uri: "post1" } }, { post: { uri: "post2" } }], cursor: "cursor123", };
const normalizedPosts = [{ uri: "post1" }, { uri: "post2" }];
const mockApi = { getFeed: async () => mockFeed, };
const dataStore = new DataStore(createSessionState(null)); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; const requests = createRequests( mockApi, dataStore, mockPreferencesProvider, );
await requests.loadNextFeedPage({ type: "feed", uri: feedURI });
// Check feed was stored assert.deepEqual(dataStore.$feeds.get(feedURI), mockFeed);
// Check posts were stored assert.deepEqual(dataStore.$posts.get("post1"), normalizedPosts[0]); assert.deepEqual(dataStore.$posts.get("post2"), normalizedPosts[1]); });
it("should append to existing feed", async () => { const dataStore = new DataStore(createSessionState(null));
// Set up existing feed const existingFeed = { feed: [{ post: { uri: "post1" } }], cursor: "cursor1", }; dataStore.$feeds.set(feedURI, existingFeed);
// New page const newPage = { feed: [{ post: { uri: "post2" } }, { post: { uri: "post3" } }], cursor: "cursor2", };
const normalizedPosts = [{ uri: "post2" }, { uri: "post3" }];
const mockApi = { getFeed: async () => newPage, };
const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; const requests = createRequests( mockApi, dataStore, mockPreferencesProvider, );
await requests.loadNextFeedPage({ type: "feed", uri: feedURI });
// Check feed was appended const storedFeed = dataStore.$feeds.get(feedURI); assert.deepEqual(storedFeed.feed.length, 3); assert.deepEqual(storedFeed.feed[0], { post: { uri: "post1" } }); assert.deepEqual(storedFeed.feed[1], { post: { uri: "post2" } }); assert.deepEqual(storedFeed.feed[2], { post: { uri: "post3" } }); assert.deepEqual(storedFeed.cursor, "cursor2");
// Check new posts were stored assert.deepEqual(dataStore.$posts.get("post2"), normalizedPosts[0]); assert.deepEqual(dataStore.$posts.get("post3"), normalizedPosts[1]); });
it("should discard a stale page when a reload lands mid-flight", async () => { const dataStore = new DataStore(createSessionState(null)); dataStore.$feeds.set(feedURI, { feed: [{ post: { uri: "post1" } }], cursor: "cursor1", });
const reloadedFeed = { feed: [{ post: { uri: "post9" } }], cursor: "cursor9", }; const mockApi = { getFeed: async () => { // Simulate a reload finishing while this page request is in flight dataStore.$feeds.set(feedURI, reloadedFeed); return { feed: [{ post: { uri: "post2" } }], cursor: "cursor2" }; }, };
const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; const requests = createRequests( mockApi, dataStore, mockPreferencesProvider, );
await requests.loadNextFeedPage({ type: "feed", uri: feedURI });
assert.deepEqual(dataStore.$feeds.get(feedURI), reloadedFeed); });
it("should emit feedLoaded with the reload flag", async () => { const dataStore = new DataStore(createSessionState(null)); dataStore.$feeds.set(feedURI, { feed: [{ post: { uri: "post1" } }], cursor: "cursor1", });
const mockApi = { getFeed: async () => ({ feed: [], cursor: "end" }), }; const events = new EventEmitter(); const capturedReloads = []; events.on("feedLoaded", ({ reload }) => capturedReloads.push(reload)); const requests = createRequests( mockApi, dataStore, { requirePreferences: () => Preferences.createLoggedOutPreferences() }, events, );
await requests.loadNextFeedPage({ type: "feed", uri: feedURI }); await requests.loadNextFeedPage( { type: "feed", uri: feedURI }, { reload: true }, );
assert.deepEqual(capturedReloads, [false, true]); });
it("should handle empty feed", async () => { const emptyFeed = { feed: [], cursor: "end", };
const mockApi = { getFeed: async () => emptyFeed, };
const dataStore = new DataStore(createSessionState(null)); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; const requests = createRequests( mockApi, dataStore, mockPreferencesProvider, );
await requests.loadNextFeedPage({ type: "feed", uri: feedURI });
assert.deepEqual(dataStore.$feeds.get(feedURI), emptyFeed); });
it("should handle feed with reply context", async () => { const feedWithReplies = { feed: [ { post: { uri: "post1" }, reply: { root: { $type: "app.bsky.feed.defs#postView", uri: "root1" }, parent: { $type: "app.bsky.feed.defs#postView", uri: "parent1" }, }, }, ], cursor: "cursor123", };
const normalizedPosts = [ { uri: "post1", content: "Reply post" }, { uri: "root1", content: "Root post" }, { uri: "parent1", content: "Parent post" }, ];
const mockApi = { getFeed: async () => feedWithReplies, };
const dataStore = new DataStore(createSessionState(null)); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; const requests = createRequests( mockApi, dataStore, mockPreferencesProvider, );
await requests.loadNextFeedPage({ type: "feed", uri: feedURI });
assert.deepEqual(dataStore.$feeds.get(feedURI), feedWithReplies); assert.deepEqual(dataStore.$posts.get("post1").uri, normalizedPosts[0].uri); assert.deepEqual(dataStore.$posts.get("root1").uri, normalizedPosts[1].uri); assert.deepEqual( dataStore.$posts.get("parent1").uri, normalizedPosts[2].uri, ); });});
describe("loadDetailedProfile", () => { const profileDID = "did:test:profile";
it("should load and store profile", async () => { const mockProfile = { did: profileDID, handle: "test.user", displayName: "Test User", description: "A test user", };
const mockApi = { getProfile: async () => mockProfile, };
const dataStore = new DataStore(createSessionState(null));
const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; const requests = createRequests( mockApi, dataStore, mockPreferencesProvider, );
await requests.loadDetailedProfile(profileDID);
// Check profile was stored assert.deepEqual(dataStore.$profiles.get(profileDID), mockProfile); });
it("should handle profile updates", async () => { const dataStore = new DataStore(createSessionState(null));
// Load initial profile const initialProfile = { did: profileDID, handle: "old.handle", displayName: "Old Name", };
const mockApi = { getProfile: async () => initialProfile, };
const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; const requests = createRequests( mockApi, dataStore, mockPreferencesProvider, );
await requests.loadDetailedProfile(profileDID);
assert.deepEqual(dataStore.$profiles.get(profileDID), initialProfile);
// Load updated profile const updatedProfile = { did: profileDID, handle: "new.handle", displayName: "New Name", };
mockApi.getProfile = async () => updatedProfile;
await requests.loadDetailedProfile(profileDID);
assert.deepEqual(dataStore.$profiles.get(profileDID), updatedProfile); });});
describe("loadPosts", () => { it("loads and stores each post by uri", async () => { const postA = { uri: "at://a", content: "A" }; const postB = { uri: "at://b", content: "B" }; let calledWith = null;
const mockApi = { getPosts: async (uris) => { calledWith = uris; return [postA, postB]; }, };
const dataStore = new DataStore(createSessionState(null)); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; const requests = createRequests( mockApi, dataStore, mockPreferencesProvider, );
await requests.loadPosts(["at://a", "at://b"]);
assert.deepEqual(calledWith, ["at://a", "at://b"]); assert.deepEqual(dataStore.$posts.get("at://a"), postA); assert.deepEqual(dataStore.$posts.get("at://b"), postB); });
it("does not call api when uris is empty", async () => { let called = false; const mockApi = { getPosts: async () => { called = true; return []; }, };
const dataStore = new DataStore(createSessionState(null)); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; const requests = createRequests( mockApi, dataStore, mockPreferencesProvider, );
await requests.loadPosts([]);
assert.deepEqual(called, false); });});
describe("loadLabelerInfo", () => { const labelerDid = "did:plc:testlabeler";
it("should load and store labeler info", async () => { const mockLabelerInfo = { uri: `at://${labelerDid}/app.bsky.labeler.service/self`, creator: { did: labelerDid, handle: "labeler.test" }, policies: { labelValueDefinitions: [ { identifier: "nsfw", locales: [{ lang: "en", name: "NSFW" }] }, ], }, };
const mockApi = { getLabeler: async () => mockLabelerInfo, };
const dataStore = new DataStore(createSessionState(null)); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; const requests = createRequests( mockApi, dataStore, mockPreferencesProvider, );
await requests.loadLabelerInfo(labelerDid);
assert.deepEqual(dataStore.$labelerInfo.get(labelerDid), mockLabelerInfo); });
it("should call api.getLabeler with correct DID", async () => { let calledWithDid = null; const mockApi = { getLabeler: async (did) => { calledWithDid = did; return { creator: { did } }; }, };
const dataStore = new DataStore(createSessionState(null)); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; const requests = createRequests( mockApi, dataStore, mockPreferencesProvider, );
await requests.loadLabelerInfo(labelerDid);
assert.deepEqual(calledWithDid, labelerDid); });
it("should overwrite existing labeler info on reload", async () => { const initialInfo = { creator: { did: labelerDid, handle: "old.handle" }, policies: { labelValueDefinitions: [] }, }; const updatedInfo = { creator: { did: labelerDid, handle: "new.handle" }, policies: { labelValueDefinitions: [{ identifier: "test" }], }, };
const dataStore = new DataStore(createSessionState(null)); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), };
let currentInfo = initialInfo; const mockApi = { getLabeler: async () => currentInfo, };
const requests = createRequests( mockApi, dataStore, mockPreferencesProvider, );
await requests.loadLabelerInfo(labelerDid); assert.deepEqual(dataStore.$labelerInfo.get(labelerDid), initialInfo);
currentInfo = updatedInfo; await requests.loadLabelerInfo(labelerDid); assert.deepEqual(dataStore.$labelerInfo.get(labelerDid), updatedInfo); });});
describe("loadMutedProfiles", () => { it("should store muted profiles on first load", async () => { const res = { mutes: [ { did: "did:plc:a", handle: "a.test" }, { did: "did:plc:b", handle: "b.test" }, ], cursor: "next", }; const mockApi = { getMutes: async () => res }; const dataStore = new DataStore(createSessionState(null)); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; const requests = createRequests( mockApi, dataStore, mockPreferencesProvider, );
await requests.loadMutedProfiles();
assert.deepEqual(dataStore.$mutedProfiles.get(), res); assert.deepEqual(dataStore.$profiles.get("did:plc:a"), { did: "did:plc:a", handle: "a.test", }); assert.deepEqual(dataStore.$profiles.get("did:plc:b"), { did: "did:plc:b", handle: "b.test", }); });
it("should append paginated muted profiles when cursor is provided", async () => { const dataStore = new DataStore(createSessionState(null)); dataStore.$mutedProfiles.set({ mutes: [{ did: "did:plc:a" }], cursor: "page2", });
const mockApi = { getMutes: async () => ({ mutes: [{ did: "did:plc:b" }], cursor: undefined, }), }; const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; const requests = createRequests( mockApi, dataStore, mockPreferencesProvider, );
await requests.loadMutedProfiles({ cursor: "page2" });
const stored = dataStore.$mutedProfiles.get(); assert.deepEqual(stored.mutes.length, 2); assert.deepEqual(stored.mutes[0].did, "did:plc:a"); assert.deepEqual(stored.mutes[1].did, "did:plc:b"); });
it("should pass cursor through to the api", async () => { let capturedCursor; const mockApi = { getMutes: async ({ cursor }) => { capturedCursor = cursor; return { mutes: [], cursor: undefined }; }, }; const dataStore = new DataStore(createSessionState(null)); dataStore.$mutedProfiles.set({ mutes: [], cursor: "abc" }); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; const requests = createRequests( mockApi, dataStore, mockPreferencesProvider, );
await requests.loadMutedProfiles({ cursor: "abc" }); assert.deepEqual(capturedCursor, "abc"); });
it("should discard the response when the cursor no longer matches", async () => { const dataStore = new DataStore(createSessionState(null)); const existing = { mutes: [{ did: "did:plc:a" }], cursor: "page3", }; dataStore.$mutedProfiles.set(existing);
const mockApi = { getMutes: async () => ({ mutes: [{ did: "did:plc:b" }], cursor: "page4", }), }; const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; const requests = createRequests( mockApi, dataStore, mockPreferencesProvider, );
await requests.loadMutedProfiles({ cursor: "page2" });
assert.deepEqual(dataStore.$mutedProfiles.get(), existing); });});
function makeRequests( api, dataStore = new DataStore(createSessionState(null)), preferences,) { const provider = { requirePreferences: () => preferences ?? Preferences.createLoggedOutPreferences(), }; return createRequests(api, dataStore, provider);}
describe("loadBlockedProfiles", () => { it("should store blocked profiles on first load", async () => { const res = { blocks: [ { did: "did:plc:a", handle: "a.test" }, { did: "did:plc:b", handle: "b.test" }, ], cursor: "next", }; const mockApi = { getBlocks: async () => res }; const dataStore = new DataStore(createSessionState(null)); const requests = makeRequests(mockApi, dataStore);
await requests.loadBlockedProfiles();
assert.deepEqual(dataStore.$blockedProfiles.get(), res); assert.deepEqual(dataStore.$profiles.get("did:plc:a"), { did: "did:plc:a", handle: "a.test", }); assert.deepEqual(dataStore.$profiles.get("did:plc:b"), { did: "did:plc:b", handle: "b.test", }); });
it("should append paginated blocked profiles when cursor is provided", async () => { const dataStore = new DataStore(createSessionState(null)); dataStore.$blockedProfiles.set({ blocks: [{ did: "did:plc:a" }], cursor: "page2", });
const mockApi = { getBlocks: async () => ({ blocks: [{ did: "did:plc:b" }], cursor: undefined, }), }; const requests = makeRequests(mockApi, dataStore);
await requests.loadBlockedProfiles({ cursor: "page2" });
const stored = dataStore.$blockedProfiles.get(); assert.deepEqual(stored.blocks.length, 2); assert.deepEqual(stored.blocks[0].did, "did:plc:a"); assert.deepEqual(stored.blocks[1].did, "did:plc:b"); });
it("should pass cursor through to the api", async () => { let capturedCursor; const mockApi = { getBlocks: async ({ cursor }) => { capturedCursor = cursor; return { blocks: [], cursor: undefined }; }, }; const dataStore = new DataStore(createSessionState(null)); dataStore.$blockedProfiles.set({ blocks: [], cursor: "abc" }); const requests = makeRequests(mockApi, dataStore);
await requests.loadBlockedProfiles({ cursor: "abc" }); assert.deepEqual(capturedCursor, "abc"); });});
describe("loadNextAuthorFeedPage", () => { const did = "did:plc:author";
it("should call getAuthorFeed with posts filter for posts feedType", async () => { let capturedParams; const mockApi = { getAuthorFeed: async (calledDid, params) => { capturedParams = { did: calledDid, ...params }; return { feed: [{ post: { uri: "p1" } }], cursor: "c1" }; }, }; const dataStore = new DataStore(createSessionState(null)); const requests = makeRequests(mockApi, dataStore);
await requests.loadNextAuthorFeedPage(did, "posts");
assert.deepEqual(capturedParams.did, did); assert.deepEqual(capturedParams.filter, "posts_and_author_threads"); assert.deepEqual(capturedParams.includePins, true); assert.deepEqual(capturedParams.cursor, ""); assert.deepEqual(dataStore.$authorFeeds.get(`${did}-posts`).feed.length, 1); });
it("should use posts_with_replies filter for replies feedType", async () => { let capturedParams; const mockApi = { getAuthorFeed: async (_did, params) => { capturedParams = params; return { feed: [], cursor: null }; }, }; const requests = makeRequests(mockApi);
await requests.loadNextAuthorFeedPage(did, "replies");
assert.deepEqual(capturedParams.filter, "posts_with_replies"); assert.deepEqual(capturedParams.includePins, false); });
it("should use posts_with_media filter for media feedType", async () => { let capturedParams; const mockApi = { getAuthorFeed: async (_did, params) => { capturedParams = params; return { feed: [], cursor: null }; }, }; const requests = makeRequests(mockApi);
await requests.loadNextAuthorFeedPage(did, "media");
assert.deepEqual(capturedParams.filter, "posts_with_media"); assert.deepEqual(capturedParams.includePins, false); });
it("should call getActorLikes for likes feedType", async () => { let actorLikesCalled = false; let authorFeedCalled = false; const mockApi = { getActorLikes: async () => { actorLikesCalled = true; return { feed: [], cursor: null }; }, getAuthorFeed: async () => { authorFeedCalled = true; return { feed: [], cursor: null }; }, }; const requests = makeRequests(mockApi);
await requests.loadNextAuthorFeedPage(did, "likes");
assert.deepEqual(actorLikesCalled, true); assert.deepEqual(authorFeedCalled, false); });
it("should append to existing feed", async () => { const feedURI = `${did}-posts`; const dataStore = new DataStore(createSessionState(null)); dataStore.$authorFeeds.set(feedURI, { feed: [{ post: { uri: "old1" } }], cursor: "c1", });
const mockApi = { getAuthorFeed: async () => ({ feed: [{ post: { uri: "new1" } }], cursor: "c2", }), }; const requests = makeRequests(mockApi, dataStore);
await requests.loadNextAuthorFeedPage(did, "posts");
const stored = dataStore.$authorFeeds.get(feedURI); assert.deepEqual(stored.feed.length, 2); assert.deepEqual(stored.feed[0].post.uri, "old1"); assert.deepEqual(stored.feed[1].post.uri, "new1"); assert.deepEqual(stored.cursor, "c2"); });
it("should reset cursor and replace feed on reload", async () => { const feedURI = `${did}-posts`; const dataStore = new DataStore(createSessionState(null)); dataStore.$authorFeeds.set(feedURI, { feed: [{ post: { uri: "old1" } }], cursor: "c1", });
let capturedCursor; const mockApi = { getAuthorFeed: async (_did, params) => { capturedCursor = params.cursor; return { feed: [{ post: { uri: "new1" } }], cursor: "c2" }; }, }; const requests = makeRequests(mockApi, dataStore);
await requests.loadNextAuthorFeedPage(did, "posts", { reload: true });
assert.deepEqual(capturedCursor, ""); const stored = dataStore.$authorFeeds.get(feedURI); assert.deepEqual(stored.feed.length, 1); assert.deepEqual(stored.feed[0].post.uri, "new1"); });
it("should throw on unknown feed type", async () => { const mockApi = { getAuthorFeed: async () => ({ feed: [], cursor: null }) }; const requests = makeRequests(mockApi);
let caught = null; try { await requests.loadNextAuthorFeedPage(did, "bogus"); } catch (error) { caught = error; } assert(caught !== null, "expected error for unknown feed type"); });});
describe("loadPostSearchTop / loadPostSearchLatest", () => { it("should clear results for both sorts when query is empty", async () => { const dataStore = new DataStore(createSessionState(null)); dataStore.$postSearchResultsTop.set({ posts: [{ uri: "p1" }], cursor: "c1", }); dataStore.$postSearchResultsLatest.set({ posts: [{ uri: "p2" }], cursor: "c2", }); const mockApi = { searchPosts: async () => ({ posts: [], cursor: null }) }; const requests = makeRequests(mockApi, dataStore);
await requests.loadPostSearchTop(""); await requests.loadPostSearchLatest("");
assert.deepEqual(dataStore.$postSearchResultsTop.get(), null); assert.deepEqual(dataStore.$postSearchResultsLatest.get(), null); });
it("should store results from a fresh search", async () => { const mockApi = { searchPosts: async () => ({ posts: [{ uri: "p1", record: {} }], cursor: "next", }), }; const dataStore = new DataStore(createSessionState(null)); const requests = makeRequests(mockApi, dataStore);
await requests.loadPostSearchTop("hello");
const stored = dataStore.$postSearchResultsTop.get(); assert.deepEqual(stored.posts.length, 1); assert.deepEqual(stored.cursor, "next"); });
it("should store each sort's results independently", async () => { const mockApi = { searchPosts: async (query, { sort }) => ({ posts: [{ uri: `post-${sort}`, record: {} }], cursor: null, }), }; const dataStore = new DataStore(createSessionState(null)); const requests = makeRequests(mockApi, dataStore);
await requests.loadPostSearchTop("hello"); await requests.loadPostSearchLatest("hello");
assert.deepEqual( dataStore.$postSearchResultsTop.get().posts[0].uri, "post-top", ); assert.deepEqual( dataStore.$postSearchResultsLatest.get().posts[0].uri, "post-latest", ); });
it("should not discard an in-flight sort when the other sort loads", async () => { const dataStore = new DataStore(createSessionState(null)); let resolveTop; const topPromise = new Promise((resolve) => { resolveTop = resolve; }); const mockApi = { searchPosts: async (query, { sort }) => { if (sort === "top") { await topPromise; return { posts: [{ uri: "top-post", record: {} }], cursor: "tc" }; } return { posts: [{ uri: "latest-post", record: {} }], cursor: "lc" }; }, }; const requests = makeRequests(mockApi, dataStore);
const topCall = requests.loadPostSearchTop("query"); await new Promise((resolve) => setTimeout(resolve, 5)); await requests.loadPostSearchLatest("query"); resolveTop(); await topCall;
assert.deepEqual( dataStore.$postSearchResultsTop.get().posts[0].uri, "top-post", ); assert.deepEqual( dataStore.$postSearchResultsLatest.get().posts[0].uri, "latest-post", ); });
it("should discard stale responses based on requestTime guard", async () => { const dataStore = new DataStore(createSessionState(null)); let resolveFirst; const firstPromise = new Promise((resolve) => { resolveFirst = resolve; }); let callIndex = 0; const mockApi = { searchPosts: async () => { callIndex += 1; if (callIndex === 1) { await firstPromise; return { posts: [{ uri: "stale", record: {} }], cursor: "stale" }; } return { posts: [{ uri: "fresh", record: {} }], cursor: "fresh" }; }, }; const requests = makeRequests(mockApi, dataStore);
const firstCall = requests.loadPostSearchTop("query"); await new Promise((resolve) => setTimeout(resolve, 5)); await requests.loadPostSearchTop("query"); resolveFirst(); await firstCall;
const stored = dataStore.$postSearchResultsTop.get(); assert.deepEqual(stored.posts[0].uri, "fresh"); assert.deepEqual(stored.cursor, "fresh"); });
it("should discard a stale cursored response that finishes dependency loading after a re-search", async () => { const dataStore = new DataStore(createSessionState(null)); const replyPost = (uri) => ({ uri, record: { reply: { parent: { uri: `${uri}-parent` } } }, }); const page1 = { posts: [replyPost("p1")], cursor: "c1" }; const page2 = { posts: [replyPost("p2")], cursor: null }; let getPostsGate = null; const mockApi = { searchPosts: async (query, { cursor }) => (cursor ? page2 : page1), getPosts: async () => { if (getPostsGate) { const gate = getPostsGate; getPostsGate = null; await gate; } return []; }, }; const requests = makeRequests(mockApi, dataStore);
await requests.loadPostSearchTop("query");
// A load-more passes the requestTime guard, then stalls loading // dependencies while a re-search and a fresh load-more complete let releaseGate; getPostsGate = new Promise((resolve) => { releaseGate = resolve; }); const staleLoadMore = requests.loadPostSearchTop("query", { cursor: "c1" }); await new Promise((resolve) => setTimeout(resolve, 5)); await requests.loadPostSearchTop("query"); await requests.loadPostSearchTop("query", { cursor: "c1" }); releaseGate(); await staleLoadMore;
const stored = dataStore.$postSearchResultsTop.get(); assert.deepEqual( stored.posts.map((post) => post.uri), ["p1", "p2"], ); });
it("should append when cursor is provided and existing results present", async () => { const dataStore = new DataStore(createSessionState(null)); dataStore.$postSearchResultsTop.set({ posts: [{ uri: "p1", record: {} }], cursor: "c1", }); const mockApi = { searchPosts: async () => ({ posts: [{ uri: "p2", record: {} }], cursor: "c2", }), }; const requests = makeRequests(mockApi, dataStore);
await requests.loadPostSearchTop("hello", { cursor: "c1" });
const stored = dataStore.$postSearchResultsTop.get(); assert.deepEqual(stored.posts.length, 2); assert.deepEqual(stored.posts[1].uri, "p2"); assert.deepEqual(stored.cursor, "c2"); });});
describe("loadProfileSearch", () => { it("should clear results when query is empty", async () => { const dataStore = new DataStore(createSessionState(null)); dataStore.$profileSearchResults.set({ actors: [{ did: "x" }], cursor: "c", }); const mockApi = { searchProfiles: async () => ({ actors: [], cursor: null }), }; const requests = makeRequests(mockApi, dataStore);
await requests.loadProfileSearch("");
assert.deepEqual(dataStore.$profileSearchResults.get(), null); });
it("should store actors from a fresh search", async () => { const mockApi = { searchProfiles: async () => ({ actors: [{ did: "did:plc:a" }], cursor: "next", }), }; const dataStore = new DataStore(createSessionState(null)); const requests = makeRequests(mockApi, dataStore);
await requests.loadProfileSearch("alice");
const stored = dataStore.$profileSearchResults.get(); assert.deepEqual(stored.actors.length, 1); assert.deepEqual(stored.cursor, "next"); });
it("should discard stale responses", async () => { const dataStore = new DataStore(createSessionState(null)); let resolveFirst; const firstPromise = new Promise((resolve) => { resolveFirst = resolve; }); let callIndex = 0; const mockApi = { searchProfiles: async () => { callIndex += 1; if (callIndex === 1) { await firstPromise; return { actors: [{ did: "stale" }], cursor: "stale" }; } return { actors: [{ did: "fresh" }], cursor: "fresh" }; }, }; const requests = makeRequests(mockApi, dataStore);
const firstCall = requests.loadProfileSearch("query"); await new Promise((resolve) => setTimeout(resolve, 5)); await requests.loadProfileSearch("query"); resolveFirst(); await firstCall;
const stored = dataStore.$profileSearchResults.get(); assert.deepEqual(stored.actors[0].did, "fresh"); });
it("should append when cursor is provided", async () => { const dataStore = new DataStore(createSessionState(null)); dataStore.$profileSearchResults.set({ actors: [{ did: "did:plc:a" }], cursor: "c1", }); const mockApi = { searchProfiles: async () => ({ actors: [{ did: "did:plc:b" }], cursor: "c2", }), }; const requests = makeRequests(mockApi, dataStore);
await requests.loadProfileSearch("query", { cursor: "c1" });
const stored = dataStore.$profileSearchResults.get(); assert.deepEqual(stored.actors.length, 2); assert.deepEqual(stored.cursor, "c2"); });
it("should discard in-flight responses after the query is cleared", async () => { const dataStore = new DataStore(createSessionState(null)); let resolveSearch; const searchPromise = new Promise((resolve) => { resolveSearch = resolve; }); const mockApi = { searchProfiles: async () => { await searchPromise; return { actors: [{ did: "stale" }], cursor: "stale" }; }, }; const requests = makeRequests(mockApi, dataStore);
const inFlight = requests.loadProfileSearch("query"); await requests.loadProfileSearch(""); resolveSearch(); await inFlight;
assert.deepEqual(dataStore.$profileSearchResults.get(), null); });});
describe("loadChatRecipientSearch", () => { it("should store the search results", async () => { const dataStore = new DataStore(createSessionState(null)); const mockApi = { searchProfilesTypeahead: async () => ({ actors: [{ did: "did:plc:a" }], }), }; const requests = makeRequests(mockApi, dataStore);
await requests.loadChatRecipientSearch("alice");
const stored = dataStore.$chatRecipientSearchResults.get(); assert.deepEqual(stored.actors.length, 1); assert.deepEqual(stored.actors[0].did, "did:plc:a"); });
it("should clear results when query is empty", async () => { const dataStore = new DataStore(createSessionState(null)); dataStore.$chatRecipientSearchResults.set({ actors: [{ did: "x" }] }); const mockApi = { searchProfilesTypeahead: async () => ({ actors: [] }), }; const requests = makeRequests(mockApi, dataStore);
await requests.loadChatRecipientSearch("");
assert.deepEqual(dataStore.$chatRecipientSearchResults.get(), null); });
it("should discard in-flight responses after the query is cleared", async () => { const dataStore = new DataStore(createSessionState(null)); let resolveSearch; const searchPromise = new Promise((resolve) => { resolveSearch = resolve; }); const mockApi = { searchProfilesTypeahead: async () => { await searchPromise; return { actors: [{ did: "stale" }] }; }, }; const requests = makeRequests(mockApi, dataStore);
const inFlight = requests.loadChatRecipientSearch("query"); await requests.loadChatRecipientSearch(""); resolveSearch(); await inFlight;
assert.deepEqual(dataStore.$chatRecipientSearchResults.get(), null); });});
describe("loadSearchTypeahead", () => { it("should store the search results and hydrate profiles", async () => { const dataStore = new DataStore(createSessionState(null)); const mockApi = { searchProfilesTypeahead: async () => ({ actors: [{ did: "did:plc:a", handle: "a.test" }], }), }; const requests = makeRequests(mockApi, dataStore);
await requests.loadSearchTypeahead("alice");
const stored = dataStore.$searchTypeaheadResults.get(); assert.deepEqual(stored.actors.length, 1); assert.deepEqual(stored.actors[0].did, "did:plc:a"); assert.deepEqual(dataStore.$profiles.get("did:plc:a"), { did: "did:plc:a", handle: "a.test", }); });
it("should clear results when query is empty", async () => { const dataStore = new DataStore(createSessionState(null)); dataStore.$searchTypeaheadResults.set({ actors: [{ did: "x" }] }); const mockApi = { searchProfilesTypeahead: async () => ({ actors: [] }), }; const requests = makeRequests(mockApi, dataStore);
await requests.loadSearchTypeahead("");
assert.deepEqual(dataStore.$searchTypeaheadResults.get(), null); });
it("should discard in-flight responses after the query is cleared", async () => { const dataStore = new DataStore(createSessionState(null)); let resolveSearch; const searchPromise = new Promise((resolve) => { resolveSearch = resolve; }); const mockApi = { searchProfilesTypeahead: async () => { await searchPromise; return { actors: [{ did: "stale" }] }; }, }; const requests = makeRequests(mockApi, dataStore);
const inFlight = requests.loadSearchTypeahead("query"); await requests.loadSearchTypeahead(""); resolveSearch(); await inFlight;
assert.deepEqual(dataStore.$searchTypeaheadResults.get(), null); });});
describe("loadSidebarSearchTypeahead", () => { it("should store the search results and hydrate profiles", async () => { const dataStore = new DataStore(createSessionState(null)); const mockApi = { searchProfilesTypeahead: async () => ({ actors: [{ did: "did:plc:a", handle: "a.test" }], }), }; const requests = makeRequests(mockApi, dataStore);
await requests.loadSidebarSearchTypeahead("alice");
const stored = dataStore.$sidebarSearchTypeaheadResults.get(); assert.deepEqual(stored.actors.length, 1); assert.deepEqual(stored.actors[0].did, "did:plc:a"); assert.deepEqual(dataStore.$profiles.get("did:plc:a"), { did: "did:plc:a", handle: "a.test", }); });
it("should clear results when query is empty", async () => { const dataStore = new DataStore(createSessionState(null)); dataStore.$sidebarSearchTypeaheadResults.set({ actors: [{ did: "x" }] }); const mockApi = { searchProfilesTypeahead: async () => ({ actors: [] }), }; const requests = makeRequests(mockApi, dataStore);
await requests.loadSidebarSearchTypeahead("");
assert.deepEqual(dataStore.$sidebarSearchTypeaheadResults.get(), null); });
it("should discard in-flight responses after the query is cleared", async () => { const dataStore = new DataStore(createSessionState(null)); let resolveSearch; const searchPromise = new Promise((resolve) => { resolveSearch = resolve; }); const mockApi = { searchProfilesTypeahead: async () => { await searchPromise; return { actors: [{ did: "stale" }] }; }, }; const requests = makeRequests(mockApi, dataStore);
const inFlight = requests.loadSidebarSearchTypeahead("query"); await requests.loadSidebarSearchTypeahead(""); resolveSearch(); await inFlight;
assert.deepEqual(dataStore.$sidebarSearchTypeaheadResults.get(), null); });
it("should not disturb the search view's typeahead results", async () => { const dataStore = new DataStore(createSessionState(null)); const mockApi = { searchProfilesTypeahead: async () => ({ actors: [{ did: "did:plc:sidebar" }], }), }; const requests = makeRequests(mockApi, dataStore); await requests.loadSearchTypeahead("alice");
await requests.loadSidebarSearchTypeahead("bob"); await requests.loadSidebarSearchTypeahead("");
assert.deepEqual(dataStore.$sidebarSearchTypeaheadResults.get(), null); assert.deepEqual( dataStore.$searchTypeaheadResults.get().actors[0].did, "did:plc:sidebar", ); });});
describe("loadFeedSearch", () => { it("should clear results when query is empty", async () => { const dataStore = new DataStore(createSessionState(null)); dataStore.$feedSearchResults.set({ feeds: [{ uri: "f1" }], cursor: "c" }); const mockApi = { searchFeedGenerators: async () => ({ feeds: [], cursor: null }), }; const requests = makeRequests(mockApi, dataStore);
await requests.loadFeedSearch("");
assert.deepEqual(dataStore.$feedSearchResults.get(), null); });
it("should store feeds and cache feed generators", async () => { const dataStore = new DataStore(createSessionState(null)); const mockApi = { searchFeedGenerators: async () => ({ feeds: [{ uri: "f1", displayName: "Feed One" }], cursor: "next", }), }; const requests = makeRequests(mockApi, dataStore);
await requests.loadFeedSearch("news");
const stored = dataStore.$feedSearchResults.get(); assert.deepEqual(stored.feeds.length, 1); assert.deepEqual( dataStore.$feedGenerators.get("f1").displayName, "Feed One", ); });
it("should discard stale responses", async () => { const dataStore = new DataStore(createSessionState(null)); let resolveFirst; const firstPromise = new Promise((resolve) => { resolveFirst = resolve; }); let callIndex = 0; const mockApi = { searchFeedGenerators: async () => { callIndex += 1; if (callIndex === 1) { await firstPromise; return { feeds: [{ uri: "stale" }], cursor: "stale" }; } return { feeds: [{ uri: "fresh" }], cursor: "fresh" }; }, }; const requests = makeRequests(mockApi, dataStore);
const firstCall = requests.loadFeedSearch("query"); await new Promise((resolve) => setTimeout(resolve, 5)); await requests.loadFeedSearch("query"); resolveFirst(); await firstCall;
const stored = dataStore.$feedSearchResults.get(); assert.deepEqual(stored.feeds[0].uri, "fresh"); });
it("should append when cursor is provided", async () => { const dataStore = new DataStore(createSessionState(null)); dataStore.$feedSearchResults.set({ feeds: [{ uri: "f1" }], cursor: "c1", }); const mockApi = { searchFeedGenerators: async () => ({ feeds: [{ uri: "f2" }], cursor: "c2", }), }; const requests = makeRequests(mockApi, dataStore);
await requests.loadFeedSearch("query", { cursor: "c1" });
const stored = dataStore.$feedSearchResults.get(); assert.deepEqual(stored.feeds.length, 2); assert.deepEqual(stored.cursor, "c2"); });});
describe("loadNotifications", () => { it("should set notifications and cursor on first load", async () => { const dataStore = new DataStore(createSessionState(null)); const mockApi = { getNotifications: async () => ({ notifications: [ { reason: "like", uri: "n1", author: { did: "did:plc:liker", handle: "liker.test" }, }, ], cursor: "next", }), getPosts: async () => [], }; const requests = makeRequests(mockApi, dataStore);
await requests.loadNotifications();
assert.deepEqual(dataStore.$notifications.get().notifications.length, 1); assert.deepEqual(dataStore.$notifications.get().cursor, "next"); assert.deepEqual(dataStore.$profiles.get("did:plc:liker"), { did: "did:plc:liker", handle: "liker.test", }); });
it("should append when cursor matches previous", async () => { const dataStore = new DataStore(createSessionState(null)); dataStore.$notifications.set({ notifications: [{ reason: "like", uri: "n1" }], cursor: "page2", });
let capturedCursor; const mockApi = { getNotifications: async ({ cursor }) => { capturedCursor = cursor; return { notifications: [ { reason: "follow", uri: "n2", author: { did: "did:plc:f" } }, ], cursor: "page3", }; }, getPosts: async () => [], }; const requests = makeRequests(mockApi, dataStore);
await requests.loadNotifications();
assert.deepEqual(capturedCursor, "page2"); assert.deepEqual(dataStore.$notifications.get().notifications.length, 2); assert.deepEqual(dataStore.$notifications.get().cursor, "page3"); });
it("should reset on reload", async () => { const dataStore = new DataStore(createSessionState(null)); dataStore.$notifications.set({ notifications: [{ reason: "like", uri: "n1" }], cursor: "page2", });
let capturedCursor; const mockApi = { getNotifications: async ({ cursor }) => { capturedCursor = cursor; return { notifications: [ { reason: "follow", uri: "n2", author: { did: "did:plc:f" } }, ], cursor: "fresh", }; }, getPosts: async () => [], }; const requests = makeRequests(mockApi, dataStore);
await requests.loadNotifications({ reload: true });
assert.deepEqual(capturedCursor, ""); const stored = dataStore.$notifications.get(); assert.deepEqual(stored.notifications.length, 1); assert.deepEqual(stored.notifications[0].uri, "n2"); assert.deepEqual(stored.cursor, "fresh"); });
it("should capture seenAt on first load", async () => { const dataStore = new DataStore(createSessionState(null)); const mockApi = { getNotifications: async () => ({ notifications: [ { reason: "like", uri: "n1", author: { did: "did:plc:liker", handle: "liker.test" }, }, ], cursor: "next", seenAt: "2025-01-15T10:00:00.000Z", }), getPosts: async () => [], }; const requests = makeRequests(mockApi, dataStore);
await requests.loadNotifications();
assert.deepEqual( dataStore.$notificationsLastSeenAt.get(), "2025-01-15T10:00:00.000Z", ); });
it("should overwrite the captured seenAt on reload", async () => { const dataStore = new DataStore(createSessionState(null)); dataStore.$notifications.set({ notifications: [{ reason: "like", uri: "n1" }], cursor: "page2", }); dataStore.$notificationsLastSeenAt.set("2025-01-14T10:00:00.000Z");
const mockApi = { getNotifications: async () => ({ notifications: [ { reason: "follow", uri: "n2", author: { did: "did:plc:f" } }, ], cursor: "fresh", seenAt: "2025-01-15T10:00:00.000Z", }), getPosts: async () => [], }; const requests = makeRequests(mockApi, dataStore);
await requests.loadNotifications({ reload: true });
assert.deepEqual( dataStore.$notificationsLastSeenAt.get(), "2025-01-15T10:00:00.000Z", ); });
it("should not capture seenAt on subsequent pages", async () => { const dataStore = new DataStore(createSessionState(null)); dataStore.$notifications.set({ notifications: [{ reason: "like", uri: "n1" }], cursor: "page2", }); dataStore.$notificationsLastSeenAt.set("2025-01-14T10:00:00.000Z");
const mockApi = { getNotifications: async () => ({ notifications: [ { reason: "follow", uri: "n2", author: { did: "did:plc:f" } }, ], cursor: "page3", seenAt: "2025-01-15T10:00:00.000Z", }), getPosts: async () => [], }; const requests = makeRequests(mockApi, dataStore);
await requests.loadNotifications();
assert.deepEqual( dataStore.$notificationsLastSeenAt.get(), "2025-01-14T10:00:00.000Z", ); });
it("should set seenAt to null when the response omits it", async () => { const dataStore = new DataStore(createSessionState(null)); dataStore.$notificationsLastSeenAt.set("2025-01-14T10:00:00.000Z"); const mockApi = { getNotifications: async () => ({ notifications: [ { reason: "like", uri: "n1", author: { did: "did:plc:liker", handle: "liker.test" }, }, ], cursor: "next", }), getPosts: async () => [], }; const requests = makeRequests(mockApi, dataStore);
await requests.loadNotifications();
assert.deepEqual(dataStore.$notificationsLastSeenAt.get(), null); });
it("should discard a stale response when a reload lands mid-flight", async () => { const dataStore = new DataStore(createSessionState(null)); dataStore.$notifications.set({ notifications: [{ uri: "n1", reason: "follow" }], cursor: "c1", });
const reloadedNotifications = { notifications: [{ uri: "n9", reason: "follow" }], cursor: "c9", }; const mockApi = { getNotifications: async () => { // Simulate a reload finishing while this page request is in flight dataStore.$notifications.set(reloadedNotifications); return { notifications: [ { uri: "n2", reason: "follow", author: { did: "did:plc:f" } }, ], cursor: "c2", }; }, }; const requests = makeRequests(mockApi, dataStore);
await requests.loadNotifications();
assert.deepEqual(dataStore.$notifications.get(), reloadedNotifications); });
it("should discard a stale page when the list reaches its end mid-flight", async () => { const dataStore = new DataStore(createSessionState(null)); dataStore.$notifications.set({ notifications: [{ uri: "n1", reason: "follow" }], cursor: "c1", });
const fullyLoadedNotifications = { notifications: [ { uri: "n1", reason: "follow" }, { uri: "n2", reason: "follow" }, ], cursor: null, }; const mockApi = { getNotifications: async () => { // Simulate a duplicate page request landing first and exhausting the list dataStore.$notifications.set(fullyLoadedNotifications); return { notifications: [ { uri: "n2", reason: "follow", author: { did: "did:plc:f" } }, ], cursor: null, }; }, }; const requests = makeRequests(mockApi, dataStore);
await requests.loadNotifications();
assert.deepEqual(dataStore.$notifications.get(), fullyLoadedNotifications); });});
describe("loadMentionNotifications", () => { it("should request only mention reasons and store results", async () => { const dataStore = new DataStore(createSessionState(null)); let capturedReasons; const mockApi = { getNotifications: async ({ reasons }) => { capturedReasons = reasons; return { notifications: [ { reason: "mention", uri: "n1", author: { did: "did:plc:m" } }, ], cursor: "next", }; }, getPosts: async () => [], }; const requests = makeRequests(mockApi, dataStore);
await requests.loadMentionNotifications();
assert.deepEqual(capturedReasons, ["mention", "reply", "quote"]); assert.deepEqual( dataStore.$mentionNotifications.get().notifications.length, 1, ); assert.deepEqual(dataStore.$mentionNotifications.get().cursor, "next"); });
it("should not capture seenAt", async () => { const dataStore = new DataStore(createSessionState(null)); const mockApi = { getNotifications: async () => ({ notifications: [ { reason: "mention", uri: "n1", author: { did: "did:plc:m" } }, ], cursor: "next", seenAt: "2025-01-15T10:00:00.000Z", }), getPosts: async () => [], }; const requests = makeRequests(mockApi, dataStore);
await requests.loadMentionNotifications();
assert.deepEqual(dataStore.$notificationsLastSeenAt.get(), null); });
it("should append when cursor matches previous", async () => { const dataStore = new DataStore(createSessionState(null)); dataStore.$mentionNotifications.set({ notifications: [{ reason: "mention", uri: "n1" }], cursor: "page2", });
const mockApi = { getNotifications: async () => ({ notifications: [ { reason: "reply", uri: "n2", author: { did: "did:plc:r" } }, ], cursor: "page3", }), getPosts: async () => [], }; const requests = makeRequests(mockApi, dataStore);
await requests.loadMentionNotifications();
assert.deepEqual( dataStore.$mentionNotifications.get().notifications.length, 2, ); assert.deepEqual(dataStore.$mentionNotifications.get().cursor, "page3"); });
it("should reset on reload", async () => { const dataStore = new DataStore(createSessionState(null)); dataStore.$mentionNotifications.set({ notifications: [{ reason: "mention", uri: "n1" }], cursor: "page2", });
const mockApi = { getNotifications: async () => ({ notifications: [ { reason: "quote", uri: "n2", author: { did: "did:plc:q" } }, ], cursor: "fresh", }), getPosts: async () => [], }; const requests = makeRequests(mockApi, dataStore);
await requests.loadMentionNotifications({ reload: true });
const stored = dataStore.$mentionNotifications.get(); assert.deepEqual(stored.notifications.length, 1); assert.deepEqual(stored.notifications[0].uri, "n2"); });});
describe("loadBookmarks", () => { it("should set bookmarks on first load", async () => { const dataStore = new DataStore(createSessionState(null)); const mockApi = { getBookmarks: async () => ({ bookmarks: [{ item: { uri: "post1", record: {} } }], cursor: "next", }), getPosts: async () => [], }; const requests = makeRequests(mockApi, dataStore);
await requests.loadBookmarks();
const stored = dataStore.$bookmarks.get(); assert.deepEqual(stored.bookmarks.length, 1); assert.deepEqual(stored.bookmarks[0].item.uri, "post1"); assert.deepEqual(stored.cursor, "next"); });
it("should append on subsequent loads", async () => { const dataStore = new DataStore(createSessionState(null)); dataStore.$bookmarks.set({ bookmarks: [{ item: { uri: "post1" } }], cursor: "c1", }); const mockApi = { getBookmarks: async () => ({ bookmarks: [{ item: { uri: "post2", record: {} } }], cursor: "c2", }), getPosts: async () => [], }; const requests = makeRequests(mockApi, dataStore);
await requests.loadBookmarks();
const stored = dataStore.$bookmarks.get(); assert.deepEqual(stored.bookmarks.length, 2); assert.deepEqual(stored.bookmarks[1].item.uri, "post2"); assert.deepEqual(stored.cursor, "c2"); });
it("should reset on reload", async () => { const dataStore = new DataStore(createSessionState(null)); dataStore.$bookmarks.set({ bookmarks: [{ item: { uri: "post1" } }], cursor: "c1", });
let capturedCursor; const mockApi = { getBookmarks: async ({ cursor }) => { capturedCursor = cursor; return { bookmarks: [{ item: { uri: "post2", record: {} } }], cursor: "fresh", }; }, getPosts: async () => [], }; const requests = makeRequests(mockApi, dataStore);
await requests.loadBookmarks({ reload: true });
assert.deepEqual(capturedCursor, ""); const stored = dataStore.$bookmarks.get(); assert.deepEqual(stored.bookmarks.length, 1); assert.deepEqual(stored.bookmarks[0].item.uri, "post2"); });});
describe("loadProfileFollowers", () => { const profileDid = "did:plc:profile";
it("should set followers on first load", async () => { const dataStore = new DataStore(createSessionState(null)); const res = { followers: [{ did: "did:plc:a" }], cursor: "next", }; const mockApi = { getFollowers: async () => res }; const requests = makeRequests(mockApi, dataStore);
await requests.loadProfileFollowers(profileDid);
assert.deepEqual(dataStore.$profileFollowers.get(profileDid), res); });
it("should append followers when cursor is provided", async () => { const dataStore = new DataStore(createSessionState(null)); dataStore.$profileFollowers.set(profileDid, { followers: [{ did: "did:plc:a" }], cursor: "c1", }); const mockApi = { getFollowers: async () => ({ followers: [{ did: "did:plc:b" }], cursor: "c2", }), }; const requests = makeRequests(mockApi, dataStore);
await requests.loadProfileFollowers(profileDid, { cursor: "c1" });
const stored = dataStore.$profileFollowers.get(profileDid); assert.deepEqual(stored.followers.length, 2); assert.deepEqual(stored.cursor, "c2"); });});
describe("loadProfileFollows", () => { const profileDid = "did:plc:profile";
it("should set follows on first load", async () => { const dataStore = new DataStore(createSessionState(null)); const res = { follows: [{ did: "did:plc:a" }], cursor: "next" }; const mockApi = { getFollows: async () => res }; const requests = makeRequests(mockApi, dataStore);
await requests.loadProfileFollows(profileDid);
assert.deepEqual(dataStore.$profileFollows.get(profileDid), res); });
it("should append follows when cursor is provided", async () => { const dataStore = new DataStore(createSessionState(null)); dataStore.$profileFollows.set(profileDid, { follows: [{ did: "did:plc:a" }], cursor: "c1", }); const mockApi = { getFollows: async () => ({ follows: [{ did: "did:plc:b" }], cursor: "c2", }), }; const requests = makeRequests(mockApi, dataStore);
await requests.loadProfileFollows(profileDid, { cursor: "c1" });
const stored = dataStore.$profileFollows.get(profileDid); assert.deepEqual(stored.follows.length, 2); assert.deepEqual(stored.cursor, "c2"); });});
describe("loadConvoList", () => { it("should set convo list and cache individual convos on first load", async () => { const dataStore = new DataStore(createSessionState(null)); const mockApi = { listConvos: async () => ({ convos: [ { id: "c1", lastMessage: null }, { id: "c2", lastMessage: null }, ], cursor: "next", }), }; const requests = makeRequests(mockApi, dataStore);
await requests.loadConvoList();
assert.deepEqual(dataStore.$convoList.get().convos.length, 2); assert.deepEqual(dataStore.$convos.get("c1").id, "c1"); assert.deepEqual(dataStore.$convos.get("c2").id, "c2"); assert.deepEqual(dataStore.$convoList.get().cursor, "next"); });
it("should append when previous cursor matches", async () => { const dataStore = new DataStore(createSessionState(null)); dataStore.$convoList.set({ convos: [{ id: "c1" }], cursor: "page2" });
const mockApi = { listConvos: async () => ({ convos: [{ id: "c2" }], cursor: "page3", }), }; const requests = makeRequests(mockApi, dataStore);
await requests.loadConvoList();
assert.deepEqual(dataStore.$convoList.get().convos.length, 2); assert.deepEqual(dataStore.$convoList.get().cursor, "page3"); });
it("should drop convos already in the list when appending a page", async () => { const dataStore = new DataStore(createSessionState(null)); dataStore.$convoList.set({ convos: [{ id: "c2", unreadCount: 1 }, { id: "c1" }], cursor: "page2", });
const mockApi = { listConvos: async () => ({ convos: [{ id: "c2", unreadCount: 0 }, { id: "c3" }], cursor: "page3", }), }; const requests = makeRequests(mockApi, dataStore);
await requests.loadConvoList();
const list = dataStore.$convoList.get(); assert.deepEqual( list.convos.map((listConvo) => listConvo.id), ["c2", "c1", "c3"], ); assert.deepEqual(list.convos[0].unreadCount, 1); });
it("should reset cursor and replace on reload", async () => { const dataStore = new DataStore(createSessionState(null)); dataStore.$convoList.set({ convos: [{ id: "c1" }], cursor: "page2" });
let capturedCursor; const mockApi = { listConvos: async ({ cursor }) => { capturedCursor = cursor; return { convos: [{ id: "c2" }], cursor: "fresh" }; }, }; const requests = makeRequests(mockApi, dataStore);
await requests.loadConvoList({ reload: true });
assert.deepEqual(capturedCursor, ""); const stored = dataStore.$convoList.get(); assert.deepEqual(stored.convos.length, 1); assert.deepEqual(stored.convos[0].id, "c2"); });});
describe("loadConvoRequestList", () => { it("should request only request convos and cache them on first load", async () => { const dataStore = new DataStore(createSessionState(null)); let capturedStatus; const mockApi = { listConvos: async ({ status }) => { capturedStatus = status; return { convos: [ { id: "r1", status: "request", lastMessage: null }, { id: "r2", status: "request", lastMessage: null }, ], cursor: "next", }; }, }; const requests = makeRequests(mockApi, dataStore);
await requests.loadConvoRequestList();
assert.deepEqual(capturedStatus, "request"); assert.deepEqual(dataStore.$convoRequestList.get().convos.length, 2); assert.deepEqual(dataStore.$convos.get("r1").id, "r1"); assert.deepEqual(dataStore.$convos.get("r2").id, "r2"); assert.deepEqual(dataStore.$convoRequestList.get().cursor, "next"); });
it("should append when previous cursor matches", async () => { const dataStore = new DataStore(createSessionState(null)); dataStore.$convoRequestList.set({ convos: [{ id: "r1" }], cursor: "page2", });
const mockApi = { listConvos: async () => ({ convos: [{ id: "r2" }], cursor: "page3", }), }; const requests = makeRequests(mockApi, dataStore);
await requests.loadConvoRequestList();
assert.deepEqual(dataStore.$convoRequestList.get().convos.length, 2); assert.deepEqual(dataStore.$convoRequestList.get().cursor, "page3"); });
it("should drop convos already in the list when appending a page", async () => { const dataStore = new DataStore(createSessionState(null)); dataStore.$convoRequestList.set({ convos: [{ id: "r2" }, { id: "r1" }], cursor: "page2", });
const mockApi = { listConvos: async () => ({ convos: [{ id: "r2" }, { id: "r3" }], cursor: "page3", }), }; const requests = makeRequests(mockApi, dataStore);
await requests.loadConvoRequestList();
assert.deepEqual( dataStore.$convoRequestList.get().convos.map((listConvo) => listConvo.id), ["r2", "r1", "r3"], ); });
it("should reset cursor and replace on reload", async () => { const dataStore = new DataStore(createSessionState(null)); dataStore.$convoRequestList.set({ convos: [{ id: "r1" }], cursor: "page2", });
let capturedCursor; const mockApi = { listConvos: async ({ cursor }) => { capturedCursor = cursor; return { convos: [{ id: "r2" }], cursor: "fresh" }; }, }; const requests = makeRequests(mockApi, dataStore);
await requests.loadConvoRequestList({ reload: true });
assert.deepEqual(capturedCursor, ""); const stored = dataStore.$convoRequestList.get(); assert.deepEqual(stored.convos.length, 1); assert.deepEqual(stored.convos[0].id, "r2"); });});
describe("loadConvo", () => { const convoId = "convo1";
it("should store the convo and track status under a namespaced key", async () => { const dataStore = new DataStore(createSessionState(null)); const mockApi = { getConvo: async () => ({ convo: { id: convoId } }), }; const requests = makeRequests(mockApi, dataStore);
await requests.loadConvo(convoId);
assert.deepEqual(dataStore.$convos.get(convoId).id, convoId); const status = requests.getStatus("loadConvo-" + convoId); assert.deepEqual(status.loading, false); assert.deepEqual(status.error, null); });
it("should add the convo to the loaded convo list", async () => { const dataStore = new DataStore(createSessionState(null)); dataStore.$convoList.set({ convos: [{ id: "other" }], cursor: null }); const mockApi = { getConvo: async () => ({ convo: { id: convoId, status: "accepted" } }), }; const requests = makeRequests(mockApi, dataStore);
await requests.loadConvo(convoId);
assert.deepEqual( dataStore.$convoList.get().convos.map((listConvo) => listConvo.id), [convoId, "other"], ); });
it("should record an ApiError under the namespaced key without rethrowing", async () => { const apiError = new ApiError({ status: 400, statusText: "Bad Request", data: { error: "InvalidConvo" }, headers: {}, url: "/x", }); const dataStore = new DataStore(createSessionState(null)); const mockApi = { getConvo: async () => { throw apiError; }, }; const requests = makeRequests(mockApi, dataStore);
await requests.loadConvo(convoId);
const status = requests.getStatus("loadConvo-" + convoId); assert.deepEqual(status.loading, false); assert( status.error === apiError, "expected status.error to be the ApiError", ); assert.deepEqual(dataStore.$convos.get(convoId), null); });});
describe("loadConvoForProfile", () => { it("should store the convo and add it to the loaded convo list", async () => { const dataStore = new DataStore(createSessionState(null)); dataStore.$convoList.set({ convos: [{ id: "other" }], cursor: null }); const mockApi = { getConvoForMembers: async () => ({ convo: { id: "c-new", status: "accepted" }, }), }; const requests = makeRequests(mockApi, dataStore);
await requests.loadConvoForProfile("did:plc:alice");
assert.deepEqual(dataStore.$convos.get("c-new").id, "c-new"); assert.deepEqual( dataStore.$convoList.get().convos.map((listConvo) => listConvo.id), ["c-new", "other"], ); });});
describe("loadConvoMembers", () => { const convoId = "convo1";
it("should store the first page with its cursor", async () => { const dataStore = new DataStore(createSessionState(null)); const mockApi = { getConvoMembers: async () => ({ members: [{ did: "did:plc:alice" }, { did: "did:plc:bob" }], cursor: "2", }), }; const requests = makeRequests(mockApi, dataStore);
await requests.loadConvoMembers(convoId);
const stored = dataStore.$convoMemberLists.get(convoId); assert.deepEqual( stored.members.map((member) => member.did), ["did:plc:alice", "did:plc:bob"], ); assert.deepEqual(stored.cursor, "2"); });
it("should append the next page using the stored cursor", async () => { const dataStore = new DataStore(createSessionState(null)); const capturedCursors = []; const pages = [ { members: [{ did: "did:plc:alice" }], cursor: "1" }, { members: [{ did: "did:plc:bob" }] }, ]; const mockApi = { getConvoMembers: async (id, { cursor }) => { capturedCursors.push(cursor); return pages.shift(); }, }; const requests = makeRequests(mockApi, dataStore);
await requests.loadConvoMembers(convoId); await requests.loadConvoMembers(convoId);
assert.deepEqual(capturedCursors, ["", "1"]); const stored = dataStore.$convoMemberLists.get(convoId); assert.deepEqual( stored.members.map((member) => member.did), ["did:plc:alice", "did:plc:bob"], ); assert.deepEqual(stored.cursor, null); });
it("should overwrite the stored list on reload", async () => { const dataStore = new DataStore(createSessionState(null)); dataStore.$convoMemberLists.set(convoId, { members: [{ did: "did:plc:stale" }], cursor: "5", }); const capturedCursors = []; const mockApi = { getConvoMembers: async (id, { cursor }) => { capturedCursors.push(cursor); return { members: [{ did: "did:plc:alice" }] }; }, }; const requests = makeRequests(mockApi, dataStore);
await requests.loadConvoMembers(convoId, { reload: true });
assert.deepEqual(capturedCursors, [""]); const stored = dataStore.$convoMemberLists.get(convoId); assert.deepEqual( stored.members.map((member) => member.did), ["did:plc:alice"], ); });
it("should record an ApiError under the namespaced key without rethrowing", async () => { const apiError = new ApiError({ status: 400, statusText: "Bad Request", data: { error: "InvalidConvo" }, headers: {}, url: "/x", }); const dataStore = new DataStore(createSessionState(null)); const mockApi = { getConvoMembers: async () => { throw apiError; }, }; const requests = makeRequests(mockApi, dataStore);
await requests.loadConvoMembers(convoId);
const status = requests.getStatus("loadConvoMembers-" + convoId); assert.deepEqual(status.loading, false); assert( status.error === apiError, "expected status.error to be the ApiError", ); assert.deepEqual(dataStore.$convoMemberLists.get(convoId), null); });});
describe("loadConvoMessages", () => { const convoId = "convo1";
it("should set messages on first load", async () => { const dataStore = new DataStore(createSessionState(null)); const mockApi = { getMessages: async () => ({ messages: [{ id: "m1" }, { id: "m2" }], cursor: null, }), }; const requests = makeRequests(mockApi, dataStore);
await requests.loadConvoMessages(convoId);
const stored = dataStore.$convoMessages.get(convoId); assert.deepEqual(stored.messages.length, 2); assert.deepEqual(dataStore.$messages.get("m1").id, "m1"); });
it("should append messages when prior cursor exists", async () => { const dataStore = new DataStore(createSessionState(null)); dataStore.$convoMessages.set(convoId, { messages: [{ id: "m1" }], cursor: "page2", });
let calls = 0; const mockApi = { getMessages: async () => { calls += 1; if (calls === 1) { return { messages: [{ id: "m2" }], cursor: null }; } return { messages: [], cursor: null }; }, }; const requests = makeRequests(mockApi, dataStore);
await requests.loadConvoMessages(convoId);
const stored = dataStore.$convoMessages.get(convoId); assert.deepEqual(stored.messages.length, 2); assert.deepEqual(stored.messages[0].id, "m1"); assert.deepEqual(stored.messages[1].id, "m2"); });
it("should reset on reload", async () => { const dataStore = new DataStore(createSessionState(null)); dataStore.$convoMessages.set(convoId, { messages: [{ id: "old" }], cursor: "page2", });
let capturedCursor; const mockApi = { getMessages: async (_id, { cursor }) => { capturedCursor = cursor; return { messages: [{ id: "fresh" }], cursor: null }; }, }; const requests = makeRequests(mockApi, dataStore);
await requests.loadConvoMessages(convoId, { reload: true });
assert.deepEqual(capturedCursor, ""); const stored = dataStore.$convoMessages.get(convoId); assert.deepEqual(stored.messages.length, 1); assert.deepEqual(stored.messages[0].id, "fresh"); });
it("should store related profiles", async () => { const dataStore = new DataStore(createSessionState(null)); const mockApi = { getMessages: async () => ({ messages: [{ id: "m1" }], cursor: null, relatedProfiles: [{ did: "did:plc:a", handle: "a.test" }], }), }; const requests = makeRequests(mockApi, dataStore);
await requests.loadConvoMessages(convoId);
assert.deepEqual(dataStore.$profiles.get("did:plc:a").handle, "a.test"); });});
describe("pollConvoMessages", () => { const convoId = "convo1"; const currentUserDid = "did:plc:me"; const otherDid = "did:plc:other";
const SYSTEM_MESSAGE_LOG_KINDS = [ "logAddMember", "logRemoveMember", "logMemberJoin", "logMemberLeave", "logLockConvo", "logUnlockConvo", "logLockConvoPermanently", "logEditGroup", "logCreateJoinLink", "logEditJoinLink", "logEnableJoinLink", "logDisableJoinLink", ];
let dataStore;
beforeEach(() => { dataStore = new DataStore(createSessionState(null)); dataStore.$currentUser.set({ did: currentUserDid }); dataStore.$convos.set(convoId, { id: convoId, members: [{ did: currentUserDid }, { did: otherDid }], kind: { $type: "chat.bsky.convo.defs#groupConvo", name: "Test Group", memberCount: 3, memberLimit: 10, lockStatus: "unlocked", createdAt: "2026-06-01T00:00:00.000Z", }, }); dataStore.$convoMessages.set(convoId, { messages: [], cursor: null }); });
function makeMessageLog(messageId, senderDid) { return { $type: "chat.bsky.convo.defs#logCreateMessage", rev: "rev1", convoId, message: { $type: "chat.bsky.convo.defs#messageView", id: messageId, rev: "rev1", text: "hello", sender: { did: senderDid }, sentAt: "2026-06-11T00:00:00.000Z", }, }; }
function makeSystemLog(logKind, messageId, data = {}) { return { $type: `chat.bsky.convo.defs#${logKind}`, rev: "rev1", convoId, message: { $type: "chat.bsky.convo.defs#systemMessageView", id: messageId, rev: "rev1", sentAt: "2026-06-11T00:00:00.000Z", data, }, relatedProfiles: [], }; }
function makeRequestsWithLogs(logs, cursor = "next") { const mockApi = { getChatLogs: async () => ({ logs, cursor }) }; return makeRequests(mockApi, dataStore); }
it("should prepend messages from other senders and return the cursor", async () => { const requests = makeRequestsWithLogs([makeMessageLog("m1", otherDid)]);
const cursor = await requests.pollConvoMessages(convoId);
assert.deepEqual(cursor, "next"); assert.deepEqual( dataStore.$convoMessages.get(convoId).messages[0].id, "m1", ); assert.deepEqual(dataStore.$messages.get("m1").id, "m1"); });
it("should ingest the current user's own messages when not already stored", async () => { const requests = makeRequestsWithLogs([ makeMessageLog("m1", currentUserDid), ]);
await requests.pollConvoMessages(convoId);
const stored = dataStore.$convoMessages.get(convoId); assert.deepEqual(stored.messages.length, 1); assert.deepEqual(stored.messages[0].id, "m1"); assert.deepEqual(dataStore.$messages.get("m1").id, "m1"); });
it("should dedupe the current user's own messages already in the store", async () => { dataStore.$convoMessages.set(convoId, { messages: [{ id: "m1" }], cursor: null, }); const requests = makeRequestsWithLogs([ makeMessageLog("m1", currentUserDid), ]);
await requests.pollConvoMessages(convoId);
assert.deepEqual(dataStore.$convoMessages.get(convoId).messages.length, 1); });
it("should ingest every system-message log kind", async () => { const logs = SYSTEM_MESSAGE_LOG_KINDS.map((logKind, index) => makeSystemLog(logKind, `sys${index}`), ); const requests = makeRequestsWithLogs(logs);
await requests.pollConvoMessages(convoId);
const stored = dataStore.$convoMessages.get(convoId); assert.deepEqual(stored.messages.length, SYSTEM_MESSAGE_LOG_KINDS.length); assert.deepEqual(dataStore.$messages.get("sys0").id, "sys0"); });
it("should store related profiles from logs", async () => { const log = makeSystemLog("logAddMember", "sys1", { member: { did: "did:plc:new" }, addedBy: { did: otherDid }, }); log.relatedProfiles = [{ did: "did:plc:new", handle: "new.test" }]; const requests = makeRequestsWithLogs([log]);
await requests.pollConvoMessages(convoId);
assert.deepEqual(dataStore.$profiles.get("did:plc:new").handle, "new.test"); });
it("should not re-ingest an already-stored message", async () => { dataStore.$convoMessages.set(convoId, { messages: [{ id: "m1" }], cursor: null, }); const requests = makeRequestsWithLogs([makeMessageLog("m1", otherDid)]);
await requests.pollConvoMessages(convoId);
assert.deepEqual(dataStore.$convoMessages.get(convoId).messages.length, 1); });
it("should update lock status from lock log events", async () => { const requests = makeRequestsWithLogs([ makeSystemLog("logLockConvo", "sys1"), ]);
await requests.pollConvoMessages(convoId);
assert.deepEqual(dataStore.$convos.get(convoId).kind.lockStatus, "locked"); });
it("should update the group name from edit-group log events", async () => { const requests = makeRequestsWithLogs([ makeSystemLog("logEditGroup", "sys1", { oldName: "Test Group", newName: "Renamed Group", }), ]);
await requests.pollConvoMessages(convoId);
assert.deepEqual(dataStore.$convos.get(convoId).kind.name, "Renamed Group"); });
it("should increment member count when members are added", async () => { const requests = makeRequestsWithLogs([ makeSystemLog("logAddMember", "sys1", { member: { did: "did:plc:new" }, addedBy: { did: otherDid }, }), makeSystemLog("logMemberJoin", "sys2", { member: { did: "did:plc:new2" }, }), ]);
await requests.pollConvoMessages(convoId);
assert.deepEqual(dataStore.$convos.get(convoId).kind.memberCount, 5); });
it("should decrement member count when members are removed", async () => { const requests = makeRequestsWithLogs([ makeSystemLog("logMemberLeave", "sys1", { member: { did: otherDid }, }), ]);
await requests.pollConvoMessages(convoId);
assert.deepEqual(dataStore.$convos.get(convoId).kind.memberCount, 2); });
it("should remove a message when a logDeleteMessage event arrives", async () => { dataStore.$convoMessages.set(convoId, { messages: [{ id: "m1" }, { id: "m2" }], cursor: null, }); dataStore.$messages.set("m1", { id: "m1" }); const requests = makeRequestsWithLogs([ { $type: "chat.bsky.convo.defs#logDeleteMessage", rev: "rev1", convoId, message: { $type: "chat.bsky.convo.defs#deletedMessageView", id: "m1", rev: "rev1", sender: { did: otherDid }, sentAt: "2026-06-11T00:00:00.000Z", }, }, ]);
await requests.pollConvoMessages(convoId);
const stored = dataStore.$convoMessages.get(convoId); assert.deepEqual(stored.messages.length, 1); assert.deepEqual(stored.messages[0].id, "m2"); assert.deepEqual(dataStore.$messages.get("m1"), null); });
it("should ignore join-request log events", async () => { const requests = makeRequestsWithLogs([ { $type: "chat.bsky.convo.defs#logIncomingJoinRequest", rev: "rev1", convoId, requestedBy: { did: "did:plc:new" }, }, ]);
await requests.pollConvoMessages(convoId);
assert.deepEqual(dataStore.$convoMessages.get(convoId).messages.length, 0); });
it("should replace the stored message when an add-reaction log arrives", async () => { dataStore.$convoMessages.set(convoId, { messages: [{ id: "m1", text: "hi", reactions: [] }], cursor: "keep", }); const requests = makeRequestsWithLogs([ { $type: "chat.bsky.convo.defs#logAddReaction", rev: "rev1", convoId, message: { id: "m1", text: "hi", reactions: [{ value: "👍", sender: { did: otherDid } }], }, reaction: { value: "👍", sender: { did: otherDid } }, relatedProfiles: [{ did: otherDid, handle: "other.test" }], }, ]);
const cursor = await requests.pollConvoMessages(convoId);
assert.deepEqual(cursor, "next"); const stored = dataStore.$convoMessages.get(convoId); assert.deepEqual(stored.messages.length, 1); assert.deepEqual(stored.messages[0].reactions.length, 1); assert.deepEqual(stored.cursor, "keep"); assert.deepEqual(dataStore.$messages.get("m1").reactions.length, 1); assert.deepEqual(dataStore.$profiles.get(otherDid).handle, "other.test"); });
it("should update only the message store when a remove-reaction log arrives for an unloaded convo", async () => { dataStore.$convoMessages.delete(convoId); const requests = makeRequestsWithLogs([ { $type: "chat.bsky.convo.defs#logRemoveReaction", rev: "rev1", convoId, message: { id: "m1", text: "hi", reactions: [] }, reaction: { value: "👍", sender: { did: otherDid } }, }, ]);
const cursor = await requests.pollConvoMessages(convoId);
assert.deepEqual(cursor, "next"); assert.deepEqual(dataStore.$messages.get("m1").reactions, []); assert.deepEqual(dataStore.$convoMessages.get(convoId), null); });
it("should stop and return the cursor when no messages data exists for the convo", async () => { dataStore.$convoMessages.delete(convoId); const requests = makeRequestsWithLogs([makeMessageLog("m1", otherDid)]);
const cursor = await requests.pollConvoMessages(convoId);
assert.deepEqual(cursor, "next"); assert.deepEqual(dataStore.$messages.get("m1"), null); assert.deepEqual(dataStore.$convoMessages.get(convoId), null); });});
describe("loadPostLikes", () => { const postUri = "at://did/post/1";
it("should set likes on first load", async () => { const dataStore = new DataStore(createSessionState(null)); const res = { likes: [{ actor: { did: "did:plc:a" } }], cursor: "next" }; const mockApi = { getLikes: async () => res }; const requests = makeRequests(mockApi, dataStore);
await requests.loadPostLikes(postUri);
assert.deepEqual(dataStore.$postLikes.get(postUri), res); });
it("should append likes when cursor is provided", async () => { const dataStore = new DataStore(createSessionState(null)); dataStore.$postLikes.set(postUri, { likes: [{ actor: { did: "did:plc:a" } }], cursor: "c1", }); const mockApi = { getLikes: async () => ({ likes: [{ actor: { did: "did:plc:b" } }], cursor: "c2", }), }; const requests = makeRequests(mockApi, dataStore);
await requests.loadPostLikes(postUri, { cursor: "c1" });
const stored = dataStore.$postLikes.get(postUri); assert.deepEqual(stored.likes.length, 2); assert.deepEqual(stored.cursor, "c2"); });});
describe("loadPostQuotes", () => { const postUri = "at://did/post/1";
it("should set quotes on first load", async () => { const dataStore = new DataStore(createSessionState(null)); const mockApi = { getQuotes: async () => ({ posts: [{ uri: "q1", record: {} }], cursor: "next", }), getPosts: async () => [], }; const requests = makeRequests(mockApi, dataStore);
await requests.loadPostQuotes(postUri);
const stored = dataStore.$postQuotes.get(postUri); assert.deepEqual(stored.posts.length, 1); assert.deepEqual(stored.cursor, "next"); });
it("should append quotes when cursor is provided", async () => { const dataStore = new DataStore(createSessionState(null)); dataStore.$postQuotes.set(postUri, { posts: [{ uri: "q1", record: {} }], cursor: "c1", }); const mockApi = { getQuotes: async () => ({ posts: [{ uri: "q2", record: {} }], cursor: "c2", }), getPosts: async () => [], }; const requests = makeRequests(mockApi, dataStore);
await requests.loadPostQuotes(postUri, { cursor: "c1" });
const stored = dataStore.$postQuotes.get(postUri); assert.deepEqual(stored.posts.length, 2); assert.deepEqual(stored.cursor, "c2"); });});
describe("loadPostReposts", () => { const postUri = "at://did/post/1";
it("should set reposts on first load", async () => { const dataStore = new DataStore(createSessionState(null)); const mockApi = { getRepostedBy: async () => ({ repostedBy: [{ did: "did:plc:a" }], cursor: "next", }), }; const requests = makeRequests(mockApi, dataStore);
await requests.loadPostReposts(postUri);
const stored = dataStore.$postReposts.get(postUri); assert.deepEqual(stored.repostedBy.length, 1); assert.deepEqual(stored.cursor, "next"); });
it("should append reposts when cursor is provided", async () => { const dataStore = new DataStore(createSessionState(null)); dataStore.$postReposts.set(postUri, { repostedBy: [{ did: "did:plc:a" }], cursor: "c1", }); const mockApi = { getRepostedBy: async () => ({ repostedBy: [{ did: "did:plc:b" }], cursor: "c2", }), }; const requests = makeRequests(mockApi, dataStore);
await requests.loadPostReposts(postUri, { cursor: "c1" });
const stored = dataStore.$postReposts.get(postUri); assert.deepEqual(stored.repostedBy.length, 2); assert.deepEqual(stored.cursor, "c2"); });});
describe("loadActorFeeds", () => { const did = "did:plc:author";
it("should set actor feeds and cache feed generators on first load", async () => { const dataStore = new DataStore(createSessionState(null)); const mockApi = { getActorFeeds: async () => ({ feeds: [{ uri: "f1", displayName: "F1" }], cursor: "next", }), }; const requests = makeRequests(mockApi, dataStore);
await requests.loadActorFeeds(did);
const stored = dataStore.$actorFeeds.get(did); assert.deepEqual(stored.feeds.length, 1); assert.deepEqual(stored.cursor, "next"); assert.deepEqual(dataStore.$feedGenerators.get("f1").displayName, "F1"); });
it("should append on subsequent calls when cursor remains", async () => { const dataStore = new DataStore(createSessionState(null)); dataStore.$actorFeeds.set(did, { feeds: [{ uri: "f1" }], cursor: "c1", }); const mockApi = { getActorFeeds: async () => ({ feeds: [{ uri: "f2" }], cursor: null, }), }; const requests = makeRequests(mockApi, dataStore);
await requests.loadActorFeeds(did);
const stored = dataStore.$actorFeeds.get(did); assert.deepEqual(stored.feeds.length, 2); assert.deepEqual(stored.cursor, null); });
it("should short-circuit when there is no remaining cursor", async () => { const dataStore = new DataStore(createSessionState(null)); dataStore.$actorFeeds.set(did, { feeds: [{ uri: "f1" }], cursor: null, }); let called = false; const mockApi = { getActorFeeds: async () => { called = true; return { feeds: [], cursor: null }; }, }; const requests = makeRequests(mockApi, dataStore);
await requests.loadActorFeeds(did);
assert.deepEqual(called, false); });
it("should reset on reload", async () => { const dataStore = new DataStore(createSessionState(null)); dataStore.$actorFeeds.set(did, { feeds: [{ uri: "f1" }], cursor: null, });
let capturedCursor; const mockApi = { getActorFeeds: async (_did, { cursor }) => { capturedCursor = cursor; return { feeds: [{ uri: "f2" }], cursor: "next" }; }, }; const requests = makeRequests(mockApi, dataStore);
await requests.loadActorFeeds(did, { reload: true });
assert.deepEqual(capturedCursor, ""); const stored = dataStore.$actorFeeds.get(did); assert.deepEqual(stored.feeds.length, 1); assert.deepEqual(stored.feeds[0].uri, "f2"); });});
describe("loadListsWithMembershipForActor", () => { const actorDid = "did:plc:target"; const list1 = { uri: "at://owner/app.bsky.graph.list/1", name: "L1" }; const list2 = { uri: "at://owner/app.bsky.graph.list/2", name: "L2" };
it("should store the first page keyed by actor", async () => { const dataStore = new DataStore(createSessionState(null)); const mockApi = { getListsWithMembership: async () => ({ listsWithMembership: [ { list: list1, listItem: { uri: "li1", subject: actorDid } }, { list: list2 }, ], cursor: "next", }), }; const requests = makeRequests(mockApi, dataStore);
await requests.loadListsWithMembershipForActor(actorDid);
const stored = dataStore.$listsWithMembershipByActor.get(actorDid); assert.deepEqual(stored.listsWithMembership.length, 2); assert.deepEqual(stored.cursor, "next"); assert.deepEqual(stored.listsWithMembership[0].listItem.uri, "li1"); });
it("should append the next page when called again with a cached cursor", async () => { const dataStore = new DataStore(createSessionState(null)); dataStore.$listsWithMembershipByActor.set(actorDid, { listsWithMembership: [{ list: list1 }], cursor: "c1", }); let capturedCursor; const mockApi = { getListsWithMembership: async (_actor, { cursor }) => { capturedCursor = cursor; return { listsWithMembership: [{ list: list2 }], cursor: null, }; }, }; const requests = makeRequests(mockApi, dataStore);
await requests.loadListsWithMembershipForActor(actorDid);
assert.deepEqual(capturedCursor, "c1"); const stored = dataStore.$listsWithMembershipByActor.get(actorDid); assert.deepEqual(stored.listsWithMembership.length, 2); assert.deepEqual(stored.cursor, null); });
it("should short-circuit when fully loaded", async () => { const dataStore = new DataStore(createSessionState(null)); dataStore.$listsWithMembershipByActor.set(actorDid, { listsWithMembership: [{ list: list1 }], cursor: null, }); let called = false; const mockApi = { getListsWithMembership: async () => { called = true; return { listsWithMembership: [], cursor: null }; }, }; const requests = makeRequests(mockApi, dataStore);
await requests.loadListsWithMembershipForActor(actorDid);
assert.deepEqual(called, false); });
it("should refetch from scratch on reload", async () => { const dataStore = new DataStore(createSessionState(null)); dataStore.$listsWithMembershipByActor.set(actorDid, { listsWithMembership: [{ list: list1 }], cursor: "c1", }); let capturedCursor; const mockApi = { getListsWithMembership: async (_actor, { cursor }) => { capturedCursor = cursor; return { listsWithMembership: [{ list: list2 }], cursor: "next", }; }, }; const requests = makeRequests(mockApi, dataStore);
await requests.loadListsWithMembershipForActor(actorDid, { reload: true });
assert.deepEqual(capturedCursor, ""); const stored = dataStore.$listsWithMembershipByActor.get(actorDid); assert.deepEqual(stored.listsWithMembership.length, 1); assert.deepEqual(stored.listsWithMembership[0].list.uri, list2.uri); });});
describe("loadHashtagFeed", () => { it("should store hashtag feed posts on first load", async () => { const dataStore = new DataStore(createSessionState(null)); const mockApi = { searchPosts: async () => ({ posts: [{ uri: "p1", record: {} }], cursor: "next", }), getPosts: async () => [], }; const requests = makeRequests(mockApi, dataStore);
await requests.loadHashtagFeed("foo", "top");
const stored = dataStore.$hashtagFeeds.get("foo-top"); assert.deepEqual(stored.posts.length, 1); assert.deepEqual(stored.posts[0].uri, "p1"); assert.deepEqual(stored.cursor, "next"); });
it("should append on subsequent loads", async () => { const dataStore = new DataStore(createSessionState(null)); dataStore.$hashtagFeeds.set("foo-top", { posts: [{ uri: "p1" }], cursor: "c1", }); const mockApi = { searchPosts: async () => ({ posts: [{ uri: "p2", record: {} }], cursor: "c2", }), getPosts: async () => [], }; const requests = makeRequests(mockApi, dataStore);
await requests.loadHashtagFeed("foo", "top");
const stored = dataStore.$hashtagFeeds.get("foo-top"); assert.deepEqual(stored.posts.length, 2); assert.deepEqual(stored.posts[1].uri, "p2"); });
it("should store an empty page when the response has no posts array", async () => { const dataStore = new DataStore(createSessionState(null)); const mockApi = { searchPosts: async () => ({ cursor: null }), getPosts: async () => [], }; const requests = makeRequests(mockApi, dataStore);
await requests.loadHashtagFeed("foo", "top");
const stored = dataStore.$hashtagFeeds.get("foo-top"); assert.deepEqual(stored.posts, []); assert.deepEqual(stored.cursor, null); });
it("should reset on reload", async () => { const dataStore = new DataStore(createSessionState(null)); dataStore.$hashtagFeeds.set("foo-top", { posts: [{ uri: "p1" }], cursor: "c1", });
let capturedCursor; const mockApi = { searchPosts: async (_query, { cursor }) => { capturedCursor = cursor; return { posts: [{ uri: "p2", record: {} }], cursor: "fresh" }; }, getPosts: async () => [], }; const requests = makeRequests(mockApi, dataStore);
await requests.loadHashtagFeed("foo", "top", { reload: true });
assert.deepEqual(capturedCursor, ""); const stored = dataStore.$hashtagFeeds.get("foo-top"); assert.deepEqual(stored.posts.length, 1); assert.deepEqual(stored.posts[0].uri, "p2"); });});
describe("loadPinnedItems", () => { it("should fan out to getFeedGenerators and getList for pinned items and cache results", async () => { const preferences = { getPinnedFeeds: () => [ { type: "feed", value: "at://did/feed/one" }, { type: "feed", value: "at://did/feed/two" }, { type: "list", value: "at://did/list/one" }, { type: "timeline", value: "following" }, ], };
let capturedFeedUris; const capturedListUris = []; const mockApi = { getFeedGenerators: async (uris) => { capturedFeedUris = uris; return uris.map((uri) => ({ uri, displayName: `name-${uri}` })); }, getList: async (uri) => { capturedListUris.push(uri); return { list: { uri, name: `list-${uri}` }, items: [], cursor: "" }; }, }; const dataStore = new DataStore(createSessionState(null)); const provider = { requirePreferences: () => preferences }; const requests = createRequests(mockApi, dataStore, provider);
await requests.loadPinnedItems();
assert.deepEqual(capturedFeedUris, [ "at://did/feed/one", "at://did/feed/two", ]); assert.deepEqual(capturedListUris, ["at://did/list/one"]); const pinned = dataStore.$pinnedItems.get(); assert.deepEqual(pinned.length, 4); assert.deepEqual(pinned[0].type, "feed"); assert.deepEqual(pinned[2].type, "list"); assert.deepEqual(pinned[3].type, "timeline"); assert.deepEqual( dataStore.$feedGenerators.get("at://did/feed/one").displayName, "name-at://did/feed/one", ); });
it("should skip the api call when no pinned feeds or lists", async () => { const preferences = { getPinnedFeeds: () => [{ type: "timeline", value: "following" }], }; let feedsCalled = false; let listCalled = false; const mockApi = { getFeedGenerators: async () => { feedsCalled = true; return []; }, getList: async () => { listCalled = true; return null; }, }; const dataStore = new DataStore(createSessionState(null)); const provider = { requirePreferences: () => preferences }; const requests = createRequests(mockApi, dataStore, provider);
await requests.loadPinnedItems();
assert.deepEqual(feedsCalled, false); assert.deepEqual(listCalled, false); const pinned = dataStore.$pinnedItems.get(); assert.deepEqual(pinned.length, 1); assert.deepEqual(pinned[0].type, "timeline"); });});
describe("enableStatus / getStatus", () => { it("should track loading start, end, and clear errors on success", async () => { const mockApi = { getMutes: async () => ({ mutes: [], cursor: null }) }; const dataStore = new DataStore(createSessionState(null)); const requests = makeRequests(mockApi, dataStore);
const initialStatus = requests.getStatus("loadMutedProfiles"); assert.deepEqual(initialStatus.loading, false); assert.deepEqual(initialStatus.error, null);
const promise = requests.loadMutedProfiles(); assert.deepEqual(requests.getStatus("loadMutedProfiles").loading, true); await promise;
const finalStatus = requests.getStatus("loadMutedProfiles"); assert.deepEqual(finalStatus.loading, false); assert.deepEqual(finalStatus.error, null); });
it("should record ApiError and clear loading on error path", async () => { const apiError = new ApiError({ status: 500, statusText: "Server Error", data: null, headers: {}, url: "/x", }); const mockApi = { getMutes: async () => { throw apiError; }, }; const dataStore = new DataStore(createSessionState(null)); const requests = makeRequests(mockApi, dataStore);
await requests.loadMutedProfiles();
const status = requests.getStatus("loadMutedProfiles"); assert.deepEqual(status.loading, false); assert( status.error === apiError, "expected status.error to be the ApiError", ); });
it("should record non-ApiErrors on the status store and rethrow them", async () => { const otherError = new TypeError("Failed to fetch"); const mockApi = { getMutes: async () => { throw otherError; }, }; const dataStore = new DataStore(createSessionState(null)); const requests = makeRequests(mockApi, dataStore);
let caught = null; try { await requests.loadMutedProfiles(); } catch (error) { caught = error; } assert(caught === otherError, "expected non-ApiError to propagate"); const status = requests.getStatus("loadMutedProfiles"); assert.deepEqual(status.loading, false); assert( status.error === otherError, "expected status.error to be the network error", ); });
it("should clear a recorded error once a later request succeeds", async () => { let shouldFail = true; const mockApi = { getMutes: async () => { if (shouldFail) { throw new TypeError("Failed to fetch"); } return { mutes: [], cursor: null }; }, }; const dataStore = new DataStore(createSessionState(null)); const requests = makeRequests(mockApi, dataStore);
await requests.loadMutedProfiles().catch(() => {}); assert(requests.getStatus("loadMutedProfiles").error !== null);
shouldFail = false; await requests.loadMutedProfiles(); assert.deepEqual(requests.getStatus("loadMutedProfiles").error, null); });
it("should namespace status by request id derived from arguments", async () => { const dataStore = new DataStore(createSessionState(null)); const mockApi = { getProfile: async (did) => ({ did, handle: "x" }), }; const requests = makeRequests(mockApi, dataStore);
await requests.loadDetailedProfile("did:plc:a"); await requests.loadDetailedProfile("did:plc:b");
assert.deepEqual( requests.getStatus("loadDetailedProfile-did:plc:a").error, null, ); assert.deepEqual( requests.getStatus("loadDetailedProfile-did:plc:a").loading, false, ); assert.deepEqual( requests.getStatus("loadDetailedProfile-did:plc:b").loading, false, ); });});
describe("_loadBlockedPosts", () => { const existingUri = "at://did:plc:blocked/app.bsky.feed.post/exists"; const deletedUri = "at://did:plc:blocked/app.bsky.feed.post/gone";
function setup({ getPosts = async () => [], getRecord }) { const mockApi = { getPosts, getRecord }; const dataStore = new DataStore(createSessionState(null)); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; const requests = createRequests( mockApi, dataStore, mockPreferencesProvider, ); return { requests, dataStore }; }
it("should mark a post unavailable when its record is confirmed deleted", async () => { const { requests, dataStore } = setup({ getRecord: async (uri) => { if (uri === deletedUri) { throw new ApiError({ status: 400, statusText: "Bad Request", data: { error: "RecordNotFound" }, headers: {}, url: "", }); } return { uri, value: {} }; }, }); await requests._loadBlockedPosts([existingUri, deletedUri]); assert.deepEqual(dataStore.$unavailablePosts.get(existingUri), null); assert(dataStore.$unavailablePosts.get(deletedUri) !== null); assert.deepEqual( dataStore.$unavailablePosts.get(deletedUri).uri, deletedUri, ); });
it("should not mark a post unavailable when the record probe fails for other reasons", async () => { const { requests, dataStore } = setup({ getRecord: async () => { throw new TypeError("network down"); }, }); await requests._loadBlockedPosts([existingUri]); assert.deepEqual(dataStore.$unavailablePosts.get(existingUri), null); });
it("should not probe records for posts that getPosts returned", async () => { const { requests, dataStore } = setup({ getPosts: async () => [{ uri: existingUri, record: { text: "hi" } }], getRecord: async () => { throw new Error("getRecord should not be called"); }, }); await requests._loadBlockedPosts([existingUri]); assert.deepEqual(dataStore.$posts.get(existingUri).record.text, "hi"); assert.deepEqual(dataStore.$unavailablePosts.get(existingUri), null); });});
function makeRequestsWithConstellation(api, dataStore, constellation) { return new Requests( api, dataStore, { requirePreferences: () => Preferences.createLoggedOutPreferences() }, new DraftMediaStore("test-media"), new EventEmitter(), constellation, );}
describe("statusStore.$statuses", () => { it("should expose combined loading and error state per request id", async () => { const apiError = new ApiError({ status: 500, statusText: "Server Error", data: null, headers: {}, url: "/x", }); const mockApi = { getMutes: async () => { throw apiError; }, }; const requests = makeRequests( mockApi, new DataStore(createSessionState(null)), );
assert.deepEqual(requests.statusStore.$statuses.get("loadMutedProfiles"), { loading: false, error: null, });
const promise = requests.loadMutedProfiles(); assert.deepEqual( requests.statusStore.$statuses.get("loadMutedProfiles").loading, true, ); await promise;
const status = requests.statusStore.$statuses.get("loadMutedProfiles"); assert.deepEqual(status.loading, false); assert(status.error === apiError, "expected the recorded ApiError"); });});
describe("loadCurrentUser", () => { it("should resolve the session did and store the profile", async () => { const profile = { did: "did:plc:me", handle: "me.test" }; let requestedDid = null; const mockApi = { getSession: async () => ({ did: "did:plc:me" }), getProfile: async (did) => { requestedDid = did; return profile; }, }; const dataStore = new DataStore(createSessionState(null)); const requests = makeRequests(mockApi, dataStore);
await requests.loadCurrentUser();
assert.deepEqual(requestedDid, "did:plc:me"); assert.deepEqual(dataStore.$currentUser.get(), profile); });
it("should fall back to the profile record when getProfile fails", async () => { const mockApi = { getSession: async () => ({ did: "did:plc:me", handle: "me.test" }), getProfile: async () => { throw new ApiError({ status: 502, statusText: "Bad Gateway" }); }, getProfileRecord: async () => ({ uri: "at://did:plc:me/app.bsky.actor.profile/self", value: { displayName: "Me" }, }), }; const dataStore = new DataStore(createSessionState(null)); const requests = makeRequests(mockApi, dataStore);
await requests.loadCurrentUser();
const currentUser = dataStore.$currentUser.get(); assert.deepEqual(currentUser.did, "did:plc:me"); assert.deepEqual(currentUser.handle, "me.test"); assert.deepEqual(currentUser.displayName, "Me"); assert.deepEqual(currentUser.isPartial, true); });
it("should fall back to a record-less profile when the user has no profile record", async () => { const mockApi = { getSession: async () => ({ did: "did:plc:me", handle: "me.test" }), getProfile: async () => { throw new ApiError({ status: 502, statusText: "Bad Gateway" }); }, getProfileRecord: async () => { throw new ApiError({ status: 400, statusText: "Bad Request", data: { error: "RecordNotFound" }, }); }, }; const dataStore = new DataStore(createSessionState(null)); const requests = makeRequests(mockApi, dataStore);
await requests.loadCurrentUser();
const currentUser = dataStore.$currentUser.get(); assert.deepEqual(currentUser.handle, "me.test"); assert.deepEqual(currentUser.displayName, null); assert.deepEqual(currentUser.isPartial, true); });
it("should rethrow when the profile record request fails for another reason", async () => { const recordError = new ApiError({ status: 500, statusText: "Oops" }); const mockApi = { getSession: async () => ({ did: "did:plc:me", handle: "me.test" }), getProfile: async () => { throw new ApiError({ status: 502, statusText: "Bad Gateway" }); }, getProfileRecord: async () => { throw recordError; }, }; const dataStore = new DataStore(createSessionState(null)); const requests = makeRequests(mockApi, dataStore);
await assert.rejects(() => requests.loadCurrentUser(), recordError); assert.deepEqual(dataStore.$currentUser.get(), null); });});
describe("loadPost", () => { it("should load and store a single post", async () => { const post = { uri: "at://did:plc:a/app.bsky.feed.post/1", record: {} }; const mockApi = { getPost: async () => post }; const dataStore = new DataStore(createSessionState(null)); const requests = makeRequests(mockApi, dataStore);
await requests.loadPost(post.uri);
assert.deepEqual(dataStore.$posts.get(post.uri), post); });});
describe("loadPostThread with a blocked parent", () => { const rootUri = "at://did:plc:root/app.bsky.feed.post/root"; const parentUri = "at://did:plc:blockedauthor/app.bsky.feed.post/parent"; const postURI = "at://did:plc:viewer/app.bsky.feed.post/main";
function makeMainPost() { return { uri: postURI, replyCount: 0, record: { reply: { root: { uri: rootUri }, parent: { uri: parentUri } }, }, }; }
function makeBlockedParent(viewer = {}) { return { $type: "app.bsky.feed.defs#blockedPost", uri: parentUri, blocked: true, author: { did: "did:plc:blockedauthor", viewer }, }; }
const parentPost = { uri: parentUri, author: { did: "did:plc:blockedauthor" }, record: { reply: { root: { uri: rootUri }, parent: { uri: rootUri } } }, }; const rootPost = { uri: rootUri, author: { did: "did:plc:root" }, record: {}, };
function makeApiWithPosts() { const postsByUri = new Map([ [parentUri, parentPost], [rootUri, rootPost], ]); return { getPostThread: async () => ({ post: makeMainPost(), parent: makeBlockedParent(), replies: [], }), getPostThreadOther: async () => [], getPosts: async (uris) => uris.map((uri) => postsByUri.get(uri)).filter(Boolean), }; }
it("should rebuild the parent chain from backlinks across blocked authors", async () => { const dataStore = new DataStore(createSessionState(null)); const constellation = { getLinks: async () => [ { did: "did:plc:blockedauthor", collection: "app.bsky.feed.post", rkey: "parent", }, ], }; const requests = makeRequestsWithConstellation( makeApiWithPosts(), dataStore, constellation, );
await requests.loadPostThread(postURI);
const thread = dataStore.$postThreads.get(postURI); assert.deepEqual(thread.parent.$type, "app.bsky.feed.defs#threadViewPost"); assert.deepEqual(thread.parent.post.uri, parentUri); assert.deepEqual(thread.parent.parent.post.uri, rootUri); assert.deepEqual(thread.parent.parent.parent, null); assert.deepEqual(dataStore.$posts.get(parentUri), parentPost); assert.deepEqual(dataStore.$posts.get(rootUri), rootPost); });
it("should walk through multiple loaded posts by the same blocked author", async () => { const grandparentUri = "at://did:plc:blockedauthor/app.bsky.feed.post/grandparent"; const nearParentPost = { uri: parentUri, author: { did: "did:plc:blockedauthor" }, record: { reply: { root: { uri: rootUri }, parent: { uri: grandparentUri } }, }, }; const grandparentPost = { uri: grandparentUri, author: { did: "did:plc:blockedauthor" }, record: { reply: { root: { uri: rootUri }, parent: { uri: rootUri } } }, }; const postsByUri = new Map([ [parentUri, nearParentPost], [grandparentUri, grandparentPost], [rootUri, rootPost], ]); const dataStore = new DataStore(createSessionState(null)); const constellation = { getLinks: async () => [ { did: "did:plc:blockedauthor", collection: "app.bsky.feed.post", rkey: "parent", }, { did: "did:plc:blockedauthor", collection: "app.bsky.feed.post", rkey: "grandparent", }, ], }; const mockApi = { getPostThread: async () => ({ post: makeMainPost(), parent: makeBlockedParent(), replies: [], }), getPostThreadOther: async () => [], getPosts: async (uris) => uris.map((uri) => postsByUri.get(uri)).filter(Boolean), }; const requests = makeRequestsWithConstellation( mockApi, dataStore, constellation, );
await requests.loadPostThread(postURI);
const thread = dataStore.$postThreads.get(postURI); assert.deepEqual(thread.parent.post.uri, parentUri); assert.deepEqual(thread.parent.parent.post.uri, grandparentUri); assert.deepEqual(thread.parent.parent.parent.post.uri, rootUri); });
it("should rethrow non-abort backlink failures", async () => { const dataStore = new DataStore(createSessionState(null)); const constellation = { getLinks: async () => { throw new TypeError("network down"); }, }; const mockApi = { getPostThread: async () => ({ post: makeMainPost(), parent: makeBlockedParent(), replies: [], }), getPostThreadOther: async () => [], }; const requests = makeRequestsWithConstellation( mockApi, dataStore, constellation, );
await assert.rejects(requests.loadPostThread(postURI), /network down/); });
it("should fall back to loading the blocked parent thread when the viewer is involved in the block", async () => { const dataStore = new DataStore(createSessionState(null)); let constellationCalled = false; const constellation = { getLinks: async () => { constellationCalled = true; return []; }, }; const parentThread = { post: { uri: parentUri, replyCount: 0 }, replies: [], }; const mockApi = { getPostThread: async (uri) => { if (uri === parentUri) return parentThread; return { post: makeMainPost(), parent: makeBlockedParent({ blocking: "at://block" }), replies: [], }; }, getPostThreadOther: async () => [], }; const requests = makeRequestsWithConstellation( mockApi, dataStore, constellation, );
await requests.loadPostThread(postURI);
assert.deepEqual(constellationCalled, false); const thread = dataStore.$postThreads.get(postURI); assert.deepEqual(thread.parent.post.uri, parentUri); });
it("should fall back to loading the blocked parent thread when backlinks time out", async () => { const dataStore = new DataStore(createSessionState(null)); const constellation = { getLinks: async () => { throw Object.assign(new Error("timed out"), { name: "AbortError" }); }, }; const parentThread = { post: { uri: parentUri, replyCount: 0 }, replies: [], }; const mockApi = { getPostThread: async (uri) => { if (uri === parentUri) return parentThread; return { post: makeMainPost(), parent: makeBlockedParent(), replies: [], }; }, getPostThreadOther: async () => [], }; const requests = makeRequestsWithConstellation( mockApi, dataStore, constellation, );
await requests.loadPostThread(postURI);
const thread = dataStore.$postThreads.get(postURI); assert.deepEqual(thread.parent.post.uri, parentUri); });
it("should fall back to loading the blocked parent thread when backlinks yield no posts", async () => { const dataStore = new DataStore(createSessionState(null)); // No backlinks by the blocked author — only the root gets appended const constellation = { getLinks: async () => [] }; const parentThread = { post: { uri: parentUri, replyCount: 0 }, replies: [], }; const mockApi = { getPostThread: async (uri) => { if (uri === parentUri) return parentThread; return { post: makeMainPost(), parent: makeBlockedParent(), replies: [], }; }, getPostThreadOther: async () => [], }; const requests = makeRequestsWithConstellation( mockApi, dataStore, constellation, );
await requests.loadPostThread(postURI);
const thread = dataStore.$postThreads.get(postURI); assert.deepEqual(thread.parent.post.uri, parentUri); });});
describe("_loadBlockedReplies", () => { const postURI = "at://did:plc:op/app.bsky.feed.post/main"; const replyUri = "at://did:plc:replier/app.bsky.feed.post/r1"; const blockedReplyUri = "at://did:plc:blocker/app.bsky.feed.post/r2";
it("should return an empty list when the thread has no post", async () => { const requests = makeRequests({}, new DataStore(createSessionState(null)));
const replies = await requests._loadBlockedReplies({});
assert.deepEqual(replies, []); });
it("should keep the loaded replies when backlinks time out", async () => { const dataStore = new DataStore(createSessionState(null)); const constellation = { getLinks: async () => { throw Object.assign(new Error("timed out"), { name: "AbortError" }); }, }; const mockApi = { getPostThread: async () => ({ post: { uri: postURI, replyCount: 1, record: {} }, replies: [], }), getPostThreadOther: async () => [], }; const requests = makeRequestsWithConstellation( mockApi, dataStore, constellation, );
await requests.loadPostThread(postURI);
assert.deepEqual(dataStore.$postThreads.get(postURI).replies, []); });
it("should rethrow non-abort backlink failures", async () => { const dataStore = new DataStore(createSessionState(null)); const constellation = { getLinks: async () => { throw new TypeError("network down"); }, }; const mockApi = { getPostThread: async () => ({ post: { uri: postURI, replyCount: 1, record: {} }, replies: [], }), getPostThreadOther: async () => [], }; const requests = makeRequestsWithConstellation( mockApi, dataStore, constellation, );
await assert.rejects(requests.loadPostThread(postURI), /network down/); });
it("should load missing replies from backlinks and mark them as blocked replies", async () => { const dataStore = new DataStore(createSessionState(null)); const constellation = { getLinks: async () => [ { did: "did:plc:replier", collection: "app.bsky.feed.post", rkey: "r1", }, { did: "did:plc:blocker", collection: "app.bsky.feed.post", rkey: "r2", }, ], }; const replyPost = { uri: replyUri, author: { did: "did:plc:replier", viewer: {} }, record: {}, }; const blockingReplyPost = { uri: blockedReplyUri, author: { did: "did:plc:blocker", viewer: { blockedBy: true } }, record: {}, }; let requestedUris = null; const mockApi = { getPostThread: async () => ({ post: { uri: postURI, replyCount: 2, record: {} }, replies: [], }), getPostThreadOther: async () => [], getPosts: async (uris) => { requestedUris = uris; return [replyPost, blockingReplyPost]; }, }; const requests = makeRequestsWithConstellation( mockApi, dataStore, constellation, );
await requests.loadPostThread(postURI);
assert.deepEqual(requestedUris, [replyUri, blockedReplyUri]); const thread = dataStore.$postThreads.get(postURI); // The reply from the author blocking the viewer is filtered out assert.deepEqual(thread.replies.length, 1); assert.deepEqual(thread.replies[0].post.uri, replyUri); assert.deepEqual(thread.replies[0].post.isBlockedReply, true); assert.deepEqual(dataStore.$posts.get(replyUri).isBlockedReply, true); });});
describe("loadNextFeedPage feed types", () => { it("should use getFollowingFeed for the timeline type", async () => { const dataStore = new DataStore(createSessionState(null)); let called = false; const mockApi = { getFollowingFeed: async () => { called = true; return { feed: [{ post: { uri: "t1" } }], cursor: "c1" }; }, }; const requests = makeRequests(mockApi, dataStore);
await requests.loadNextFeedPage({ type: "timeline", uri: "following" });
assert.deepEqual(called, true); assert.deepEqual(dataStore.$feeds.get("following").feed.length, 1); });
it("should use getListFeed for the list type", async () => { const dataStore = new DataStore(createSessionState(null)); const listUri = "at://did/app.bsky.graph.list/1"; let requestedUri = null; const mockApi = { getListFeed: async (uri) => { requestedUri = uri; return { feed: [{ post: { uri: "l1" } }], cursor: "c1" }; }, }; const requests = makeRequests(mockApi, dataStore);
await requests.loadNextFeedPage({ type: "list", uri: listUri });
assert.deepEqual(requestedUri, listUri); assert.deepEqual(dataStore.$feeds.get(listUri).feed.length, 1); });
it("should reject on an unknown feed type", async () => { const requests = makeRequests({}, new DataStore(createSessionState(null)));
await assert.rejects( requests.loadNextFeedPage({ type: "bogus", uri: "x" }), /Unknown pinned item type/, ); });});
describe("loadDetailedProfiles", () => { it("should store each profile in both profile maps", async () => { const profiles = [ { did: "did:plc:a", handle: "a.test" }, { did: "did:plc:b", handle: "b.test" }, ]; const mockApi = { getProfiles: async () => profiles }; const dataStore = new DataStore(createSessionState(null)); const requests = makeRequests(mockApi, dataStore);
await requests.loadDetailedProfiles(["did:plc:a", "did:plc:b"]);
assert.deepEqual(dataStore.$profiles.get("did:plc:a"), profiles[0]); assert.deepEqual(dataStore.$detailedProfiles.get("did:plc:b"), profiles[1]); });
it("should not call the api when dids is empty", async () => { let called = false; const mockApi = { getProfiles: async () => { called = true; return []; }, }; const requests = makeRequests( mockApi, new DataStore(createSessionState(null)), );
await requests.loadDetailedProfiles([]);
assert.deepEqual(called, false); });});
describe("_loadJoinLinkPreviews", () => { it("should fetch distinct codes and store previews by code", async () => { const dataStore = new DataStore(createSessionState(null)); let requestedCodes = null; const mockApi = { isAuthenticated: true, getJoinLinkPreviews: async (codes) => { requestedCodes = codes; return { joinLinkPreviews: [ { code: "abc", name: "Group" }, { name: "no code" }, ], }; }, }; const requests = makeRequests(mockApi, dataStore);
await requests._loadJoinLinkPreviews(["abc", "abc", null, "def"]);
assert.deepEqual(requestedCodes, ["abc", "def"]); assert.deepEqual( dataStore.$joinLinkPreviewsByCode.get("abc").name, "Group", ); assert.deepEqual(dataStore.$joinLinkPreviewsByCode.get("def"), null); });
it("should not fetch when unauthenticated or when there are no codes", async () => { let called = false; const mockApi = { isAuthenticated: false, getJoinLinkPreviews: async () => { called = true; return { joinLinkPreviews: [] }; }, }; const requests = makeRequests( mockApi, new DataStore(createSessionState(null)), );
await requests._loadJoinLinkPreviews(["abc"]); assert.deepEqual(called, false);
mockApi.isAuthenticated = true; await requests._loadJoinLinkPreviews([null, undefined]); assert.deepEqual(called, false); });
it("should swallow fetch failures", async () => { const mockApi = { isAuthenticated: true, getJoinLinkPreviews: async () => { throw new Error("boom"); }, }; const requests = makeRequests( mockApi, new DataStore(createSessionState(null)), );
await requests._loadJoinLinkPreviews(["abc"]); });});
describe("_loadPostDependencies", () => { it("should not reject when a dependency loader fails", async () => { const mockApi = { getPosts: async () => { throw new Error("network down"); }, }; const requests = makeRequests( mockApi, new DataStore(createSessionState(null)), ); const blockedPost = { $type: "app.bsky.feed.defs#blockedPost", uri: "at://did:plc:x/app.bsky.feed.post/1", };
await requests._loadPostDependencies([blockedPost]); });});
describe("loadFeedGenerator / loadList / loadStarterPack", () => { it("should store the feed generator by uri", async () => { const dataStore = new DataStore(createSessionState(null)); const feedGenerator = { uri: "at://did/feed/1", displayName: "Feed" }; const mockApi = { getFeedGenerator: async () => feedGenerator }; const requests = makeRequests(mockApi, dataStore);
await requests.loadFeedGenerator(feedGenerator.uri);
assert.deepEqual( dataStore.$feedGenerators.get(feedGenerator.uri), feedGenerator, ); });
it("should store the list view by uri", async () => { const dataStore = new DataStore(createSessionState(null)); const listUri = "at://did/app.bsky.graph.list/1"; let capturedOptions = null; const mockApi = { getList: async (_uri, options) => { capturedOptions = options; return { list: { uri: listUri, name: "L1" }, items: [] }; }, }; const requests = makeRequests(mockApi, dataStore);
await requests.loadList(listUri);
assert.deepEqual(capturedOptions, { limit: 1 }); assert.deepEqual(dataStore.$lists.get(listUri).name, "L1"); });
it("should store the starter pack by uri", async () => { const dataStore = new DataStore(createSessionState(null)); const starterPack = { uri: "at://did/starterpack/1", record: {} }; const mockApi = { getStarterPack: async () => starterPack }; const requests = makeRequests(mockApi, dataStore);
await requests.loadStarterPack(starterPack.uri);
assert.deepEqual(dataStore.$starterPacks.get(starterPack.uri), starterPack); });});
describe("loadListMembers", () => { const listUri = "at://did/app.bsky.graph.list/1";
it("should store the first page and hydrate member profiles", async () => { const dataStore = new DataStore(createSessionState(null)); const mockApi = { getList: async () => ({ list: { uri: listUri }, items: [{ uri: "li1", subject: { did: "did:plc:a", handle: "a" } }], cursor: "next", }), }; const requests = makeRequests(mockApi, dataStore);
await requests.loadListMembers(listUri);
const stored = dataStore.$listMembers.get(listUri); assert.deepEqual(stored.items.length, 1); assert.deepEqual(stored.cursor, "next"); assert.deepEqual(dataStore.$profiles.get("did:plc:a").handle, "a"); });
it("should short-circuit when fully loaded", async () => { const dataStore = new DataStore(createSessionState(null)); dataStore.$listMembers.set(listUri, { items: [], cursor: null }); let called = false; const mockApi = { getList: async () => { called = true; return { items: [], cursor: null }; }, }; const requests = makeRequests(mockApi, dataStore);
await requests.loadListMembers(listUri);
assert.deepEqual(called, false); });
it("should refetch from scratch on reload", async () => { const dataStore = new DataStore(createSessionState(null)); dataStore.$listMembers.set(listUri, { items: [{ uri: "li1", subject: { did: "did:plc:a" } }], cursor: null, }); let capturedCursor; const mockApi = { getList: async (_uri, { cursor }) => { capturedCursor = cursor; return { items: [{ uri: "li2", subject: { did: "did:plc:b" } }], cursor: "next", }; }, }; const requests = makeRequests(mockApi, dataStore);
await requests.loadListMembers(listUri, { reload: true });
assert.deepEqual(capturedCursor, ""); const stored = dataStore.$listMembers.get(listUri); assert.deepEqual(stored.items.length, 1); assert.deepEqual(stored.items[0].uri, "li2"); });});
describe("loadActorLists", () => { const did = "did:plc:author";
it("should store actor lists and cache each list view", async () => { const dataStore = new DataStore(createSessionState(null)); const mockApi = { getActorLists: async () => ({ lists: [{ uri: "at://did/app.bsky.graph.list/1", name: "L1" }], cursor: "next", }), }; const requests = makeRequests(mockApi, dataStore);
await requests.loadActorLists(did);
const stored = dataStore.$actorLists.get(did); assert.deepEqual(stored.lists.length, 1); assert.deepEqual(stored.cursor, "next"); assert.deepEqual( dataStore.$lists.get("at://did/app.bsky.graph.list/1").name, "L1", ); });
it("should short-circuit when there is no remaining cursor", async () => { const dataStore = new DataStore(createSessionState(null)); dataStore.$actorLists.set(did, { lists: [], cursor: null }); let called = false; const mockApi = { getActorLists: async () => { called = true; return { lists: [], cursor: null }; }, }; const requests = makeRequests(mockApi, dataStore);
await requests.loadActorLists(did);
assert.deepEqual(called, false); });
it("should refetch from scratch on reload", async () => { const dataStore = new DataStore(createSessionState(null)); dataStore.$actorLists.set(did, { lists: [{ uri: "old" }], cursor: null, }); let capturedCursor; const mockApi = { getActorLists: async (_did, { cursor }) => { capturedCursor = cursor; return { lists: [{ uri: "new" }], cursor: "next" }; }, }; const requests = makeRequests(mockApi, dataStore);
await requests.loadActorLists(did, { reload: true });
assert.deepEqual(capturedCursor, ""); const stored = dataStore.$actorLists.get(did); assert.deepEqual(stored.lists.length, 1); assert.deepEqual(stored.lists[0].uri, "new"); });});
describe("loadCurrentUserLists", () => { it("should do nothing when not authenticated", async () => { let called = false; const mockApi = { getActorLists: async () => { called = true; return { lists: [], cursor: null }; }, }; const requests = makeRequests( mockApi, new DataStore(createSessionState(null)), );
await requests.loadCurrentUserLists();
assert.deepEqual(called, false); });
it("should load the session user's lists without waiting for the current user profile", async () => { const dataStore = new DataStore(createSessionState(null)); let requestedDid = null; const mockApi = { isAuthenticated: true, session: { did: "did:plc:me" }, getActorLists: async (did) => { requestedDid = did; return { lists: [{ uri: "l1" }], cursor: null }; }, }; const requests = makeRequests(mockApi, dataStore);
await requests.loadCurrentUserLists();
assert.deepEqual(requestedDid, "did:plc:me"); assert.deepEqual(dataStore.$actorLists.get("did:plc:me").lists.length, 1); });});
describe("loadDrafts", () => { function makeRequestsWithDraftStore(api, dataStore, draftMediaStore) { return new Requests( api, dataStore, { requirePreferences: () => Preferences.createLoggedOutPreferences() }, draftMediaStore, new EventEmitter(), stubConstellation, ); }
it("should store the drafts page and load local media refs", async () => { const dataStore = new DataStore(createSessionState(null)); const draftView = { draft: { posts: [ { embedImages: [{ localRef: { path: "media/1" } }], embedVideos: [{ localRef: { path: "media/2" } }], }, ], }, }; const mockApi = { getDrafts: async () => ({ drafts: [draftView], cursor: "next" }), }; let loadedRefs = null; const draftMediaStore = { load: async (refs) => { loadedRefs = refs; }, }; const requests = makeRequestsWithDraftStore( mockApi, dataStore, draftMediaStore, );
await requests.loadDrafts();
assert.deepEqual(loadedRefs, ["media/1", "media/2"]); const stored = dataStore.$drafts.get(); assert.deepEqual(stored.drafts.length, 1); assert.deepEqual(stored.cursor, "next"); });
it("should refetch from scratch on reload", async () => { const dataStore = new DataStore(createSessionState(null)); dataStore.$drafts.set({ drafts: [{ draft: {} }], cursor: "c1" }); let capturedCursor; const mockApi = { getDrafts: async ({ cursor }) => { capturedCursor = cursor; return { drafts: [{ draft: { posts: [] } }], cursor: null }; }, }; const requests = makeRequestsWithDraftStore(mockApi, dataStore, { load: async () => {}, });
await requests.loadDrafts({ reload: true });
assert.deepEqual(capturedCursor, ""); const stored = dataStore.$drafts.get(); assert.deepEqual(stored.drafts.length, 1); assert.deepEqual(stored.cursor, null); });});
describe("loadKnownFollowers", () => { const profileDid = "did:plc:target";
it("should store known followers and hydrate profiles on first load", async () => { const dataStore = new DataStore(createSessionState(null)); const mockApi = { getKnownFollowers: async () => ({ followers: [{ did: "did:plc:a", handle: "a" }], cursor: "next", }), }; const requests = makeRequests(mockApi, dataStore);
await requests.loadKnownFollowers(profileDid);
const stored = dataStore.$knownFollowers.get(profileDid); assert.deepEqual(stored.followers.length, 1); assert.deepEqual(stored.cursor, "next"); assert.deepEqual(dataStore.$profiles.get("did:plc:a").handle, "a"); });
it("should append when cursor is provided", async () => { const dataStore = new DataStore(createSessionState(null)); dataStore.$knownFollowers.set(profileDid, { followers: [{ did: "did:plc:a" }], cursor: "c1", }); const mockApi = { getKnownFollowers: async () => ({ followers: [{ did: "did:plc:b" }], cursor: null, }), }; const requests = makeRequests(mockApi, dataStore);
await requests.loadKnownFollowers(profileDid, { cursor: "c1" });
const stored = dataStore.$knownFollowers.get(profileDid); assert.deepEqual(stored.followers.length, 2); assert.deepEqual(stored.cursor, null); });});
describe("loadProfileChatStatus", () => { it("should store the availability response keyed by profile did", async () => { const dataStore = new DataStore(createSessionState(null)); let requestedDids = null; const availability = { canChat: true }; const mockApi = { getConvoAvailability: async (dids) => { requestedDids = dids; return availability; }, }; const requests = makeRequests(mockApi, dataStore);
await requests.loadProfileChatStatus("did:plc:a");
assert.deepEqual(requestedDids, ["did:plc:a"]); assert.deepEqual( dataStore.$profileChatStatus.get("did:plc:a"), availability, ); });});