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
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653365436553656365736583659366036613662366336643665366636673668366936703671367236733674367536763677367836793680368136823683368436853686368736883689369036913692369336943695369636973698369937003701370237033704370537063707370837093710371137123713371437153716371737183719372037213722372337243725372637273728372937303731373237333734373537363737373837393740374137423743374437453746374737483749375037513752375337543755375637573758375937603761376237633764376537663767376837693770377137723773377437753776377737783779378037813782378337843785378637873788378937903791379237933794379537963797379837993800380138023803380438053806380738083809381038113812381338143815381638173818381938203821382238233824382538263827382838293830383138323833383438353836383738383839384038413842384338443845384638473848384938503851385238533854385538563857385838593860386138623863386438653866386738683869387038713872387338743875387638773878387938803881388238833884388538863887388838893890389138923893389438953896389738983899390039013902390339043905390639073908390939103911391239133914391539163917391839193920392139223923392439253926392739283929393039313932393339343935393639373938393939403941394239433944394539463947394839493950395139523953395439553956395739583959396039613962396339643965396639673968396939703971397239733974397539763977397839793980398139823983398439853986398739883989399039913992399339943995399639973998399940004001400240034004400540064007400840094010401140124013401440154016401740184019402040214022402340244025402640274028402940304031403240334034403540364037403840394040404140424043404440454046404740484049405040514052405340544055405640574058405940604061406240634064406540664067406840694070407140724073407440754076407740784079408040814082408340844085408640874088408940904091409240934094409540964097409840994100410141024103410441054106410741084109411041114112411341144115411641174118411941204121412241234124412541264127412841294130413141324133413441354136413741384139414041414142414341444145414641474148414941504151415241534154415541564157415841594160416141624163416441654166416741684169417041714172417341744175417641774178417941804181418241834184418541864187418841894190419141924193419441954196419741984199420042014202420342044205420642074208420942104211421242134214421542164217421842194220422142224223422442254226422742284229423042314232423342344235423642374238423942404241424242434244424542464247424842494250425142524253425442554256425742584259426042614262426342644265426642674268426942704271427242734274427542764277427842794280428142824283428442854286428742884289429042914292429342944295429642974298429943004301430243034304430543064307430843094310431143124313431443154316431743184319432043214322432343244325432643274328432943304331433243334334433543364337433843394340434143424343434443454346434743484349435043514352435343544355435643574358435943604361436243634364436543664367436843694370437143724373437443754376437743784379438043814382438343844385438643874388438943904391439243934394439543964397439843994400440144024403440444054406440744084409441044114412441344144415441644174418441944204421442244234424442544264427442844294430443144324433443444354436443744384439444044414442444344444445444644474448444944504451445244534454445544564457445844594460446144624463446444654466446744684469447044714472447344744475447644774478447944804481448244834484448544864487448844894490449144924493449444954496449744984499450045014502450345044505450645074508450945104511451245134514451545164517451845194520452145224523452445254526452745284529453045314532453345344535453645374538453945404541454245434544454545464547454845494550455145524553455445554556455745584559456045614562456345644565456645674568456945704571457245734574457545764577457845794580458145824583458445854586458745884589459045914592459345944595459645974598459946004601460246034604460546064607460846094610461146124613461446154616461746184619462046214622462346244625462646274628462946304631463246334634463546364637463846394640464146424643464446454646464746484649465046514652465346544655465646574658465946604661466246634664466546664667466846694670467146724673467446754676467746784679468046814682468346844685468646874688468946904691469246934694469546964697469846994700470147024703470447054706470747084709471047114712471347144715471647174718471947204721472247234724472547264727472847294730473147324733473447354736473747384739474047414742474347444745474647474748474947504751475247534754475547564757475847594760476147624763476447654766476747684769477047714772477347744775477647774778477947804781478247834784478547864787478847894790479147924793479447954796479747984799480048014802480348044805480648074808480948104811481248134814481548164817481848194820482148224823482448254826482748284829483048314832483348344835483648374838483948404841484248434844484548464847484848494850485148524853485448554856485748584859486048614862486348644865486648674868486948704871487248734874487548764877487848794880488148824883488448854886488748884889489048914892489348944895489648974898489949004901490249034904490549064907490849094910491149124913491449154916491749184919492049214922492349244925492649274928492949304931493249334934493549364937493849394940494149424943494449454946494749484949495049514952495349544955495649574958495949604961496249634964496549664967496849694970497149724973497449754976497749784979498049814982498349844985498649874988498949904991499249934994499549964997499849995000500150025003500450055006500750085009501050115012501350145015501650175018501950205021502250235024502550265027502850295030503150325033503450355036503750385039504050415042504350445045504650475048504950505051505250535054505550565057505850595060506150625063506450655066506750685069507050715072507350745075507650775078507950805081508250835084508550865087508850895090509150925093509450955096509750985099510051015102510351045105510651075108510951105111511251135114511551165117511851195120512151225123512451255126512751285129513051315132513351345135513651375138import { describe, it } from "node:test";import assert from "node:assert/strict";import { Mutations } from "/js/dataLayer/mutations.js";import { DataStore } from "/js/dataLayer/dataStore.js";import { createSessionState } from "/js/dataLayer/sessionState.js";import { DraftMediaStore } from "/js/drafts.js";import { PatchStore } from "/js/dataLayer/patchStore.js";import { Derived } from "/js/dataLayer/derived.js";import { Preferences } from "/js/preferences.js";import { Signal } from "/js/signals.js";import { HiddenFeedItemsStore } from "/js/dataLayer/hiddenFeedItemsStore.js";import { CDN_URL } from "/js/config.js";import { ApiError } from "/js/api.js";import { trackDisposable } from "../../testHelpers.js";
function escapeRegExp(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");}
const mockIdentityResolver = { resolveHandle: async () => null,};
function makeMutations(api, dataStore, patchStore, preferencesProvider) { return new Mutations( api, dataStore, patchStore, preferencesProvider, mockIdentityResolver, new DraftMediaStore("test-media"), );}
// The appliers require the patches array. Helpers that fetch the current// patches for a key and apply them.function applyPostPatches(patchStore, post) { const patches = patchStore.$postPatches.get(post.uri) || []; return patchStore.applyPostPatches(post, patches);}
function applyProfilePatches(patchStore, profile) { const patches = patchStore.$profilePatches.get(profile.did) || []; return patchStore.applyProfilePatches(profile, patches);}
function applyConvoPatches(patchStore, convo) { const patches = patchStore.$convoPatches.get(convo.id) || []; return patchStore.applyConvoPatches(convo, patches);}
function makeDerived( dataStore, patchStore, preferencesProvider, isAuthenticated = true,) { // Derived' $preferences computed reads `preferencesProvider.$preferences.get()`. // If the provider doesn't supply that signal, give it a passthrough. const provider = preferencesProvider.$preferences ? preferencesProvider : { ...preferencesProvider, $preferences: new Signal.State( preferencesProvider.requirePreferences ? preferencesProvider.requirePreferences() : null, ), }; const derived = new Derived( dataStore, patchStore, provider, new HiddenFeedItemsStore(), isAuthenticated, new DraftMediaStore("test-media"), ); trackDisposable(derived.liveStatusScheduler); return derived;}
describe("addLike", () => { const testPost = { uri: "at://did:test/app.bsky.feed.post/test", likeCount: 5, viewer: { like: null }, };
it("should add optimistic patch immediately", () => { const mockApi = { createLikeRecord: async () => ({ uri: "like-uri" }), }; const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; const mutations = makeMutations( mockApi, dataStore, patchStore, mockPreferencesProvider, );
mutations.addLike(testPost);
const patchedPost = applyPostPatches(patchStore, testPost); assert.deepEqual(patchedPost.viewer.like, "fake like"); assert.deepEqual(patchedPost.likeCount, 6); });
it("should update dataStore and remove patch on success", async () => { const mockLike = { uri: "like-123" }; const mockApi = { createLikeRecord: async () => mockLike, }; const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; const mutations = makeMutations( mockApi, dataStore, patchStore, mockPreferencesProvider, );
await mutations.addLike(testPost);
const storedPost = dataStore.$posts.get(testPost.uri); assert.deepEqual(storedPost.viewer.like, "like-123"); assert.deepEqual(storedPost.likeCount, 6);
const patchedPost = applyPostPatches(patchStore, storedPost); assert.deepEqual(patchedPost, storedPost); });
it("should only count once across concurrent like operations", async () => { const mockApi = { createLikeRecord: async () => new Promise((resolve) => setTimeout(() => resolve({ uri: "like-uri" }), 50), ), }; const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; const mutations = makeMutations( mockApi, dataStore, patchStore, mockPreferencesProvider, );
const promise1 = mutations.addLike(testPost); const promise2 = mutations.addLike(testPost);
// The second patch is a no-op because the first already applied the like. const patchedPost = applyPostPatches(patchStore, testPost); assert.deepEqual(patchedPost.likeCount, 6); assert.deepEqual(patchedPost.viewer.like, "fake like");
await Promise.all([promise1, promise2]); });
it("should not double-count when a refresh lands while the like is in flight", async () => { const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockApi = { createLikeRecord: async () => { // Simulate a feed refresh delivering the server state (like already // applied) before the mutation's own commit runs. dataStore.$posts.set(testPost.uri, { ...testPost, likeCount: 6, viewer: { like: "server-like-uri" }, }); return { uri: "like-123" }; }, }; const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; const mutations = makeMutations( mockApi, dataStore, patchStore, mockPreferencesProvider, );
await mutations.addLike(testPost);
const storedPost = dataStore.$posts.get(testPost.uri); assert.deepEqual(storedPost.likeCount, 6); assert.deepEqual(storedPost.viewer.like, "server-like-uri"); assert.deepEqual(applyPostPatches(patchStore, storedPost).likeCount, 6); });});
describe("removeLike", () => { const testPost = { uri: "at://did:test/app.bsky.feed.post/test", likeCount: 6, viewer: { like: "existing-like-uri" }, };
it("should add optimistic patch immediately", () => { const mockApi = { deleteLikeRecord: async () => new Promise((resolve) => { setTimeout(resolve, 100); }), }; const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; const mutations = makeMutations( mockApi, dataStore, patchStore, mockPreferencesProvider, );
mutations.removeLike(testPost);
const patchedPost = applyPostPatches(patchStore, testPost); assert.deepEqual(patchedPost.viewer.like, null); assert.deepEqual(patchedPost.likeCount, 5); });
it("should update dataStore and remove patch on success", async () => { const mockApi = { deleteLikeRecord: async () => {}, }; const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; const mutations = makeMutations( mockApi, dataStore, patchStore, mockPreferencesProvider, );
await mutations.removeLike(testPost);
const storedPost = dataStore.$posts.get(testPost.uri); assert.deepEqual(storedPost.viewer.like, null); assert.deepEqual(storedPost.likeCount, 5);
const patchedPost = applyPostPatches(patchStore, storedPost); assert.deepEqual(patchedPost, storedPost); });});
describe("followProfile", () => { const testProfile = { uri: "did:test:profile", did: "did:test:profile", handle: "test.user", followersCount: 10, viewer: { following: null }, };
it("should add optimistic patch immediately", () => { const mockApi = { createFollowRecord: async () => new Promise((resolve) => { setTimeout(() => resolve({ uri: "follow-uri" }), 100); }), }; const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; const mutations = makeMutations( mockApi, dataStore, patchStore, mockPreferencesProvider, );
mutations.followProfile(testProfile);
const patchedProfile = applyProfilePatches(patchStore, testProfile); assert.deepEqual(patchedProfile.viewer.following, "fake following"); assert.deepEqual(patchedProfile.followersCount, 11); });
it("should update dataStore and remove patch on success", async () => { const mockFollow = { uri: "follow-123" }; const mockApi = { createFollowRecord: async () => mockFollow, }; const dataStore = new DataStore(createSessionState(null)); dataStore.$detailedProfiles.set(testProfile.did, testProfile); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; const mutations = makeMutations( mockApi, dataStore, patchStore, mockPreferencesProvider, );
await mutations.followProfile(testProfile);
const storedProfile = dataStore.$profiles.get(testProfile.did); assert.deepEqual(storedProfile.viewer.following, "follow-123");
const storedDetailed = dataStore.$detailedProfiles.get(testProfile.did); assert.deepEqual(storedDetailed.viewer.following, "follow-123"); assert.deepEqual(storedDetailed.followersCount, 11);
const patchedProfile = applyProfilePatches(patchStore, storedProfile); assert.deepEqual(patchedProfile, storedProfile); });
it("should update hydrated post authors after the patch is removed", async () => { const mockApi = { createFollowRecord: async () => ({ uri: "follow-123" }), }; const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; const derived = makeDerived(dataStore, patchStore, mockPreferencesProvider); const mutations = makeMutations( mockApi, dataStore, patchStore, mockPreferencesProvider, );
const postUri = "at://did:test:profile/app.bsky.feed.post/1"; dataStore.setPosts([ { uri: postUri, record: { text: "hello" }, author: { did: testProfile.did, handle: testProfile.handle, viewer: { following: null }, }, viewer: {}, labels: [], }, ]);
await mutations.followProfile(testProfile);
const hydratedPost = derived.$hydratedPosts.get(postUri); assert.deepEqual(hydratedPost.author.viewer.following, "follow-123"); });});
describe("unfollowProfile", () => { const testProfile = { uri: "did:test:profile", did: "did:test:profile", handle: "test.user", followersCount: 10, viewer: { following: "existing-follow-uri" }, };
it("should add optimistic patch immediately", () => { const mockApi = { deleteFollowRecord: async () => new Promise((resolve) => { setTimeout(resolve, 100); }), }; const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; const mutations = makeMutations( mockApi, dataStore, patchStore, mockPreferencesProvider, );
mutations.unfollowProfile(testProfile);
const patchedProfile = applyProfilePatches(patchStore, testProfile); assert.deepEqual(patchedProfile.viewer.following, null); assert.deepEqual(patchedProfile.followersCount, 9); });
it("should update dataStore and remove patch on success", async () => { const mockApi = { deleteFollowRecord: async () => {}, }; const dataStore = new DataStore(createSessionState(null)); dataStore.$detailedProfiles.set(testProfile.did, testProfile); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; const mutations = makeMutations( mockApi, dataStore, patchStore, mockPreferencesProvider, );
await mutations.unfollowProfile(testProfile);
const storedProfile = dataStore.$profiles.get(testProfile.did); assert.deepEqual(storedProfile.viewer.following, null);
const storedDetailed = dataStore.$detailedProfiles.get(testProfile.did); assert.deepEqual(storedDetailed.viewer.following, null); assert.deepEqual(storedDetailed.followersCount, 9);
const patchedProfile = applyProfilePatches(patchStore, storedProfile); assert.deepEqual(patchedProfile, storedProfile); });});
describe("subscribeLabeler", () => { const testProfile = { did: "did:test:labeler", handle: "labeler.test", }; const testLabelerInfo = { creator: { did: "did:test:labeler" }, policies: { labelValueDefinitions: [] }, };
it("should add optimistic preference patch immediately", () => { const mockPreferencesProvider = { requirePreferences: () => ({ subscribeLabeler: () => Preferences.createLoggedOutPreferences(), }), updatePreferences: async () => { await new Promise((resolve) => setTimeout(resolve, 100)); }, }; const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mutations = makeMutations( {}, dataStore, patchStore, mockPreferencesProvider, );
mutations.subscribeLabeler(testProfile, testLabelerInfo);
const patches = patchStore.$preferencePatches.get(); assert.deepEqual(patches.length, 1); assert.deepEqual(patches[0].body.type, "subscribeLabeler"); assert.deepEqual(patches[0].body.did, testProfile.did); });
it("should remove patch after successful update", async () => { const mockPreferencesProvider = { requirePreferences: () => ({ subscribeLabeler: () => Preferences.createLoggedOutPreferences(), }), updatePreferences: async () => {}, }; const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mutations = makeMutations( {}, dataStore, patchStore, mockPreferencesProvider, );
await mutations.subscribeLabeler(testProfile, testLabelerInfo);
const patches = patchStore.$preferencePatches.get(); assert.deepEqual(patches.length, 0); });
it("should remove patch even on error", async () => { const mockPreferencesProvider = { requirePreferences: () => ({ subscribeLabeler: () => Preferences.createLoggedOutPreferences(), }), updatePreferences: async () => { throw new Error("API error"); }, }; const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mutations = makeMutations( {}, dataStore, patchStore, mockPreferencesProvider, );
let errorThrown = false; try { await mutations.subscribeLabeler(testProfile, testLabelerInfo); } catch (e) { errorThrown = true; }
assert.deepEqual(errorThrown, true); const patches = patchStore.$preferencePatches.get(); assert.deepEqual(patches.length, 0); });});
describe("unsubscribeLabeler", () => { const testProfile = { did: "did:test:labeler", handle: "labeler.test", };
it("should add optimistic preference patch immediately", () => { const mockPreferencesProvider = { requirePreferences: () => ({ unsubscribeLabeler: () => Preferences.createLoggedOutPreferences(), }), updatePreferences: async () => { await new Promise((resolve) => setTimeout(resolve, 100)); }, }; const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mutations = makeMutations( {}, dataStore, patchStore, mockPreferencesProvider, );
mutations.unsubscribeLabeler(testProfile);
const patches = patchStore.$preferencePatches.get(); assert.deepEqual(patches.length, 1); assert.deepEqual(patches[0].body.type, "unsubscribeLabeler"); assert.deepEqual(patches[0].body.did, testProfile.did); });
it("should remove patch after successful update", async () => { const mockPreferencesProvider = { requirePreferences: () => ({ unsubscribeLabeler: () => Preferences.createLoggedOutPreferences(), }), updatePreferences: async () => {}, }; const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mutations = makeMutations( {}, dataStore, patchStore, mockPreferencesProvider, );
await mutations.unsubscribeLabeler(testProfile);
const patches = patchStore.$preferencePatches.get(); assert.deepEqual(patches.length, 0); });
it("should remove patch even on error", async () => { const mockPreferencesProvider = { requirePreferences: () => ({ unsubscribeLabeler: () => Preferences.createLoggedOutPreferences(), }), updatePreferences: async () => { throw new Error("API error"); }, }; const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mutations = makeMutations( {}, dataStore, patchStore, mockPreferencesProvider, );
let errorThrown = false; try { await mutations.unsubscribeLabeler(testProfile); } catch (e) { errorThrown = true; }
assert.deepEqual(errorThrown, true); const patches = patchStore.$preferencePatches.get(); assert.deepEqual(patches.length, 0); });});
describe("updateLabelerSetting", () => { const labelerDid = "did:test:labeler"; const label = "nsfw"; const visibility = "warn";
it("should add optimistic preference patch immediately", () => { const mockPreferencesProvider = { requirePreferences: () => ({ setContentLabelPref: () => Preferences.createLoggedOutPreferences(), }), updatePreferences: async () => { await new Promise((resolve) => setTimeout(resolve, 100)); }, }; const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mutations = makeMutations( {}, dataStore, patchStore, mockPreferencesProvider, );
mutations.updateLabelerSetting({ labelerDid, label, visibility });
const patches = patchStore.$preferencePatches.get(); assert.deepEqual(patches.length, 1); assert.deepEqual(patches[0].body.type, "setContentLabelPref"); assert.deepEqual(patches[0].body.label, label); assert.deepEqual(patches[0].body.visibility, visibility); assert.deepEqual(patches[0].body.labelerDid, labelerDid); });
it("should remove patch after successful update", async () => { const mockPreferencesProvider = { requirePreferences: () => ({ setContentLabelPref: () => Preferences.createLoggedOutPreferences(), }), updatePreferences: async () => {}, }; const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mutations = makeMutations( {}, dataStore, patchStore, mockPreferencesProvider, );
await mutations.updateLabelerSetting({ labelerDid, label, visibility });
const patches = patchStore.$preferencePatches.get(); assert.deepEqual(patches.length, 0); });
it("should remove patch even on error", async () => { const mockPreferencesProvider = { requirePreferences: () => ({ setContentLabelPref: () => Preferences.createLoggedOutPreferences(), }), updatePreferences: async () => { throw new Error("API error"); }, }; const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mutations = makeMutations( {}, dataStore, patchStore, mockPreferencesProvider, );
let errorThrown = false; try { await mutations.updateLabelerSetting({ labelerDid, label, visibility }); } catch (e) { errorThrown = true; }
assert.deepEqual(errorThrown, true); const patches = patchStore.$preferencePatches.get(); assert.deepEqual(patches.length, 0); });
it("should call setContentLabelPref with correct parameters", async () => { let setContentLabelPrefCalledWith = null; const mockPreferencesProvider = { requirePreferences: () => ({ setContentLabelPref: (params) => { setContentLabelPrefCalledWith = params; return Preferences.createLoggedOutPreferences(); }, }), updatePreferences: async () => {}, }; const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mutations = makeMutations( {}, dataStore, patchStore, mockPreferencesProvider, );
await mutations.updateLabelerSetting({ labelerDid, label, visibility });
assert.deepEqual(setContentLabelPrefCalledWith.labelerDid, labelerDid); assert.deepEqual(setContentLabelPrefCalledWith.label, label); assert.deepEqual(setContentLabelPrefCalledWith.visibility, visibility); });});
describe("Error Handling and Edge Cases", () => { it("should handle multiple mutations on same resource", async () => { const post = { uri: "post1", likeCount: 5, viewer: { like: null }, };
const mockApi = { createLikeRecord: async () => new Promise((resolve) => setTimeout(() => resolve({ uri: "like1" }), 50), ), deleteLikeRecord: async () => new Promise((resolve) => setTimeout(resolve, 75)), }; const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; const mutations = makeMutations( mockApi, dataStore, patchStore, mockPreferencesProvider, );
const likePromise = mutations.addLike(post); await new Promise((resolve) => setTimeout(resolve, 10));
const unlikePromise = mutations.removeLike({ ...post, likeCount: 6, viewer: { like: "like1" }, });
const patchedPost = applyPostPatches(patchStore, post); assert.deepEqual(patchedPost.likeCount, 5);
await Promise.all([likePromise, unlikePromise]); });
it("should handle API methods that return undefined", async () => { const post = { uri: "post1", likeCount: 5, viewer: { like: "like1" } };
const mockApi = { deleteLikeRecord: async () => undefined, }; const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; const mutations = makeMutations( mockApi, dataStore, patchStore, mockPreferencesProvider, );
await mutations.removeLike(post);
const storedPost = dataStore.$posts.get(post.uri); assert.deepEqual(storedPost.viewer.like, null); });});
describe("addMutedWord", () => { it("should call updatePreferences with new muted word", async () => { let updatedPreferences = null; const mockPreferencesProvider = { requirePreferences: () => new Preferences([], []), updatePreferences: async (prefs) => { updatedPreferences = prefs; }, }; const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mutations = makeMutations( {}, dataStore, patchStore, mockPreferencesProvider, );
await mutations.addMutedWord({ value: "testword", targets: ["content", "tag"], actorTarget: "all", });
const words = updatedPreferences.getMutedWords(); assert.deepEqual(words.length, 1); assert.deepEqual(words[0].value, "testword"); assert.deepEqual(words[0].targets.length, 2); assert.deepEqual(words[0].actorTarget, "all"); });
it("should pass expiresAt through to preferences", async () => { let updatedPreferences = null; const mockPreferencesProvider = { requirePreferences: () => new Preferences([], []), updatePreferences: async (prefs) => { updatedPreferences = prefs; }, }; const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mutations = makeMutations( {}, dataStore, patchStore, mockPreferencesProvider, );
const expiresAt = "2026-05-01T00:00:00.000Z"; await mutations.addMutedWord({ value: "temp", targets: ["tag"], actorTarget: "exclude-following", expiresAt, });
const words = updatedPreferences.getMutedWords(); assert.deepEqual(words[0].expiresAt, expiresAt); assert.deepEqual(words[0].actorTarget, "exclude-following"); });});
describe("removeMutedWord", () => { it("should call updatePreferences with word removed", async () => { let updatedPreferences = null; const existingPrefs = new Preferences( [ { $type: "app.bsky.actor.defs#mutedWordsPref", items: [ { id: "word-1", value: "remove-me", targets: ["content"], actorTarget: "all", }, { id: "word-2", value: "keep-me", targets: ["tag"], actorTarget: "all", }, ], }, ], [], ); const mockPreferencesProvider = { requirePreferences: () => existingPrefs, updatePreferences: async (prefs) => { updatedPreferences = prefs; }, }; const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mutations = makeMutations( {}, dataStore, patchStore, mockPreferencesProvider, );
await mutations.removeMutedWord("word-1");
const words = updatedPreferences.getMutedWords(); assert.deepEqual(words.length, 1); assert.deepEqual(words[0].value, "keep-me"); });});
describe("updateProfile", () => { const testProfile = { did: "did:plc:test123", displayName: "Old Name", description: "Old bio", avatar: "https://example.com/avatar.jpg", banner: "https://example.com/banner.jpg", viewer: {}, };
function createMutationsWithMockApi(mockApi) { const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; dataStore.$profiles.set(testProfile.did, testProfile); dataStore.$currentUser.set(testProfile); return { mutations: makeMutations( mockApi, dataStore, patchStore, mockPreferencesProvider, ), dataStore, patchStore, }; }
function makeMockApi(overrides = {}) { return { getProfileRecord: async () => ({ value: {}, cid: "cid123" }), putProfileRecord: async () => ({}), uploadBlob: async () => ({ ref: { $link: "blob-link" }, mimeType: "image/jpeg", size: 100, }), getProfile: async (did) => ({ did, displayName: "Fetched Name", description: "Fetched bio", viewer: {}, }), ...overrides, }; }
it("should call getProfileRecord and putProfileRecord", async () => { let getRecordCalled = false; let putRecordCalled = false; let putRecordArgs = null; const mockApi = makeMockApi({ getProfileRecord: async () => { getRecordCalled = true; return { value: { displayName: "Old Name", description: "Old bio" }, cid: "cid123", }; }, putProfileRecord: async (record, swapRecord) => { putRecordCalled = true; putRecordArgs = { record, swapRecord }; return {}; }, });
const { mutations } = createMutationsWithMockApi(mockApi); await mutations.updateProfile(testProfile, { displayName: "New Name", description: "New bio", });
assert.deepEqual(getRecordCalled, true); assert.deepEqual(putRecordCalled, true); assert.deepEqual(putRecordArgs.record.displayName, "New Name"); assert.deepEqual(putRecordArgs.record.description, "New bio"); assert.deepEqual(putRecordArgs.swapRecord, "cid123"); });
it("should upload avatar blob when provided", async () => { let uploadBlobCalled = false; const mockApi = makeMockApi({ uploadBlob: async () => { uploadBlobCalled = true; return { ref: { $link: "avatar-blob" }, mimeType: "image/jpeg", size: 100, }; }, });
const { mutations } = createMutationsWithMockApi(mockApi); const fakeBlob = new Blob(["test"], { type: "image/jpeg" }); await mutations.updateProfile(testProfile, { displayName: "Test", description: "Test", avatarBlob: fakeBlob, });
assert.deepEqual(uploadBlobCalled, true); });
it("should upload banner blob when provided", async () => { let uploadBlobCallCount = 0; const mockApi = makeMockApi({ uploadBlob: async () => { uploadBlobCallCount++; return { ref: { $link: "blob-link" }, mimeType: "image/jpeg", size: 100, }; }, });
const { mutations } = createMutationsWithMockApi(mockApi); const fakeBlob = new Blob(["test"], { type: "image/jpeg" }); await mutations.updateProfile(testProfile, { displayName: "Test", description: "Test", bannerBlob: fakeBlob, });
assert.deepEqual(uploadBlobCallCount, 1); });
it("patches $profiles in place with the new displayName and description", async () => { // The appview lags briefly after putRecord, so we patch locally rather // than round-tripping through getProfile (which can return stale data). const { mutations, dataStore } = createMutationsWithMockApi(makeMockApi()); await mutations.updateProfile(testProfile, { displayName: "Patched Name", description: "Patched bio", });
const updatedProfile = dataStore.$profiles.get(testProfile.did); assert.deepEqual(updatedProfile.displayName, "Patched Name"); assert.deepEqual(updatedProfile.description, "Patched bio"); // Non-patched fields survive. assert.deepEqual(updatedProfile.did, testProfile.did); });
it("sets the local avatar and banner to CDN URLs built from the uploaded refs", async () => { let uploadCallIndex = 0; const mockApi = makeMockApi({ uploadBlob: async () => { uploadCallIndex++; return { ref: { $link: `bafkreiblob${uploadCallIndex}` }, mimeType: "image/jpeg", size: 100, }; }, });
const { mutations, dataStore } = createMutationsWithMockApi(mockApi); await mutations.updateProfile(testProfile, { displayName: "Name", description: "Bio", avatarBlob: new Blob(["a"], { type: "image/jpeg" }), bannerBlob: new Blob(["b"], { type: "image/jpeg" }), });
const updatedProfile = dataStore.$profiles.get(testProfile.did); assert.match( updatedProfile.avatar, new RegExp( `^${escapeRegExp(CDN_URL)}/img/avatar/plain/did:plc:test123/bafkreiblob\\d+@jpeg$`, ), ); assert.match( updatedProfile.banner, new RegExp( `^${escapeRegExp(CDN_URL)}/img/banner/plain/did:plc:test123/bafkreiblob\\d+@jpeg$`, ), ); });
it("clears the local avatar and banner when removeAvatar/removeBanner are true", async () => { const { mutations, dataStore } = createMutationsWithMockApi(makeMockApi()); await mutations.updateProfile(testProfile, { displayName: "Name", description: "Bio", removeAvatar: true, removeBanner: true, });
const updatedProfile = dataStore.$profiles.get(testProfile.did); assert.equal(updatedProfile.avatar, ""); assert.equal(updatedProfile.banner, ""); });
it("should rethrow non-400 errors from getProfileRecord", async () => { const mockApi = makeMockApi({ getProfileRecord: async () => { throw { status: 500, message: "Internal Server Error" }; }, });
const { mutations } = createMutationsWithMockApi(mockApi); try { await mutations.updateProfile(testProfile, { displayName: "New Name", description: "New bio", }); throw new Error("Expected updateProfile to throw"); } catch (error) { assert.deepEqual(error.status, 500); } });
it("should update currentUser when editing own profile", async () => { const { mutations, dataStore } = createMutationsWithMockApi(makeMockApi()); await mutations.updateProfile(testProfile, { displayName: "Updated User", description: "Updated bio", });
const currentUser = dataStore.$currentUser.get(); assert.deepEqual(currentUser.displayName, "Updated User"); assert.deepEqual(currentUser.description, "Updated bio"); });});
describe("pinPost", () => { const testUser = { did: "did:plc:user", handle: "user.test", viewer: {}, }; const testPost = { uri: "at://did:plc:user/app.bsky.feed.post/abc", cid: "cid-abc", author: testUser, record: { text: "hi" }, };
function setup(mockApi, { pinnedPost = null, authorFeed = null } = {}) { const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; dataStore.$currentUser.set({ ...testUser, pinnedPost }); if (authorFeed) { dataStore.$authorFeeds.set(`${testUser.did}-posts`, authorFeed); } const derived = makeDerived(dataStore, patchStore, mockPreferencesProvider); return { mutations: makeMutations( mockApi, dataStore, patchStore, mockPreferencesProvider, ), dataStore, patchStore, derived, }; }
it("should set pinnedPost on currentUser and call putProfileRecord", async () => { let putRecordArgs = null; const mockApi = { getProfileRecord: async () => ({ value: { displayName: "Me" }, cid: "cid-profile", }), putProfileRecord: async (record, swapRecord) => { putRecordArgs = { record, swapRecord }; return {}; }, }; const { mutations, dataStore } = setup(mockApi);
await mutations.pinPost(testPost);
assert.deepEqual(dataStore.$currentUser.get().pinnedPost.uri, testPost.uri); assert.deepEqual(dataStore.$currentUser.get().pinnedPost.cid, testPost.cid); assert.deepEqual(putRecordArgs.record.pinnedPost.uri, testPost.uri); assert.deepEqual(putRecordArgs.record.pinnedPost.cid, testPost.cid); assert.deepEqual(putRecordArgs.record.displayName, "Me"); assert.deepEqual(putRecordArgs.swapRecord, "cid-profile"); });
it("should pin in the author feed after server success", async () => { const otherItem = { post: { uri: "at://did:plc:user/app.bsky.feed.post/other" }, }; const targetItem = { post: testPost }; const mockApi = { getProfileRecord: async () => ({ value: {}, cid: "cid-profile" }), putProfileRecord: async () => ({}), }; const { mutations, dataStore } = setup(mockApi, { authorFeed: { feed: [otherItem, targetItem], cursor: "" }, });
await mutations.pinPost(testPost);
const feed = dataStore.$authorFeeds.get(`${testUser.did}-posts`).feed; assert.deepEqual(feed[0].post.uri, testPost.uri); assert.deepEqual(feed[0].reason.$type, "app.bsky.feed.defs#reasonPin"); assert.deepEqual(feed.length, 2); });
it("should optimistically patch currentUser and author feed while in flight", async () => { const otherPost = { uri: "at://did:plc:user/app.bsky.feed.post/other", cid: "cid-other", author: testUser, record: { text: "other" }, }; const otherItem = { post: otherPost }; const targetItem = { post: testPost }; let putResolve; const putPromise = new Promise((resolve) => { putResolve = resolve; }); const mockApi = { getProfileRecord: async () => ({ value: {}, cid: "cid-profile" }), putProfileRecord: () => putPromise, }; const { mutations, derived, dataStore } = setup(mockApi, { authorFeed: { feed: [otherItem, targetItem], cursor: "" }, }); dataStore.$posts.set(otherPost.uri, otherPost); dataStore.$posts.set(testPost.uri, testPost);
const promise = mutations.pinPost(testPost); // Yield so the patches apply before we inspect them. await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(derived.$currentUser.get().pinnedPost.uri, testPost.uri); const inFlightFeed = derived.$hydratedAuthorFeeds.get( `${testUser.did}-posts`, ).feed; assert.deepEqual(inFlightFeed[0].post.uri, testPost.uri); assert.deepEqual( inFlightFeed[0].reason.$type, "app.bsky.feed.defs#reasonPin", );
putResolve({}); await promise;
// After success, dataStore matches the previously-patched view. assert.deepEqual(derived.$currentUser.get().pinnedPost.uri, testPost.uri); });
it("should revert to original state on failure", async () => { const otherPost = { uri: "at://did:plc:user/app.bsky.feed.post/other", cid: "cid-other", author: testUser, record: { text: "other" }, }; const otherItem = { post: otherPost }; const targetItem = { post: testPost }; const mockApi = { getProfileRecord: async () => ({ value: {}, cid: "cid-profile" }), putProfileRecord: async () => { throw new Error("server error"); }, }; const previousPinned = { uri: "at://did:plc:user/app.bsky.feed.post/old", cid: "cid-old", }; const { mutations, dataStore, derived } = setup(mockApi, { pinnedPost: previousPinned, authorFeed: { feed: [otherItem, targetItem], cursor: "" }, }); dataStore.$posts.set(otherPost.uri, otherPost); dataStore.$posts.set(testPost.uri, testPost);
let threw = false; try { await mutations.pinPost(testPost); } catch (e) { threw = true; } assert.deepEqual(threw, true); // Patches removed; derived reflect original dataStore. assert.deepEqual( derived.$currentUser.get().pinnedPost.uri, previousPinned.uri, ); const feed = derived.$hydratedAuthorFeeds.get(`${testUser.did}-posts`).feed; assert.deepEqual(feed[0].post.uri, otherItem.post.uri); // dataStore unchanged. assert.deepEqual( dataStore.$currentUser.get().pinnedPost.uri, previousPinned.uri, ); });});
describe("unpinPost", () => { const testUser = { did: "did:plc:user", handle: "user.test", viewer: {}, }; const testPost = { uri: "at://did:plc:user/app.bsky.feed.post/abc", cid: "cid-abc", author: testUser, record: { text: "hi" }, };
function setup(mockApi, { pinnedPost, authorFeed = null } = {}) { const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; dataStore.$currentUser.set({ ...testUser, pinnedPost }); if (authorFeed) { dataStore.$authorFeeds.set(`${testUser.did}-posts`, authorFeed); } return { mutations: makeMutations( mockApi, dataStore, patchStore, mockPreferencesProvider, ), dataStore, }; }
it("should clear pinnedPost on currentUser and putProfileRecord without it", async () => { let putRecordArgs = null; const mockApi = { getProfileRecord: async () => ({ value: { displayName: "Me", pinnedPost: { uri: testPost.uri, cid: testPost.cid }, }, cid: "cid-profile", }), putProfileRecord: async (record, swapRecord) => { putRecordArgs = { record, swapRecord }; return {}; }, }; const { mutations, dataStore } = setup(mockApi, { pinnedPost: { uri: testPost.uri, cid: testPost.cid }, });
await mutations.unpinPost(testPost);
assert.deepEqual(dataStore.$currentUser.get().pinnedPost, undefined); assert.deepEqual("pinnedPost" in putRecordArgs.record, false); assert.deepEqual(putRecordArgs.record.displayName, "Me"); });
it("should be a no-op when a different post is pinned", async () => { let putCalled = false; const mockApi = { getProfileRecord: async () => ({ value: {}, cid: "cid-profile" }), putProfileRecord: async () => { putCalled = true; return {}; }, }; const otherPinned = { uri: "at://did:plc:user/app.bsky.feed.post/other", cid: "cid-other", }; const { mutations, dataStore } = setup(mockApi, { pinnedPost: otherPinned, });
await mutations.unpinPost(testPost);
assert.deepEqual(putCalled, false); assert.deepEqual( dataStore.$currentUser.get().pinnedPost.uri, otherPinned.uri, ); });});
describe("muteProfile", () => { const profile = { did: "did:plc:target", handle: "target.bsky.social", viewer: {}, };
function setup(mockApi = {}) { const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; const mutations = makeMutations( { muteActor: async () => ({}), ...mockApi }, dataStore, patchStore, mockPreferencesProvider, ); return { mutations, dataStore }; }
it("should set viewer.muted on the profile", async () => { const { mutations, dataStore } = setup(); await mutations.muteProfile(profile); assert.deepEqual(dataStore.$profiles.get(profile.did).viewer.muted, true); });
it("should prepend muted profile to the cached list", async () => { const { mutations, dataStore } = setup(); const existing = { did: "did:plc:other", viewer: { muted: true } }; dataStore.$mutedProfiles.set({ mutes: [existing], cursor: "abc" });
await mutations.muteProfile(profile);
const stored = dataStore.$mutedProfiles.get(); assert.deepEqual(stored.mutes.length, 2); assert.deepEqual(stored.mutes[0].did, profile.did); assert.deepEqual(stored.mutes[1].did, existing.did); assert.deepEqual(stored.cursor, "abc"); });
it("should not duplicate when already present in the cached list", async () => { const { mutations, dataStore } = setup(); dataStore.$mutedProfiles.set({ mutes: [{ ...profile, viewer: { muted: true } }], cursor: null, });
await mutations.muteProfile(profile);
assert.deepEqual(dataStore.$mutedProfiles.get().mutes.length, 1); });
it("should not initialize the cached list if it was not loaded", async () => { const { mutations, dataStore } = setup(); await mutations.muteProfile(profile); assert.deepEqual(dataStore.$mutedProfiles.get(), null); });});
describe("unmuteProfile", () => { const profile = { did: "did:plc:target", handle: "target.bsky.social", viewer: { muted: true }, };
function setup(mockApi = {}) { const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; const mutations = makeMutations( { unmuteActor: async () => ({}), ...mockApi }, dataStore, patchStore, mockPreferencesProvider, ); return { mutations, dataStore }; }
it("should clear viewer.muted on the profile", async () => { const { mutations, dataStore } = setup(); await mutations.unmuteProfile(profile); assert.deepEqual(dataStore.$profiles.get(profile.did).viewer.muted, false); });
it("should remove profile from the cached list", async () => { const { mutations, dataStore } = setup(); const other = { did: "did:plc:other", viewer: { muted: true } }; dataStore.$mutedProfiles.set({ mutes: [profile, other], cursor: "abc", });
await mutations.unmuteProfile(profile);
const stored = dataStore.$mutedProfiles.get(); assert.deepEqual(stored.mutes.length, 1); assert.deepEqual(stored.mutes[0].did, other.did); assert.deepEqual(stored.cursor, "abc"); });
it("should be a no-op on the cached list when not present", async () => { const { mutations, dataStore } = setup(); const other = { did: "did:plc:other", viewer: { muted: true } }; dataStore.$mutedProfiles.set({ mutes: [other], cursor: null });
await mutations.unmuteProfile(profile);
assert.deepEqual(dataStore.$mutedProfiles.get().mutes.length, 1); });});
describe("blockProfile", () => { const profile = { did: "did:plc:target", handle: "target.bsky.social", viewer: {}, }; const blockUri = "at://did:plc:me/app.bsky.graph.block/123";
function setup(mockApi = {}) { const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; const mutations = makeMutations( { blockActor: async () => ({ uri: blockUri }), ...mockApi }, dataStore, patchStore, mockPreferencesProvider, ); return { mutations, dataStore }; }
it("should set viewer.blocking on the profile", async () => { const { mutations, dataStore } = setup(); await mutations.blockProfile(profile); assert.deepEqual( dataStore.$profiles.get(profile.did).viewer.blocking, blockUri, ); });
it("should prepend blocked profile to the cached list", async () => { const { mutations, dataStore } = setup(); const existing = { did: "did:plc:other", viewer: { blocking: "at://existing-block" }, }; dataStore.$blockedProfiles.set({ blocks: [existing], cursor: "abc" });
await mutations.blockProfile(profile);
const stored = dataStore.$blockedProfiles.get(); assert.deepEqual(stored.blocks.length, 2); assert.deepEqual(stored.blocks[0].did, profile.did); assert.deepEqual(stored.blocks[1].did, existing.did); assert.deepEqual(stored.cursor, "abc"); });
it("should not duplicate when already present in the cached list", async () => { const { mutations, dataStore } = setup(); dataStore.$blockedProfiles.set({ blocks: [{ ...profile, viewer: { blocking: blockUri } }], cursor: null, });
await mutations.blockProfile(profile);
assert.deepEqual(dataStore.$blockedProfiles.get().blocks.length, 1); });
it("should not initialize the cached list if it was not loaded", async () => { const { mutations, dataStore } = setup(); await mutations.blockProfile(profile); assert.deepEqual(dataStore.$blockedProfiles.get(), null); });
it("should update author viewer.blocking on cached posts by that author", async () => { const { mutations, dataStore } = setup(); const post = { uri: "at://did:plc:target/app.bsky.feed.post/1", author: { did: profile.did, viewer: {} }, }; const otherPost = { uri: "at://did:plc:someone/app.bsky.feed.post/1", author: { did: "did:plc:someone", viewer: {} }, }; dataStore.$posts.set(post.uri, post); dataStore.$posts.set(otherPost.uri, otherPost);
await mutations.blockProfile(profile);
assert.deepEqual( dataStore.$posts.get(post.uri).author.viewer.blocking, blockUri, ); assert.deepEqual( dataStore.$posts.get(otherPost.uri).author.viewer.blocking, undefined, ); });});
describe("unblockProfile", () => { const profile = { did: "did:plc:target", handle: "target.bsky.social", viewer: { blocking: "at://did:plc:me/app.bsky.graph.block/123" }, };
function setup(mockApi = {}) { const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; const mutations = makeMutations( { unblockActor: async () => ({}), ...mockApi }, dataStore, patchStore, mockPreferencesProvider, ); return { mutations, dataStore }; }
it("should clear viewer.blocking on the profile", async () => { const { mutations, dataStore } = setup(); await mutations.unblockProfile(profile); assert.deepEqual( dataStore.$profiles.get(profile.did).viewer.blocking, null, ); });
it("should remove profile from the cached list", async () => { const { mutations, dataStore } = setup(); const other = { did: "did:plc:other", viewer: { blocking: "at://other-block" }, }; dataStore.$blockedProfiles.set({ blocks: [profile, other], cursor: "abc", });
await mutations.unblockProfile(profile);
const stored = dataStore.$blockedProfiles.get(); assert.deepEqual(stored.blocks.length, 1); assert.deepEqual(stored.blocks[0].did, other.did); assert.deepEqual(stored.cursor, "abc"); });
it("should be a no-op on the cached list when not present", async () => { const { mutations, dataStore } = setup(); const other = { did: "did:plc:other", viewer: { blocking: "at://other-block" }, }; dataStore.$blockedProfiles.set({ blocks: [other], cursor: null });
await mutations.unblockProfile(profile);
assert.deepEqual(dataStore.$blockedProfiles.get().blocks.length, 1); });
it("should clear author viewer.blocking on cached posts by that author", async () => { const { mutations, dataStore } = setup(); const post = { uri: "at://did:plc:target/app.bsky.feed.post/1", author: { did: profile.did, viewer: { blocking: "at://old" } }, }; dataStore.$posts.set(post.uri, post);
await mutations.unblockProfile(profile);
assert.deepEqual( dataStore.$posts.get(post.uri).author.viewer.blocking, null, ); });});
describe("addBookmark", () => { const testPost = { uri: "at://did:test/app.bsky.feed.post/test", bookmarkCount: 2, viewer: { bookmarked: false }, };
function setup(mockApi = {}) { const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; const mutations = makeMutations( { createBookmark: async () => ({}), ...mockApi }, dataStore, patchStore, mockPreferencesProvider, ); return { mutations, dataStore, patchStore }; }
it("should add optimistic patch immediately", () => { const { mutations, patchStore } = setup({ createBookmark: () => new Promise((resolve) => setTimeout(resolve, 100)), }); mutations.addBookmark(testPost); const patched = applyPostPatches(patchStore, testPost); assert.deepEqual(patched.viewer.bookmarked, true); assert.deepEqual(patched.bookmarkCount, 3); });
it("should update dataStore and remove patch on success", async () => { const { mutations, dataStore, patchStore } = setup(); await mutations.addBookmark(testPost); const stored = dataStore.$posts.get(testPost.uri); assert.deepEqual(stored.viewer.bookmarked, true); assert.deepEqual(stored.bookmarkCount, 3); assert.deepEqual(applyPostPatches(patchStore, stored), stored); });
it("should prepend post to the cached bookmarks feed", async () => { const { mutations, dataStore } = setup(); const existingItem = { item: { uri: "at://did:test/app.bsky.feed.post/other" }, }; dataStore.$bookmarks.set({ bookmarks: [existingItem], cursor: "abc" });
await mutations.addBookmark(testPost);
const stored = dataStore.$bookmarks.get(); assert.deepEqual(stored.bookmarks.length, 2); assert.deepEqual(stored.bookmarks[0].item.uri, testPost.uri); assert.deepEqual(stored.bookmarks[1].item.uri, existingItem.item.uri); assert.deepEqual(stored.cursor, "abc"); });
it("should not initialize the bookmarks feed if it was not loaded", async () => { const { mutations, dataStore } = setup(); await mutations.addBookmark(testPost); assert.deepEqual(dataStore.$bookmarks.get(), null); });});
describe("removeBookmark", () => { const testPost = { uri: "at://did:test/app.bsky.feed.post/test", bookmarkCount: 3, viewer: { bookmarked: true }, };
function setup(mockApi = {}) { const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; const mutations = makeMutations( { deleteBookmark: async () => ({}), ...mockApi }, dataStore, patchStore, mockPreferencesProvider, ); return { mutations, dataStore, patchStore }; }
it("should add optimistic patch immediately", () => { const { mutations, patchStore } = setup({ deleteBookmark: () => new Promise((resolve) => setTimeout(resolve, 100)), }); mutations.removeBookmark(testPost); const patched = applyPostPatches(patchStore, testPost); assert.deepEqual(patched.viewer.bookmarked, false); assert.deepEqual(patched.bookmarkCount, 2); });
it("should update dataStore and remove patch on success", async () => { const { mutations, dataStore, patchStore } = setup(); await mutations.removeBookmark(testPost); const stored = dataStore.$posts.get(testPost.uri); assert.deepEqual(stored.viewer.bookmarked, false); assert.deepEqual(stored.bookmarkCount, 2); assert.deepEqual(applyPostPatches(patchStore, stored), stored); });
it("should remove post from the cached bookmarks feed", async () => { const { mutations, dataStore } = setup(); const otherItem = { item: { uri: "at://did:test/app.bsky.feed.post/other" }, }; dataStore.$bookmarks.set({ bookmarks: [{ item: testPost }, otherItem], cursor: "abc", });
await mutations.removeBookmark(testPost);
const stored = dataStore.$bookmarks.get(); assert.deepEqual(stored.bookmarks.length, 1); assert.deepEqual(stored.bookmarks[0].item.uri, otherItem.item.uri); assert.deepEqual(stored.cursor, "abc"); });});
describe("createRepost", () => { const currentUser = { did: "did:plc:me", handle: "me.test", viewer: {}, }; const testPost = { uri: "at://did:plc:author/app.bsky.feed.post/1", cid: "cid-1", author: { did: "did:plc:author", viewer: {} }, repostCount: 4, viewer: { repost: null }, };
function setup(mockApi = {}, { authorFeed } = {}) { const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; dataStore.$currentUser.set(currentUser); if (authorFeed) { dataStore.$authorFeeds.set(`${currentUser.did}-posts`, authorFeed); } const mutations = makeMutations( { createRepostRecord: async () => ({ uri: "at://did:plc:me/app.bsky.feed.repost/abc", cid: "repost-cid", }), ...mockApi, }, dataStore, patchStore, mockPreferencesProvider, ); return { mutations, dataStore, patchStore }; }
it("should add optimistic patch immediately", () => { const { mutations, patchStore } = setup({ createRepostRecord: () => new Promise((resolve) => setTimeout(() => resolve({ uri: "x", cid: "y" }), 100), ), }); mutations.createRepost(testPost); const patched = applyPostPatches(patchStore, testPost); assert.deepEqual(patched.repostCount, 5); assert.deepEqual(patched.viewer.repost, "fake repost"); });
it("should update dataStore with repost uri and incremented count", async () => { const { mutations, dataStore } = setup(); await mutations.createRepost(testPost); const stored = dataStore.$posts.get(testPost.uri); assert.deepEqual( stored.viewer.repost, "at://did:plc:me/app.bsky.feed.repost/abc", ); assert.deepEqual(stored.repostCount, 5); });
it("should add a reasonRepost feed item to the current user's author feed", async () => { const { mutations, dataStore } = setup( {}, { authorFeed: { feed: [], cursor: "c1" } }, ); await mutations.createRepost(testPost); const feed = dataStore.$authorFeeds.get(`${currentUser.did}-posts`); assert.deepEqual(feed.feed.length, 1); assert.deepEqual(feed.feed[0].post.uri, testPost.uri); assert.deepEqual( feed.feed[0].reason.$type, "app.bsky.feed.defs#reasonRepost", ); assert.deepEqual(feed.feed[0].reason.by.did, currentUser.did); assert.deepEqual( feed.feed[0].reason.uri, "at://did:plc:me/app.bsky.feed.repost/abc", ); assert.deepEqual(feed.cursor, "c1"); });
it("should not add a duplicate feed item when the feed already has the repost", async () => { // A refresh-delivered reasonRepost may omit the optional uri field. const existingItem = { post: { uri: testPost.uri }, reason: { $type: "app.bsky.feed.defs#reasonRepost", by: currentUser, indexedAt: "2024-01-01T00:00:00.000Z", }, }; const { mutations, dataStore } = setup( {}, { authorFeed: { feed: [existingItem], cursor: "c1" } }, ); await mutations.createRepost(testPost); const feed = dataStore.$authorFeeds.get(`${currentUser.did}-posts`); assert.deepEqual(feed.feed.length, 1); assert.deepEqual(feed.feed[0], existingItem); });});
describe("deleteRepost", () => { const currentUser = { did: "did:plc:me", handle: "me.test", viewer: {}, }; const repostUri = "at://did:plc:me/app.bsky.feed.repost/abc"; const testPost = { uri: "at://did:plc:author/app.bsky.feed.post/1", cid: "cid-1", author: { did: "did:plc:author", viewer: {} }, repostCount: 5, viewer: { repost: repostUri }, };
function setup(mockApi = {}, { authorFeed } = {}) { const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; dataStore.$currentUser.set(currentUser); if (authorFeed) { dataStore.$authorFeeds.set(`${currentUser.did}-posts`, authorFeed); } const mutations = makeMutations( { deleteRepostRecord: async () => ({}), ...mockApi }, dataStore, patchStore, mockPreferencesProvider, ); return { mutations, dataStore, patchStore }; }
it("should add optimistic patch immediately", () => { const { mutations, patchStore } = setup({ deleteRepostRecord: () => new Promise((resolve) => setTimeout(resolve, 100)), }); mutations.deleteRepost(testPost); const patched = applyPostPatches(patchStore, testPost); assert.deepEqual(patched.repostCount, 4); assert.deepEqual(patched.viewer.repost, null); });
it("should update dataStore clearing repost uri and decrementing count", async () => { const { mutations, dataStore } = setup(); await mutations.deleteRepost(testPost); const stored = dataStore.$posts.get(testPost.uri); assert.deepEqual(stored.viewer.repost, null); assert.deepEqual(stored.repostCount, 4); });
it("should remove the matching repost feed item from the author feed", async () => { const matchingItem = { post: testPost, reason: { $type: "app.bsky.feed.defs#reasonRepost", uri: repostUri, }, }; const otherItem = { post: { uri: "at://did:plc:other/app.bsky.feed.post/2", }, }; const { mutations, dataStore } = setup( {}, { authorFeed: { feed: [matchingItem, otherItem], cursor: "c1" } }, );
await mutations.deleteRepost(testPost);
const feed = dataStore.$authorFeeds.get(`${currentUser.did}-posts`); assert.deepEqual(feed.feed.length, 1); assert.deepEqual(feed.feed[0].post.uri, otherItem.post.uri); assert.deepEqual(feed.cursor, "c1"); });});
describe("pinFeed", () => { const feedUri = "at://did:plc:feed/app.bsky.feed.generator/cool";
function setupWithPreferences(preferencesObj) { let updatedPreferences = null; const preferences = new Preferences(preferencesObj, []); const mockPreferencesProvider = { requirePreferences: () => preferences, updatePreferences: async (prefs) => { updatedPreferences = prefs; }, }; const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mutations = makeMutations( {}, dataStore, patchStore, mockPreferencesProvider, ); return { mutations, patchStore, getUpdatedPreferences: () => updatedPreferences, }; }
it("should append a pinned saved-feed entry when not previously saved", async () => { const { mutations, getUpdatedPreferences } = setupWithPreferences([ { $type: "app.bsky.actor.defs#savedFeedsPrefV2", items: [], }, ]); await mutations.pinFeed(feedUri); const pinned = getUpdatedPreferences().getPinnedFeeds(); assert.deepEqual(pinned.length, 1); assert.deepEqual(pinned[0].value, feedUri); assert.deepEqual(pinned[0].pinned, true); });
it("should pin an existing saved-feed entry without duplicating it", async () => { const { mutations, getUpdatedPreferences } = setupWithPreferences([ { $type: "app.bsky.actor.defs#savedFeedsPrefV2", items: [ { id: "1", value: feedUri, type: "feed", pinned: false }, { id: "2", value: "at://did:plc:feed/app.bsky.feed.generator/other", type: "feed", pinned: true, }, ], }, ]); await mutations.pinFeed(feedUri); const updated = getUpdatedPreferences(); const allItems = updated.obj[0].items; assert.deepEqual(allItems.length, 2); assert.deepEqual(updated.isFeedPinned(feedUri), true); });
it("should add an optimistic patch and remove it on success", async () => { let updateResolve; const updatePromise = new Promise((resolve) => { updateResolve = resolve; }); const preferences = new Preferences( [{ $type: "app.bsky.actor.defs#savedFeedsPrefV2", items: [] }], [], ); const mockPreferencesProvider = { requirePreferences: () => preferences, updatePreferences: () => updatePromise, }; const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mutations = makeMutations( {}, dataStore, patchStore, mockPreferencesProvider, );
const promise = mutations.pinFeed(feedUri); const patches = patchStore.$preferencePatches.get(); assert.deepEqual(patches.length, 1); assert.deepEqual(patches[0].body.type, "pinFeed"); assert.deepEqual(patches[0].body.feedUri, feedUri);
updateResolve(); await promise; assert.deepEqual(patchStore.$preferencePatches.get().length, 0); });});
describe("unpinFeed", () => { const feedUri = "at://did:plc:feed/app.bsky.feed.generator/cool";
it("should clear the pinned flag on the saved-feed entry", async () => { let updatedPreferences = null; const preferences = new Preferences( [ { $type: "app.bsky.actor.defs#savedFeedsPrefV2", items: [{ id: "1", value: feedUri, type: "feed", pinned: true }], }, ], [], ); const mockPreferencesProvider = { requirePreferences: () => preferences, updatePreferences: async (prefs) => { updatedPreferences = prefs; }, }; const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mutations = makeMutations( {}, dataStore, patchStore, mockPreferencesProvider, );
await mutations.unpinFeed(feedUri); assert.deepEqual(updatedPreferences.isFeedPinned(feedUri), false); assert.deepEqual(updatedPreferences.obj[0].items.length, 1); });
it("should add an optimistic patch and remove it on success", async () => { let updateResolve; const updatePromise = new Promise((resolve) => { updateResolve = resolve; }); const preferences = new Preferences( [ { $type: "app.bsky.actor.defs#savedFeedsPrefV2", items: [{ id: "1", value: feedUri, type: "feed", pinned: true }], }, ], [], ); const mockPreferencesProvider = { requirePreferences: () => preferences, updatePreferences: () => updatePromise, }; const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mutations = makeMutations( {}, dataStore, patchStore, mockPreferencesProvider, );
const promise = mutations.unpinFeed(feedUri); const patches = patchStore.$preferencePatches.get(); assert.deepEqual(patches.length, 1); assert.deepEqual(patches[0].body.type, "unpinFeed"); assert.deepEqual(patches[0].body.feedUri, feedUri);
updateResolve(); await promise; assert.deepEqual(patchStore.$preferencePatches.get().length, 0); });});
describe("setPinnedItems", () => { const feedA = "at://did:plc:x/app.bsky.feed.generator/a"; const feedB = "at://did:plc:x/app.bsky.feed.generator/b"; const listA = "at://did:plc:x/app.bsky.graph.list/a";
function setup({ preloadPinnedItems = true } = {}) { let updatedPreferences = null; let resolveUpdate; const updatePromise = new Promise((resolve) => { resolveUpdate = resolve; }); const preferences = new Preferences( [ { $type: "app.bsky.actor.defs#savedFeedsPrefV2", items: [ { id: "1", value: "following", type: "timeline", pinned: true, }, { id: "2", value: feedA, type: "feed", pinned: true }, { id: "3", value: listA, type: "list", pinned: true }, { id: "4", value: feedB, type: "feed", pinned: false }, ], }, ], [], ); const mockPreferencesProvider = { requirePreferences: () => preferences, updatePreferences: async (prefs) => { updatedPreferences = prefs; await updatePromise; }, }; const dataStore = new DataStore(createSessionState(null)); if (preloadPinnedItems) { dataStore.$pinnedItems.set([ { type: "timeline", data: { uri: "following" } }, { type: "feed", data: { uri: feedA } }, { type: "list", data: { uri: listA } }, ]); } const patchStore = new PatchStore(); const mutations = makeMutations( {}, dataStore, patchStore, mockPreferencesProvider, ); return { mutations, patchStore, dataStore, resolveUpdate, getUpdatedPreferences: () => updatedPreferences, }; }
it("does not touch $pinnedItems until the network round-trip resolves", async () => { const { mutations, dataStore, resolveUpdate } = setup(); const before = dataStore.$pinnedItems .get() .map((it) => (it.type === "timeline" ? "following" : it.data.uri)); const promise = mutations.setPinnedItems([listA, "following", feedA]); const during = dataStore.$pinnedItems .get() .map((it) => (it.type === "timeline" ? "following" : it.data.uri)); assert.deepEqual(during, before); resolveUpdate(); await promise; const after = dataStore.$pinnedItems .get() .map((it) => (it.type === "timeline" ? "following" : it.data.uri)); assert.deepEqual(after, [listA, "following", feedA]); });
it("sends the reordered preferences to updatePreferences", async () => { const { mutations, resolveUpdate, getUpdatedPreferences } = setup(); const promise = mutations.setPinnedItems([feedA, listA, "following"]); resolveUpdate(); await promise; const items = getUpdatedPreferences().obj[0].items; assert.deepEqual( items.map((it) => it.value), [feedA, listA, "following", feedB], ); });
it("leaves $pinnedItems untouched when updatePreferences rejects", async () => { const preferences = new Preferences( [ { $type: "app.bsky.actor.defs#savedFeedsPrefV2", items: [ { id: "1", value: feedA, type: "feed", pinned: true }, { id: "2", value: listA, type: "list", pinned: true }, ], }, ], [], ); const error = new Error("network"); const mockPreferencesProvider = { requirePreferences: () => preferences, updatePreferences: async () => { throw error; }, }; const dataStore = new DataStore(createSessionState(null)); dataStore.$pinnedItems.set([ { type: "feed", data: { uri: feedA } }, { type: "list", data: { uri: listA } }, ]); const patchStore = new PatchStore(); const mutations = makeMutations( {}, dataStore, patchStore, mockPreferencesProvider, ); const originalConsoleError = console.error; console.error = () => {}; try { await assert.rejects( () => mutations.setPinnedItems([listA, feedA]), (err) => err === error, ); } finally { console.error = originalConsoleError; } const after = dataStore.$pinnedItems.get().map((it) => it.data.uri); assert.deepEqual(after, [feedA, listA]); });});
describe("hidePost", () => { const testPost = { uri: "at://did:plc:author/app.bsky.feed.post/1" };
it("should write a preference adding the post to the hidden list", async () => { let updatedPreferences = null; const preferences = new Preferences([], []); const mockPreferencesProvider = { requirePreferences: () => preferences, updatePreferences: async (prefs) => { updatedPreferences = prefs; }, }; const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mutations = makeMutations( {}, dataStore, patchStore, mockPreferencesProvider, );
await mutations.hidePost(testPost);
assert.deepEqual(updatedPreferences.isPostHidden(testPost.uri), true); });
it("should add an optimistic post patch and remove it on success", async () => { let updateResolve; const updatePromise = new Promise((resolve) => { updateResolve = resolve; }); const preferences = new Preferences([], []); const mockPreferencesProvider = { requirePreferences: () => preferences, updatePreferences: () => updatePromise, }; const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mutations = makeMutations( {}, dataStore, patchStore, mockPreferencesProvider, );
const promise = mutations.hidePost(testPost); const patches = patchStore.$postPatches.get(testPost.uri) || []; assert.deepEqual(patches.length, 1); assert.deepEqual(patches[0].body.type, "hidePost");
updateResolve(); await promise; assert.deepEqual( (patchStore.$postPatches.get(testPost.uri) || []).length, 0, ); });});
describe("updateMutedWord", () => { it("should call updatePreferences with the word updated", async () => { let updatedPreferences = null; const existingPrefs = new Preferences( [ { $type: "app.bsky.actor.defs#mutedWordsPref", items: [ { id: "word-1", value: "old-value", targets: ["content"], actorTarget: "all", }, ], }, ], [], ); const mockPreferencesProvider = { requirePreferences: () => existingPrefs, updatePreferences: async (prefs) => { updatedPreferences = prefs; }, }; const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mutations = makeMutations( {}, dataStore, patchStore, mockPreferencesProvider, );
await mutations.updateMutedWord("word-1", { value: "new-value", targets: ["tag"], });
const words = updatedPreferences.getMutedWords(); assert.deepEqual(words.length, 1); assert.deepEqual(words[0].value, "new-value"); assert.deepEqual(words[0].targets[0], "tag"); assert.deepEqual(words[0].actorTarget, "all"); });});
describe("updatePostNotificationSubscription", () => { const profile = { did: "did:plc:target", handle: "target.bsky.social", viewer: {}, };
it("should set viewer.activitySubscription on the profile", async () => { const subscription = { post: true, reply: false }; const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; let calledWith = null; const mutations = makeMutations( { putActivitySubscription: async (did, sub) => { calledWith = { did, sub }; }, }, dataStore, patchStore, mockPreferencesProvider, );
await mutations.updatePostNotificationSubscription(profile, subscription);
assert.deepEqual(calledWith.did, profile.did); assert.deepEqual(calledWith.sub, subscription); assert.deepEqual( dataStore.$profiles.get(profile.did).viewer.activitySubscription, subscription, ); });
it("should remove the patch on failure and rethrow", async () => { const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; const mutations = makeMutations( { putActivitySubscription: async () => { throw new Error("api error"); }, }, dataStore, patchStore, mockPreferencesProvider, );
let threw = false; try { await mutations.updatePostNotificationSubscription(profile, { post: true, }); } catch (e) { threw = true; } assert.deepEqual(threw, true); assert.deepEqual(patchStore._getProfilePatches(profile.did).length, 0); });});
describe("updatePostgateEmbeddingRules", () => { const userDid = "did:plc:me"; const postUri = `at://${userDid}/app.bsky.feed.post/abc`; const basePost = { uri: postUri, cid: "cid-abc", author: { did: userDid, viewer: {} }, record: { text: "hi" }, viewer: {}, }; const disableRule = { $type: "app.bsky.feed.postgate#disableRule" };
function recordNotFoundError() { return new ApiError({ status: 400, statusText: "Bad Request", data: { error: "RecordNotFound" }, headers: {}, url: "", }); }
function setup(mockApi, { post = basePost } = {}) { const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; dataStore.$currentUser.set({ did: userDid, handle: "me.test" }); dataStore.$posts.set(post.uri, post); return { mutations: makeMutations( mockApi, dataStore, patchStore, mockPreferencesProvider, ), dataStore, patchStore, }; }
it("creates a record when disabling quotes with no prior record", async () => { let putArgs = null; const mockApi = { getPostgateRecord: async () => { throw recordNotFoundError(); }, putPostgateRecord: async (rkey, record, swapRecord) => { putArgs = { rkey, record, swapRecord }; return { cid: "cid-gate" }; }, }; const { mutations, dataStore } = setup(mockApi);
await mutations.updatePostgateEmbeddingRules(basePost, [disableRule]);
assert.deepEqual(putArgs.rkey, "abc"); assert.deepEqual(putArgs.swapRecord, null); assert.deepEqual(putArgs.record.embeddingRules, [disableRule]); assert.deepEqual(putArgs.record.post, postUri); assert.deepEqual( dataStore.$posts.get(postUri).viewer.embeddingDisabled, true, ); });
it("writes rules without a disable rule but keeps embedding enabled", async () => { let putArgs = null; const futureRule = { $type: "app.bsky.feed.postgate#futureRule" }; const mockApi = { getPostgateRecord: async () => { throw recordNotFoundError(); }, putPostgateRecord: async (rkey, record, swapRecord) => { putArgs = { rkey, record, swapRecord }; return { cid: "cid-gate" }; }, }; const { mutations, dataStore } = setup(mockApi);
await mutations.updatePostgateEmbeddingRules(basePost, [futureRule]);
assert.deepEqual(putArgs.record.embeddingRules, [futureRule]); assert.deepEqual( dataStore.$posts.get(postUri).viewer.embeddingDisabled, false, ); });
it("skips the write when enabling quotes with no prior record", async () => { let putCalled = false; const mockApi = { getPostgateRecord: async () => { throw recordNotFoundError(); }, putPostgateRecord: async () => { putCalled = true; return { cid: "x" }; }, }; const { mutations, dataStore } = setup(mockApi);
await mutations.updatePostgateEmbeddingRules(basePost, null);
assert.deepEqual(putCalled, false); assert.deepEqual( dataStore.$posts.get(postUri).viewer.embeddingDisabled, false, ); });
it("preserves detachedEmbeddingUris and passes the prior cid as swap", async () => { let putArgs = null; const detachedEmbeddingUris = [ "at://did:plc:other/app.bsky.feed.post/quote1", ]; const mockApi = { getPostgateRecord: async () => ({ cid: "cid-prior", value: { $type: "app.bsky.feed.postgate", post: postUri, embeddingRules: [disableRule], detachedEmbeddingUris, }, }), putPostgateRecord: async (rkey, record, swapRecord) => { putArgs = { rkey, record, swapRecord }; return { cid: "cid-next" }; }, }; const { mutations, dataStore } = setup(mockApi, { post: { ...basePost, viewer: { embeddingDisabled: true } }, });
await mutations.updatePostgateEmbeddingRules(basePost, null);
assert.deepEqual(putArgs.swapRecord, "cid-prior"); assert.deepEqual( putArgs.record.detachedEmbeddingUris, detachedEmbeddingUris, ); assert(!("embeddingRules" in putArgs.record)); assert.deepEqual( dataStore.$posts.get(postUri).viewer.embeddingDisabled, false, ); });
it("removes the patch and rethrows on failure", async () => { const mockApi = { getPostgateRecord: async () => { throw recordNotFoundError(); }, putPostgateRecord: async () => { throw new Error("network down"); }, }; const { mutations, patchStore } = setup(mockApi);
await assert.rejects( () => mutations.updatePostgateEmbeddingRules(basePost, [disableRule]), /network down/, ); assert.deepEqual(patchStore.$postPatches.get(postUri) ?? [], []); });
it("refuses to write for a post the user doesn't own", async () => { const foreignPost = { ...basePost, uri: "at://did:plc:other/app.bsky.feed.post/abc", }; const { mutations } = setup({}, { post: foreignPost }); await assert.rejects( () => mutations.updatePostgateEmbeddingRules(foreignPost, [disableRule]), /post author/, ); });});
describe("updateThreadgateAllow", () => { const userDid = "did:plc:me"; const postUri = `at://${userDid}/app.bsky.feed.post/abc`; const basePost = { uri: postUri, cid: "cid-abc", author: { did: userDid, viewer: {} }, record: { text: "hi" }, };
function recordNotFoundError() { const error = new ApiError({ status: 400, statusText: "Bad Request", data: { error: "RecordNotFound" }, headers: {}, url: "", }); return error; }
function setup(mockApi, { post = basePost } = {}) { const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; dataStore.$currentUser.set({ did: userDid, handle: "me.test" }); dataStore.$posts.set(post.uri, post); return { mutations: makeMutations( mockApi, dataStore, patchStore, mockPreferencesProvider, ), dataStore, patchStore, }; }
it("creates a record when none exists, with a null swap", async () => { let putArgs = null; const mockApi = { getThreadgateRecord: async () => { throw recordNotFoundError(); }, putThreadgateRecord: async (rkey, record, swapRecord) => { putArgs = { rkey, record, swapRecord }; return { cid: "cid-new-gate" }; }, }; const { mutations, dataStore } = setup(mockApi);
await mutations.updateThreadgateAllow(basePost, []);
assert.deepEqual(putArgs.rkey, "abc"); assert.deepEqual(putArgs.swapRecord, null); assert.deepEqual(putArgs.record.allow, []); assert.deepEqual(putArgs.record.post, postUri); const updated = dataStore.$posts.get(postUri); assert.deepEqual(updated.threadgate.record.allow, []); assert.deepEqual(updated.threadgate.cid, "cid-new-gate"); assert.deepEqual( updated.threadgate.uri, `at://${userDid}/app.bsky.feed.threadgate/abc`, ); });
it("preserves hiddenReplies and passes the prior cid as swap", async () => { let putArgs = null; const hiddenReplies = [`at://${userDid}/app.bsky.feed.post/reply`]; const mockApi = { getThreadgateRecord: async () => ({ cid: "cid-prior", value: { $type: "app.bsky.feed.threadgate", post: postUri, allow: [], hiddenReplies, createdAt: "2024-01-01T00:00:00.000Z", }, }), putThreadgateRecord: async (rkey, record, swapRecord) => { putArgs = { rkey, record, swapRecord }; return { cid: "cid-next" }; }, }; const { mutations } = setup(mockApi);
const allow = [{ $type: "app.bsky.feed.threadgate#mentionRule" }]; await mutations.updateThreadgateAllow(basePost, allow);
assert.deepEqual(putArgs.swapRecord, "cid-prior"); assert.deepEqual(putArgs.record.hiddenReplies, hiddenReplies); assert.deepEqual(putArgs.record.allow, allow); });
it("deletes the allow key when editing back to everybody", async () => { let putArgs = null; const mockApi = { getThreadgateRecord: async () => ({ cid: "cid-prior", value: { $type: "app.bsky.feed.threadgate", post: postUri, allow: [], }, }), putThreadgateRecord: async (rkey, record, swapRecord) => { putArgs = { rkey, record, swapRecord }; return { cid: "cid-next" }; }, }; const { mutations, dataStore } = setup(mockApi);
await mutations.updateThreadgateAllow(basePost, null);
assert(!("allow" in putArgs.record)); assert(!("allow" in dataStore.$posts.get(postUri).threadgate.record)); });
it("fails and reverts on a swap conflict", async () => { const mockApi = { getThreadgateRecord: async () => ({ cid: "cid-prior", value: { $type: "app.bsky.feed.threadgate", post: postUri }, }), putThreadgateRecord: async () => { throw new ApiError({ status: 400, statusText: "Bad Request", data: { error: "InvalidSwap" }, headers: {}, url: "", }); }, }; const { mutations, patchStore } = setup(mockApi);
await assert.rejects(() => mutations.updateThreadgateAllow(basePost, [])); assert.deepEqual(patchStore.$postPatches.get(postUri) ?? [], []); });
it("applies an optimistic patch during the write and removes it after", async () => { let resolvePut; const mockApi = { getThreadgateRecord: async () => { throw recordNotFoundError(); }, putThreadgateRecord: () => new Promise((resolve) => { resolvePut = () => resolve({ cid: "cid-next" }); }), }; const { mutations, dataStore, patchStore } = setup(mockApi);
const promise = mutations.updateThreadgateAllow(basePost, []); await new Promise((resolve) => setTimeout(resolve, 0)); const patched = applyPostPatches(patchStore, dataStore.$posts.get(postUri)); assert.deepEqual(patched.threadgate.record.allow, []);
resolvePut(); await promise; assert.deepEqual(patchStore.$postPatches.get(postUri) ?? [], []); });
it("removes the patch and rethrows on failure", async () => { const mockApi = { getThreadgateRecord: async () => { throw recordNotFoundError(); }, putThreadgateRecord: async () => { throw new Error("network down"); }, }; const { mutations, dataStore, patchStore } = setup(mockApi);
await assert.rejects( () => mutations.updateThreadgateAllow(basePost, []), /network down/, ); assert.deepEqual(patchStore.$postPatches.get(postUri) ?? [], []); assert.deepEqual(dataStore.$posts.get(postUri).threadgate, undefined); });
it("refuses to write for a post the user doesn't own", async () => { const foreignPost = { ...basePost, uri: "at://did:plc:other/app.bsky.feed.post/abc", }; const { mutations } = setup({}, { post: foreignPost }); await assert.rejects( () => mutations.updateThreadgateAllow(foreignPost, []), /thread author/, ); });});
describe("createThread", () => { const currentUserDid = "did:plc:me"; const newPostUri = `at://${currentUserDid}/app.bsky.feed.post/new`;
function setup({ replyPostThread, authorFeed, replyAuthorFeed } = {}) { const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; const mutations = makeMutations( {}, dataStore, patchStore, mockPreferencesProvider, ); const fullPost = { uri: newPostUri, cid: "cid-new", author: { did: currentUserDid, viewer: {} }, record: { text: "hello" }, viewer: {}, }; mutations.postCreator = { createThread: async () => ({ uris: [fullPost.uri], posts: [fullPost], }), }; if (replyPostThread) { dataStore.$postThreads.set(replyPostThread.post.uri, replyPostThread); } if (authorFeed) { dataStore.$authorFeeds.set(`${currentUserDid}-posts`, authorFeed); } if (replyAuthorFeed) { dataStore.$authorFeeds.set(`${currentUserDid}-replies`, replyAuthorFeed); } return { mutations, dataStore, fullPost }; }
it("should store the new post and mark priorityReply", async () => { const { mutations, dataStore } = setup(); const result = await mutations.createThread({ posts: [{ postText: "hello" }], }); assert.deepEqual(result.uris, [newPostUri]); const stored = dataStore.$posts.get(newPostUri); assert.deepEqual(stored.uri, newPostUri); assert.deepEqual(stored.viewer.priorityReply, true); });
it("should add the new post to the author posts feed when loaded", async () => { const { mutations, dataStore } = setup({ authorFeed: { feed: [], cursor: "c1" }, }); await mutations.createThread({ posts: [{ postText: "hello" }] }); const feed = dataStore.$authorFeeds.get(`${currentUserDid}-posts`); assert.deepEqual(feed.feed.length, 1); assert.deepEqual(feed.feed[0].post.uri, newPostUri); assert.deepEqual(feed.cursor, "c1"); });
it("should prepend the reply to the parent's post thread when present", async () => { const replyTo = { uri: "at://did:plc:other/app.bsky.feed.post/parent", cid: "cid-parent", }; const replyRoot = replyTo; const replyPostThread = { post: replyTo, replies: [ { $type: "app.bsky.feed.defs#threadViewPost", post: { uri: "at://did:plc:other/app.bsky.feed.post/existing" }, replies: [], }, ], }; const { mutations, dataStore } = setup({ replyPostThread, replyAuthorFeed: { feed: [], cursor: "c1" }, });
await mutations.createThread({ posts: [{ postText: "hi" }], replyTo, replyRoot, });
const updatedThread = dataStore.$postThreads.get(replyTo.uri); assert.deepEqual(updatedThread.replies.length, 2); assert.deepEqual(updatedThread.replies[0].post.uri, newPostUri); const repliesFeed = dataStore.$authorFeeds.get(`${currentUserDid}-replies`); assert.deepEqual(repliesFeed.feed.length, 1); assert.deepEqual(repliesFeed.feed[0].post.uri, newPostUri); });
it("still resolves with uris when the app view fetch fails, without mutating stores", async () => { const { mutations, dataStore } = setup({ authorFeed: { feed: [], cursor: "c1" }, }); mutations.postCreator = { createThread: async () => ({ uris: [newPostUri], posts: null, }), };
const result = await mutations.createThread({ posts: [{ postText: "hello" }], });
assert.deepEqual(result.uris, [newPostUri]); assert.deepEqual(result.posts, null); assert.deepEqual(dataStore.$posts.get(newPostUri), null); const feed = dataStore.$authorFeeds.get(`${currentUserDid}-posts`); assert.deepEqual(feed.feed.length, 0); });});
describe("deletePost", () => { it("should call api.deletePost and replace the stored post with a not-found post", async () => { const post = { uri: "at://did:plc:me/app.bsky.feed.post/abc", cid: "cid-abc", }; let apiCalledWith = null; const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; dataStore.$posts.set(post.uri, { ...post, record: { text: "hi" } }); const mutations = makeMutations( { deletePost: async (passed) => { apiCalledWith = passed; }, }, dataStore, patchStore, mockPreferencesProvider, );
await mutations.deletePost(post);
assert.deepEqual(apiCalledWith, post); const stored = dataStore.$posts.get(post.uri); assert.deepEqual(stored.uri, post.uri); assert.deepEqual(stored.$type, "app.bsky.feed.defs#notFoundPost"); });});
describe("createMessage", () => { const convoId = "convo-1"; const sentMessage = { id: "msg-1", text: "hello", sender: { did: "did:plc:me" }, };
function setup({ convoMessages, convo } = {}) { const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; if (convoMessages) { dataStore.$convoMessages.set(convoId, convoMessages); } if (convo) { dataStore.$convos.set(convoId, convo); } const mutations = makeMutations( { sendMessage: async () => sentMessage }, dataStore, patchStore, mockPreferencesProvider, ); return { mutations, dataStore }; }
it("should store the new message and return it", async () => { const { mutations, dataStore } = setup(); const result = await mutations.createMessage(convoId, { text: "hello" }); assert.deepEqual(result, sentMessage); assert.deepEqual(dataStore.$messages.get(sentMessage.id), sentMessage); });
it("should prepend the message to the cached convo messages", async () => { const existingMessage = { id: "msg-old", text: "earlier" }; const { mutations, dataStore } = setup({ convoMessages: { messages: [existingMessage], cursor: "c1" }, }); await mutations.createMessage(convoId, { text: "hello" }); const stored = dataStore.$convoMessages.get(convoId); assert.deepEqual(stored.messages.length, 2); assert.deepEqual(stored.messages[0].id, sentMessage.id); assert.deepEqual(stored.messages[1].id, existingMessage.id); assert.deepEqual(stored.cursor, "c1"); });
it("should update the convo's lastMessage", async () => { const convo = { id: convoId, unreadCount: 0 }; const { mutations, dataStore } = setup({ convo }); await mutations.createMessage(convoId, { text: "hello" }); const stored = dataStore.$convos.get(convoId); assert.deepEqual(stored.lastMessage.id, sentMessage.id); assert.deepEqual( stored.lastMessage.$type, "chat.bsky.convo.defs#messageView", ); });
it("should pass replyTo to the api", async () => { let apiCalledWith = null; const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; const mutations = makeMutations( { sendMessage: async (id, body) => { apiCalledWith = body; return sentMessage; }, }, dataStore, patchStore, mockPreferencesProvider, ); await mutations.createMessage(convoId, { text: "hello", replyTo: { messageId: "msg-target" }, }); assert.deepEqual(apiCalledWith.replyTo.messageId, "msg-target"); });
it("should pass embed to the api", async () => { let apiCalledWith = null; const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; const mutations = makeMutations( { sendMessage: async (id, body) => { apiCalledWith = body; return sentMessage; }, }, dataStore, patchStore, mockPreferencesProvider, ); const embed = { $type: "app.bsky.embed.record", record: { uri: "at://did:plc:abc/app.bsky.feed.post/3abc", cid: "cid1" }, }; await mutations.createMessage(convoId, { text: "hello", embed }); assert.deepEqual(apiCalledWith.embed, embed); });
it("should propagate the raw error on send failure", async () => { const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; const mutations = makeMutations( { sendMessage: async () => { throw new Error("block between recipient and sender"); }, }, dataStore, patchStore, mockPreferencesProvider, ); let caught = null; try { await mutations.createMessage(convoId, { text: "hello" }); } catch (error) { caught = error; } assert.deepEqual(caught?.message, "block between recipient and sender"); });});
describe("acceptConvo", () => { const convo = { id: "convo-1", status: "request" };
function setup({ convoList, convoRequestList } = {}) { const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; if (convoList) { dataStore.$convoList.set({ convos: convoList, cursor: "list-cursor" }); } if (convoRequestList) { dataStore.$convoRequestList.set({ convos: convoRequestList, cursor: "request-cursor", }); } let acceptCalledWith = null; const mutations = makeMutations( { acceptConvo: async (id) => { acceptCalledWith = id; }, }, dataStore, patchStore, mockPreferencesProvider, ); return { mutations, dataStore, getAcceptArg: () => acceptCalledWith }; }
it("should set the convo status to accepted in the store", async () => { const { mutations, dataStore, getAcceptArg } = setup(); const result = await mutations.acceptConvo(convo); assert.deepEqual(getAcceptArg(), convo.id); assert.deepEqual(result.status, "accepted"); assert.deepEqual(dataStore.$convos.get(convo.id).status, "accepted"); });
it("should update the matching convo in the convo list", async () => { const otherConvo = { id: "convo-2", status: "accepted" }; const { mutations, dataStore } = setup({ convoList: [convo, otherConvo], }); await mutations.acceptConvo(convo); const list = dataStore.$convoList.get(); assert.deepEqual(list.convos.length, 2); assert.deepEqual( list.convos.find((c) => c.id === convo.id).status, "accepted", ); assert.deepEqual( list.convos.find((c) => c.id === otherConvo.id).status, "accepted", ); assert.deepEqual(list.cursor, "list-cursor"); });
it("should remove the convo from the request list", async () => { const otherRequest = { id: "convo-3", status: "request" }; const { mutations, dataStore } = setup({ convoRequestList: [convo, otherRequest], }); await mutations.acceptConvo(convo); const requestList = dataStore.$convoRequestList.get(); assert.deepEqual(requestList.convos.length, 1); assert.deepEqual(requestList.convos[0].id, otherRequest.id); assert.deepEqual(requestList.cursor, "request-cursor"); });
it("should add the convo to the convo list when not already present", async () => { const otherConvo = { id: "convo-2", status: "accepted" }; const { mutations, dataStore } = setup({ convoList: [otherConvo], }); await mutations.acceptConvo(convo); const list = dataStore.$convoList.get(); assert.deepEqual(list.convos.length, 2); assert.deepEqual( list.convos.find((c) => c.id === convo.id).status, "accepted", ); });});
describe("createGroupChat", () => { function setup({ convoList, apiFailure } = {}) { const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; if (convoList) { dataStore.$convoList.set({ convos: convoList, cursor: "list-cursor" }); } const groupConvo = { id: "convo-group-1", status: "accepted", kind: { $type: "chat.bsky.convo.defs#groupConvo", name: "Trip" }, }; const calls = []; const mutations = makeMutations( { createGroupChat: async (name, memberDids, options) => { calls.push({ name, memberDids, options }); if (apiFailure) throw apiFailure; return { convo: groupConvo }; }, }, dataStore, patchStore, mockPreferencesProvider, ); return { mutations, dataStore, calls, groupConvo }; }
it("should call the api and return the created convo", async () => { const { mutations, calls, groupConvo } = setup(); const result = await mutations.createGroupChat("Trip", [ "did:plc:alice", "did:plc:bob", ]); assert.deepEqual(calls.length, 1); assert.deepEqual(calls[0].name, "Trip"); assert.deepEqual(calls[0].memberDids, ["did:plc:alice", "did:plc:bob"]); assert.deepEqual(result, groupConvo); });
it("should save the convo and add it to the convo list", async () => { const otherConvo = { id: "convo-2", status: "accepted" }; const { mutations, dataStore, groupConvo } = setup({ convoList: [otherConvo], }); await mutations.createGroupChat("Trip", ["did:plc:alice"]); assert.deepEqual(dataStore.$convos.get(groupConvo.id), groupConvo); const list = dataStore.$convoList.get(); assert.deepEqual( list.convos.map((convo) => convo.id), [groupConvo.id, otherConvo.id], ); assert.deepEqual(list.cursor, "list-cursor"); });
it("should rethrow api failures without touching the store", async () => { const failure = new Error("BlockedActor"); const { mutations, dataStore } = setup({ convoList: [], apiFailure: failure, }); await assert.rejects( () => mutations.createGroupChat("Trip", ["did:plc:alice"]), failure, ); assert.deepEqual(dataStore.$convoList.get().convos, []); });});
describe("rejectConvo", () => { const convo = { id: "convo-1", status: "request" };
it("should clear the convo, call api.leaveConvo, and remove it from the request list only", async () => { const otherAccepted = { id: "convo-2", status: "accepted" }; const otherRequest = { id: "convo-3", status: "request" }; const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; dataStore.$convos.set(convo.id, convo); dataStore.$convoList.set({ convos: [otherAccepted], cursor: "list-cursor", }); dataStore.$convoRequestList.set({ convos: [convo, otherRequest], cursor: "request-cursor", }); let leaveCalledWith = null; const mutations = makeMutations( { leaveConvo: async (id) => { leaveCalledWith = id; }, }, dataStore, patchStore, mockPreferencesProvider, );
await mutations.rejectConvo(convo);
assert.deepEqual(leaveCalledWith, convo.id); assert.deepEqual(dataStore.$convos.get(convo.id), null); const requestList = dataStore.$convoRequestList.get(); assert.deepEqual(requestList.convos.length, 1); assert.deepEqual(requestList.convos[0].id, otherRequest.id); assert.deepEqual(requestList.cursor, "request-cursor"); // Accepted list must not be touched by rejectConvo. const acceptedList = dataStore.$convoList.get(); assert.deepEqual(acceptedList.convos.length, 1); assert.deepEqual(acceptedList.convos[0].id, otherAccepted.id); assert.deepEqual(acceptedList.cursor, "list-cursor"); });});
describe("leaveConvo", () => { const convo = { id: "convo-1", status: "accepted" };
it("should clear the convo, call api.leaveConvo, and remove it from the accepted list only", async () => { const otherAccepted = { id: "convo-2", status: "accepted" }; const otherRequest = { id: "convo-3", status: "request" }; const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; dataStore.$convos.set(convo.id, convo); dataStore.$convoList.set({ convos: [convo, otherAccepted], cursor: "list-cursor", }); dataStore.$convoRequestList.set({ convos: [otherRequest], cursor: "request-cursor", }); let leaveCalledWith = null; const mutations = makeMutations( { leaveConvo: async (id) => { leaveCalledWith = id; }, }, dataStore, patchStore, mockPreferencesProvider, );
await mutations.leaveConvo(convo);
assert.deepEqual(leaveCalledWith, convo.id); assert.deepEqual(dataStore.$convos.get(convo.id), null); const acceptedList = dataStore.$convoList.get(); assert.deepEqual(acceptedList.convos.length, 1); assert.deepEqual(acceptedList.convos[0].id, otherAccepted.id); assert.deepEqual(acceptedList.cursor, "list-cursor"); // Request list must not be touched by leaveConvo. const requestList = dataStore.$convoRequestList.get(); assert.deepEqual(requestList.convos.length, 1); assert.deepEqual(requestList.convos[0].id, otherRequest.id); assert.deepEqual(requestList.cursor, "request-cursor"); });
it("should leave the store unchanged when api.leaveConvo throws", async () => { const otherAccepted = { id: "convo-2", status: "accepted" }; const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; dataStore.$convos.set(convo.id, convo); dataStore.$convoList.set({ convos: [convo, otherAccepted], cursor: "list-cursor", }); const mutations = makeMutations( { leaveConvo: async () => { throw new Error("boom"); }, }, dataStore, patchStore, mockPreferencesProvider, );
await assert.rejects(() => mutations.leaveConvo(convo));
assert.deepEqual(dataStore.$convos.get(convo.id), convo); const acceptedList = dataStore.$convoList.get(); assert.deepEqual(acceptedList.convos.length, 2); assert.deepEqual(acceptedList.convos[0].id, convo.id); });});
describe("setConvoMuted", () => { const convo = { id: "convo-1", muted: false };
it("should optimistically patch, then write to dataStore and clear the patch on success", async () => { const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; dataStore.$convos.set(convo.id, convo); let muteCalledWith = null; let patchDuringApi = null; const mutations = makeMutations( { muteConvo: async (id) => { muteCalledWith = id; patchDuringApi = applyConvoPatches( patchStore, dataStore.$convos.get(convo.id), ); }, }, dataStore, patchStore, mockPreferencesProvider, );
await mutations.setConvoMuted(convo, true);
assert.deepEqual(muteCalledWith, convo.id); // While the API call was in flight the patched view was already muted. assert.deepEqual(patchDuringApi.muted, true); // On success the dataStore is updated and the patch is cleared. assert.deepEqual(dataStore.$convos.get(convo.id).muted, true); assert.deepEqual(patchStore.$convoPatches.get(convo.id), []); });
it("should call api.unmuteConvo when muted=false", async () => { const mutedConvo = { id: "convo-1", muted: true }; const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; dataStore.$convos.set(mutedConvo.id, mutedConvo); let unmuteCalledWith = null; const mutations = makeMutations( { unmuteConvo: async (id) => { unmuteCalledWith = id; }, }, dataStore, patchStore, mockPreferencesProvider, );
await mutations.setConvoMuted(mutedConvo, false);
assert.deepEqual(unmuteCalledWith, mutedConvo.id); assert.deepEqual(dataStore.$convos.get(mutedConvo.id).muted, false); });
it("should revert the optimistic patch and leave the dataStore unchanged when the api throws", async () => { const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; dataStore.$convos.set(convo.id, convo); const mutations = makeMutations( { muteConvo: async () => { throw new Error("boom"); }, }, dataStore, patchStore, mockPreferencesProvider, );
await assert.rejects(() => mutations.setConvoMuted(convo, true));
assert.deepEqual(dataStore.$convos.get(convo.id).muted, false); assert.deepEqual(patchStore.$convoPatches.get(convo.id), []); assert.deepEqual( applyConvoPatches(patchStore, dataStore.$convos.get(convo.id)).muted, false, ); });
it("should not write to dataStore if the underlying convo was cleared during the api call", async () => { const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; dataStore.$convos.set(convo.id, convo); const mutations = makeMutations( { muteConvo: async () => { dataStore.$convos.set(convo.id, null); }, }, dataStore, patchStore, mockPreferencesProvider, );
await mutations.setConvoMuted(convo, true);
assert.deepEqual(dataStore.$convos.get(convo.id), null); assert.deepEqual(patchStore.$convoPatches.get(convo.id), []); });});
describe("markConvoAsRead", () => { it("should call api.markConvoAsRead and zero the unread count", async () => { const convoId = "convo-1"; const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; dataStore.$convos.set(convoId, { id: convoId, unreadCount: 4 }); let calledWith = null; const mutations = makeMutations( { markConvoAsRead: async (id) => { calledWith = id; }, }, dataStore, patchStore, mockPreferencesProvider, );
await mutations.markConvoAsRead(convoId);
assert.deepEqual(calledWith, convoId); assert.deepEqual(dataStore.$convos.get(convoId).unreadCount, 0); });
it("should not throw when the convo is not cached", async () => { const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; const mutations = makeMutations( { markConvoAsRead: async () => {} }, dataStore, patchStore, mockPreferencesProvider, ); await mutations.markConvoAsRead("missing"); // SignalMap returns null for uninitialized keys (was `undefined` pre-refactor). assert.deepEqual(dataStore.$convos.get("missing"), null); });
it("should not call the api when the convo has no unread messages", async () => { const convoId = "convo-read"; const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; dataStore.$convos.set(convoId, { id: convoId, unreadCount: 0 }); let callCount = 0; const mutations = makeMutations( { markConvoAsRead: async () => { callCount++; }, }, dataStore, patchStore, mockPreferencesProvider, );
await mutations.markConvoAsRead(convoId);
assert.deepEqual(callCount, 0); });});
describe("addMessageReaction", () => { const convoId = "convo-1"; const messageId = "msg-1"; const currentUserDid = "did:plc:me"; const emoji = "👍"; const updatedMessage = { id: messageId, reactions: [{ value: emoji, sender: { did: currentUserDid } }], };
function setup({ convo } = {}) { const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; dataStore.$currentUser.set({ did: currentUserDid, handle: "me.test" }); if (convo) { dataStore.$convos.set(convoId, convo); } const mutations = makeMutations( { addMessageReaction: async () => updatedMessage }, dataStore, patchStore, mockPreferencesProvider, ); return { mutations, dataStore, patchStore }; }
it("should add an optimistic patch with the reaction", () => { const { mutations, patchStore } = setup(); mutations.addMessageReaction(convoId, messageId, emoji); const patches = patchStore._getMessagePatches(messageId); assert.deepEqual(patches.length, 1); assert.deepEqual(patches[0].body.type, "addReaction"); assert.deepEqual(patches[0].body.reaction.value, emoji); assert.deepEqual(patches[0].body.reaction.sender.did, currentUserDid); });
it("should store the returned message and clear the patch on success", async () => { const { mutations, dataStore, patchStore } = setup(); await mutations.addMessageReaction(convoId, messageId, emoji); assert.deepEqual(dataStore.$messages.get(messageId), updatedMessage); assert.deepEqual(patchStore._getMessagePatches(messageId).length, 0); });
it("should update the convo's lastReaction when the convo is cached", async () => { const { mutations, dataStore } = setup({ convo: { id: convoId, unreadCount: 0 }, }); await mutations.addMessageReaction(convoId, messageId, emoji); const convo = dataStore.$convos.get(convoId); assert.deepEqual( convo.lastReaction.$type, "chat.bsky.convo.defs#messageAndReactionView", ); assert.deepEqual(convo.lastReaction.message.id, messageId); assert.deepEqual(convo.lastReaction.reaction.value, emoji); });});
describe("removeMessageReaction", () => { const convoId = "convo-1"; const messageId = "msg-1"; const currentUserDid = "did:plc:me"; const emoji = "👍"; const updatedMessage = { id: messageId, reactions: [] };
function setup({ convo } = {}) { const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; dataStore.$currentUser.set({ did: currentUserDid, handle: "me.test" }); if (convo) { dataStore.$convos.set(convoId, convo); } const mutations = makeMutations( { removeMessageReaction: async () => updatedMessage }, dataStore, patchStore, mockPreferencesProvider, ); return { mutations, dataStore, patchStore }; }
it("should add an optimistic removeReaction patch", () => { const { mutations, patchStore } = setup(); mutations.removeMessageReaction(convoId, messageId, emoji); const patches = patchStore._getMessagePatches(messageId); assert.deepEqual(patches.length, 1); assert.deepEqual(patches[0].body.type, "removeReaction"); assert.deepEqual(patches[0].body.value, emoji); assert.deepEqual(patches[0].body.currentUserDid, currentUserDid); });
it("should store the returned message and clear the patch on success", async () => { const { mutations, dataStore, patchStore } = setup(); await mutations.removeMessageReaction(convoId, messageId, emoji); assert.deepEqual(dataStore.$messages.get(messageId), updatedMessage); assert.deepEqual(patchStore._getMessagePatches(messageId).length, 0); });
it("should clear the convo's lastReaction when the convo is cached", async () => { const { mutations, dataStore } = setup({ convo: { id: convoId, lastReaction: { existing: true }, }, }); await mutations.removeMessageReaction(convoId, messageId, emoji); assert.deepEqual(dataStore.$convos.get(convoId).lastReaction, null); });});
describe("sendShowLessInteraction", () => { const postURI = "at://did:plc:author/app.bsky.feed.post/1"; const feedUri = "at://did:plc:feedgen/app.bsky.feed.generator/cool"; const feedContext = "ctx"; const feedProxyUrl = "https://feed.example/xrpc";
it("should append the interaction to the dataStore (empty list branch)", async () => { const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; let sendArgs = null; const mutations = makeMutations( { sendInteractions: async (interactions, proxy) => { sendArgs = { interactions, proxy }; }, }, dataStore, patchStore, mockPreferencesProvider, );
await mutations.sendShowLessInteraction( postURI, feedUri, feedContext, feedProxyUrl, );
const stored = dataStore.$showLessInteractions.get(feedUri); assert.deepEqual(stored.length, 1); assert.deepEqual(stored[0].item, postURI); assert.deepEqual(stored[0].event, "app.bsky.feed.defs#requestLess"); assert.deepEqual(stored[0].feedContext, feedContext); assert.deepEqual(sendArgs.interactions.length, 1); assert.deepEqual(sendArgs.interactions[0].item, postURI); assert.deepEqual(sendArgs.proxy, feedProxyUrl); });
it("should append to an existing list (non-empty branch)", async () => { const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; dataStore.$showLessInteractions.set(feedUri, [ { item: "existing", event: "x" }, ]); const mutations = makeMutations( { sendInteractions: async () => {} }, dataStore, patchStore, mockPreferencesProvider, );
await mutations.sendShowLessInteraction( postURI, feedUri, feedContext, feedProxyUrl, );
const stored = dataStore.$showLessInteractions.get(feedUri); assert.deepEqual(stored.length, 2); assert.deepEqual(stored[1].item, postURI); });
it("should key stored interactions by feed", async () => { const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; const mutations = makeMutations( { sendInteractions: async () => {} }, dataStore, patchStore, mockPreferencesProvider, ); const otherFeedUri = "at://did:plc:feedgen/app.bsky.feed.generator/other";
await mutations.sendShowLessInteraction( postURI, feedUri, feedContext, feedProxyUrl, );
assert.deepEqual(dataStore.$showLessInteractions.get(feedUri).length, 1); assert.deepEqual(dataStore.$showLessInteractions.get(otherFeedUri), null); });
it("should omit feedContext when null but keep an empty string", async () => { const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; const sentInteractions = []; const mutations = makeMutations( { sendInteractions: async (interactions) => { sentInteractions.push(...interactions); }, }, dataStore, patchStore, mockPreferencesProvider, );
await mutations.sendShowLessInteraction( postURI, feedUri, null, feedProxyUrl, ); await mutations.sendShowLessInteraction(postURI, feedUri, "", feedProxyUrl);
assert.deepEqual(sentInteractions, [ { item: postURI, event: "app.bsky.feed.defs#requestLess" }, { item: postURI, event: "app.bsky.feed.defs#requestLess", feedContext: "", }, ]); });
it("should store but not send when there is no feed proxy url", async () => { const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; const sentInteractions = []; const mutations = makeMutations( { sendInteractions: async (interactions) => { sentInteractions.push(...interactions); }, }, dataStore, patchStore, mockPreferencesProvider, );
await mutations.sendShowLessInteraction(postURI, feedUri, null, null);
assert.deepEqual(sentInteractions, []); assert.deepEqual(dataStore.$showLessInteractions.get(feedUri).length, 1); });});
describe("sendShowMoreInteraction", () => { const postURI = "at://did:plc:author/app.bsky.feed.post/1"; const feedUri = "at://did:plc:feedgen/app.bsky.feed.generator/cool"; const feedContext = "ctx"; const feedProxyUrl = "https://feed.example/xrpc";
it("should append the interaction to the dataStore (empty list branch)", async () => { const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; let sendArgs = null; const mutations = makeMutations( { sendInteractions: async (interactions, proxy) => { sendArgs = { interactions, proxy }; }, }, dataStore, patchStore, mockPreferencesProvider, );
await mutations.sendShowMoreInteraction( postURI, feedUri, feedContext, feedProxyUrl, );
const stored = dataStore.$showMoreInteractions.get(feedUri); assert.deepEqual(stored.length, 1); assert.deepEqual(stored[0].item, postURI); assert.deepEqual(stored[0].event, "app.bsky.feed.defs#requestMore"); assert.deepEqual(stored[0].feedContext, feedContext); assert.deepEqual(sendArgs.interactions[0].item, postURI); assert.deepEqual(sendArgs.proxy, feedProxyUrl); });
it("should append to an existing list (non-empty branch)", async () => { const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; dataStore.$showMoreInteractions.set(feedUri, [ { item: "existing", event: "x" }, ]); const mutations = makeMutations( { sendInteractions: async () => {} }, dataStore, patchStore, mockPreferencesProvider, );
await mutations.sendShowMoreInteraction( postURI, feedUri, feedContext, feedProxyUrl, );
const stored = dataStore.$showMoreInteractions.get(feedUri); assert.deepEqual(stored.length, 2); assert.deepEqual(stored[1].item, postURI); });});
describe("pinList", () => { const listUri = "at://did:test/app.bsky.graph.list/abc";
function makeMockProvider({ updatePreferences } = {}) { const pinFeedCalls = []; return { pinFeedCalls, provider: { requirePreferences: () => ({ pinFeed: (feedUri, type) => { pinFeedCalls.push({ feedUri, type }); return Preferences.createLoggedOutPreferences(); }, }), updatePreferences: updatePreferences ?? (async () => {}), }, }; }
it("should add optimistic patch with entryType 'list'", () => { const { provider } = makeMockProvider({ updatePreferences: async () => new Promise((resolve) => setTimeout(resolve, 100)), }); const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mutations = makeMutations({}, dataStore, patchStore, provider);
mutations.pinList(listUri);
const patches = patchStore.$preferencePatches.get(); assert.deepEqual(patches.length, 1); assert.deepEqual(patches[0].body.type, "pinFeed"); assert.deepEqual(patches[0].body.feedUri, listUri); assert.deepEqual(patches[0].body.entryType, "list"); });
it("should call preferences.pinFeed with type 'list'", async () => { const { provider, pinFeedCalls } = makeMockProvider(); const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mutations = makeMutations({}, dataStore, patchStore, provider);
await mutations.pinList(listUri);
assert.deepEqual(pinFeedCalls.length, 1); assert.deepEqual(pinFeedCalls[0].feedUri, listUri); assert.deepEqual(pinFeedCalls[0].type, "list"); });
it("should remove patch after successful update", async () => { const { provider } = makeMockProvider(); const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mutations = makeMutations({}, dataStore, patchStore, provider);
await mutations.pinList(listUri);
assert.deepEqual(patchStore.$preferencePatches.get().length, 0); });
it("should remove patch even on error", async () => { const { provider } = makeMockProvider({ updatePreferences: async () => { throw new Error("API error"); }, }); const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mutations = makeMutations({}, dataStore, patchStore, provider);
let errorThrown = false; try { await mutations.pinList(listUri); } catch (e) { errorThrown = true; }
assert.deepEqual(errorThrown, true); assert.deepEqual(patchStore.$preferencePatches.get().length, 0); });});
describe("pinFeed entryType", () => { it("should add optimistic patch with entryType 'feed'", () => { const feedUri = "at://did:test/app.bsky.feed.generator/xyz"; const preferences = new Preferences( [{ $type: "app.bsky.actor.defs#savedFeedsPrefV2", items: [] }], [], ); const mockPreferencesProvider = { requirePreferences: () => preferences, updatePreferences: () => new Promise((resolve) => setTimeout(resolve, 100)), }; const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mutations = makeMutations( {}, dataStore, patchStore, mockPreferencesProvider, );
mutations.pinFeed(feedUri);
const patches = patchStore.$preferencePatches.get(); assert.deepEqual(patches.length, 1); assert.deepEqual(patches[0].body.entryType, "feed"); });});
describe("unpinList", () => { const listUri = "at://did:test/app.bsky.graph.list/abc";
it("should call preferences.unpinFeed with the list URI", async () => { const unpinCalls = []; const provider = { requirePreferences: () => ({ unpinFeed: (uri) => { unpinCalls.push(uri); return Preferences.createLoggedOutPreferences(); }, }), updatePreferences: async () => {}, }; const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mutations = makeMutations({}, dataStore, patchStore, provider);
await mutations.unpinList(listUri);
assert.deepEqual(unpinCalls.length, 1); assert.deepEqual(unpinCalls[0], listUri); });
it("should add and remove an unpinFeed patch", async () => { let updateResolve; const updatePromise = new Promise((resolve) => { updateResolve = resolve; }); const provider = { requirePreferences: () => ({ unpinFeed: () => Preferences.createLoggedOutPreferences(), }), updatePreferences: () => updatePromise, }; const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mutations = makeMutations({}, dataStore, patchStore, provider);
const promise = mutations.unpinList(listUri); const patches = patchStore.$preferencePatches.get(); assert.deepEqual(patches.length, 1); assert.deepEqual(patches[0].body.type, "unpinFeed"); assert.deepEqual(patches[0].body.feedUri, listUri);
updateResolve(); await promise; assert.deepEqual(patchStore.$preferencePatches.get().length, 0); });});
describe("addProfileToList", () => { const testProfile = { did: "did:test:profile", handle: "test.user", }; const testList = { uri: "at://did:test:owner/app.bsky.graph.list/abc", name: "Test List", };
it("should add the membership after the API call succeeds", async () => { const mockApi = { createListItemRecord: async () => ({ uri: "listitem-real-uri" }), }; const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; const mutations = makeMutations( mockApi, dataStore, patchStore, mockPreferencesProvider, );
dataStore.$listsWithMembershipByActor.set(testProfile.did, { listsWithMembership: [{ list: testList }], cursor: null, });
await mutations.addProfileToList(testProfile, testList);
const entry = dataStore.$listsWithMembershipByActor.get(testProfile.did); assert.deepEqual(entry.listsWithMembership.length, 1); assert.deepEqual( entry.listsWithMembership[0].listItem.uri, "listitem-real-uri", ); assert.deepEqual( entry.listsWithMembership[0].listItem.subject, testProfile.did, ); });
it("should leave the membership map untouched when no entry is cached for the actor", async () => { const mockApi = { createListItemRecord: async () => ({ uri: "listitem-real-uri" }), }; const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; const mutations = makeMutations( mockApi, dataStore, patchStore, mockPreferencesProvider, );
await mutations.addProfileToList(testProfile, testList);
assert.deepEqual( dataStore.$listsWithMembershipByActor.get(testProfile.did) ?? null, null, ); });
it("should prepend the profile to a cached list-members entry when present", async () => { const mockApi = { createListItemRecord: async () => ({ uri: "listitem-real-uri" }), }; const dataStore = new DataStore(createSessionState(null)); dataStore.$listMembers.set(testList.uri, { items: [ { uri: "listitem-other-uri", subject: { did: "did:test:other", handle: "other.user" }, }, ], cursor: null, }); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; const mutations = makeMutations( mockApi, dataStore, patchStore, mockPreferencesProvider, );
await mutations.addProfileToList(testProfile, testList);
const cached = dataStore.$listMembers.get(testList.uri); assert.deepEqual(cached.items.length, 2); assert.deepEqual(cached.items[0].uri, "listitem-real-uri"); assert.deepEqual(cached.items[0].subject.did, testProfile.did); });
it("should not touch state when the API call fails", async () => { const mockApi = { createListItemRecord: async () => { throw new Error("nope"); }, }; const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; const mutations = makeMutations( mockApi, dataStore, patchStore, mockPreferencesProvider, );
let caught = null; try { await mutations.addProfileToList(testProfile, testList); } catch (error) { caught = error; } assert.deepEqual(caught.message, "nope"); assert.deepEqual( dataStore.$listsWithMembershipByActor.get(testProfile.did) ?? null, null, ); });});
describe("removeProfileFromList", () => { const testProfile = { did: "did:test:profile", handle: "test.user", }; const testList = { uri: "at://did:test:owner/app.bsky.graph.list/abc", name: "Test List", }; const membershipUri = "at://did:test:viewer/app.bsky.graph.listitem/xyz";
it("should remove the membership after the API call succeeds", async () => { const mockApi = { deleteListItemRecord: async () => {}, }; const dataStore = new DataStore(createSessionState(null)); dataStore.$listsWithMembershipByActor.set(testProfile.did, { listsWithMembership: [ { list: testList, listItem: { uri: membershipUri, subject: testProfile.did }, }, ], cursor: null, }); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; const mutations = makeMutations( mockApi, dataStore, patchStore, mockPreferencesProvider, );
await mutations.removeProfileFromList(testProfile, testList, membershipUri);
const entry = dataStore.$listsWithMembershipByActor.get(testProfile.did); assert.deepEqual(entry.listsWithMembership[0].listItem ?? null, null); });
it("should leave the membership map untouched when no entry is cached for the actor", async () => { const mockApi = { deleteListItemRecord: async () => {}, }; const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; const mutations = makeMutations( mockApi, dataStore, patchStore, mockPreferencesProvider, );
await mutations.removeProfileFromList(testProfile, testList, membershipUri);
assert.deepEqual( dataStore.$listsWithMembershipByActor.get(testProfile.did) ?? null, null, ); });
it("should remove the profile from a cached list-members entry", async () => { const mockApi = { deleteListItemRecord: async () => {}, }; const dataStore = new DataStore(createSessionState(null)); dataStore.$listsWithMembershipByActor.set(testProfile.did, { listsWithMembership: [ { list: testList, listItem: { uri: membershipUri, subject: testProfile.did }, }, ], cursor: null, }); dataStore.$listMembers.set(testList.uri, { items: [ { uri: membershipUri, subject: { did: testProfile.did, handle: testProfile.handle }, }, { uri: "listitem-other-uri", subject: { did: "did:test:other", handle: "other.user" }, }, ], cursor: null, }); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; const mutations = makeMutations( mockApi, dataStore, patchStore, mockPreferencesProvider, );
await mutations.removeProfileFromList(testProfile, testList, membershipUri);
const cached = dataStore.$listMembers.get(testList.uri); assert.deepEqual(cached.items.length, 1); assert.deepEqual(cached.items[0].subject.did, "did:test:other"); });
it("should leave state unchanged when the API call fails", async () => { const mockApi = { deleteListItemRecord: async () => { throw new Error("boom"); }, }; const dataStore = new DataStore(createSessionState(null)); const initialListItem = { uri: membershipUri, subject: testProfile.did }; dataStore.$listsWithMembershipByActor.set(testProfile.did, { listsWithMembership: [{ list: testList, listItem: initialListItem }], cursor: null, }); const patchStore = new PatchStore(); const mockPreferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; const mutations = makeMutations( mockApi, dataStore, patchStore, mockPreferencesProvider, );
let caught = null; try { await mutations.removeProfileFromList( testProfile, testList, membershipUri, ); } catch (error) { caught = error; } assert.deepEqual(caught.message, "boom"); const entry = dataStore.$listsWithMembershipByActor.get(testProfile.did); assert.deepEqual(entry.listsWithMembership.length, 1); assert.deepEqual(entry.listsWithMembership[0].listItem.uri, membershipUri); });});
describe("$detailedProfiles mirroring", () => { const targetDid = "did:plc:target"; const baseProfile = { did: targetDid, handle: "target.bsky.social", followersCount: 10, viewer: {}, }; // Detailed shape — superset, with fields that the basic profile won't carry. const detailedSeed = { ...baseProfile, description: "Detailed bio", pinnedPost: { uri: "at://pinned" }, };
function setup(mockApi, { seedDetailed = true } = {}) { const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const preferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; dataStore.$profiles.set(targetDid, baseProfile); if (seedDetailed) { dataStore.$detailedProfiles.set(targetDid, detailedSeed); } const mutations = makeMutations( mockApi, dataStore, patchStore, preferencesProvider, ); return { mutations, dataStore }; }
it("updateProfile patches both $profiles and $detailedProfiles in place", async () => { const mockApi = { getProfileRecord: async () => ({ value: {}, cid: "cid" }), putProfileRecord: async () => ({}), }; const { mutations, dataStore } = setup(mockApi); await mutations.updateProfile(baseProfile, { displayName: "Updated Name", description: "Updated bio", }); const patchedBasic = dataStore.$profiles.get(targetDid); assert.deepEqual(patchedBasic.displayName, "Updated Name"); assert.deepEqual(patchedBasic.description, "Updated bio"); assert.deepEqual(patchedBasic.followersCount, 10); // Detailed-only fields survive on the detailed entry. const patchedDetailed = dataStore.$detailedProfiles.get(targetDid); assert.deepEqual(patchedDetailed.displayName, "Updated Name"); assert.deepEqual(patchedDetailed.description, "Updated bio"); assert.deepEqual(patchedDetailed.pinnedPost.uri, "at://pinned"); });
it("followProfile mirrors viewer.following and count into $detailedProfiles", async () => { const mockApi = { createFollowRecord: async () => ({ uri: "at://follow" }), }; const { mutations, dataStore } = setup(mockApi); await mutations.followProfile(baseProfile); const detailed = dataStore.$detailedProfiles.get(targetDid); assert.deepEqual(detailed.viewer.following, "at://follow"); assert.deepEqual(detailed.followersCount, 11); // Preserves detailed-only fields like description/pinnedPost. assert.deepEqual(detailed.description, "Detailed bio"); assert.deepEqual(detailed.pinnedPost.uri, "at://pinned"); });
it("followProfile does not seed $detailedProfiles when no entry exists", async () => { const mockApi = { createFollowRecord: async () => ({ uri: "at://follow" }), }; const { mutations, dataStore } = setup(mockApi, { seedDetailed: false }); await mutations.followProfile(baseProfile); assert.deepEqual(dataStore.$detailedProfiles.get(targetDid), null); });
it("unfollowProfile mirrors viewer.following=null and decremented count", async () => { const seedFollowed = { ...detailedSeed, viewer: { following: "at://x" } }; const mockApi = { deleteFollowRecord: async () => {} }; const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const preferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; dataStore.$profiles.set(targetDid, { ...baseProfile, viewer: { following: "at://x" }, }); dataStore.$detailedProfiles.set(targetDid, seedFollowed); const mutations = makeMutations( mockApi, dataStore, patchStore, preferencesProvider, ); await mutations.unfollowProfile({ ...baseProfile, viewer: { following: "at://x" }, }); const detailed = dataStore.$detailedProfiles.get(targetDid); assert.deepEqual(detailed.viewer.following, null); assert.deepEqual(detailed.followersCount, 9); assert.deepEqual(detailed.pinnedPost.uri, "at://pinned"); });
it("muteProfile mirrors viewer.muted=true into $detailedProfiles", async () => { const { mutations, dataStore } = setup({ muteActor: async () => ({}) }); await mutations.muteProfile(baseProfile); assert.deepEqual( dataStore.$detailedProfiles.get(targetDid).viewer.muted, true, ); });
it("unmuteProfile mirrors viewer.muted=false into $detailedProfiles", async () => { const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const preferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; dataStore.$profiles.set(targetDid, { ...baseProfile, viewer: { muted: true }, }); dataStore.$detailedProfiles.set(targetDid, { ...detailedSeed, viewer: { muted: true }, }); const mutations = makeMutations( { unmuteActor: async () => ({}) }, dataStore, patchStore, preferencesProvider, ); await mutations.unmuteProfile({ ...baseProfile, viewer: { muted: true }, }); assert.deepEqual( dataStore.$detailedProfiles.get(targetDid).viewer.muted, false, ); });
it("blockProfile mirrors viewer.blocking into $detailedProfiles", async () => { const { mutations, dataStore } = setup({ blockActor: async () => ({ uri: "at://block" }), }); await mutations.blockProfile(baseProfile); assert.deepEqual( dataStore.$detailedProfiles.get(targetDid).viewer.blocking, "at://block", ); });
it("unblockProfile mirrors viewer.blocking=null into $detailedProfiles", async () => { const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const preferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; dataStore.$profiles.set(targetDid, { ...baseProfile, viewer: { blocking: "at://block" }, }); dataStore.$detailedProfiles.set(targetDid, { ...detailedSeed, viewer: { blocking: "at://block" }, }); const mutations = makeMutations( { unblockActor: async () => {} }, dataStore, patchStore, preferencesProvider, ); await mutations.unblockProfile({ ...baseProfile, viewer: { blocking: "at://block" }, }); assert.deepEqual( dataStore.$detailedProfiles.get(targetDid).viewer.blocking, null, ); });
it("updatePostNotificationSubscription mirrors viewer.activitySubscription", async () => { const subscription = { post: true, reply: false }; const { mutations, dataStore } = setup({ putActivitySubscription: async () => ({}), }); await mutations.updatePostNotificationSubscription( baseProfile, subscription, ); assert.deepEqual( dataStore.$detailedProfiles.get(targetDid).viewer.activitySubscription, subscription, ); });});
describe("updateList", () => { const listUri = "at://did:plc:test123/app.bsky.graph.list/mylist"; const testList = { uri: listUri, cid: "listcid", name: "Old Name", description: "Old description", purpose: "app.bsky.graph.defs#curatelist", creator: { did: "did:plc:test123", handle: "test.bsky.social" }, viewer: {}, };
function setup(overrides = {}) { const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const preferencesProvider = { requirePreferences: () => Preferences.createLoggedOutPreferences(), }; dataStore.$lists.set(listUri, testList); const api = { getListRecord: async () => ({ value: { purpose: "app.bsky.graph.defs#curatelist", name: "Old Name", description: "Old description", createdAt: "2024-01-01T00:00:00.000Z", }, cid: "listcid", }), putListRecord: async () => ({}), uploadBlob: async () => ({ ref: { $link: "list-avatar-blob" }, mimeType: "image/jpeg", size: 100, }), ...overrides, }; return { api, dataStore, mutations: makeMutations(api, dataStore, patchStore, preferencesProvider), }; }
it("puts the record with merged fields and the swapCid from getListRecord", async () => { let putArgs = null; const { mutations } = setup({ putListRecord: async (rkey, record, swapCid) => { putArgs = { rkey, record, swapCid }; return {}; }, });
await mutations.updateList(testList, { name: "New Name", description: "New description", });
assert.equal(putArgs.rkey, "mylist"); assert.equal(putArgs.record.name, "New Name"); assert.equal(putArgs.record.description, "New description"); assert.equal(putArgs.record.purpose, "app.bsky.graph.defs#curatelist"); assert.equal(putArgs.record.createdAt, "2024-01-01T00:00:00.000Z"); assert.equal(putArgs.swapCid, "listcid"); });
it("uploads and sets the avatar blob when provided", async () => { let uploadCalled = false; let putRecord = null; const { mutations } = setup({ uploadBlob: async () => { uploadCalled = true; return { ref: { $link: "avatar-blob" }, mimeType: "image/jpeg", size: 100, }; }, putListRecord: async (rkey, record) => { putRecord = record; return {}; }, });
await mutations.updateList(testList, { name: "Name", description: "Desc", avatarBlob: new Blob(["x"], { type: "image/jpeg" }), });
assert.equal(uploadCalled, true); assert.deepEqual(putRecord.avatar, { ref: { $link: "avatar-blob" }, mimeType: "image/jpeg", size: 100, }); });
it("strips stale descriptionFacets from the put record when description changes", async () => { let putRecord = null; const { mutations } = setup({ getListRecord: async () => ({ value: { purpose: "app.bsky.graph.defs#curatelist", name: "Old Name", description: "Old description", descriptionFacets: [ { index: { byteStart: 0, byteEnd: 3 }, features: [] }, ], createdAt: "2024-01-01T00:00:00.000Z", }, cid: "listcid", }), putListRecord: async (rkey, record) => { putRecord = record; return {}; }, });
await mutations.updateList(testList, { name: "Old Name", description: "Totally different text", });
assert.equal("descriptionFacets" in putRecord, false); });
it("clears local descriptionFacets when description changes", async () => { const { mutations, dataStore } = setup(); dataStore.$lists.set(listUri, { ...testList, descriptionFacets: [ { index: { byteStart: 0, byteEnd: 3 }, features: [] }, ], });
await mutations.updateList(testList, { name: "Old Name", description: "Totally different text", });
assert.deepEqual(dataStore.$lists.get(listUri).descriptionFacets, []); });
it("deletes the avatar from the record when removeAvatar is true", async () => { let putRecord = null; const { mutations } = setup({ getListRecord: async () => ({ value: { purpose: "app.bsky.graph.defs#curatelist", name: "Old Name", description: "Old description", avatar: { ref: { $link: "existing-avatar" } }, createdAt: "2024-01-01T00:00:00.000Z", }, cid: "listcid", }), putListRecord: async (rkey, record) => { putRecord = record; return {}; }, });
await mutations.updateList(testList, { name: "Name", description: "Desc", removeAvatar: true, });
assert.equal("avatar" in putRecord, false); });
it("patches dataStore.$lists in place with the new name and description", async () => { // The appview lags briefly after putRecord, so we patch locally rather // than round-tripping through getList (which can return stale data). const { mutations, dataStore } = setup();
await mutations.updateList(testList, { name: "Patched Name", description: "Patched description", });
const updated = dataStore.$lists.get(listUri); assert.equal(updated.name, "Patched Name"); assert.equal(updated.description, "Patched description"); // Non-patched fields survive. assert.equal(updated.uri, listUri); assert.equal(updated.purpose, "app.bsky.graph.defs#curatelist"); });
it("sets the local avatar to a CDN URL built from the uploaded blob ref", async () => { const { mutations, dataStore } = setup({ uploadBlob: async () => ({ ref: { $link: "bafkreiavatarcid" }, mimeType: "image/jpeg", size: 100, }), });
await mutations.updateList(testList, { name: "Name", description: "Desc", avatarBlob: new Blob(["x"], { type: "image/jpeg" }), });
assert.equal( dataStore.$lists.get(listUri).avatar, `${CDN_URL}/img/avatar/plain/did:plc:test123/bafkreiavatarcid@jpeg`, ); });
it("clears the local avatar when removeAvatar is true", async () => { const { mutations, dataStore } = setup(); dataStore.$lists.set(listUri, { ...testList, avatar: "https://example.com/list-avatar.jpg", });
await mutations.updateList(testList, { name: "Name", description: "Desc", removeAvatar: true, });
assert.equal(dataStore.$lists.get(listUri).avatar, ""); });});
describe("deleteList", () => { const listUri = "at://did:plc:test123/app.bsky.graph.list/mylist"; const otherListUri = "at://did:plc:test123/app.bsky.graph.list/other"; const testList = { uri: listUri, cid: "listcid", name: "My List", purpose: "app.bsky.graph.defs#curatelist", creator: { did: "did:plc:test123", handle: "test.bsky.social" }, viewer: {}, };
function setup({ listItems = [], overrides = {} } = {}) { const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const preferences = Preferences.createLoggedOutPreferences(); const preferencesProvider = { requirePreferences: () => preferences, updatePreferences: async () => {}, }; dataStore.$lists.set(listUri, testList); const api = { getListItems: async () => ({ records: listItems, cursor: "" }), applyWrites: async () => ({}), ...overrides, }; return { api, dataStore, preferencesProvider, mutations: makeMutations(api, dataStore, patchStore, preferencesProvider), }; }
it("deletes the list record via applyWrites and clears the local list", async () => { const writesCalls = []; const { mutations, dataStore } = setup({ overrides: { applyWrites: async (writes) => { writesCalls.push(writes); return {}; }, }, });
await mutations.deleteList(testList);
assert.equal(writesCalls.length, 1); assert.deepEqual(writesCalls[0], [ { $type: "com.atproto.repo.applyWrites#delete", collection: "app.bsky.graph.list", rkey: "mylist", }, ]); assert.equal(dataStore.$lists.get(listUri), null); });
it("also deletes listitems belonging to this list, ignoring items in other lists", async () => { const writesCalls = []; const listItems = [ { uri: "at://did:plc:test123/app.bsky.graph.listitem/keep", value: { list: otherListUri }, }, { uri: "at://did:plc:test123/app.bsky.graph.listitem/item1", value: { list: listUri }, }, { uri: "at://did:plc:test123/app.bsky.graph.listitem/item2", value: { list: listUri }, }, ]; const { mutations } = setup({ listItems, overrides: { applyWrites: async (writes) => { writesCalls.push(writes); return {}; }, }, });
await mutations.deleteList(testList);
const flat = writesCalls.flat(); const deletedRkeys = flat .filter((w) => w.collection === "app.bsky.graph.listitem") .map((w) => w.rkey); assert.deepEqual(deletedRkeys.sort(), ["item1", "item2"]); assert.equal( flat.filter((w) => w.collection === "app.bsky.graph.list").length, 1, ); });
it("chunks applyWrites into batches of 10", async () => { const listItems = Array.from({ length: 25 }, (_, i) => ({ uri: `at://did:plc:test123/app.bsky.graph.listitem/item${i}`, value: { list: listUri }, })); const writesCalls = []; const { mutations } = setup({ listItems, overrides: { applyWrites: async (writes) => { writesCalls.push(writes); return {}; }, }, });
await mutations.deleteList(testList);
// 25 items + 1 list record = 26 writes → 10, 10, 6 assert.deepEqual( writesCalls.map((c) => c.length), [10, 10, 6], ); });
it("clears cached list members for the deleted list", async () => { const { mutations, dataStore } = setup(); dataStore.$listMembers.set(listUri, { items: [{ uri: "at://x", subject: { did: "did:plc:m1" } }], cursor: "", });
await mutations.deleteList(testList);
assert.equal(dataStore.$listMembers.get(listUri), null); });
it("removes the list from $actorLists for the creator", async () => { const { mutations, dataStore } = setup(); const otherList = { uri: otherListUri, name: "Other" }; dataStore.$actorLists.set(testList.creator.did, { lists: [testList, otherList], cursor: "", });
await mutations.deleteList(testList);
const remaining = dataStore.$actorLists.get(testList.creator.did); assert.deepEqual( remaining.lists.map((entry) => entry.uri), [otherListUri], ); });
it("removes the list from cached $listsWithMembershipByActor entries", async () => { const { mutations, dataStore } = setup(); dataStore.$listsWithMembershipByActor.set("did:plc:member1", { listsWithMembership: [ { list: { uri: listUri }, listItem: { uri: "at://li1" } }, { list: { uri: otherListUri }, listItem: { uri: "at://li2" } }, ], cursor: "", }); dataStore.$listsWithMembershipByActor.set("did:plc:untouched", { listsWithMembership: [ { list: { uri: otherListUri }, listItem: { uri: "at://li3" } }, ], cursor: "", });
await mutations.deleteList(testList);
assert.deepEqual( dataStore.$listsWithMembershipByActor .get("did:plc:member1") .listsWithMembership.map((entry) => entry.list.uri), [otherListUri], ); // Unrelated entries are untouched. assert.equal( dataStore.$listsWithMembershipByActor.get("did:plc:untouched") .listsWithMembership.length, 1, ); });
it("removes the list from $pinnedItems if present", async () => { const { mutations, dataStore } = setup(); dataStore.$pinnedItems.set([ { type: "timeline", data: { uri: "following" } }, { type: "list", data: { uri: listUri, displayName: "My List" } }, { type: "list", data: { uri: otherListUri, displayName: "Other" } }, ]);
await mutations.deleteList(testList);
assert.deepEqual( dataStore.$pinnedItems.get().map((item) => item.data.uri), ["following", otherListUri], ); });
it("unpins the list if it was pinned", async () => { const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); let preferences = Preferences.createLoggedOutPreferences().pinFeed( listUri, "list", ); const updateCalls = []; const preferencesProvider = { requirePreferences: () => preferences, updatePreferences: async (next) => { updateCalls.push(next); preferences = next; }, }; dataStore.$lists.set(listUri, testList); const api = { getListItems: async () => ({ records: [], cursor: "" }), applyWrites: async () => ({}), }; const mutations = makeMutations( api, dataStore, patchStore, preferencesProvider, );
assert.equal(preferences.isFeedPinned(listUri), true); await mutations.deleteList(testList);
assert.equal(updateCalls.length, 1); assert.equal(preferences.isFeedPinned(listUri), false); });});
describe("setSelectedFeedUri", () => { it("should set the selection", () => { const dataStore = new DataStore(createSessionState(null)); const mutations = makeMutations({}, dataStore, new PatchStore(), {}); mutations.setSelectedFeedUri("following"); assert.deepEqual(dataStore.$selectedFeedUri.get(), "following"); mutations.setSelectedFeedUri(null); assert.deepEqual(dataStore.$selectedFeedUri.get(), null); });});
describe("updatePostInteractionSettings", () => { function makePreferenceMutations(initialPrefs) { let updatedPreferences = null; const mockPreferencesProvider = { requirePreferences: () => new Preferences(initialPrefs, []), updatePreferences: async (prefs) => { updatedPreferences = prefs; }, }; const dataStore = new DataStore(createSessionState(null)); const patchStore = new PatchStore(); const mutations = makeMutations( {}, dataStore, patchStore, mockPreferencesProvider, ); return { mutations, getUpdatedPreferences: () => updatedPreferences }; }
it("should call updatePreferences with the new default settings", async () => { const { mutations, getUpdatedPreferences } = makePreferenceMutations([]); const rules = [{ $type: "app.bsky.feed.threadgate#followingRule" }]; const embeddingRules = [{ $type: "app.bsky.feed.postgate#disableRule" }]; await mutations.updatePostInteractionSettings({ threadgateAllowRules: rules, postgateEmbeddingRules: embeddingRules, }); assert.deepEqual(getUpdatedPreferences().getPostInteractionSettings(), { threadgateAllowRules: rules, postgateEmbeddingRules: embeddingRules, }); });
it("should persist an empty threadgate array without decaying it to null", async () => { const { mutations, getUpdatedPreferences } = makePreferenceMutations([]); await mutations.updatePostInteractionSettings({ threadgateAllowRules: [], postgateEmbeddingRules: null, }); assert.deepEqual( getUpdatedPreferences().getPostInteractionSettings().threadgateAllowRules, [], ); });
it("should clear the default when passed nulls", async () => { const { mutations, getUpdatedPreferences } = makePreferenceMutations([ { $type: "app.bsky.actor.defs#postInteractionSettingsPref", threadgateAllowRules: [], postgateEmbeddingRules: [ { $type: "app.bsky.feed.postgate#disableRule" }, ], }, ]); await mutations.updatePostInteractionSettings({ threadgateAllowRules: null, postgateEmbeddingRules: null, }); assert.deepEqual(getUpdatedPreferences().getPostInteractionSettings(), { threadgateAllowRules: null, postgateEmbeddingRules: null, }); });});