ViewController.swift 331 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653365436553656365736583659366036613662366336643665366636673668366936703671367236733674367536763677367836793680368136823683368436853686368736883689369036913692369336943695369636973698369937003701370237033704370537063707370837093710371137123713371437153716371737183719372037213722372337243725372637273728372937303731373237333734373537363737373837393740374137423743374437453746374737483749375037513752375337543755375637573758375937603761376237633764376537663767376837693770377137723773377437753776377737783779378037813782378337843785378637873788378937903791379237933794379537963797379837993800380138023803380438053806380738083809381038113812381338143815381638173818381938203821382238233824382538263827382838293830383138323833383438353836383738383839384038413842384338443845384638473848384938503851385238533854385538563857385838593860386138623863386438653866386738683869387038713872387338743875387638773878387938803881388238833884388538863887388838893890389138923893389438953896389738983899390039013902390339043905390639073908390939103911391239133914391539163917391839193920392139223923392439253926392739283929393039313932393339343935393639373938393939403941394239433944394539463947394839493950395139523953395439553956395739583959396039613962396339643965396639673968396939703971397239733974397539763977397839793980398139823983398439853986398739883989399039913992399339943995399639973998399940004001400240034004400540064007400840094010401140124013401440154016401740184019402040214022402340244025402640274028402940304031403240334034403540364037403840394040404140424043404440454046404740484049405040514052405340544055405640574058405940604061406240634064406540664067406840694070407140724073407440754076407740784079408040814082408340844085408640874088408940904091409240934094409540964097409840994100410141024103410441054106410741084109411041114112411341144115411641174118411941204121412241234124412541264127412841294130413141324133413441354136413741384139414041414142414341444145414641474148414941504151415241534154415541564157415841594160416141624163416441654166416741684169417041714172417341744175417641774178417941804181418241834184418541864187418841894190419141924193419441954196419741984199420042014202420342044205420642074208420942104211421242134214421542164217421842194220422142224223422442254226422742284229423042314232423342344235423642374238423942404241424242434244424542464247424842494250425142524253425442554256425742584259426042614262426342644265426642674268426942704271427242734274427542764277427842794280428142824283428442854286428742884289429042914292429342944295429642974298429943004301430243034304430543064307430843094310431143124313431443154316431743184319432043214322432343244325432643274328432943304331433243334334433543364337433843394340434143424343434443454346434743484349435043514352435343544355435643574358435943604361436243634364436543664367436843694370437143724373437443754376437743784379438043814382438343844385438643874388438943904391439243934394439543964397439843994400440144024403440444054406440744084409441044114412441344144415441644174418441944204421442244234424442544264427442844294430443144324433443444354436443744384439444044414442444344444445444644474448444944504451445244534454445544564457445844594460446144624463446444654466446744684469447044714472447344744475447644774478447944804481448244834484448544864487448844894490449144924493449444954496449744984499450045014502450345044505450645074508450945104511451245134514451545164517451845194520452145224523452445254526452745284529453045314532453345344535453645374538453945404541454245434544454545464547454845494550455145524553455445554556455745584559456045614562456345644565456645674568456945704571457245734574457545764577457845794580458145824583458445854586458745884589459045914592459345944595459645974598459946004601460246034604460546064607460846094610461146124613461446154616461746184619462046214622462346244625462646274628462946304631463246334634463546364637463846394640464146424643464446454646464746484649465046514652465346544655465646574658465946604661466246634664466546664667466846694670467146724673467446754676467746784679468046814682468346844685468646874688468946904691469246934694469546964697469846994700470147024703470447054706470747084709471047114712471347144715471647174718471947204721472247234724472547264727472847294730473147324733473447354736473747384739474047414742474347444745474647474748474947504751475247534754475547564757475847594760476147624763476447654766476747684769477047714772477347744775477647774778477947804781478247834784478547864787478847894790479147924793479447954796479747984799480048014802480348044805480648074808480948104811481248134814481548164817481848194820482148224823482448254826482748284829483048314832483348344835483648374838483948404841484248434844484548464847484848494850485148524853485448554856485748584859486048614862486348644865486648674868486948704871487248734874487548764877487848794880488148824883488448854886488748884889489048914892489348944895489648974898489949004901490249034904490549064907490849094910491149124913491449154916491749184919492049214922492349244925492649274928492949304931493249334934493549364937493849394940494149424943494449454946494749484949495049514952495349544955495649574958495949604961496249634964496549664967496849694970497149724973497449754976497749784979498049814982498349844985498649874988498949904991499249934994499549964997499849995000500150025003500450055006500750085009501050115012501350145015501650175018501950205021502250235024502550265027502850295030503150325033503450355036503750385039504050415042504350445045504650475048504950505051505250535054505550565057505850595060506150625063506450655066506750685069507050715072507350745075507650775078507950805081508250835084508550865087508850895090509150925093509450955096509750985099510051015102510351045105510651075108510951105111511251135114511551165117511851195120512151225123512451255126512751285129513051315132513351345135513651375138513951405141514251435144514551465147514851495150515151525153515451555156515751585159516051615162516351645165516651675168516951705171517251735174517551765177517851795180518151825183518451855186518751885189519051915192519351945195519651975198519952005201520252035204520552065207520852095210521152125213521452155216521752185219522052215222522352245225522652275228522952305231523252335234523552365237523852395240524152425243524452455246524752485249525052515252525352545255525652575258525952605261526252635264526552665267526852695270527152725273527452755276527752785279528052815282528352845285528652875288528952905291529252935294529552965297529852995300530153025303530453055306530753085309531053115312531353145315531653175318531953205321532253235324532553265327532853295330533153325333533453355336533753385339534053415342534353445345534653475348534953505351535253535354535553565357535853595360536153625363536453655366536753685369537053715372537353745375537653775378537953805381538253835384538553865387538853895390539153925393539453955396539753985399540054015402540354045405540654075408540954105411541254135414541554165417541854195420542154225423542454255426542754285429543054315432543354345435543654375438543954405441544254435444544554465447544854495450545154525453545454555456545754585459546054615462546354645465546654675468546954705471547254735474547554765477547854795480548154825483548454855486548754885489549054915492549354945495549654975498549955005501550255035504550555065507550855095510551155125513551455155516551755185519552055215522552355245525552655275528552955305531553255335534553555365537553855395540554155425543554455455546554755485549555055515552555355545555555655575558555955605561556255635564556555665567556855695570557155725573557455755576557755785579558055815582558355845585558655875588558955905591559255935594559555965597559855995600560156025603560456055606560756085609561056115612561356145615561656175618561956205621562256235624562556265627562856295630563156325633563456355636563756385639564056415642564356445645564656475648564956505651565256535654565556565657565856595660566156625663566456655666566756685669567056715672567356745675567656775678567956805681568256835684568556865687568856895690569156925693569456955696569756985699570057015702570357045705570657075708570957105711571257135714571557165717571857195720572157225723572457255726572757285729573057315732573357345735573657375738573957405741574257435744574557465747574857495750575157525753575457555756575757585759576057615762576357645765576657675768576957705771577257735774577557765777577857795780578157825783578457855786578757885789579057915792579357945795579657975798579958005801580258035804580558065807580858095810581158125813581458155816581758185819582058215822582358245825582658275828582958305831583258335834583558365837583858395840584158425843584458455846584758485849585058515852585358545855585658575858585958605861586258635864586558665867586858695870587158725873587458755876587758785879588058815882588358845885588658875888588958905891589258935894589558965897589858995900590159025903590459055906590759085909591059115912591359145915591659175918591959205921592259235924592559265927592859295930593159325933593459355936593759385939594059415942594359445945594659475948594959505951595259535954595559565957595859595960596159625963596459655966596759685969597059715972597359745975597659775978597959805981598259835984598559865987598859895990599159925993599459955996599759985999600060016002600360046005600660076008600960106011601260136014601560166017601860196020602160226023602460256026602760286029603060316032603360346035603660376038603960406041604260436044604560466047604860496050605160526053605460556056605760586059606060616062606360646065606660676068606960706071607260736074607560766077607860796080608160826083608460856086608760886089609060916092609360946095609660976098609961006101610261036104610561066107610861096110611161126113611461156116611761186119612061216122612361246125612661276128612961306131613261336134613561366137613861396140614161426143614461456146614761486149615061516152615361546155615661576158615961606161616261636164616561666167616861696170617161726173617461756176617761786179618061816182618361846185618661876188618961906191619261936194619561966197619861996200620162026203620462056206620762086209621062116212621362146215621662176218621962206221622262236224622562266227622862296230623162326233623462356236623762386239624062416242624362446245624662476248624962506251625262536254625562566257625862596260626162626263626462656266626762686269627062716272627362746275627662776278627962806281628262836284628562866287628862896290629162926293629462956296629762986299630063016302630363046305630663076308630963106311631263136314631563166317631863196320632163226323632463256326632763286329633063316332633363346335633663376338633963406341634263436344634563466347634863496350635163526353635463556356635763586359636063616362636363646365636663676368636963706371637263736374637563766377637863796380638163826383638463856386638763886389639063916392639363946395639663976398639964006401640264036404640564066407640864096410641164126413641464156416641764186419642064216422642364246425642664276428642964306431643264336434643564366437643864396440644164426443644464456446644764486449645064516452645364546455645664576458645964606461646264636464646564666467646864696470647164726473647464756476647764786479648064816482648364846485648664876488648964906491649264936494649564966497649864996500650165026503650465056506650765086509651065116512651365146515651665176518651965206521652265236524652565266527652865296530653165326533653465356536653765386539654065416542654365446545654665476548654965506551655265536554655565566557655865596560656165626563656465656566656765686569657065716572657365746575657665776578657965806581658265836584658565866587658865896590659165926593659465956596659765986599660066016602660366046605660666076608660966106611661266136614661566166617661866196620662166226623662466256626662766286629663066316632663366346635663666376638663966406641664266436644664566466647664866496650665166526653665466556656665766586659666066616662666366646665666666676668666966706671667266736674667566766677667866796680668166826683668466856686668766886689669066916692669366946695669666976698669967006701670267036704670567066707670867096710671167126713671467156716671767186719672067216722672367246725672667276728672967306731673267336734673567366737673867396740674167426743674467456746674767486749675067516752675367546755675667576758675967606761676267636764676567666767676867696770677167726773677467756776677767786779678067816782678367846785678667876788678967906791679267936794679567966797679867996800680168026803680468056806680768086809681068116812681368146815681668176818681968206821682268236824682568266827682868296830683168326833683468356836683768386839684068416842684368446845684668476848684968506851685268536854685568566857685868596860686168626863686468656866686768686869687068716872687368746875687668776878687968806881688268836884688568866887688868896890689168926893689468956896689768986899690069016902690369046905690669076908690969106911691269136914691569166917691869196920692169226923692469256926692769286929693069316932693369346935693669376938693969406941694269436944694569466947694869496950695169526953695469556956695769586959696069616962696369646965696669676968696969706971697269736974697569766977697869796980698169826983698469856986698769886989699069916992699369946995699669976998699970007001700270037004700570067007700870097010701170127013701470157016701770187019702070217022702370247025702670277028702970307031703270337034703570367037703870397040704170427043704470457046704770487049705070517052705370547055705670577058705970607061706270637064706570667067706870697070707170727073707470757076707770787079708070817082708370847085708670877088708970907091709270937094709570967097709870997100710171027103710471057106710771087109711071117112711371147115711671177118711971207121712271237124712571267127712871297130713171327133713471357136713771387139714071417142714371447145714671477148714971507151715271537154715571567157715871597160716171627163716471657166716771687169717071717172717371747175717671777178717971807181718271837184718571867187718871897190719171927193719471957196719771987199720072017202720372047205
  1. //
  2. // ViewController.swift
  3. // zoom_app
  4. //
  5. // Created by Dev Mac 1 on 14/04/2026.
  6. //
  7. import Cocoa
  8. import CryptoKit
  9. import StoreKit
  10. import WebKit
  11. class ViewController: NSViewController {
  12. enum HomeSection: String {
  13. case home = "Home"
  14. case meetings = "Meetings"
  15. case scheduler = "Scheduler"
  16. case settings = "Settings"
  17. }
  18. private let googleOAuth = GoogleOAuthService.shared
  19. private let zoomOAuth = ZoomOAuthService.shared
  20. private let loginStateKey = "zoom_app.isLoggedIn"
  21. private let darkModeDefaultsKey = "settings.darkModeEnabled"
  22. private let ratingPromptShownKey = "zoom_app.ratingPromptShown"
  23. private let ratingCompletedKey = "zoom_app.ratingCompleted"
  24. private let usageSessionStartedAtKey = "zoom_app.usageSessionStartedAt"
  25. private let meetingTimeZoneOverrideKey = "zoom_app.meetingTimeZoneOverrideByMeetingID"
  26. private struct Palette {
  27. let isDarkMode: Bool
  28. var appBackground: NSColor {
  29. isDarkMode
  30. ? NSColor(calibratedRed: 10 / 255, green: 11 / 255, blue: 12 / 255, alpha: 1)
  31. : NSColor(calibratedWhite: 0.96, alpha: 1)
  32. }
  33. var sidebarBackground: NSColor {
  34. isDarkMode
  35. ? NSColor(calibratedRed: 16 / 255, green: 17 / 255, blue: 19 / 255, alpha: 1)
  36. : NSColor(calibratedWhite: 0.94, alpha: 1)
  37. }
  38. var sidebarActiveBackground: NSColor {
  39. isDarkMode
  40. ? NSColor(calibratedRed: 22 / 255, green: 23 / 255, blue: 26 / 255, alpha: 1)
  41. : NSColor(calibratedWhite: 0.86, alpha: 1)
  42. }
  43. var cardBackground: NSColor {
  44. isDarkMode
  45. ? NSColor(calibratedRed: 20 / 255, green: 21 / 255, blue: 24 / 255, alpha: 1)
  46. : NSColor.white
  47. }
  48. var secondaryCardBackground: NSColor {
  49. isDarkMode
  50. ? NSColor(calibratedRed: 22 / 255, green: 23 / 255, blue: 26 / 255, alpha: 1)
  51. // Slightly darker than pure white so cards are visible on Light background.
  52. : NSColor(calibratedWhite: 0.93, alpha: 1)
  53. }
  54. var appShellBackground: NSColor { appBackground }
  55. var contentShellBackground: NSColor { appBackground }
  56. var topStripBackground: NSColor { chromeUnifiedBackground }
  57. var chromeUnifiedBackground: NSColor {
  58. isDarkMode
  59. ? NSColor(calibratedRed: 22 / 255, green: 23 / 255, blue: 26 / 255, alpha: 1)
  60. : NSColor(calibratedWhite: 0.92, alpha: 1)
  61. }
  62. var searchPillBackground: NSColor {
  63. isDarkMode
  64. ? NSColor.white.withAlphaComponent(0.06)
  65. : NSColor.black.withAlphaComponent(0.06)
  66. }
  67. var meetingCardBackground: NSColor {
  68. isDarkMode
  69. ? NSColor(calibratedRed: 30 / 255, green: 34 / 255, blue: 42 / 255, alpha: 1)
  70. : NSColor(calibratedWhite: 0.98, alpha: 1)
  71. }
  72. var sectionCard: NSColor {
  73. isDarkMode
  74. ? NSColor(calibratedRed: 18 / 255, green: 19 / 255, blue: 22 / 255, alpha: 1)
  75. : NSColor.white
  76. }
  77. var inputBackground: NSColor {
  78. isDarkMode
  79. ? NSColor(calibratedRed: 24 / 255, green: 25 / 255, blue: 29 / 255, alpha: 1)
  80. : NSColor(calibratedWhite: 0.97, alpha: 1)
  81. }
  82. var inputBorder: NSColor {
  83. isDarkMode
  84. ? NSColor.white.withAlphaComponent(0.08)
  85. : NSColor.black.withAlphaComponent(0.10)
  86. }
  87. var primaryText: NSColor { isDarkMode ? NSColor(calibratedWhite: 0.98, alpha: 1) : NSColor(calibratedWhite: 0.10, alpha: 1) }
  88. var secondaryText: NSColor { isDarkMode ? NSColor(calibratedWhite: 0.78, alpha: 1) : NSColor(calibratedWhite: 0.30, alpha: 1) }
  89. var mutedText: NSColor { isDarkMode ? NSColor(calibratedWhite: 0.66, alpha: 1) : NSColor(calibratedWhite: 0.42, alpha: 1) }
  90. }
  91. private final class TopAlignedClipView: NSClipView {
  92. override func constrainBoundsRect(_ proposedBounds: NSRect) -> NSRect {
  93. var rect = super.constrainBoundsRect(proposedBounds)
  94. rect.origin.x = 0
  95. return rect
  96. }
  97. }
  98. private final class HoverButton: NSButton {
  99. var normalColor: NSColor = .clear { didSet { applyBackground() } }
  100. var hoverColor: NSColor = .clear
  101. var onHoverChanged: ((Bool) -> Void)?
  102. private var tracking: NSTrackingArea?
  103. private var hovering = false { didSet { applyBackground() } }
  104. override func updateTrackingAreas() {
  105. super.updateTrackingAreas()
  106. if let tracking { removeTrackingArea(tracking) }
  107. let area = NSTrackingArea(rect: bounds, options: [.activeInActiveApp, .mouseEnteredAndExited, .inVisibleRect], owner: self, userInfo: nil)
  108. addTrackingArea(area)
  109. tracking = area
  110. }
  111. override func mouseEntered(with event: NSEvent) {
  112. hovering = true
  113. onHoverChanged?(true)
  114. }
  115. override func mouseExited(with event: NSEvent) {
  116. hovering = false
  117. onHoverChanged?(false)
  118. }
  119. private func applyBackground() {
  120. wantsLayer = true
  121. layer?.backgroundColor = (hovering ? hoverColor : normalColor).cgColor
  122. }
  123. }
  124. private final class HoverTileView: NSView {
  125. var onHoverChanged: ((Bool) -> Void)?
  126. private var tracking: NSTrackingArea?
  127. override func updateTrackingAreas() {
  128. super.updateTrackingAreas()
  129. if let tracking { removeTrackingArea(tracking) }
  130. let area = NSTrackingArea(
  131. rect: bounds,
  132. options: [.activeInActiveApp, .mouseEnteredAndExited, .inVisibleRect],
  133. owner: self,
  134. userInfo: nil
  135. )
  136. addTrackingArea(area)
  137. tracking = area
  138. }
  139. override func mouseEntered(with event: NSEvent) {
  140. super.mouseEntered(with: event)
  141. onHoverChanged?(true)
  142. }
  143. override func mouseExited(with event: NSEvent) {
  144. super.mouseExited(with: event)
  145. onHoverChanged?(false)
  146. }
  147. }
  148. private var palette = Palette(isDarkMode: true)
  149. private var darkModeEnabled: Bool {
  150. get {
  151. let hasValue = UserDefaults.standard.object(forKey: darkModeDefaultsKey) != nil
  152. return hasValue ? UserDefaults.standard.bool(forKey: darkModeDefaultsKey) : systemPrefersDarkMode()
  153. }
  154. set { UserDefaults.standard.set(newValue, forKey: darkModeDefaultsKey) }
  155. }
  156. private let sidebarWidth: CGFloat = 78
  157. private var appBackground: NSColor { palette.appBackground }
  158. private var sidebarBackground: NSColor { palette.sidebarBackground }
  159. private var sidebarActiveBackground: NSColor { palette.sidebarActiveBackground }
  160. private var cardBackground: NSColor { palette.cardBackground }
  161. private var secondaryCardBackground: NSColor { palette.secondaryCardBackground }
  162. private var appShellBackground: NSColor { palette.appShellBackground }
  163. private var contentShellBackground: NSColor { palette.contentShellBackground }
  164. private var topStripBackground: NSColor { palette.topStripBackground }
  165. private var chromeUnifiedBackground: NSColor { palette.chromeUnifiedBackground }
  166. private var searchPillBackground: NSColor { palette.searchPillBackground }
  167. private var meetingCardBackground: NSColor { palette.meetingCardBackground }
  168. private let appShellCornerRadius: CGFloat = 20
  169. private let homeChromeHeaderHeight: CGFloat = 56
  170. private let nativeTrafficLightsLeading: CGFloat = 14
  171. private let nativeTrafficLightsTopInset: CGFloat = 20
  172. private let brandLeadingInset: CGFloat = 84
  173. private let brandFullScreenLeadingInset: CGFloat = 14
  174. private let accentBlue = NSColor(calibratedRed: 27 / 255, green: 115 / 255, blue: 232 / 255, alpha: 1)
  175. private let accentOrange = NSColor(calibratedRed: 254 / 255, green: 117 / 255, blue: 46 / 255, alpha: 1)
  176. private var primaryText: NSColor { palette.primaryText }
  177. private var secondaryText: NSColor { palette.secondaryText }
  178. private var mutedText: NSColor { palette.mutedText }
  179. private let rootContainer = NSView()
  180. private var homeView: NSView?
  181. private weak var googleButton: NSButton?
  182. private weak var timeLabel: NSTextField?
  183. private weak var dateLabel: NSTextField?
  184. private weak var meetingsDayHeaderLabel: NSTextField?
  185. private weak var emptyMeetingLabel: NSTextField?
  186. private weak var meetingsListStack: NSStackView?
  187. private weak var meetingsStatusLabel: NSTextField?
  188. private weak var meetingsScrollView: NSScrollView?
  189. private weak var meetingsPrevDayButton: NSButton?
  190. private weak var meetingsNextDayButton: NSButton?
  191. private weak var meetingsTodayButton: NSButton?
  192. private weak var refreshMeetingsButton: NSButton?
  193. private weak var homeWelcomeLabel: NSTextField?
  194. private weak var homeTimeLabelView: NSTextField?
  195. private weak var homeDateLabelView: NSTextField?
  196. private weak var homeActionsRow: NSView?
  197. private weak var homeMeetingsPanel: NSView?
  198. private weak var homePlaceholderLabel: NSTextField?
  199. private weak var homeSearchField: NSTextField?
  200. private weak var homeSearchPill: NSView?
  201. private weak var meetingSearchOverlayView: NSView?
  202. private weak var meetingSearchPanelView: NSView?
  203. private weak var meetingSearchField: NSTextField?
  204. private weak var meetingSearchResultsStack: NSStackView?
  205. private weak var meetingSearchEmptyLabel: NSTextField?
  206. private weak var homeSettingsView: NSView?
  207. private weak var schedulerRootView: NSView?
  208. private weak var schedulerMonthLabel: NSTextField?
  209. private weak var schedulerCalendarGridStack: NSStackView?
  210. private weak var schedulerSelectedDateLabel: NSTextField?
  211. private weak var schedulerMeetingsListStack: NSStackView?
  212. private weak var schedulerMeetingsEmptyLabel: NSTextField?
  213. private weak var schedulerTodayButton: NSButton?
  214. private weak var meetingsPageRootView: NSView?
  215. private weak var meetingsPageCategoryFilter: NSPopUpButton?
  216. private weak var meetingsPageFromDatePicker: NSDatePicker?
  217. private weak var meetingsPageToDatePicker: NSDatePicker?
  218. private weak var meetingsPageRefreshButton: NSButton?
  219. private weak var meetingsPageCountLabel: NSTextField?
  220. private weak var meetingsPageStatusLabel: NSTextField?
  221. private weak var meetingsPageListStack: NSStackView?
  222. private weak var meetingsPageEmptyLabel: NSTextField?
  223. private weak var meetingsPageLoadMoreButton: NSButton?
  224. private weak var meetingsPageScrollView: NSScrollView?
  225. private weak var homePremiumLockOverlay: NSButton?
  226. private weak var meetingsPremiumLockOverlay: NSButton?
  227. private weak var schedulerPremiumLockOverlay: NSButton?
  228. private var schedulerDayButtons: [Date: NSButton] = [:]
  229. private var schedulerDateByButtonTag: [Int: Date] = [:]
  230. private var nextSchedulerButtonTag: Int = 1
  231. private weak var settingsDarkModeSwitch: NSSwitch?
  232. private weak var settingsUpgradeButton: NSButton?
  233. private weak var settingsUpgradeTitleLabel: NSTextField?
  234. private weak var settingsGoogleActionButton: NSButton?
  235. private weak var topBarPremiumButton: NSButton?
  236. private weak var topBarAuthButton: NSButton?
  237. private var topBarAuthButtonWidthConstraint: NSLayoutConstraint?
  238. private var profileMenuPopover: NSPopover?
  239. private weak var paywallOverlayView: NSView?
  240. private weak var paywallPanelView: NSView?
  241. private weak var paywallBodyStack: NSStackView?
  242. private weak var paywallPlansRowWide: NSStackView?
  243. private weak var paywallPlansGridNarrow: NSStackView?
  244. private var paywallIsDesktopLayout = false
  245. private var paywallPanelWidthConstraint: NSLayoutConstraint?
  246. private var paywallPanelHeightConstraint: NSLayoutConstraint?
  247. private var joinMeetingWindow: NSWindow?
  248. private var scheduleMeetingWindow: NSWindow?
  249. private weak var scheduleTopicField: NSTextField?
  250. private weak var scheduleDateField: NSTextField?
  251. private weak var scheduleTimeField: NSTextField?
  252. private weak var scheduleTimeZonePopup: NSPopUpButton?
  253. private weak var scheduleDurationField: NSTextField?
  254. private weak var scheduleSubmitButton: NSButton?
  255. private var scheduleTimeZoneDisplayToIdentifier: [String: String] = [:]
  256. private weak var joinURLField: NSTextField?
  257. private weak var joinMeetingIDField: NSTextField?
  258. private weak var joinPasscodeField: NSTextField?
  259. private weak var joinURLFieldsContainer: NSView?
  260. private weak var joinIDFieldsContainer: NSView?
  261. private weak var joinModeSegment: NSSegmentedControl?
  262. private let paywallContentWidth: CGFloat = 520
  263. private var selectedPremiumPlan: PremiumPlan = .yearly
  264. private var paywallPlanViews: [PremiumPlan: [NSView]] = [:]
  265. private var premiumPlanByView = [ObjectIdentifier: PremiumPlan]()
  266. private var paywallPlanPriceLabels: [PremiumPlan: [NSTextField]] = [:]
  267. private var paywallPlanSubtitleLabels: [PremiumPlan: [NSTextField]] = [:]
  268. private var paywallPlanStrikeLabels: [PremiumPlan: [NSTextField]] = [:]
  269. private weak var paywallOfferLabel: NSTextField?
  270. private weak var paywallContinueLabel: NSTextField?
  271. private weak var paywallContinueButton: NSView?
  272. private var paywallPurchaseTask: Task<Void, Never>?
  273. private var paywallContinueEnabled = true
  274. private var lastKnownPremiumAccess = false
  275. private var allScheduledMeetings: [ScheduledMeeting] = []
  276. private var selectedMeetingsDayStart: Date = Calendar.current.startOfDay(for: Date())
  277. private var schedulerVisibleMonthStart: Date = {
  278. let calendar = Calendar.current
  279. let today = Date()
  280. let components = calendar.dateComponents([.year, .month], from: today)
  281. return calendar.date(from: components) ?? calendar.startOfDay(for: today)
  282. }()
  283. private var selectedHomeSidebarItem: String = "Home"
  284. private var homeSidebarRowViews: [String: NSView] = [:]
  285. private var homeSidebarIconViews: [String: NSImageView] = [:]
  286. private var homeSidebarLabels: [String: NSTextField] = [:]
  287. private var searchTextObserver: NSObjectProtocol?
  288. private var searchShortcutMonitor: Any?
  289. private var searchOutsideClickMonitor: Any?
  290. private var meetingSearchTextObserver: NSObjectProtocol?
  291. private var globalSearchResults: [ScheduledMeeting] = []
  292. private var topBarProfileInitial = "H"
  293. private var clockTimer: Timer?
  294. private var meetingsRefreshTimer: Timer?
  295. private var brandLeadingConstraint: NSLayoutConstraint?
  296. private var windowFullScreenObservers: [NSObjectProtocol] = []
  297. private var isSigningIn = false
  298. private var isPromptingZoomCredentials = false
  299. private var isLoadingMeetings = false
  300. private var meetingsScrollObserver: NSObjectProtocol?
  301. private var meetingsPageScrollObserver: NSObjectProtocol?
  302. private var lastMeetingsRefreshAt = Date.distantPast
  303. private var lastScrollEdgeRefreshAt = Date.distantPast
  304. private var lastMeetingsPageAutoLoadAt = Date.distantPast
  305. // Keep this conservative to avoid Zoom API rate limits.
  306. private let meetingsRefreshInterval: TimeInterval = 60
  307. private let scrollRefreshCooldown: TimeInterval = 3
  308. private let meetingsPageAutoLoadCooldown: TimeInterval = 0.25
  309. private var meetingsRateLimitedUntil: Date?
  310. private var zoomAccountTimeZone: TimeZone?
  311. private var meetingTimeZoneOverrides: [String: String] = [:]
  312. private var meetingsPageFilteredMeetings: [ScheduledMeeting] = []
  313. private var meetingsPageSelectedRangePreset: String = "All"
  314. private var meetingsPageFromDateFilter: Date?
  315. private var meetingsPageToDateFilter: Date?
  316. private var meetingsPageVisibleCount: Int = 0
  317. private let meetingsPageSize: Int = 8
  318. private var premiumRatingPromptWorkItem: DispatchWorkItem?
  319. private var usageRatingPromptWorkItem: DispatchWorkItem?
  320. private let usageRatingPromptDelay: TimeInterval = 30 * 60
  321. private let premiumRatingPromptDelay: TimeInterval = 10
  322. private enum SidebarStyle {
  323. case home
  324. }
  325. private enum PremiumPlan: String, CaseIterable {
  326. case weekly = "com.mqldev.zoomapp.premium.weekly"
  327. case monthly = "com.mqldev.zoomapp.premium.monthly"
  328. case yearly = "com.mqldev.zoomapp.premium.yearly"
  329. case lifetime = "com.mqldev.zoomapp.premium.lifetime"
  330. var displayName: String {
  331. switch self {
  332. case .weekly: return "Premium Weekly"
  333. case .monthly: return "Premium Monthly"
  334. case .yearly: return "Premium Yearly"
  335. case .lifetime: return "Premium Lifetime"
  336. }
  337. }
  338. }
  339. private final class StoreKitCoordinator {
  340. enum PurchaseOutcome {
  341. case success
  342. case pending
  343. case cancelled
  344. case failed(String)
  345. }
  346. private(set) var productsByID: [String: Product] = [:]
  347. private(set) var activeProductIDs = Set<String>()
  348. var hasPremiumAccess: Bool { !activeProductIDs.isEmpty }
  349. var hasLifetimePremiumAccess: Bool { activeProductIDs.contains(PremiumPlan.lifetime.rawValue) }
  350. private var transactionUpdatesTask: Task<Void, Never>?
  351. var onEntitlementsChanged: ((Bool) -> Void)?
  352. deinit {
  353. transactionUpdatesTask?.cancel()
  354. }
  355. func start() async {
  356. await refreshProducts()
  357. await refreshEntitlements()
  358. observeTransactionUpdatesIfNeeded()
  359. }
  360. func refreshProducts() async {
  361. let productIDs = PremiumPlan.allCases.map(\.rawValue)
  362. var loadedProducts: [Product] = []
  363. var attempts = 0
  364. let maxAttempts = 3
  365. while attempts < maxAttempts {
  366. attempts += 1
  367. do {
  368. loadedProducts = try await Product.products(for: productIDs)
  369. } catch {
  370. loadedProducts = []
  371. }
  372. if loadedProducts.isEmpty == false {
  373. break
  374. }
  375. if attempts < maxAttempts {
  376. // StoreKit can transiently return empty right after launch/network changes.
  377. try? await Task.sleep(nanoseconds: UInt64(attempts) * 500_000_000)
  378. }
  379. }
  380. if loadedProducts.isEmpty == false {
  381. productsByID = Dictionary(uniqueKeysWithValues: loadedProducts.map { ($0.id, $0) })
  382. }
  383. }
  384. func purchase(plan: PremiumPlan) async -> PurchaseOutcome {
  385. guard let product = productsByID[plan.rawValue] else {
  386. await refreshProducts()
  387. guard let refreshed = productsByID[plan.rawValue] else {
  388. return .failed("Product not available. Check StoreKit configuration and product IDs.")
  389. }
  390. return await purchase(product: refreshed)
  391. }
  392. return await purchase(product: product)
  393. }
  394. func restorePurchases() async -> String {
  395. do {
  396. try await AppStore.sync()
  397. await refreshEntitlements()
  398. return hasPremiumAccess ? "Purchases restored successfully." : "No previous premium purchase was found for this Apple ID."
  399. } catch {
  400. return "Restore failed. \(error.localizedDescription)"
  401. }
  402. }
  403. private func purchase(product: Product) async -> PurchaseOutcome {
  404. do {
  405. let result = try await product.purchase()
  406. switch result {
  407. case .success(let verification):
  408. guard case .verified(let transaction) = verification else { return .failed("Purchase verification failed.") }
  409. await transaction.finish()
  410. await refreshEntitlements()
  411. return .success
  412. case .pending:
  413. return .pending
  414. case .userCancelled:
  415. return .cancelled
  416. @unknown default:
  417. return .failed("Unknown purchase state.")
  418. }
  419. } catch {
  420. return .failed(error.localizedDescription)
  421. }
  422. }
  423. private func observeTransactionUpdatesIfNeeded() {
  424. guard transactionUpdatesTask == nil else { return }
  425. transactionUpdatesTask = Task { [weak self] in
  426. for await update in Transaction.updates {
  427. guard case .verified(let transaction) = update else { continue }
  428. guard PremiumPlan.allCases.map(\.rawValue).contains(transaction.productID) else { continue }
  429. await self?.refreshEntitlements()
  430. await transaction.finish()
  431. }
  432. }
  433. }
  434. @MainActor
  435. private func refreshEntitlements() async {
  436. let previousHasPremiumAccess = hasPremiumAccess
  437. let allIDs = Set(PremiumPlan.allCases.map(\.rawValue))
  438. var active = Set<String>()
  439. for await entitlement in Transaction.currentEntitlements {
  440. guard case .verified(let transaction) = entitlement else { continue }
  441. guard allIDs.contains(transaction.productID) else { continue }
  442. if Self.isTransactionActive(transaction) {
  443. active.insert(transaction.productID)
  444. }
  445. }
  446. // StoreKit testing can briefly report empty current entitlements even though a latest
  447. // verified transaction exists for a non-consumable. Merge in latest transactions.
  448. for productID in allIDs {
  449. guard let latest = await Transaction.latest(for: productID),
  450. case .verified(let transaction) = latest else { continue }
  451. if Self.isTransactionActive(transaction) {
  452. active.insert(productID)
  453. }
  454. }
  455. activeProductIDs = active
  456. let newHasPremiumAccess = hasPremiumAccess
  457. if newHasPremiumAccess != previousHasPremiumAccess {
  458. onEntitlementsChanged?(newHasPremiumAccess)
  459. }
  460. }
  461. private static func isTransactionActive(_ transaction: Transaction) -> Bool {
  462. if transaction.revocationDate != nil { return false }
  463. if let expirationDate = transaction.expirationDate {
  464. return expirationDate > Date()
  465. }
  466. return true
  467. }
  468. }
  469. private let storeKitCoordinator = StoreKitCoordinator()
  470. private var storeKitStartupTask: Task<Void, Never>?
  471. private var launchPaywallWorkItem: DispatchWorkItem?
  472. private let launchPaywallDelay: TimeInterval = 2
  473. private var hasPresentedLaunchPaywall = false
  474. override func viewDidLoad() {
  475. super.viewDidLoad()
  476. darkModeEnabled = systemPrefersDarkMode()
  477. palette = Palette(isDarkMode: darkModeEnabled)
  478. NSApp.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
  479. view.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
  480. meetingTimeZoneOverrides = UserDefaults.standard.dictionary(forKey: meetingTimeZoneOverrideKey) as? [String: String] ?? [:]
  481. ensureUsageSessionStartDate()
  482. scheduleUsageBasedRatingPromptIfNeeded()
  483. setupUI()
  484. startStoreKit()
  485. }
  486. override func viewDidAppear() {
  487. super.viewDidAppear()
  488. if let window = view.window {
  489. window.setContentSize(NSSize(width: 1020, height: 690))
  490. applyWindowBackgroundForCurrentTheme(window)
  491. // Use full-size content view so custom top chrome sits in the titlebar region.
  492. window.titleVisibility = .hidden
  493. window.titlebarAppearsTransparent = true
  494. window.isMovableByWindowBackground = true
  495. window.styleMask.insert(.fullSizeContentView)
  496. installWindowFullScreenObservers(for: window)
  497. }
  498. alignNativeTrafficLights()
  499. showHomeView(profile: nil)
  500. syncBrandPositionForCurrentWindowState(animated: false)
  501. scheduleLaunchPaywallIfNeeded()
  502. }
  503. override func viewDidLayout() {
  504. super.viewDidLayout()
  505. alignNativeTrafficLights()
  506. if paywallOverlayView != nil {
  507. updatePaywallPanelSize()
  508. updatePaywallResponsiveLayout()
  509. }
  510. }
  511. private func setupUI() {
  512. view.wantsLayer = true
  513. view.layer?.backgroundColor = appBackground.cgColor
  514. rootContainer.translatesAutoresizingMaskIntoConstraints = false
  515. view.addSubview(rootContainer)
  516. NSLayoutConstraint.activate([
  517. rootContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor),
  518. rootContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor),
  519. rootContainer.topAnchor.constraint(equalTo: view.topAnchor),
  520. rootContainer.bottomAnchor.constraint(equalTo: view.bottomAnchor)
  521. ])
  522. }
  523. private func installWindowFullScreenObservers(for window: NSWindow) {
  524. guard windowFullScreenObservers.isEmpty else { return }
  525. let center = NotificationCenter.default
  526. let enterObserver = center.addObserver(
  527. forName: NSWindow.didEnterFullScreenNotification,
  528. object: window,
  529. queue: .main
  530. ) { [weak self] _ in
  531. self?.syncBrandPositionForCurrentWindowState(animated: true)
  532. }
  533. let exitObserver = center.addObserver(
  534. forName: NSWindow.didExitFullScreenNotification,
  535. object: window,
  536. queue: .main
  537. ) { [weak self] _ in
  538. self?.syncBrandPositionForCurrentWindowState(animated: true)
  539. }
  540. windowFullScreenObservers = [enterObserver, exitObserver]
  541. }
  542. private func syncBrandPositionForCurrentWindowState(animated: Bool) {
  543. guard let constraint = brandLeadingConstraint else { return }
  544. let isFullScreen = view.window?.styleMask.contains(.fullScreen) == true
  545. let targetInset = isFullScreen ? brandFullScreenLeadingInset : brandLeadingInset
  546. guard abs(constraint.constant - targetInset) > 0.5 else { return }
  547. if animated {
  548. NSAnimationContext.runAnimationGroup { context in
  549. context.duration = 0.2
  550. constraint.animator().constant = targetInset
  551. view.layoutSubtreeIfNeeded()
  552. }
  553. } else {
  554. constraint.constant = targetInset
  555. }
  556. }
  557. private func showHomeView(profile: GoogleUserProfile?) {
  558. clearMeetingsScrollObserver()
  559. clearMeetingsPageScrollObserver()
  560. removeSearchFieldObserver()
  561. removeMeetingSearchObserver()
  562. removeSearchShortcutMonitor()
  563. homeSearchField = nil
  564. homeSearchPill = nil
  565. meetingSearchOverlayView = nil
  566. meetingSearchPanelView = nil
  567. meetingSearchField = nil
  568. meetingSearchResultsStack = nil
  569. meetingSearchEmptyLabel = nil
  570. meetingsPageRootView = nil
  571. homeView?.removeFromSuperview()
  572. if let profileName = profile?.name?.trimmingCharacters(in: .whitespacesAndNewlines), profileName.isEmpty == false {
  573. topBarProfileInitial = String(profileName.prefix(1)).uppercased()
  574. }
  575. selectedMeetingsDayStart = Calendar.current.startOfDay(for: Date())
  576. homeView = makeHomeView(profile: profile)
  577. if let homeView { attachToRoot(homeView) }
  578. installSearchShortcutMonitor()
  579. startClock()
  580. applyAuthenticationUIState(triggerInitialRefresh: true)
  581. updateHomeSidebarHighlight()
  582. updateSelectedHomeSectionUI()
  583. }
  584. private func isUserLoggedIn() -> Bool {
  585. UserDefaults.standard.bool(forKey: loginStateKey)
  586. }
  587. private func persistLoggedInState(_ loggedIn: Bool) {
  588. UserDefaults.standard.set(loggedIn, forKey: loginStateKey)
  589. }
  590. private func attachToRoot(_ subview: NSView) {
  591. subview.translatesAutoresizingMaskIntoConstraints = false
  592. if subview.superview != rootContainer {
  593. rootContainer.addSubview(subview)
  594. }
  595. NSLayoutConstraint.activate([
  596. subview.leadingAnchor.constraint(equalTo: rootContainer.leadingAnchor),
  597. subview.trailingAnchor.constraint(equalTo: rootContainer.trailingAnchor),
  598. subview.topAnchor.constraint(equalTo: rootContainer.topAnchor),
  599. subview.bottomAnchor.constraint(equalTo: rootContainer.bottomAnchor)
  600. ])
  601. }
  602. @objc private func googleLoginTapped() {
  603. guard isSigningIn == false else { return }
  604. isSigningIn = true
  605. googleButton?.title = "..."
  606. googleButton?.isEnabled = false
  607. Task {
  608. do {
  609. let token = try await googleOAuth.validAccessToken(presentingWindow: view.window)
  610. let profile = try? await googleOAuth.fetchUserProfile(accessToken: token)
  611. await MainActor.run {
  612. self.googleButton?.title = "G"
  613. self.resetLoginSigningInState()
  614. self.completeLoginAndRestart(profile: profile)
  615. }
  616. } catch {
  617. await MainActor.run {
  618. self.isSigningIn = false
  619. self.googleButton?.title = "G"
  620. self.googleButton?.isEnabled = true
  621. self.showSimpleError("Google login failed", error: error)
  622. }
  623. }
  624. }
  625. }
  626. /// Primary Zoom sign-in: browser OAuth, token refresh, then home with scheduled meetings.
  627. @objc private func zoomPrimarySignInTapped() {
  628. guard isSigningIn == false else { return }
  629. isSigningIn = true
  630. topBarAuthButton?.title = "Signing in..."
  631. topBarAuthButton?.isEnabled = false
  632. googleButton?.isEnabled = false
  633. Task {
  634. do {
  635. let configured = await MainActor.run { self.ensureZoomOAuthClientConfigured() }
  636. guard configured else {
  637. await MainActor.run { self.resetLoginSigningInState() }
  638. return
  639. }
  640. let zoomToken = try await zoomOAuth.validAccessToken(presentingWindow: view.window)
  641. let zoomUser = try? await fetchZoomUserProfile(accessToken: zoomToken)
  642. let profile = zoomUser.map { GoogleUserProfile(name: $0.displayName, email: $0.email, picture: $0.pictureURL) }
  643. let profileTimeZone = parseZoomTimeZone(zoomUser?.timezone)
  644. await MainActor.run {
  645. self.resetLoginSigningInState()
  646. self.zoomAccountTimeZone = profileTimeZone
  647. self.completeLoginAndRestart(profile: profile)
  648. }
  649. } catch {
  650. await MainActor.run {
  651. self.resetLoginSigningInState()
  652. self.showSimpleError("Zoom sign-in failed", error: error)
  653. }
  654. }
  655. }
  656. }
  657. @objc private func scheduleMeetingTapped() {
  658. guard isUserLoggedIn() else {
  659. showSimpleAlert(title: "Sign in required", message: "Please sign in with Zoom to schedule a meeting.")
  660. return
  661. }
  662. guard ensurePremiumAccess() else { return }
  663. if let existing = scheduleMeetingWindow {
  664. existing.makeKeyAndOrderFront(nil)
  665. NSApp.activate(ignoringOtherApps: true)
  666. DispatchQueue.main.async { [weak self] in
  667. _ = self?.scheduleTopicField?.becomeFirstResponder()
  668. }
  669. return
  670. }
  671. let content = makeScheduleMeetingPanelContent()
  672. let controller = NSViewController()
  673. controller.view = content
  674. let panel = NSPanel(
  675. contentRect: NSRect(x: 0, y: 0, width: 520, height: 580),
  676. styleMask: [.titled, .closable, .fullSizeContentView],
  677. backing: .buffered,
  678. defer: false
  679. )
  680. panel.title = "Schedule a meeting"
  681. panel.titleVisibility = .hidden
  682. panel.titlebarAppearsTransparent = true
  683. panel.hidesOnDeactivate = true
  684. panel.isReleasedWhenClosed = false
  685. panel.standardWindowButton(.closeButton)?.isHidden = true
  686. panel.standardWindowButton(.miniaturizeButton)?.isHidden = true
  687. panel.standardWindowButton(.zoomButton)?.isHidden = true
  688. panel.isMovableByWindowBackground = true
  689. panel.becomesKeyOnlyIfNeeded = false
  690. panel.appearance = NSAppearance(named: palette.isDarkMode ? .darkAqua : .aqua)
  691. panel.center()
  692. panel.contentViewController = controller
  693. panel.delegate = self
  694. applyWindowBackgroundForCurrentTheme(panel)
  695. panel.makeKeyAndOrderFront(nil)
  696. NSApp.activate(ignoringOtherApps: true)
  697. scheduleMeetingWindow = panel
  698. DispatchQueue.main.async { [weak self] in
  699. _ = self?.scheduleTopicField?.becomeFirstResponder()
  700. }
  701. }
  702. private func makeScheduleMeetingPanelContent() -> NSView {
  703. let root = NSView()
  704. root.translatesAutoresizingMaskIntoConstraints = false
  705. root.wantsLayer = true
  706. root.layer?.backgroundColor = appBackground.cgColor
  707. let titleLabel = makeLabel("Schedule a meeting", size: 18, color: primaryText, weight: .semibold, centered: false)
  708. let subtitleLabel = makeLabel("Creates a Zoom meeting on your account", size: 12, color: mutedText, weight: .regular, centered: false)
  709. let closeButton = HoverButton(title: "✕", target: self, action: #selector(scheduleMeetingCancelTapped))
  710. closeButton.translatesAutoresizingMaskIntoConstraints = false
  711. closeButton.isBordered = false
  712. closeButton.bezelStyle = .regularSquare
  713. closeButton.wantsLayer = true
  714. closeButton.layer?.cornerRadius = 14
  715. closeButton.normalColor = palette.inputBackground
  716. closeButton.hoverColor = palette.isDarkMode ? NSColor.white.withAlphaComponent(0.10) : NSColor.black.withAlphaComponent(0.06)
  717. closeButton.layer?.borderColor = palette.inputBorder.cgColor
  718. closeButton.layer?.borderWidth = 1
  719. closeButton.font = .systemFont(ofSize: 13, weight: .bold)
  720. closeButton.contentTintColor = secondaryText
  721. closeButton.toolTip = "Close"
  722. closeButton.widthAnchor.constraint(equalToConstant: 28).isActive = true
  723. closeButton.heightAnchor.constraint(equalToConstant: 28).isActive = true
  724. let titleRow = NSStackView()
  725. titleRow.orientation = .horizontal
  726. titleRow.alignment = .centerY
  727. titleRow.spacing = 10
  728. titleRow.translatesAutoresizingMaskIntoConstraints = false
  729. let titleStack = NSStackView(views: [titleLabel, subtitleLabel])
  730. titleStack.orientation = .vertical
  731. titleStack.spacing = 2
  732. titleStack.alignment = .leading
  733. let titleSpacer = NSView()
  734. titleSpacer.translatesAutoresizingMaskIntoConstraints = false
  735. titleRow.addArrangedSubview(titleStack)
  736. titleRow.addArrangedSubview(titleSpacer)
  737. titleRow.addArrangedSubview(closeButton)
  738. let headerDivider = NSView()
  739. headerDivider.wantsLayer = true
  740. headerDivider.layer?.backgroundColor = palette.inputBorder.cgColor
  741. headerDivider.translatesAutoresizingMaskIntoConstraints = false
  742. let topicBox = makeJoinFormField(placeholder: "Meeting topic")
  743. scheduleTopicField = topicBox.textField
  744. topicBox.textField.stringValue = "Zoom meeting"
  745. let defaultStart = Date().addingTimeInterval(3600)
  746. let dateOnlyFormatter = DateFormatter()
  747. dateOnlyFormatter.locale = Locale(identifier: "en_US_POSIX")
  748. dateOnlyFormatter.timeZone = TimeZone.current
  749. dateOnlyFormatter.dateFormat = "yyyy-MM-dd"
  750. let time12Formatter = DateFormatter()
  751. time12Formatter.locale = Locale(identifier: "en_US_POSIX")
  752. time12Formatter.timeZone = TimeZone.current
  753. time12Formatter.dateFormat = "h:mm a"
  754. let dateBox = makeJoinFormField(placeholder: "YYYY-MM-DD")
  755. scheduleDateField = dateBox.textField
  756. dateBox.textField.stringValue = dateOnlyFormatter.string(from: defaultStart)
  757. let timeBox = makeJoinFormField(placeholder: "2:30 PM")
  758. scheduleTimeField = timeBox.textField
  759. timeBox.textField.stringValue = time12Formatter.string(from: defaultStart)
  760. let timeHint = makeLabel("12-hour time in the timezone below · example 2:30 PM", size: 11, color: mutedText, weight: .regular, centered: false)
  761. let tzLabel = makeLabel("Timezone", size: 12, color: secondaryText, weight: .medium, centered: false)
  762. let tzCombo = NSPopUpButton()
  763. tzCombo.translatesAutoresizingMaskIntoConstraints = false
  764. tzCombo.font = .systemFont(ofSize: 14, weight: .regular)
  765. tzCombo.wantsLayer = true
  766. tzCombo.layer?.cornerRadius = 10
  767. tzCombo.layer?.borderWidth = 1
  768. tzCombo.layer?.borderColor = palette.inputBorder.cgColor
  769. tzCombo.layer?.backgroundColor = palette.inputBackground.cgColor
  770. let tzOptions = makeTimeZoneDisplayOptions(referenceDate: defaultStart)
  771. scheduleTimeZoneDisplayToIdentifier = Dictionary(uniqueKeysWithValues: tzOptions.map { ($0.display, $0.identifier) })
  772. tzCombo.removeAllItems()
  773. tzCombo.addItems(withTitles: tzOptions.map(\.display))
  774. if let selected = tzOptions.first(where: { $0.identifier == TimeZone.current.identifier }) {
  775. tzCombo.selectItem(withTitle: selected.display)
  776. } else {
  777. tzCombo.selectItem(at: 0)
  778. }
  779. tzCombo.toolTip = "Timezone with GMT offset. Meeting start is interpreted in this zone."
  780. scheduleTimeZonePopup = tzCombo
  781. let tzHint = makeLabel("Includes GMT offset, e.g. GMT+05:00 - Asia/Karachi", size: 11, color: mutedText, weight: .regular, centered: false)
  782. let tzStack = NSStackView(views: [tzLabel, tzCombo, tzHint])
  783. tzStack.orientation = .vertical
  784. tzStack.spacing = 8
  785. tzStack.alignment = .leading
  786. tzStack.translatesAutoresizingMaskIntoConstraints = false
  787. let durationBox = makeJoinFormField(placeholder: "Duration in minutes")
  788. durationBox.textField.stringValue = "60"
  789. scheduleDurationField = durationBox.textField
  790. let topicLabel = makeLabel("Topic", size: 12, color: secondaryText, weight: .medium, centered: false)
  791. let startLabel = makeLabel("Start date & time", size: 12, color: secondaryText, weight: .medium, centered: false)
  792. let durationLabel = makeLabel("Duration", size: 12, color: secondaryText, weight: .medium, centered: false)
  793. let topicStack = NSStackView(views: [topicLabel, topicBox])
  794. topicStack.orientation = .vertical
  795. topicStack.spacing = 8
  796. topicStack.alignment = .leading
  797. topicStack.translatesAutoresizingMaskIntoConstraints = false
  798. let dateTimeRow = NSStackView(views: [dateBox, timeBox])
  799. dateTimeRow.orientation = .horizontal
  800. dateTimeRow.spacing = 10
  801. dateTimeRow.alignment = .top
  802. dateTimeRow.distribution = .fillEqually
  803. dateTimeRow.translatesAutoresizingMaskIntoConstraints = false
  804. let startStack = NSStackView(views: [startLabel, dateTimeRow, timeHint])
  805. startStack.orientation = .vertical
  806. startStack.spacing = 8
  807. startStack.alignment = .leading
  808. startStack.translatesAutoresizingMaskIntoConstraints = false
  809. let durationStack = NSStackView(views: [durationLabel, durationBox])
  810. durationStack.orientation = .vertical
  811. durationStack.spacing = 8
  812. durationStack.alignment = .leading
  813. durationStack.translatesAutoresizingMaskIntoConstraints = false
  814. let formCard = NSView()
  815. formCard.translatesAutoresizingMaskIntoConstraints = false
  816. formCard.wantsLayer = true
  817. formCard.layer?.backgroundColor = secondaryCardBackground.cgColor
  818. formCard.layer?.cornerRadius = 14
  819. formCard.layer?.borderWidth = 1
  820. formCard.layer?.borderColor = palette.inputBorder.cgColor
  821. let cancelButton = NSButton(title: "Cancel", target: self, action: #selector(scheduleMeetingCancelTapped))
  822. cancelButton.isBordered = false
  823. cancelButton.wantsLayer = true
  824. cancelButton.layer?.cornerRadius = 10
  825. cancelButton.layer?.backgroundColor = palette.inputBackground.cgColor
  826. cancelButton.layer?.borderWidth = 1
  827. cancelButton.layer?.borderColor = palette.inputBorder.cgColor
  828. cancelButton.contentTintColor = primaryText
  829. cancelButton.font = .systemFont(ofSize: 13, weight: .semibold)
  830. let submitButton = HoverButton(title: "Schedule", target: self, action: #selector(scheduleMeetingSubmitTapped))
  831. submitButton.isBordered = false
  832. submitButton.wantsLayer = true
  833. submitButton.layer?.cornerRadius = 10
  834. submitButton.normalColor = accentBlue
  835. submitButton.hoverColor = accentBlue.blended(withFraction: 0.12, of: .white) ?? accentBlue
  836. submitButton.contentTintColor = .white
  837. submitButton.font = .systemFont(ofSize: 13, weight: .bold)
  838. submitButton.keyEquivalent = "\r"
  839. scheduleSubmitButton = submitButton
  840. let buttons = NSStackView(views: [cancelButton, submitButton])
  841. buttons.orientation = .horizontal
  842. buttons.spacing = 12
  843. buttons.alignment = .centerY
  844. buttons.distribution = .fillEqually
  845. let innerStack = NSStackView(views: [topicStack, tzStack, startStack, durationStack, buttons])
  846. innerStack.orientation = .vertical
  847. innerStack.spacing = 18
  848. innerStack.alignment = .leading
  849. innerStack.translatesAutoresizingMaskIntoConstraints = false
  850. formCard.addSubview(innerStack)
  851. [titleRow, headerDivider, formCard].forEach { root.addSubview($0) }
  852. NSLayoutConstraint.activate([
  853. titleRow.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
  854. titleRow.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -16),
  855. titleRow.topAnchor.constraint(equalTo: root.topAnchor, constant: 18),
  856. headerDivider.topAnchor.constraint(equalTo: titleRow.bottomAnchor, constant: 14),
  857. headerDivider.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
  858. headerDivider.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -20),
  859. headerDivider.heightAnchor.constraint(equalToConstant: 1),
  860. formCard.topAnchor.constraint(equalTo: headerDivider.bottomAnchor, constant: 14),
  861. formCard.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
  862. formCard.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -20),
  863. formCard.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -20),
  864. innerStack.leadingAnchor.constraint(equalTo: formCard.leadingAnchor, constant: 16),
  865. innerStack.trailingAnchor.constraint(equalTo: formCard.trailingAnchor, constant: -16),
  866. innerStack.topAnchor.constraint(equalTo: formCard.topAnchor, constant: 16),
  867. innerStack.bottomAnchor.constraint(equalTo: formCard.bottomAnchor, constant: -16),
  868. topicBox.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
  869. tzCombo.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
  870. tzCombo.heightAnchor.constraint(equalToConstant: 34),
  871. dateTimeRow.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
  872. durationBox.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
  873. buttons.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
  874. submitButton.heightAnchor.constraint(equalToConstant: 40),
  875. cancelButton.heightAnchor.constraint(equalToConstant: 40)
  876. ])
  877. return root
  878. }
  879. @objc private func scheduleMeetingCancelTapped() {
  880. scheduleMeetingWindow?.performClose(nil)
  881. }
  882. @objc private func scheduleMeetingSubmitTapped() {
  883. let topicRaw = scheduleTopicField?.stringValue ?? ""
  884. let topic = topicRaw.trimmingCharacters(in: .whitespacesAndNewlines)
  885. guard topic.isEmpty == false else {
  886. showSimpleAlert(title: "Topic required", message: "Enter a name for your meeting.")
  887. return
  888. }
  889. let tzId = scheduleTimeZonePopup?.titleOfSelectedItem?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  890. if tzId.isEmpty == false,
  891. scheduleTimeZoneDisplayToIdentifier[tzId] == nil,
  892. TimeZone(identifier: tzId) == nil {
  893. showSimpleAlert(
  894. title: "Timezone",
  895. message: "Choose a timezone from the list (with GMT offset), for example GMT+05:00 - Asia/Karachi."
  896. )
  897. return
  898. }
  899. guard let draft = scheduleMeetingDraftFromFormFields() else {
  900. showSimpleAlert(
  901. title: "Date & time",
  902. message: "Enter the date as YYYY-MM-DD and the time in 12-hour form with AM or PM (in the selected timezone), for example 2026-04-17 and 2:30 PM."
  903. )
  904. return
  905. }
  906. if draft.startDate < Date().addingTimeInterval(-30) {
  907. showSimpleAlert(title: "Start time", message: "Choose a start time in the future.")
  908. return
  909. }
  910. let durationRaw = scheduleDurationField?.stringValue ?? ""
  911. let digits = durationRaw.filter(\.isNumber)
  912. guard let duration = Int(digits), duration >= 1, duration <= 24 * 60 else {
  913. showSimpleAlert(title: "Duration", message: "Enter duration in minutes (1–1440).")
  914. return
  915. }
  916. scheduleSubmitButton?.isEnabled = false
  917. let tz = draft.timeZone
  918. Task {
  919. do {
  920. let configured = await MainActor.run { self.ensureZoomOAuthClientConfigured() }
  921. guard configured else {
  922. await MainActor.run { self.scheduleSubmitButton?.isEnabled = true }
  923. return
  924. }
  925. let token = try await zoomOAuth.validAccessToken(presentingWindow: view.window)
  926. let result = try await createZoomMeeting(
  927. accessToken: token,
  928. topic: topic,
  929. startTimeUTC: draft.startTimeUTC,
  930. durationMinutes: duration,
  931. timeZone: tz
  932. )
  933. await MainActor.run {
  934. self.saveMeetingTimeZoneOverride(meetingID: result.id.map(String.init), timeZone: tz)
  935. }
  936. await MainActor.run {
  937. self.scheduleSubmitButton?.isEnabled = true
  938. self.scheduleMeetingWindow?.performClose(nil)
  939. self.meetingsStatusLabel?.stringValue = "Meeting scheduled."
  940. self.triggerMeetingsRefresh(force: true)
  941. if let join = result.join_url, join.isEmpty == false {
  942. NSPasteboard.general.clearContents()
  943. NSPasteboard.general.setString(join, forType: .string)
  944. self.showSimpleAlert(
  945. title: "Meeting scheduled",
  946. message: "The join link was copied to your clipboard. It will also appear in your upcoming meetings list below."
  947. )
  948. } else {
  949. self.showSimpleAlert(title: "Meeting scheduled", message: "The meeting was added to your list.")
  950. }
  951. }
  952. } catch {
  953. await MainActor.run {
  954. self.scheduleSubmitButton?.isEnabled = true
  955. if case ZoomOAuthError.missingRequiredScope(_) = error {
  956. self.zoomOAuth.clearSavedTokens()
  957. self.showSimpleAlert(
  958. title: "Zoom permissions",
  959. message: "Your Zoom app needs the meeting:write scope to schedule meetings. Add it in the Zoom Marketplace app settings, then sign in again."
  960. )
  961. } else if case ZoomOAuthError.rateLimited(let retryAfter) = error {
  962. let seconds = max(retryAfter ?? 300, 30)
  963. let minutes = Int(ceil(Double(seconds) / 60.0))
  964. self.showSimpleAlert(title: "Rate limited", message: "Zoom asked to wait before scheduling again. Try in about \(minutes) min.")
  965. } else {
  966. self.showSimpleError("Could not schedule", error: error)
  967. }
  968. }
  969. }
  970. }
  971. }
  972. private func resetScheduleMeetingPanelReferences() {
  973. scheduleMeetingWindow = nil
  974. scheduleTopicField = nil
  975. scheduleDateField = nil
  976. scheduleTimeField = nil
  977. scheduleTimeZonePopup = nil
  978. scheduleDurationField = nil
  979. scheduleSubmitButton = nil
  980. scheduleTimeZoneDisplayToIdentifier = [:]
  981. }
  982. /// Resolves the schedule panel timezone (IANA id from combo, or system default).
  983. private func resolvedScheduleTimeZoneForMeeting() -> TimeZone {
  984. let raw = scheduleTimeZonePopup?.titleOfSelectedItem?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  985. if let identifier = scheduleTimeZoneDisplayToIdentifier[raw],
  986. let mapped = TimeZone(identifier: identifier) {
  987. return mapped
  988. }
  989. guard raw.isEmpty == false, let tz = TimeZone(identifier: raw) else { return TimeZone.current }
  990. return tz
  991. }
  992. private struct TimeZoneDisplayOption {
  993. let identifier: String
  994. let display: String
  995. }
  996. private func makeTimeZoneDisplayOptions(referenceDate: Date) -> [TimeZoneDisplayOption] {
  997. TimeZone.knownTimeZoneIdentifiers.compactMap { identifier in
  998. guard let tz = TimeZone(identifier: identifier) else { return nil }
  999. let seconds = tz.secondsFromGMT(for: referenceDate)
  1000. let sign = seconds >= 0 ? "+" : "-"
  1001. let absSeconds = abs(seconds)
  1002. let hours = absSeconds / 3600
  1003. let minutes = (absSeconds % 3600) / 60
  1004. let offset = String(format: "GMT%@%02d:%02d", sign, hours, minutes)
  1005. return TimeZoneDisplayOption(identifier: identifier, display: "\(offset) - \(identifier)")
  1006. }
  1007. .sorted { lhs, rhs in
  1008. if lhs.display == rhs.display { return lhs.identifier < rhs.identifier }
  1009. return lhs.display < rhs.display
  1010. }
  1011. }
  1012. /// Parses zoom timezone values like `Asia/Karachi`, `GMT+05:00`, `UTC+5`, `+0500`, or `Z`.
  1013. private func parseZoomTimeZone(_ raw: String?) -> TimeZone? {
  1014. guard let raw else { return nil }
  1015. let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
  1016. guard trimmed.isEmpty == false else { return nil }
  1017. if let named = TimeZone(identifier: trimmed) { return named }
  1018. if trimmed.caseInsensitiveCompare("Z") == .orderedSame { return TimeZone(secondsFromGMT: 0) }
  1019. let normalized = trimmed.uppercased()
  1020. let pattern = #"^(?:GMT|UTC)?\s*([+-])(\d{1,2})(?::?(\d{2}))?$"#
  1021. guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
  1022. let nsRange = NSRange(normalized.startIndex..<normalized.endIndex, in: normalized)
  1023. guard let match = regex.firstMatch(in: normalized, options: [], range: nsRange),
  1024. match.numberOfRanges == 4,
  1025. let signRange = Range(match.range(at: 1), in: normalized),
  1026. let hourRange = Range(match.range(at: 2), in: normalized) else {
  1027. return nil
  1028. }
  1029. let sign = normalized[signRange] == "-" ? -1 : 1
  1030. let hours = Int(normalized[hourRange]) ?? -1
  1031. let minutes: Int = {
  1032. if let minuteRange = Range(match.range(at: 3), in: normalized) {
  1033. return Int(normalized[minuteRange]) ?? -1
  1034. }
  1035. return 0
  1036. }()
  1037. guard (0...23).contains(hours), (0...59).contains(minutes) else { return nil }
  1038. return TimeZone(secondsFromGMT: sign * ((hours * 3600) + (minutes * 60)))
  1039. }
  1040. private func saveMeetingTimeZoneOverride(meetingID: String?, timeZone: TimeZone) {
  1041. guard let meetingID, meetingID.isEmpty == false else { return }
  1042. meetingTimeZoneOverrides[meetingID] = timeZone.identifier
  1043. UserDefaults.standard.set(meetingTimeZoneOverrides, forKey: meetingTimeZoneOverrideKey)
  1044. }
  1045. private struct ScheduleMeetingDraft {
  1046. let startDate: Date
  1047. let startTimeUTC: String
  1048. let timeZone: TimeZone
  1049. }
  1050. /// Keeps entered local date/time exact for Zoom (`start_time`) while also producing `Date` for validation.
  1051. private func scheduleMeetingDraftFromFormFields() -> ScheduleMeetingDraft? {
  1052. let datePart = scheduleDateField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  1053. let timePart = scheduleTimeField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  1054. guard datePart.isEmpty == false, timePart.isEmpty == false else { return nil }
  1055. let tz = resolvedScheduleTimeZoneForMeeting()
  1056. let normalizedTime = timePart.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
  1057. // Parse date strictly as yyyy-MM-dd.
  1058. let dateComponentsRaw = datePart.split(separator: "-")
  1059. guard dateComponentsRaw.count == 3,
  1060. let year = Int(dateComponentsRaw[0]),
  1061. let month = Int(dateComponentsRaw[1]),
  1062. let day = Int(dateComponentsRaw[2]) else {
  1063. return nil
  1064. }
  1065. // Parse time strictly as h:mm AM/PM (optional :ss).
  1066. // This avoids any locale/date formatter ambiguity that can flip AM/PM.
  1067. let pattern = #"^\s*(\d{1,2}):(\d{2})(?::(\d{2}))?\s*([AaPp][Mm])\s*$"#
  1068. guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
  1069. let nsRange = NSRange(normalizedTime.startIndex..<normalizedTime.endIndex, in: normalizedTime)
  1070. guard let match = regex.firstMatch(in: normalizedTime, options: [], range: nsRange),
  1071. match.numberOfRanges == 5,
  1072. let hourRange = Range(match.range(at: 1), in: normalizedTime),
  1073. let minuteRange = Range(match.range(at: 2), in: normalizedTime),
  1074. let meridiemRange = Range(match.range(at: 4), in: normalizedTime) else {
  1075. return nil
  1076. }
  1077. let rawHour = Int(normalizedTime[hourRange]) ?? -1
  1078. let minute = Int(normalizedTime[minuteRange]) ?? -1
  1079. let second: Int = {
  1080. if let secondRange = Range(match.range(at: 3), in: normalizedTime) {
  1081. return Int(normalizedTime[secondRange]) ?? -1
  1082. }
  1083. return 0
  1084. }()
  1085. let meridiem = normalizedTime[meridiemRange].uppercased()
  1086. guard (1...12).contains(rawHour),
  1087. (0...59).contains(minute),
  1088. (0...59).contains(second) else {
  1089. return nil
  1090. }
  1091. var hour24 = rawHour % 12
  1092. if meridiem == "PM" { hour24 += 12 }
  1093. var calendar = Calendar(identifier: .gregorian)
  1094. calendar.timeZone = tz
  1095. var components = DateComponents()
  1096. components.year = year
  1097. components.month = month
  1098. components.day = day
  1099. components.hour = hour24
  1100. components.minute = minute
  1101. components.second = second
  1102. components.timeZone = tz
  1103. guard let startDate = calendar.date(from: components) else { return nil }
  1104. let utcFormatter = DateFormatter()
  1105. utcFormatter.locale = Locale(identifier: "en_US_POSIX")
  1106. utcFormatter.timeZone = TimeZone(secondsFromGMT: 0)
  1107. utcFormatter.calendar = Calendar(identifier: .gregorian)
  1108. // Send explicit UTC (`Z`) so Zoom receives an unambiguous instant in time.
  1109. utcFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'"
  1110. return ScheduleMeetingDraft(
  1111. startDate: startDate,
  1112. startTimeUTC: utcFormatter.string(from: startDate),
  1113. timeZone: tz
  1114. )
  1115. }
  1116. @objc private func joinMeetingTapped() {
  1117. if let existing = joinMeetingWindow {
  1118. existing.makeKeyAndOrderFront(nil)
  1119. NSApp.activate(ignoringOtherApps: true)
  1120. DispatchQueue.main.async { [weak self] in
  1121. _ = self?.joinURLField?.becomeFirstResponder()
  1122. }
  1123. return
  1124. }
  1125. let content = makeJoinMeetingPanelContent()
  1126. let controller = NSViewController()
  1127. controller.view = content
  1128. let panel = NSPanel(
  1129. contentRect: NSRect(x: 0, y: 0, width: 520, height: 430),
  1130. styleMask: [.titled, .closable, .fullSizeContentView],
  1131. backing: .buffered,
  1132. defer: false
  1133. )
  1134. panel.title = "Join a meeting"
  1135. panel.titleVisibility = .hidden
  1136. panel.titlebarAppearsTransparent = true
  1137. panel.hidesOnDeactivate = true
  1138. panel.isReleasedWhenClosed = false
  1139. panel.standardWindowButton(.closeButton)?.isHidden = true
  1140. panel.standardWindowButton(.miniaturizeButton)?.isHidden = true
  1141. panel.standardWindowButton(.zoomButton)?.isHidden = true
  1142. panel.isMovableByWindowBackground = true
  1143. // Allow the panel to become key immediately so text fields receive keyboard input (avoids "can't type" with floating/key heuristics).
  1144. panel.becomesKeyOnlyIfNeeded = false
  1145. panel.appearance = NSAppearance(named: palette.isDarkMode ? .darkAqua : .aqua)
  1146. panel.center()
  1147. panel.contentViewController = controller
  1148. panel.delegate = self
  1149. applyWindowBackgroundForCurrentTheme(panel)
  1150. panel.makeKeyAndOrderFront(nil)
  1151. NSApp.activate(ignoringOtherApps: true)
  1152. joinMeetingWindow = panel
  1153. DispatchQueue.main.async { [weak self] in
  1154. guard let self else { return }
  1155. _ = self.joinURLField?.becomeFirstResponder()
  1156. }
  1157. }
  1158. private func makeJoinMeetingPanelContent() -> NSView {
  1159. let root = NSView()
  1160. root.translatesAutoresizingMaskIntoConstraints = false
  1161. root.wantsLayer = true
  1162. root.layer?.backgroundColor = appBackground.cgColor
  1163. let titleLabel = makeLabel("Join a meeting", size: 18, color: primaryText, weight: .semibold, centered: false)
  1164. let subtitleLabel = makeLabel("Opens in your default web browser", size: 12, color: mutedText, weight: .regular, centered: false)
  1165. let closeButton = HoverButton(title: "✕", target: self, action: #selector(joinMeetingCancelTapped))
  1166. closeButton.translatesAutoresizingMaskIntoConstraints = false
  1167. closeButton.isBordered = false
  1168. closeButton.bezelStyle = .regularSquare
  1169. closeButton.wantsLayer = true
  1170. closeButton.layer?.cornerRadius = 14
  1171. closeButton.normalColor = palette.inputBackground
  1172. closeButton.hoverColor = palette.isDarkMode ? NSColor.white.withAlphaComponent(0.10) : NSColor.black.withAlphaComponent(0.06)
  1173. closeButton.layer?.borderColor = palette.inputBorder.cgColor
  1174. closeButton.layer?.borderWidth = 1
  1175. closeButton.font = .systemFont(ofSize: 13, weight: .bold)
  1176. closeButton.contentTintColor = secondaryText
  1177. closeButton.toolTip = "Close"
  1178. closeButton.widthAnchor.constraint(equalToConstant: 28).isActive = true
  1179. closeButton.heightAnchor.constraint(equalToConstant: 28).isActive = true
  1180. let titleRow = NSStackView()
  1181. titleRow.orientation = .horizontal
  1182. titleRow.alignment = .centerY
  1183. titleRow.spacing = 10
  1184. titleRow.translatesAutoresizingMaskIntoConstraints = false
  1185. let titleStack = NSStackView(views: [titleLabel, subtitleLabel])
  1186. titleStack.orientation = .vertical
  1187. titleStack.spacing = 2
  1188. titleStack.alignment = .leading
  1189. let titleSpacer = NSView()
  1190. titleSpacer.translatesAutoresizingMaskIntoConstraints = false
  1191. titleRow.addArrangedSubview(titleStack)
  1192. titleRow.addArrangedSubview(titleSpacer)
  1193. titleRow.addArrangedSubview(closeButton)
  1194. let headerDivider = NSView()
  1195. headerDivider.wantsLayer = true
  1196. headerDivider.layer?.backgroundColor = palette.inputBorder.cgColor
  1197. headerDivider.translatesAutoresizingMaskIntoConstraints = false
  1198. let mode = NSSegmentedControl(labels: ["Join with URL", "Join with ID"], trackingMode: .selectOne, target: self, action: #selector(joinMeetingModeChanged(_:)))
  1199. mode.segmentStyle = .rounded
  1200. mode.selectedSegment = 0
  1201. mode.translatesAutoresizingMaskIntoConstraints = false
  1202. mode.font = .systemFont(ofSize: 12, weight: .semibold)
  1203. mode.controlSize = .large
  1204. if #available(macOS 11.0, *) {
  1205. mode.selectedSegmentBezelColor = accentBlue
  1206. }
  1207. joinModeSegment = mode
  1208. let urlBox = makeJoinFormField(placeholder: "https://zoom.us/j/… or paste invite link")
  1209. joinURLField = urlBox.textField
  1210. let urlLabel = makeLabel("Meeting link", size: 12, color: secondaryText, weight: .medium, centered: false)
  1211. let urlStack = NSStackView(views: [urlLabel, urlBox])
  1212. urlStack.orientation = .vertical
  1213. urlStack.spacing = 8
  1214. urlStack.alignment = .leading
  1215. urlStack.translatesAutoresizingMaskIntoConstraints = false
  1216. joinURLFieldsContainer = urlStack
  1217. let idBox = makeJoinFormField(placeholder: "Meeting ID (numbers only)")
  1218. let passBox = makeJoinFormField(placeholder: "Passcode (if required)")
  1219. joinMeetingIDField = idBox.textField
  1220. joinPasscodeField = passBox.textField
  1221. let idLabel = makeLabel("Meeting ID", size: 12, color: secondaryText, weight: .medium, centered: false)
  1222. let passLabel = makeLabel("Passcode", size: 12, color: secondaryText, weight: .medium, centered: false)
  1223. let idStack = NSStackView(views: [idLabel, idBox, passLabel, passBox])
  1224. idStack.orientation = .vertical
  1225. idStack.spacing = 8
  1226. idStack.alignment = .leading
  1227. idStack.translatesAutoresizingMaskIntoConstraints = false
  1228. idStack.isHidden = true
  1229. joinIDFieldsContainer = idStack
  1230. let formCard = NSView()
  1231. formCard.translatesAutoresizingMaskIntoConstraints = false
  1232. formCard.wantsLayer = true
  1233. formCard.layer?.backgroundColor = secondaryCardBackground.cgColor
  1234. formCard.layer?.cornerRadius = 14
  1235. formCard.layer?.borderWidth = 1
  1236. formCard.layer?.borderColor = palette.inputBorder.cgColor
  1237. let cancelButton = NSButton(title: "Cancel", target: self, action: #selector(joinMeetingCancelTapped))
  1238. cancelButton.isBordered = false
  1239. cancelButton.wantsLayer = true
  1240. cancelButton.layer?.cornerRadius = 10
  1241. cancelButton.layer?.backgroundColor = palette.inputBackground.cgColor
  1242. cancelButton.layer?.borderWidth = 1
  1243. cancelButton.layer?.borderColor = palette.inputBorder.cgColor
  1244. cancelButton.contentTintColor = primaryText
  1245. cancelButton.font = .systemFont(ofSize: 13, weight: .semibold)
  1246. let joinButton = HoverButton(title: "Join", target: self, action: #selector(joinMeetingSubmitTapped))
  1247. joinButton.isBordered = false
  1248. joinButton.wantsLayer = true
  1249. joinButton.layer?.cornerRadius = 10
  1250. joinButton.normalColor = accentBlue
  1251. joinButton.hoverColor = accentBlue.blended(withFraction: 0.12, of: .white) ?? accentBlue
  1252. joinButton.contentTintColor = .white
  1253. joinButton.font = .systemFont(ofSize: 13, weight: .bold)
  1254. joinButton.keyEquivalent = "\r"
  1255. let buttons = NSStackView(views: [cancelButton, joinButton])
  1256. buttons.orientation = .horizontal
  1257. buttons.spacing = 12
  1258. buttons.alignment = .centerY
  1259. buttons.distribution = .fillEqually
  1260. let innerStack = NSStackView(views: [mode, urlStack, idStack, buttons])
  1261. innerStack.orientation = .vertical
  1262. innerStack.spacing = 18
  1263. innerStack.alignment = .leading
  1264. innerStack.translatesAutoresizingMaskIntoConstraints = false
  1265. formCard.addSubview(innerStack)
  1266. [titleRow, headerDivider, formCard].forEach { root.addSubview($0) }
  1267. NSLayoutConstraint.activate([
  1268. titleRow.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
  1269. titleRow.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -16),
  1270. titleRow.topAnchor.constraint(equalTo: root.topAnchor, constant: 18),
  1271. headerDivider.topAnchor.constraint(equalTo: titleRow.bottomAnchor, constant: 14),
  1272. headerDivider.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
  1273. headerDivider.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -20),
  1274. headerDivider.heightAnchor.constraint(equalToConstant: 1),
  1275. formCard.topAnchor.constraint(equalTo: headerDivider.bottomAnchor, constant: 14),
  1276. formCard.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
  1277. formCard.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -20),
  1278. formCard.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -20),
  1279. innerStack.leadingAnchor.constraint(equalTo: formCard.leadingAnchor, constant: 16),
  1280. innerStack.trailingAnchor.constraint(equalTo: formCard.trailingAnchor, constant: -16),
  1281. innerStack.topAnchor.constraint(equalTo: formCard.topAnchor, constant: 16),
  1282. innerStack.bottomAnchor.constraint(equalTo: formCard.bottomAnchor, constant: -16),
  1283. mode.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
  1284. urlBox.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
  1285. idBox.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
  1286. passBox.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
  1287. buttons.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
  1288. joinButton.heightAnchor.constraint(equalToConstant: 40),
  1289. cancelButton.heightAnchor.constraint(equalToConstant: 40)
  1290. ])
  1291. return root
  1292. }
  1293. private func makeJoinFormField(placeholder: String) -> JoinPanelFieldContainer {
  1294. JoinPanelFieldContainer(
  1295. placeholder: placeholder,
  1296. normalBorder: palette.inputBorder,
  1297. focusBorder: accentBlue.withAlphaComponent(0.9),
  1298. fill: palette.inputBackground,
  1299. primaryText: primaryText,
  1300. mutedText: mutedText
  1301. )
  1302. }
  1303. @objc private func joinMeetingModeChanged(_ sender: NSSegmentedControl) {
  1304. let urlMode = sender.selectedSegment == 0
  1305. joinURLFieldsContainer?.isHidden = urlMode == false
  1306. joinIDFieldsContainer?.isHidden = urlMode
  1307. }
  1308. @objc private func joinMeetingCancelTapped() {
  1309. joinMeetingWindow?.performClose(nil)
  1310. }
  1311. @objc private func joinMeetingSubmitTapped() {
  1312. guard let segment = joinModeSegment else { return }
  1313. if segment.selectedSegment == 0 {
  1314. let raw = joinURLField?.stringValue ?? ""
  1315. guard let url = parseZoomJoinURLFromUserInput(raw) else {
  1316. showSimpleAlert(title: "Invalid link", message: "Enter a full Zoom meeting link (for example, https://zoom.us/j/…).")
  1317. return
  1318. }
  1319. openZoomMeetingInDefaultBrowser(url)
  1320. } else {
  1321. let idRaw = joinMeetingIDField?.stringValue ?? ""
  1322. let digits = idRaw.filter(\.isNumber)
  1323. guard digits.isEmpty == false else {
  1324. showSimpleAlert(title: "Meeting ID required", message: "Enter the numeric meeting ID.")
  1325. return
  1326. }
  1327. let pass = joinPasscodeField?.stringValue ?? ""
  1328. guard let url = zoomWebClientJoinURL(meetingIdDigits: digits, passcode: pass) else {
  1329. showSimpleAlert(title: "Unable to join", message: "Could not build a join link from that meeting ID.")
  1330. return
  1331. }
  1332. openZoomMeetingInDefaultBrowser(url)
  1333. }
  1334. }
  1335. /// Same web join path used when expanding scheduled meetings (`/wc/join/` + optional `pwd`).
  1336. private func zoomWebClientJoinURL(meetingIdDigits: String, passcode: String) -> URL? {
  1337. guard meetingIdDigits.isEmpty == false else { return nil }
  1338. var components = URLComponents()
  1339. components.scheme = "https"
  1340. components.host = "zoom.us"
  1341. components.path = "/wc/join/\(meetingIdDigits)"
  1342. let trimmed = passcode.trimmingCharacters(in: .whitespacesAndNewlines)
  1343. if trimmed.isEmpty == false {
  1344. components.queryItems = [URLQueryItem(name: "pwd", value: trimmed)]
  1345. }
  1346. return components.url
  1347. }
  1348. private func parseZoomJoinURLFromUserInput(_ raw: String) -> URL? {
  1349. let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
  1350. guard trimmed.isEmpty == false else { return nil }
  1351. let normalized: String
  1352. if trimmed.lowercased().hasPrefix("http://") || trimmed.lowercased().hasPrefix("https://") {
  1353. normalized = trimmed
  1354. } else {
  1355. normalized = "https://\(trimmed)"
  1356. }
  1357. guard let url = URL(string: normalized), let host = url.host?.lowercased() else { return nil }
  1358. let isZoom = host == "zoom.us" || host.hasSuffix(".zoom.us")
  1359. || host == "zoom.com" || host.hasSuffix(".zoom.com")
  1360. guard isZoom else { return nil }
  1361. return url
  1362. }
  1363. private func openZoomMeetingInDefaultBrowser(_ url: URL) {
  1364. let opened = NSWorkspace.shared.open(url)
  1365. if opened {
  1366. joinMeetingWindow?.performClose(nil)
  1367. } else {
  1368. showSimpleAlert(title: "Unable to open", message: "Your default browser could not be opened.")
  1369. }
  1370. }
  1371. private func resetJoinMeetingPanelReferences() {
  1372. joinMeetingWindow = nil
  1373. joinURLField = nil
  1374. joinMeetingIDField = nil
  1375. joinPasscodeField = nil
  1376. joinURLFieldsContainer = nil
  1377. joinIDFieldsContainer = nil
  1378. joinModeSegment = nil
  1379. }
  1380. @objc private func logoutTapped() {
  1381. meetingsRefreshTimer?.invalidate()
  1382. meetingsRefreshTimer = nil
  1383. clearMeetingsScrollObserver()
  1384. googleOAuth.clearSavedTokens()
  1385. zoomOAuth.clearSavedTokens()
  1386. persistLoggedInState(false)
  1387. allScheduledMeetings = []
  1388. applyAuthenticationUIState(triggerInitialRefresh: false)
  1389. Task { @MainActor in
  1390. self.applyMeetings([])
  1391. }
  1392. }
  1393. @objc private func topBarAuthButtonTapped() {
  1394. if isUserLoggedIn() {
  1395. showProfileMenu()
  1396. } else {
  1397. zoomPrimarySignInTapped()
  1398. }
  1399. }
  1400. @objc private func profileMenuLogoutTapped() {
  1401. profileMenuPopover?.performClose(nil)
  1402. logoutTapped()
  1403. }
  1404. private func showProfileMenu() {
  1405. guard let button = topBarAuthButton else { return }
  1406. // Recreate so the popover always matches current light/dark mode.
  1407. profileMenuPopover = makeProfileMenuPopover()
  1408. guard let popover = profileMenuPopover else { return }
  1409. if popover.isShown {
  1410. popover.performClose(nil)
  1411. return
  1412. }
  1413. popover.show(relativeTo: button.bounds, of: button, preferredEdge: .maxY)
  1414. }
  1415. private func makeProfileMenuPopover() -> NSPopover {
  1416. let popover = NSPopover()
  1417. popover.behavior = .transient
  1418. popover.animates = true
  1419. let contentController = NSViewController()
  1420. let root = NSView(frame: NSRect(x: 0, y: 0, width: 198, height: 86))
  1421. root.wantsLayer = true
  1422. root.layer?.cornerRadius = 12
  1423. root.layer?.backgroundColor = (palette.isDarkMode
  1424. ? NSColor(calibratedRed: 30 / 255, green: 33 / 255, blue: 40 / 255, alpha: 1)
  1425. : NSColor(calibratedRed: 246 / 255, green: 247 / 255, blue: 250 / 255, alpha: 1)
  1426. ).cgColor
  1427. root.layer?.borderWidth = 1
  1428. root.layer?.borderColor = (palette.isDarkMode
  1429. ? NSColor.white.withAlphaComponent(0.08)
  1430. : NSColor.black.withAlphaComponent(0.08)
  1431. ).cgColor
  1432. let logoutButton = NSButton(title: "Logout", target: self, action: #selector(profileMenuLogoutTapped))
  1433. logoutButton.translatesAutoresizingMaskIntoConstraints = false
  1434. logoutButton.isBordered = false
  1435. logoutButton.wantsLayer = true
  1436. logoutButton.layer?.cornerRadius = 9
  1437. logoutButton.layer?.backgroundColor = (palette.isDarkMode
  1438. ? NSColor.white.withAlphaComponent(0.08)
  1439. : NSColor(calibratedWhite: 0.0, alpha: 0.06)
  1440. ).cgColor
  1441. logoutButton.contentTintColor = (palette.isDarkMode
  1442. ? .white
  1443. : NSColor(calibratedRed: 22 / 255, green: 24 / 255, blue: 29 / 255, alpha: 1)
  1444. )
  1445. logoutButton.font = .systemFont(ofSize: 13, weight: .semibold)
  1446. logoutButton.alignment = .left
  1447. logoutButton.imagePosition = .imageLeading
  1448. logoutButton.imageHugsTitle = true
  1449. logoutButton.bezelStyle = .shadowlessSquare
  1450. logoutButton.title = " Logout"
  1451. if let symbol = NSImage(systemSymbolName: "rectangle.portrait.and.arrow.right", accessibilityDescription: "Logout") {
  1452. logoutButton.image = symbol.withSymbolConfiguration(.init(pointSize: 13, weight: .semibold))
  1453. }
  1454. let subtitle = makeLabel(
  1455. "Account",
  1456. size: 11,
  1457. color: palette.isDarkMode
  1458. ? NSColor.white.withAlphaComponent(0.72)
  1459. : NSColor(calibratedRed: 88 / 255, green: 92 / 255, blue: 102 / 255, alpha: 1),
  1460. weight: .medium,
  1461. centered: false
  1462. )
  1463. subtitle.translatesAutoresizingMaskIntoConstraints = false
  1464. root.addSubview(subtitle)
  1465. root.addSubview(logoutButton)
  1466. NSLayoutConstraint.activate([
  1467. subtitle.topAnchor.constraint(equalTo: root.topAnchor, constant: 10),
  1468. subtitle.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 12),
  1469. subtitle.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -12),
  1470. logoutButton.topAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 8),
  1471. logoutButton.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 10),
  1472. logoutButton.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -10),
  1473. logoutButton.heightAnchor.constraint(equalToConstant: 34)
  1474. ])
  1475. contentController.view = root
  1476. popover.contentViewController = contentController
  1477. popover.contentSize = root.frame.size
  1478. return popover
  1479. }
  1480. @objc private func topBarPlaceholderTapped() {
  1481. // Reserved for future titlebar control actions.
  1482. }
  1483. @objc private func upgradeToProTapped() {
  1484. if storeKitCoordinator.hasLifetimePremiumAccess {
  1485. openManageSubscriptions()
  1486. } else {
  1487. showPaywall()
  1488. }
  1489. }
  1490. private func openManageSubscriptions() {
  1491. if let url = URL(string: "https://apps.apple.com/account/subscriptions") {
  1492. NSWorkspace.shared.open(url)
  1493. }
  1494. }
  1495. @objc private func refreshMeetingsTapped() {
  1496. guard isUserLoggedIn() else {
  1497. applyGuestLoginMessages()
  1498. return
  1499. }
  1500. guard ensurePremiumAccess() else {
  1501. applyPremiumLockMessages()
  1502. return
  1503. }
  1504. Task { @MainActor in
  1505. self.animateMeetingsRefresh()
  1506. self.setMeetingsLoadingUI(true)
  1507. }
  1508. triggerMeetingsRefresh(force: true)
  1509. }
  1510. @MainActor
  1511. private func setMeetingsLoadingUI(_ loading: Bool) {
  1512. refreshMeetingsButton?.isEnabled = loading == false
  1513. meetingsPageRefreshButton?.isEnabled = loading == false
  1514. if loading {
  1515. meetingsPageStatusLabel?.stringValue = "Refreshing meetings..."
  1516. }
  1517. }
  1518. @MainActor
  1519. private func animateMeetingsRefresh() {
  1520. guard let stack = meetingsListStack, stack.arrangedSubviews.isEmpty == false else { return }
  1521. let views = stack.arrangedSubviews
  1522. let dimmed: CGFloat = 0.86
  1523. NSAnimationContext.runAnimationGroup { context in
  1524. context.duration = 0.07
  1525. context.timingFunction = CAMediaTimingFunction(name: .linear)
  1526. views.forEach { $0.animator().alphaValue = dimmed }
  1527. } completionHandler: {
  1528. NSAnimationContext.runAnimationGroup { context in
  1529. context.duration = 0.08
  1530. context.timingFunction = CAMediaTimingFunction(name: .linear)
  1531. views.forEach { $0.animator().alphaValue = 1.0 }
  1532. }
  1533. }
  1534. }
  1535. private func startMeetingsAutoRefresh() {
  1536. guard isUserLoggedIn() else { return }
  1537. meetingsRefreshTimer?.invalidate()
  1538. // Poll Zoom meetings periodically so newly scheduled meetings appear automatically.
  1539. meetingsRefreshTimer = Timer.scheduledTimer(withTimeInterval: meetingsRefreshInterval, repeats: true) { [weak self] _ in
  1540. guard let self else { return }
  1541. self.triggerMeetingsRefresh()
  1542. }
  1543. }
  1544. private func triggerMeetingsRefresh(force: Bool = false) {
  1545. guard isUserLoggedIn() else { return }
  1546. guard storeKitCoordinator.hasPremiumAccess else { return }
  1547. let now = Date()
  1548. if let until = meetingsRateLimitedUntil, now < until {
  1549. return
  1550. }
  1551. if force == false, now.timeIntervalSince(lastMeetingsRefreshAt) < meetingsRefreshInterval {
  1552. return
  1553. }
  1554. lastMeetingsRefreshAt = now
  1555. Task { await self.loadScheduledMeetings() }
  1556. }
  1557. private func clearMeetingsScrollObserver() {
  1558. if let meetingsScrollObserver {
  1559. NotificationCenter.default.removeObserver(meetingsScrollObserver)
  1560. }
  1561. meetingsScrollObserver = nil
  1562. meetingsScrollView?.contentView.postsBoundsChangedNotifications = false
  1563. meetingsScrollView = nil
  1564. }
  1565. private func clearMeetingsPageScrollObserver() {
  1566. if let meetingsPageScrollObserver {
  1567. NotificationCenter.default.removeObserver(meetingsPageScrollObserver)
  1568. }
  1569. meetingsPageScrollObserver = nil
  1570. meetingsPageScrollView?.contentView.postsBoundsChangedNotifications = false
  1571. meetingsPageScrollView = nil
  1572. }
  1573. private func removeSearchFieldObserver() {
  1574. if let searchTextObserver {
  1575. NotificationCenter.default.removeObserver(searchTextObserver)
  1576. }
  1577. searchTextObserver = nil
  1578. }
  1579. private func removeMeetingSearchObserver() {
  1580. if let meetingSearchTextObserver {
  1581. NotificationCenter.default.removeObserver(meetingSearchTextObserver)
  1582. }
  1583. meetingSearchTextObserver = nil
  1584. }
  1585. private func installSearchShortcutMonitor() {
  1586. removeSearchShortcutMonitor()
  1587. searchShortcutMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
  1588. guard let self else { return event }
  1589. if event.keyCode == 53, self.meetingSearchOverlayView?.isHidden == false {
  1590. DispatchQueue.main.async {
  1591. self.hideMeetingSearchPopup()
  1592. }
  1593. return nil
  1594. }
  1595. guard event.modifierFlags.contains(.command),
  1596. event.charactersIgnoringModifiers?.lowercased() == "e" else { return event }
  1597. guard self.homeSearchPill != nil else { return event }
  1598. DispatchQueue.main.async {
  1599. self.showMeetingSearchPopup(seedQuery: self.meetingSearchField?.stringValue ?? "")
  1600. }
  1601. return nil
  1602. }
  1603. searchOutsideClickMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown) { [weak self] event in
  1604. guard let self else { return event }
  1605. guard let window = self.view.window, event.window === window else { return event }
  1606. guard self.meetingSearchOverlayView?.isHidden == false else { return event }
  1607. guard let panel = self.meetingSearchPanelView else { return event }
  1608. let rectInWindow = panel.convert(panel.bounds, to: nil)
  1609. if rectInWindow.contains(event.locationInWindow) == false {
  1610. DispatchQueue.main.async {
  1611. self.hideMeetingSearchPopup()
  1612. }
  1613. }
  1614. return event
  1615. }
  1616. }
  1617. private func removeSearchShortcutMonitor() {
  1618. if let searchShortcutMonitor {
  1619. NSEvent.removeMonitor(searchShortcutMonitor)
  1620. }
  1621. searchShortcutMonitor = nil
  1622. if let searchOutsideClickMonitor {
  1623. NSEvent.removeMonitor(searchOutsideClickMonitor)
  1624. }
  1625. searchOutsideClickMonitor = nil
  1626. }
  1627. private func isSearchFieldActive(_ field: NSTextField, in window: NSWindow) -> Bool {
  1628. guard let fr = window.firstResponder else { return false }
  1629. if fr === field { return true }
  1630. if let editor = field.currentEditor(), fr === editor { return true }
  1631. return false
  1632. }
  1633. @MainActor
  1634. private func applySearchPillFocusBorder(focused: Bool) {
  1635. homeSearchPill?.layer?.borderWidth = focused ? 1.5 : 0
  1636. homeSearchPill?.layer?.borderColor = accentBlue.cgColor
  1637. }
  1638. @MainActor
  1639. private func focusHomeSearchField() {
  1640. guard let field = homeSearchField else { return }
  1641. view.window?.makeFirstResponder(field)
  1642. }
  1643. @MainActor
  1644. private func showMeetingSearchPopup(seedQuery: String) {
  1645. guard let overlay = meetingSearchOverlayView else { return }
  1646. overlay.isHidden = false
  1647. overlay.alphaValue = 0
  1648. NSAnimationContext.runAnimationGroup { context in
  1649. context.duration = 0.16
  1650. overlay.animator().alphaValue = 1
  1651. }
  1652. if let field = meetingSearchField {
  1653. field.stringValue = seedQuery
  1654. view.window?.makeFirstResponder(field)
  1655. }
  1656. applyGlobalMeetingSearch(query: seedQuery)
  1657. }
  1658. @MainActor
  1659. private func hideMeetingSearchPopup() {
  1660. guard let overlay = meetingSearchOverlayView else { return }
  1661. overlay.isHidden = true
  1662. overlay.alphaValue = 1
  1663. view.window?.makeFirstResponder(nil)
  1664. }
  1665. private func applyGlobalMeetingSearch(query: String) {
  1666. let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
  1667. let now = Date()
  1668. let dateFormatter = DateFormatter()
  1669. dateFormatter.dateStyle = .medium
  1670. dateFormatter.timeStyle = .short
  1671. let filtered: [ScheduledMeeting]
  1672. if trimmed.isEmpty {
  1673. filtered = allScheduledMeetings
  1674. } else {
  1675. filtered = allScheduledMeetings.filter { meeting in
  1676. let dateText = dateFormatter.string(from: meeting.start).lowercased()
  1677. return meeting.title.lowercased().contains(trimmed)
  1678. || meeting.host.lowercased().contains(trimmed)
  1679. || meeting.source.lowercased().contains(trimmed)
  1680. || (meeting.meetingID?.lowercased().contains(trimmed) ?? false)
  1681. || dateText.contains(trimmed)
  1682. }
  1683. }
  1684. globalSearchResults = filtered.sorted { lhs, rhs in
  1685. let lhsIsUpcoming = lhs.start >= now
  1686. let rhsIsUpcoming = rhs.start >= now
  1687. if lhsIsUpcoming != rhsIsUpcoming {
  1688. return lhsIsUpcoming && !rhsIsUpcoming
  1689. }
  1690. if lhsIsUpcoming {
  1691. return lhs.start < rhs.start
  1692. }
  1693. return lhs.start > rhs.start
  1694. }
  1695. renderMeetingSearchResults()
  1696. }
  1697. private func renderMeetingSearchResults() {
  1698. guard let stack = meetingSearchResultsStack else { return }
  1699. stack.arrangedSubviews.forEach { row in
  1700. stack.removeArrangedSubview(row)
  1701. row.removeFromSuperview()
  1702. }
  1703. meetingSearchEmptyLabel?.isHidden = globalSearchResults.isEmpty == false
  1704. for (index, meeting) in globalSearchResults.enumerated() {
  1705. let row = makeMeetingSearchResultRow(meeting)
  1706. row.tag = index
  1707. stack.addArrangedSubview(row)
  1708. row.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  1709. }
  1710. }
  1711. private func makeMeetingSearchResultRow(_ meeting: ScheduledMeeting) -> NSButton {
  1712. let row = NSButton(title: "", target: self, action: #selector(meetingSearchResultTapped(_:)))
  1713. row.isBordered = false
  1714. row.bezelStyle = .regularSquare
  1715. row.setButtonType(.momentaryChange)
  1716. row.wantsLayer = true
  1717. row.layer?.backgroundColor = meetingCardBackground.cgColor
  1718. row.layer?.cornerRadius = 10
  1719. row.layer?.borderWidth = 1
  1720. row.layer?.borderColor = palette.inputBorder.cgColor
  1721. row.translatesAutoresizingMaskIntoConstraints = false
  1722. row.heightAnchor.constraint(equalToConstant: 84).isActive = true
  1723. let title = makeLabel(meeting.title, size: 14, color: primaryText, weight: .semibold, centered: false)
  1724. title.lineBreakMode = .byTruncatingTail
  1725. let subtitle = makeLabel(compactMeetingSummary(meeting), size: 12, color: secondaryText, weight: .regular, centered: false)
  1726. subtitle.lineBreakMode = .byTruncatingTail
  1727. let meta = makeLabel("Host: \(meeting.host) • \(meeting.source)", size: 11, color: mutedText, weight: .regular, centered: false)
  1728. meta.lineBreakMode = .byTruncatingTail
  1729. [title, subtitle, meta].forEach {
  1730. $0.translatesAutoresizingMaskIntoConstraints = false
  1731. row.addSubview($0)
  1732. }
  1733. NSLayoutConstraint.activate([
  1734. title.topAnchor.constraint(equalTo: row.topAnchor, constant: 10),
  1735. title.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 12),
  1736. title.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -12),
  1737. subtitle.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 4),
  1738. subtitle.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  1739. subtitle.trailingAnchor.constraint(equalTo: title.trailingAnchor),
  1740. meta.topAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 4),
  1741. meta.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  1742. meta.trailingAnchor.constraint(equalTo: title.trailingAnchor)
  1743. ])
  1744. return row
  1745. }
  1746. private func compactMeetingSummary(_ meeting: ScheduledMeeting) -> String {
  1747. let dateFormatter = DateFormatter()
  1748. dateFormatter.dateFormat = "EEE, MMM d"
  1749. let timeFormatter = DateFormatter()
  1750. timeFormatter.dateFormat = "h:mm a"
  1751. let displayTimeZone = meeting.meetingTimeZone ?? TimeZone.current
  1752. dateFormatter.timeZone = displayTimeZone
  1753. timeFormatter.timeZone = displayTimeZone
  1754. let start = timeFormatter.string(from: meeting.start)
  1755. let end = meeting.end.map { timeFormatter.string(from: $0) }
  1756. let range = end.map { "\(start) - \($0)" } ?? start
  1757. if let id = meeting.meetingID, id.isEmpty == false {
  1758. return "\(dateFormatter.string(from: meeting.start)) • \(range) • ID \(id)"
  1759. }
  1760. return "\(dateFormatter.string(from: meeting.start)) • \(range)"
  1761. }
  1762. @objc private func topSearchPillTapped() {
  1763. showMeetingSearchPopup(seedQuery: meetingSearchField?.stringValue ?? "")
  1764. }
  1765. @objc private func meetingSearchResultTapped(_ sender: NSButton) {
  1766. guard let meeting = globalSearchResults[safe: sender.tag] else { return }
  1767. if let url = meeting.webURL {
  1768. NSWorkspace.shared.open(url)
  1769. } else {
  1770. let pasteboard = NSPasteboard.general
  1771. pasteboard.clearContents()
  1772. if let id = meeting.meetingID, id.isEmpty == false {
  1773. pasteboard.setString(id, forType: .string)
  1774. } else {
  1775. pasteboard.setString(meeting.title, forType: .string)
  1776. }
  1777. }
  1778. hideMeetingSearchPopup()
  1779. }
  1780. private func observeMeetingsScrollEdges(in scrollView: NSScrollView) {
  1781. clearMeetingsScrollObserver()
  1782. meetingsScrollView = scrollView
  1783. scrollView.contentView.postsBoundsChangedNotifications = true
  1784. meetingsScrollObserver = NotificationCenter.default.addObserver(
  1785. forName: NSView.boundsDidChangeNotification,
  1786. object: scrollView.contentView,
  1787. queue: .main
  1788. ) { [weak self, weak scrollView] _ in
  1789. guard let self, let scrollView else { return }
  1790. self.refreshMeetingsIfScrolledToEdge(scrollView)
  1791. }
  1792. }
  1793. private func refreshMeetingsIfScrolledToEdge(_ scrollView: NSScrollView) {
  1794. guard let documentView = scrollView.documentView else { return }
  1795. let visibleRect = scrollView.contentView.bounds
  1796. let contentHeight = documentView.bounds.height
  1797. let viewportHeight = visibleRect.height
  1798. if contentHeight <= viewportHeight + 2 {
  1799. return
  1800. }
  1801. let maxOffset = max(contentHeight - viewportHeight, 0)
  1802. let y = visibleRect.origin.y
  1803. let threshold: CGFloat = 12
  1804. let reachedTop = y <= threshold
  1805. let reachedBottom = y >= (maxOffset - threshold)
  1806. guard reachedTop || reachedBottom else { return }
  1807. let now = Date()
  1808. if now.timeIntervalSince(lastScrollEdgeRefreshAt) < scrollRefreshCooldown {
  1809. return
  1810. }
  1811. lastScrollEdgeRefreshAt = now
  1812. triggerMeetingsRefresh(force: true)
  1813. }
  1814. private func observeMeetingsPageInfiniteScroll(in scrollView: NSScrollView) {
  1815. clearMeetingsPageScrollObserver()
  1816. meetingsPageScrollView = scrollView
  1817. scrollView.contentView.postsBoundsChangedNotifications = true
  1818. meetingsPageScrollObserver = NotificationCenter.default.addObserver(
  1819. forName: NSView.boundsDidChangeNotification,
  1820. object: scrollView.contentView,
  1821. queue: .main
  1822. ) { [weak self, weak scrollView] _ in
  1823. guard let self, let scrollView else { return }
  1824. self.loadMoreMeetingsIfNeededForScroll(scrollView)
  1825. }
  1826. }
  1827. private func loadMoreMeetingsIfNeededForScroll(_ scrollView: NSScrollView) {
  1828. guard meetingsPageVisibleCount < meetingsPageFilteredMeetings.count else { return }
  1829. guard let documentView = scrollView.documentView else { return }
  1830. let visibleRect = scrollView.contentView.bounds
  1831. let contentHeight = documentView.bounds.height
  1832. let viewportHeight = visibleRect.height
  1833. guard contentHeight > 0, viewportHeight > 0 else { return }
  1834. let maxOffset = max(contentHeight - viewportHeight, 0)
  1835. let y = visibleRect.origin.y
  1836. let threshold: CGFloat = 72
  1837. guard y >= (maxOffset - threshold) else { return }
  1838. let now = Date()
  1839. guard now.timeIntervalSince(lastMeetingsPageAutoLoadAt) >= meetingsPageAutoLoadCooldown else { return }
  1840. lastMeetingsPageAutoLoadAt = now
  1841. let nextCount = meetingsPageVisibleCount + meetingsPageSize
  1842. meetingsPageVisibleCount = min(nextCount, meetingsPageFilteredMeetings.count)
  1843. renderMeetingsPageList()
  1844. }
  1845. @MainActor
  1846. private func resetLoginSigningInState() {
  1847. isSigningIn = false
  1848. updateTopBarAuthButton()
  1849. googleButton?.isEnabled = true
  1850. }
  1851. @MainActor
  1852. private func completeLoginAndRestart(profile: GoogleUserProfile?) {
  1853. persistLoggedInState(true)
  1854. showHomeView(profile: profile)
  1855. if let window = view.window {
  1856. window.makeKeyAndOrderFront(nil)
  1857. }
  1858. NSApp.activate(ignoringOtherApps: true)
  1859. }
  1860. @MainActor
  1861. private func updateTopBarAuthButton() {
  1862. guard let button = topBarAuthButton else { return }
  1863. let loggedIn = isUserLoggedIn()
  1864. if loggedIn {
  1865. button.title = topBarProfileInitial
  1866. button.toolTip = "Profile"
  1867. button.font = .systemFont(ofSize: 14, weight: .bold)
  1868. button.contentTintColor = primaryText
  1869. button.layer?.backgroundColor = accentBlue.withAlphaComponent(0.75).cgColor
  1870. button.layer?.cornerRadius = 10
  1871. topBarAuthButtonWidthConstraint?.constant = 34
  1872. } else {
  1873. button.title = "Sign in with Zoom"
  1874. button.toolTip = "Sign in with Zoom"
  1875. button.font = .systemFont(ofSize: 12, weight: .semibold)
  1876. button.contentTintColor = .white
  1877. button.layer?.backgroundColor = accentBlue.cgColor
  1878. button.layer?.cornerRadius = 11
  1879. topBarAuthButtonWidthConstraint?.constant = 120
  1880. }
  1881. button.isEnabled = isSigningIn == false
  1882. }
  1883. @MainActor
  1884. private func applyGuestLoginMessages() {
  1885. let isHome = selectedHomeSidebarItem == HomeSection.home.rawValue
  1886. let isMeetings = selectedHomeSidebarItem == HomeSection.meetings.rawValue
  1887. let isScheduler = selectedHomeSidebarItem == HomeSection.scheduler.rawValue
  1888. meetingsStatusLabel?.stringValue = "Sign in with Zoom to load meetings."
  1889. emptyMeetingLabel?.isHidden = isHome == false
  1890. emptyMeetingLabel?.stringValue = "Please sign in with Zoom to view your scheduled meetings."
  1891. meetingsPageStatusLabel?.stringValue = "Sign in with Zoom to view meetings."
  1892. meetingsPageEmptyLabel?.isHidden = isMeetings == false
  1893. meetingsPageEmptyLabel?.stringValue = "Please sign in with Zoom to view your meetings."
  1894. meetingsPageCountLabel?.stringValue = "0 scheduled"
  1895. schedulerMeetingsEmptyLabel?.isHidden = isScheduler == false
  1896. schedulerMeetingsEmptyLabel?.stringValue = "Please sign in with Zoom to use Scheduler."
  1897. }
  1898. @MainActor
  1899. private func applyPremiumLockMessages() {
  1900. // Keep underlying section labels hidden while locked to avoid duplicate text beneath overlays.
  1901. meetingsStatusLabel?.stringValue = ""
  1902. emptyMeetingLabel?.isHidden = true
  1903. meetingsPageStatusLabel?.stringValue = ""
  1904. meetingsPageEmptyLabel?.isHidden = true
  1905. meetingsPageCountLabel?.stringValue = ""
  1906. schedulerMeetingsEmptyLabel?.isHidden = true
  1907. }
  1908. private func ensurePremiumAccess() -> Bool {
  1909. guard storeKitCoordinator.hasPremiumAccess else {
  1910. showPaywall()
  1911. return false
  1912. }
  1913. return true
  1914. }
  1915. @MainActor
  1916. private func applyAuthenticationUIState(triggerInitialRefresh: Bool) {
  1917. let loggedIn = isUserLoggedIn()
  1918. updateTopBarAuthButton()
  1919. settingsGoogleActionButton?.title = loggedIn ? "Sign Out" : "Sign in with Zoom"
  1920. settingsGoogleActionButton?.isEnabled = true
  1921. updatePremiumLockedSections()
  1922. meetingsRefreshTimer?.invalidate()
  1923. meetingsRefreshTimer = nil
  1924. meetingsRateLimitedUntil = nil
  1925. if loggedIn {
  1926. if storeKitCoordinator.hasPremiumAccess {
  1927. startMeetingsAutoRefresh()
  1928. if triggerInitialRefresh {
  1929. triggerMeetingsRefresh(force: true)
  1930. }
  1931. } else {
  1932. applyMeetings([])
  1933. applyPremiumLockMessages()
  1934. }
  1935. } else {
  1936. applyGuestLoginMessages()
  1937. }
  1938. }
  1939. /// Returns false if the user cancelled or left credentials empty.
  1940. @MainActor
  1941. private func ensureZoomOAuthClientConfigured() -> Bool {
  1942. if zoomOAuth.configuredClientId() != nil, zoomOAuth.configuredClientSecret() != nil {
  1943. return true
  1944. }
  1945. return presentZoomOAuthCredentialPrompt()
  1946. }
  1947. private func showSimpleError(_ title: String, error: Error) {
  1948. let alert = NSAlert()
  1949. alert.alertStyle = .warning
  1950. alert.messageText = title
  1951. alert.informativeText = error.localizedDescription
  1952. alert.runModal()
  1953. }
  1954. private func showSimpleAlert(title: String, message: String) {
  1955. let alert = NSAlert()
  1956. alert.alertStyle = .informational
  1957. alert.messageText = title
  1958. alert.informativeText = message
  1959. alert.runModal()
  1960. }
  1961. private struct ScheduledMeeting {
  1962. let title: String
  1963. let start: Date
  1964. let end: Date?
  1965. let meetingTimeZone: TimeZone?
  1966. let host: String
  1967. let source: String
  1968. let meetingID: String?
  1969. let typeLabel: String
  1970. let statusLabel: String
  1971. let webURL: URL?
  1972. }
  1973. @MainActor
  1974. private func applyMeetings(_ meetings: [ScheduledMeeting]) {
  1975. allScheduledMeetings = meetings
  1976. if meetings.isEmpty == false {
  1977. let calendar = Calendar.current
  1978. let selectedStart = calendar.startOfDay(for: selectedMeetingsDayStart)
  1979. let selectedEnd = calendar.date(byAdding: .day, value: 1, to: selectedStart) ?? selectedStart.addingTimeInterval(60 * 60 * 24)
  1980. let selectedDayHasMeetings = meetings.contains { meeting in
  1981. meeting.start >= selectedStart && meeting.start < selectedEnd
  1982. }
  1983. if selectedDayHasMeetings == false,
  1984. let firstUpcoming = meetings
  1985. .filter({ $0.start >= Date() })
  1986. .sorted(by: { $0.start < $1.start })
  1987. .first {
  1988. selectedMeetingsDayStart = calendar.startOfDay(for: firstUpcoming.start)
  1989. }
  1990. }
  1991. applyFilteredMeetings()
  1992. applyMeetingsPageFilters(resetVisible: true)
  1993. }
  1994. @MainActor
  1995. private func applyFilteredMeetings() {
  1996. guard let stack = meetingsListStack else { return }
  1997. let showingHomeSection = selectedHomeSidebarItem == HomeSection.home.rawValue
  1998. stack.arrangedSubviews.forEach { view in
  1999. stack.removeArrangedSubview(view)
  2000. view.removeFromSuperview()
  2001. }
  2002. guard isUserLoggedIn() else {
  2003. meetingsStatusLabel?.stringValue = "Sign in with Zoom to load meetings."
  2004. emptyMeetingLabel?.isHidden = showingHomeSection == false
  2005. emptyMeetingLabel?.stringValue = "Please sign in with Zoom to view your scheduled meetings."
  2006. applySchedulerMeetingsForSelectedDate()
  2007. renderSchedulerCalendarGrid()
  2008. return
  2009. }
  2010. guard storeKitCoordinator.hasPremiumAccess else {
  2011. meetingsStatusLabel?.stringValue = ""
  2012. emptyMeetingLabel?.isHidden = true
  2013. applySchedulerMeetingsForSelectedDate()
  2014. renderSchedulerCalendarGrid()
  2015. return
  2016. }
  2017. let query = (homeSearchField?.stringValue ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
  2018. let calendar = Calendar.current
  2019. let dayStart = selectedMeetingsDayStart
  2020. let dayEnd = calendar.date(byAdding: .day, value: 1, to: dayStart) ?? dayStart.addingTimeInterval(60 * 60 * 24)
  2021. let source = allScheduledMeetings.filter { meeting in
  2022. meeting.start >= dayStart && meeting.start < dayEnd
  2023. }
  2024. let filtered: [ScheduledMeeting]
  2025. if query.isEmpty {
  2026. filtered = source
  2027. } else {
  2028. filtered = source.filter { meeting in
  2029. meeting.title.lowercased().contains(query)
  2030. || meeting.host.lowercased().contains(query)
  2031. || meeting.source.lowercased().contains(query)
  2032. }
  2033. }
  2034. let ordered = filtered.sorted(by: { $0.start < $1.start })
  2035. if ordered.isEmpty {
  2036. emptyMeetingLabel?.isHidden = showingHomeSection == false
  2037. if source.isEmpty {
  2038. meetingsStatusLabel?.stringValue = "Upcoming meetings"
  2039. emptyMeetingLabel?.stringValue = "No meetings scheduled for \(meetingsDayDisplayName(for: dayStart))."
  2040. } else {
  2041. meetingsStatusLabel?.stringValue = "Upcoming meetings"
  2042. emptyMeetingLabel?.stringValue = "No meetings match your search."
  2043. }
  2044. applySchedulerMeetingsForSelectedDate()
  2045. renderSchedulerCalendarGrid()
  2046. return
  2047. }
  2048. emptyMeetingLabel?.isHidden = true
  2049. meetingsStatusLabel?.stringValue = "Upcoming meetings"
  2050. for meeting in ordered {
  2051. let card = makeMeetingRowCard(meeting)
  2052. stack.addArrangedSubview(card)
  2053. // Make each meeting card span the full list width (like Zoom).
  2054. if card.constraints.contains(where: { $0.firstAttribute == .width }) == false {
  2055. card.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  2056. }
  2057. }
  2058. applySchedulerMeetingsForSelectedDate()
  2059. renderSchedulerCalendarGrid()
  2060. }
  2061. @MainActor
  2062. private func applyMeetingsPageFilters(resetVisible: Bool) {
  2063. guard isUserLoggedIn() else {
  2064. meetingsPageFilteredMeetings = []
  2065. meetingsPageVisibleCount = 0
  2066. renderMeetingsPageList()
  2067. return
  2068. }
  2069. guard storeKitCoordinator.hasPremiumAccess else {
  2070. meetingsPageFilteredMeetings = []
  2071. meetingsPageVisibleCount = 0
  2072. renderMeetingsPageList()
  2073. return
  2074. }
  2075. let calendar = Calendar.current
  2076. let now = Date()
  2077. let activePreset = meetingsPageCategoryFilter?.titleOfSelectedItem ?? meetingsPageSelectedRangePreset
  2078. meetingsPageSelectedRangePreset = activePreset
  2079. let activeFromDate: Date?
  2080. let activeToDate: Date?
  2081. if activePreset == "Custom range",
  2082. let fromPicker = meetingsPageFromDatePicker,
  2083. let toPicker = meetingsPageToDatePicker {
  2084. let fromDay = calendar.startOfDay(for: fromPicker.dateValue)
  2085. let toDay = calendar.startOfDay(for: toPicker.dateValue)
  2086. if fromDay <= toDay {
  2087. activeFromDate = fromDay
  2088. activeToDate = toDay
  2089. } else {
  2090. activeFromDate = toDay
  2091. activeToDate = fromDay
  2092. }
  2093. meetingsPageFromDateFilter = activeFromDate
  2094. meetingsPageToDateFilter = activeToDate
  2095. } else {
  2096. activeFromDate = meetingsPageFromDateFilter
  2097. activeToDate = meetingsPageToDateFilter
  2098. }
  2099. var filtered = allScheduledMeetings.filter { meeting in
  2100. guard meeting.start >= now else { return false }
  2101. switch activePreset {
  2102. case "Today":
  2103. let dayStart = calendar.startOfDay(for: now)
  2104. let dayEnd = calendar.date(byAdding: .day, value: 1, to: dayStart) ?? now
  2105. return meeting.start >= now && meeting.start < dayEnd
  2106. case "This week":
  2107. let weekInterval = calendar.dateInterval(of: .weekOfYear, for: now)
  2108. let weekEnd = weekInterval?.end ?? now
  2109. return meeting.start >= now && meeting.start < weekEnd
  2110. case "This month":
  2111. let monthInterval = calendar.dateInterval(of: .month, for: now)
  2112. let monthEnd = monthInterval?.end ?? now
  2113. return meeting.start >= now && meeting.start < monthEnd
  2114. case "Custom range":
  2115. if let fromDate = activeFromDate {
  2116. let startOfFrom = calendar.startOfDay(for: fromDate)
  2117. if meeting.start < startOfFrom { return false }
  2118. }
  2119. if let toDate = activeToDate {
  2120. let startOfTo = calendar.startOfDay(for: toDate)
  2121. let endOfTo = calendar.date(byAdding: .day, value: 1, to: startOfTo) ?? toDate
  2122. if meeting.start >= endOfTo { return false }
  2123. }
  2124. return true
  2125. default:
  2126. return true
  2127. }
  2128. }
  2129. filtered.sort { $0.start < $1.start }
  2130. meetingsPageFilteredMeetings = filtered
  2131. if resetVisible {
  2132. meetingsPageVisibleCount = min(meetingsPageSize, filtered.count)
  2133. } else if filtered.count < meetingsPageVisibleCount {
  2134. meetingsPageVisibleCount = filtered.count
  2135. }
  2136. renderMeetingsPageList()
  2137. }
  2138. @MainActor
  2139. private func renderMeetingsPageList() {
  2140. guard let stack = meetingsPageListStack else { return }
  2141. stack.arrangedSubviews.forEach { view in
  2142. stack.removeArrangedSubview(view)
  2143. view.removeFromSuperview()
  2144. }
  2145. guard isUserLoggedIn() else {
  2146. meetingsPageCountLabel?.stringValue = "0 scheduled"
  2147. meetingsPageStatusLabel?.stringValue = "Sign in with Zoom to view meetings."
  2148. meetingsPageEmptyLabel?.isHidden = false
  2149. meetingsPageEmptyLabel?.stringValue = "Please sign in with Zoom to view your meetings."
  2150. meetingsPageLoadMoreButton?.isHidden = true
  2151. return
  2152. }
  2153. guard storeKitCoordinator.hasPremiumAccess else {
  2154. meetingsPageCountLabel?.stringValue = ""
  2155. meetingsPageStatusLabel?.stringValue = ""
  2156. meetingsPageEmptyLabel?.isHidden = true
  2157. meetingsPageLoadMoreButton?.isHidden = true
  2158. return
  2159. }
  2160. let totalCount = meetingsPageFilteredMeetings.count
  2161. let visibleCount = min(meetingsPageVisibleCount, totalCount)
  2162. meetingsPageCountLabel?.stringValue = "\(totalCount) scheduled"
  2163. if totalCount == 0 {
  2164. meetingsPageStatusLabel?.stringValue = "Showing 0 meetings"
  2165. meetingsPageEmptyLabel?.isHidden = false
  2166. meetingsPageLoadMoreButton?.isHidden = true
  2167. return
  2168. }
  2169. meetingsPageEmptyLabel?.isHidden = true
  2170. meetingsPageStatusLabel?.stringValue = "Showing \(visibleCount) of \(totalCount) meetings"
  2171. for meeting in meetingsPageFilteredMeetings.prefix(visibleCount) {
  2172. let card = makeMeetingRowCard(meeting)
  2173. stack.addArrangedSubview(card)
  2174. if card.constraints.contains(where: { $0.firstAttribute == .width }) == false {
  2175. card.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  2176. }
  2177. }
  2178. meetingsPageLoadMoreButton?.isHidden = true
  2179. }
  2180. @objc private func meetingsPageApplyFiltersTapped() {
  2181. let selectedPreset = meetingsPageCategoryFilter?.titleOfSelectedItem ?? "All"
  2182. meetingsPageSelectedRangePreset = selectedPreset
  2183. updateMeetingsDatePickersEnabledState()
  2184. if selectedPreset == "Custom range",
  2185. let fromPicker = meetingsPageFromDatePicker, let toPicker = meetingsPageToDatePicker {
  2186. let fromDay = Calendar.current.startOfDay(for: fromPicker.dateValue)
  2187. let toDay = Calendar.current.startOfDay(for: toPicker.dateValue)
  2188. if fromDay <= toDay {
  2189. meetingsPageFromDateFilter = fromDay
  2190. meetingsPageToDateFilter = toDay
  2191. } else {
  2192. meetingsPageFromDateFilter = toDay
  2193. meetingsPageToDateFilter = fromDay
  2194. }
  2195. } else {
  2196. meetingsPageFromDateFilter = nil
  2197. meetingsPageToDateFilter = nil
  2198. }
  2199. Task { @MainActor in
  2200. self.applyMeetingsPageFilters(resetVisible: true)
  2201. }
  2202. }
  2203. @objc private func meetingsPageResetFiltersTapped() {
  2204. meetingsPageSelectedRangePreset = "All"
  2205. meetingsPageFromDateFilter = nil
  2206. meetingsPageToDateFilter = nil
  2207. meetingsPageCategoryFilter?.selectItem(withTitle: "All")
  2208. meetingsPageFromDatePicker?.dateValue = Date()
  2209. meetingsPageToDatePicker?.dateValue = Date()
  2210. updateMeetingsDatePickersEnabledState()
  2211. Task { @MainActor in
  2212. self.applyMeetingsPageFilters(resetVisible: true)
  2213. }
  2214. }
  2215. @objc private func meetingsPageRangePresetChanged(_ sender: NSPopUpButton) {
  2216. meetingsPageSelectedRangePreset = sender.titleOfSelectedItem ?? "All"
  2217. updateMeetingsDatePickersEnabledState()
  2218. Task { @MainActor in
  2219. self.applyMeetingsPageFilters(resetVisible: true)
  2220. }
  2221. }
  2222. private func updateMeetingsDatePickersEnabledState() {
  2223. let isCustomRange = meetingsPageSelectedRangePreset == "Custom range"
  2224. meetingsPageFromDatePicker?.isEnabled = isCustomRange
  2225. meetingsPageToDatePicker?.isEnabled = isCustomRange
  2226. meetingsPageFromDatePicker?.alphaValue = isCustomRange ? 1.0 : 0.55
  2227. meetingsPageToDatePicker?.alphaValue = isCustomRange ? 1.0 : 0.55
  2228. }
  2229. private func meetingsForDay(_ dayStart: Date) -> [ScheduledMeeting] {
  2230. let calendar = Calendar.current
  2231. let safeDayStart = calendar.startOfDay(for: dayStart)
  2232. let dayEnd = calendar.date(byAdding: .day, value: 1, to: safeDayStart) ?? safeDayStart.addingTimeInterval(60 * 60 * 24)
  2233. return allScheduledMeetings
  2234. .filter { meeting in
  2235. meeting.start >= safeDayStart && meeting.start < dayEnd
  2236. }
  2237. .sorted(by: { $0.start < $1.start })
  2238. }
  2239. @MainActor
  2240. private func setSelectedMeetingsDayStart(_ newDayStart: Date) {
  2241. let calendar = Calendar.current
  2242. selectedMeetingsDayStart = calendar.startOfDay(for: newDayStart)
  2243. let monthComponents = calendar.dateComponents([.year, .month], from: selectedMeetingsDayStart)
  2244. schedulerVisibleMonthStart = calendar.date(from: monthComponents) ?? selectedMeetingsDayStart
  2245. updateMeetingsDayUI()
  2246. applyFilteredMeetings()
  2247. }
  2248. @MainActor
  2249. private func updateMeetingsDayUI() {
  2250. let dayStart = selectedMeetingsDayStart
  2251. let formatter = DateFormatter()
  2252. formatter.dateFormat = "EEEE, MMM d"
  2253. meetingsDayHeaderLabel?.stringValue = formatter.string(from: dayStart)
  2254. let isToday = Calendar.current.isDate(dayStart, inSameDayAs: Date())
  2255. meetingsTodayButton?.isEnabled = isToday == false
  2256. meetingsTodayButton?.alphaValue = isToday ? 0.55 : 1.0
  2257. schedulerTodayButton?.isEnabled = isToday == false
  2258. schedulerTodayButton?.alphaValue = isToday ? 0.55 : 1.0
  2259. updateSchedulerDateHeader()
  2260. applySchedulerMeetingsForSelectedDate()
  2261. renderSchedulerCalendarGrid()
  2262. }
  2263. private func meetingsDayDisplayName(for dayStart: Date) -> String {
  2264. let calendar = Calendar.current
  2265. if calendar.isDate(dayStart, inSameDayAs: Date()) { return "today" }
  2266. if let tomorrow = calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: Date())),
  2267. calendar.isDate(dayStart, inSameDayAs: tomorrow) { return "tomorrow" }
  2268. if let yesterday = calendar.date(byAdding: .day, value: -1, to: calendar.startOfDay(for: Date())),
  2269. calendar.isDate(dayStart, inSameDayAs: yesterday) { return "yesterday" }
  2270. let formatter = DateFormatter()
  2271. formatter.dateFormat = "EEEE, MMM d"
  2272. return formatter.string(from: dayStart)
  2273. }
  2274. @objc private func meetingsPrevDayTapped() {
  2275. let calendar = Calendar.current
  2276. let prev = calendar.date(byAdding: .day, value: -1, to: selectedMeetingsDayStart) ?? selectedMeetingsDayStart.addingTimeInterval(-60 * 60 * 24)
  2277. Task { @MainActor in
  2278. self.setSelectedMeetingsDayStart(prev)
  2279. }
  2280. }
  2281. @objc private func meetingsNextDayTapped() {
  2282. let calendar = Calendar.current
  2283. let next = calendar.date(byAdding: .day, value: 1, to: selectedMeetingsDayStart) ?? selectedMeetingsDayStart.addingTimeInterval(60 * 60 * 24)
  2284. Task { @MainActor in
  2285. self.setSelectedMeetingsDayStart(next)
  2286. }
  2287. }
  2288. @objc private func meetingsTodayTapped() {
  2289. Task { @MainActor in
  2290. self.setSelectedMeetingsDayStart(Date())
  2291. }
  2292. }
  2293. private func updateSchedulerDateHeader() {
  2294. let formatter = DateFormatter()
  2295. formatter.dateFormat = "MMMM yyyy"
  2296. schedulerMonthLabel?.stringValue = formatter.string(from: schedulerVisibleMonthStart)
  2297. let selectedFormatter = DateFormatter()
  2298. selectedFormatter.dateFormat = "EEEE, MMM d"
  2299. schedulerSelectedDateLabel?.stringValue = selectedFormatter.string(from: selectedMeetingsDayStart)
  2300. }
  2301. @MainActor
  2302. private func applySchedulerMeetingsForSelectedDate() {
  2303. guard let stack = schedulerMeetingsListStack else { return }
  2304. stack.arrangedSubviews.forEach { view in
  2305. stack.removeArrangedSubview(view)
  2306. view.removeFromSuperview()
  2307. }
  2308. guard isUserLoggedIn() else {
  2309. schedulerMeetingsEmptyLabel?.isHidden = false
  2310. schedulerMeetingsEmptyLabel?.stringValue = "Please sign in with Zoom to use Scheduler."
  2311. return
  2312. }
  2313. guard storeKitCoordinator.hasPremiumAccess else {
  2314. schedulerMeetingsEmptyLabel?.isHidden = true
  2315. return
  2316. }
  2317. let meetings = meetingsForDay(selectedMeetingsDayStart)
  2318. if meetings.isEmpty {
  2319. schedulerMeetingsEmptyLabel?.isHidden = false
  2320. schedulerMeetingsEmptyLabel?.stringValue = "No meetings scheduled for \(meetingsDayDisplayName(for: selectedMeetingsDayStart))."
  2321. return
  2322. }
  2323. schedulerMeetingsEmptyLabel?.isHidden = true
  2324. for meeting in meetings {
  2325. let card = makeMeetingRowCard(meeting)
  2326. stack.addArrangedSubview(card)
  2327. if card.constraints.contains(where: { $0.firstAttribute == .width }) == false {
  2328. card.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  2329. }
  2330. }
  2331. }
  2332. @MainActor
  2333. private func renderSchedulerCalendarGrid() {
  2334. guard let gridStack = schedulerCalendarGridStack else { return }
  2335. schedulerDayButtons = [:]
  2336. schedulerDateByButtonTag = [:]
  2337. nextSchedulerButtonTag = 1
  2338. gridStack.arrangedSubviews.forEach { row in
  2339. gridStack.removeArrangedSubview(row)
  2340. row.removeFromSuperview()
  2341. }
  2342. let calendar = Calendar.current
  2343. let monthStart = schedulerVisibleMonthStart
  2344. let monthRange = calendar.range(of: .day, in: .month, for: monthStart) ?? 1..<32
  2345. let monthWeekday = calendar.component(.weekday, from: monthStart)
  2346. let leadingSlots = monthWeekday - 1
  2347. let totalSlots = 42
  2348. for week in 0..<6 {
  2349. let row = NSStackView()
  2350. row.orientation = .horizontal
  2351. row.distribution = .fillEqually
  2352. row.spacing = 6
  2353. row.translatesAutoresizingMaskIntoConstraints = false
  2354. gridStack.addArrangedSubview(row)
  2355. row.widthAnchor.constraint(equalTo: gridStack.widthAnchor).isActive = true
  2356. for weekday in 0..<7 {
  2357. let cellIndex = (week * 7) + weekday
  2358. let dayOffset = cellIndex - leadingSlots
  2359. let dayDate = calendar.date(byAdding: .day, value: dayOffset, to: monthStart) ?? monthStart
  2360. let dayNumber = calendar.component(.day, from: dayDate)
  2361. let inCurrentMonth = monthRange.contains(dayNumber) && calendar.isDate(dayDate, equalTo: monthStart, toGranularity: .month)
  2362. let hasMeetings = isUserLoggedIn() && meetingsForDay(dayDate).isEmpty == false
  2363. let button = makeSchedulerDayCell(
  2364. day: dayNumber,
  2365. date: dayDate,
  2366. inCurrentMonth: inCurrentMonth,
  2367. selected: calendar.isDate(dayDate, inSameDayAs: selectedMeetingsDayStart),
  2368. isToday: calendar.isDateInToday(dayDate),
  2369. hasMeetings: hasMeetings
  2370. )
  2371. row.addArrangedSubview(button)
  2372. schedulerDayButtons[calendar.startOfDay(for: dayDate)] = button
  2373. }
  2374. }
  2375. // Keep a consistent 6x7 grid footprint.
  2376. if totalSlots == 42 {
  2377. gridStack.needsLayout = true
  2378. }
  2379. }
  2380. private func makeSchedulerDayCell(
  2381. day: Int,
  2382. date: Date,
  2383. inCurrentMonth: Bool,
  2384. selected: Bool,
  2385. isToday: Bool,
  2386. hasMeetings: Bool
  2387. ) -> NSButton {
  2388. let button = HoverButton(title: "\(day)", target: self, action: #selector(schedulerDayTapped(_:)))
  2389. button.isBordered = false
  2390. button.bezelStyle = .shadowlessSquare
  2391. button.focusRingType = .none
  2392. button.font = .systemFont(ofSize: 16, weight: selected ? .semibold : .regular)
  2393. button.alignment = .center
  2394. button.wantsLayer = true
  2395. button.layer?.cornerRadius = 16
  2396. button.layer?.borderWidth = selected ? 1.5 : (hasMeetings ? 1 : 0)
  2397. let normalBackground = selected
  2398. ? accentBlue.withAlphaComponent(palette.isDarkMode ? 0.92 : 0.16)
  2399. : NSColor.clear
  2400. let hoverBackground = selected
  2401. ? accentBlue.withAlphaComponent(palette.isDarkMode ? 1.0 : 0.24)
  2402. : (palette.isDarkMode ? NSColor.white.withAlphaComponent(0.08) : NSColor.black.withAlphaComponent(0.06))
  2403. button.normalColor = normalBackground
  2404. button.hoverColor = hoverBackground
  2405. button.onHoverChanged = { [weak button] hovering in
  2406. guard let button, selected == false else { return }
  2407. if hovering {
  2408. button.layer?.borderWidth = max(button.layer?.borderWidth ?? 0, 1)
  2409. button.layer?.borderColor = (hasMeetings
  2410. ? self.accentBlue.withAlphaComponent(0.65)
  2411. : self.accentBlue.withAlphaComponent(0.45)
  2412. ).cgColor
  2413. } else if isToday {
  2414. button.layer?.borderColor = self.accentOrange.withAlphaComponent(0.8).cgColor
  2415. button.layer?.borderWidth = 1
  2416. } else if hasMeetings {
  2417. button.layer?.borderColor = self.accentBlue.withAlphaComponent(0.45).cgColor
  2418. button.layer?.borderWidth = 1
  2419. } else {
  2420. button.layer?.borderColor = NSColor.clear.cgColor
  2421. button.layer?.borderWidth = 0
  2422. }
  2423. }
  2424. if selected {
  2425. button.layer?.borderColor = accentBlue.cgColor
  2426. } else if isToday {
  2427. button.layer?.borderColor = accentOrange.withAlphaComponent(0.8).cgColor
  2428. button.layer?.borderWidth = 1
  2429. } else if hasMeetings {
  2430. button.layer?.borderColor = accentBlue.withAlphaComponent(0.45).cgColor
  2431. } else {
  2432. button.layer?.borderColor = NSColor.clear.cgColor
  2433. }
  2434. button.contentTintColor = selected
  2435. ? primaryText
  2436. : (inCurrentMonth ? primaryText : mutedText.withAlphaComponent(0.75))
  2437. let tag = nextSchedulerButtonTag
  2438. nextSchedulerButtonTag += 1
  2439. button.tag = tag
  2440. schedulerDateByButtonTag[tag] = Calendar.current.startOfDay(for: date)
  2441. button.translatesAutoresizingMaskIntoConstraints = false
  2442. button.heightAnchor.constraint(equalToConstant: 34).isActive = true
  2443. return button
  2444. }
  2445. @objc private func schedulerDayTapped(_ sender: NSButton) {
  2446. guard let selectedDate = schedulerDateByButtonTag[sender.tag] else { return }
  2447. Task { @MainActor in
  2448. self.setSelectedMeetingsDayStart(selectedDate)
  2449. }
  2450. }
  2451. @objc private func schedulerPrevMonthTapped() {
  2452. let calendar = Calendar.current
  2453. let prev = calendar.date(byAdding: .month, value: -1, to: schedulerVisibleMonthStart) ?? schedulerVisibleMonthStart
  2454. let monthComponents = calendar.dateComponents([.year, .month], from: prev)
  2455. schedulerVisibleMonthStart = calendar.date(from: monthComponents) ?? prev
  2456. updateSchedulerDateHeader()
  2457. renderSchedulerCalendarGrid()
  2458. }
  2459. @objc private func schedulerNextMonthTapped() {
  2460. let calendar = Calendar.current
  2461. let next = calendar.date(byAdding: .month, value: 1, to: schedulerVisibleMonthStart) ?? schedulerVisibleMonthStart
  2462. let monthComponents = calendar.dateComponents([.year, .month], from: next)
  2463. schedulerVisibleMonthStart = calendar.date(from: monthComponents) ?? next
  2464. updateSchedulerDateHeader()
  2465. renderSchedulerCalendarGrid()
  2466. }
  2467. @objc private func schedulerTodayTapped() {
  2468. Task { @MainActor in
  2469. self.setSelectedMeetingsDayStart(Date())
  2470. }
  2471. }
  2472. private func loadScheduledMeetings() async {
  2473. guard isUserLoggedIn() else {
  2474. await MainActor.run {
  2475. self.applyMeetings([])
  2476. self.applyGuestLoginMessages()
  2477. }
  2478. return
  2479. }
  2480. guard storeKitCoordinator.hasPremiumAccess else {
  2481. await MainActor.run {
  2482. self.applyMeetings([])
  2483. self.applyPremiumLockMessages()
  2484. }
  2485. return
  2486. }
  2487. if isLoadingMeetings { return }
  2488. isLoadingMeetings = true
  2489. defer {
  2490. isLoadingMeetings = false
  2491. Task { @MainActor in
  2492. self.setMeetingsLoadingUI(false)
  2493. }
  2494. }
  2495. await MainActor.run {
  2496. self.setMeetingsLoadingUI(true)
  2497. }
  2498. do {
  2499. let zoomToken = try await zoomOAuth.validAccessToken(presentingWindow: view.window)
  2500. if zoomAccountTimeZone == nil {
  2501. if let profile = try? await fetchZoomUserProfile(accessToken: zoomToken) {
  2502. let profileTZ = parseZoomTimeZone(profile.timezone)
  2503. await MainActor.run { self.zoomAccountTimeZone = profileTZ }
  2504. }
  2505. }
  2506. let zoomMeetings = try await fetchZoomScheduledMeetings(accessToken: zoomToken, fallbackTimeZone: zoomAccountTimeZone)
  2507. await MainActor.run {
  2508. self.meetingsRateLimitedUntil = nil
  2509. self.applyMeetings(zoomMeetings)
  2510. }
  2511. } catch {
  2512. await MainActor.run {
  2513. self.applyMeetings([])
  2514. if case ZoomOAuthError.missingClientId = error {
  2515. self.meetingsStatusLabel?.stringValue = "Zoom OAuth app not configured."
  2516. self.meetingsPageStatusLabel?.stringValue = "Zoom OAuth app not configured."
  2517. self.promptForZoomOAuthCredentialsIfNeeded()
  2518. } else if case ZoomOAuthError.missingClientSecret = error {
  2519. self.meetingsStatusLabel?.stringValue = "Zoom OAuth app not configured."
  2520. self.meetingsPageStatusLabel?.stringValue = "Zoom OAuth app not configured."
  2521. self.promptForZoomOAuthCredentialsIfNeeded()
  2522. } else if case ZoomOAuthError.missingRequiredScope = error {
  2523. self.zoomOAuth.clearSavedTokens()
  2524. self.meetingsStatusLabel?.stringValue = "Zoom permissions are missing. Update your Zoom app scopes, then sign in again."
  2525. self.meetingsPageStatusLabel?.stringValue = "Zoom permissions are missing. Update your Zoom app scopes, then sign in again."
  2526. } else if case ZoomOAuthError.rateLimited(let retryAfterSeconds) = error {
  2527. let seconds = max(retryAfterSeconds ?? 300, 30)
  2528. self.meetingsRateLimitedUntil = Date().addingTimeInterval(TimeInterval(seconds))
  2529. let minutes = Int(ceil(Double(seconds) / 60.0))
  2530. self.meetingsStatusLabel?.stringValue = "Zoom rate limit reached. Please try again in \(minutes) min."
  2531. self.meetingsPageStatusLabel?.stringValue = "Zoom rate limit reached. Please try again in \(minutes) min."
  2532. } else {
  2533. self.meetingsStatusLabel?.stringValue = "Unable to load meetings right now. Please try again shortly."
  2534. self.meetingsPageStatusLabel?.stringValue = "Unable to load meetings right now. Please try again shortly."
  2535. }
  2536. }
  2537. }
  2538. }
  2539. @MainActor
  2540. private func presentZoomOAuthCredentialPrompt() -> Bool {
  2541. let alert = NSAlert()
  2542. alert.alertStyle = .informational
  2543. alert.messageText = "Configure Zoom OAuth"
  2544. alert.informativeText = "Enter your Zoom Marketplace OAuth app Client ID and Client Secret once (or set ZoomOAuthClientId in Info.plist and ZOOM_OAUTH_CLIENT_SECRET in the run environment). After this, sign-in and token refresh run automatically. Enable meeting:read and meeting:write scopes on the app so listing and scheduling work."
  2545. let wrapper = NSStackView()
  2546. wrapper.orientation = .vertical
  2547. wrapper.spacing = 8
  2548. wrapper.translatesAutoresizingMaskIntoConstraints = false
  2549. let clientIdField = NSTextField()
  2550. clientIdField.placeholderString = "Zoom Client ID"
  2551. clientIdField.stringValue = zoomOAuth.configuredClientId() ?? ""
  2552. let clientSecretField = NSSecureTextField()
  2553. clientSecretField.placeholderString = "Zoom Client Secret"
  2554. clientSecretField.stringValue = zoomOAuth.configuredClientSecret() ?? ""
  2555. [clientIdField, clientSecretField].forEach { field in
  2556. field.translatesAutoresizingMaskIntoConstraints = false
  2557. field.widthAnchor.constraint(equalToConstant: 420).isActive = true
  2558. wrapper.addArrangedSubview(field)
  2559. }
  2560. alert.accessoryView = wrapper
  2561. alert.addButton(withTitle: "Save")
  2562. alert.addButton(withTitle: "Cancel")
  2563. let result = alert.runModal()
  2564. if result == .alertFirstButtonReturn {
  2565. var clientId = clientIdField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
  2566. var clientSecret = clientSecretField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
  2567. if clientId.isEmpty { clientId = zoomOAuth.configuredClientId() ?? "" }
  2568. if clientSecret.isEmpty { clientSecret = zoomOAuth.configuredClientSecret() ?? "" }
  2569. if clientId.isEmpty == false, clientSecret.isEmpty == false {
  2570. zoomOAuth.setClientCredentials(clientId: clientId, clientSecret: clientSecret)
  2571. return true
  2572. }
  2573. meetingsStatusLabel?.stringValue = "Both Zoom OAuth Client ID and Client Secret are required (or set bundled values / ZOOM_OAUTH_CLIENT_SECRET)."
  2574. }
  2575. return false
  2576. }
  2577. @MainActor
  2578. private func promptForZoomOAuthCredentialsIfNeeded() {
  2579. guard isPromptingZoomCredentials == false else { return }
  2580. isPromptingZoomCredentials = true
  2581. defer { isPromptingZoomCredentials = false }
  2582. if presentZoomOAuthCredentialPrompt() {
  2583. meetingsStatusLabel?.stringValue = "Configured. Starting Zoom OAuth..."
  2584. Task { await self.loadScheduledMeetings() }
  2585. }
  2586. }
  2587. private struct ZoomUserMeResponse: Decodable {
  2588. let first_name: String?
  2589. let last_name: String?
  2590. let display_name: String?
  2591. let email: String?
  2592. let pic_url: String?
  2593. let timezone: String?
  2594. var displayName: String? {
  2595. if let display_name, display_name.isEmpty == false { return display_name }
  2596. let parts = [first_name, last_name].compactMap { $0 }.filter { $0.isEmpty == false }
  2597. return parts.isEmpty ? nil : parts.joined(separator: " ")
  2598. }
  2599. var pictureURL: String? {
  2600. guard let pic_url, pic_url.isEmpty == false else { return nil }
  2601. return pic_url
  2602. }
  2603. }
  2604. private func fetchZoomUserProfile(accessToken: String) async throws -> ZoomUserMeResponse {
  2605. let url = URL(string: "https://api.zoom.us/v2/users/me")!
  2606. var request = URLRequest(url: url)
  2607. request.httpMethod = "GET"
  2608. request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
  2609. let (data, response) = try await URLSession.shared.data(for: request)
  2610. guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
  2611. throw GoogleOAuthError.tokenExchangeFailed(String(data: data, encoding: .utf8) ?? "Failed to load Zoom profile")
  2612. }
  2613. return try JSONDecoder().decode(ZoomUserMeResponse.self, from: data)
  2614. }
  2615. private struct ZoomCreateMeetingBody: Encodable {
  2616. let topic: String
  2617. let type: Int
  2618. let start_time: String
  2619. let duration: Int
  2620. let timezone: String
  2621. }
  2622. private struct ZoomCreateMeetingAPIResult: Decodable {
  2623. let id: Int?
  2624. let join_url: String?
  2625. }
  2626. /// Creates a scheduled meeting via [Zoom Create meeting](https://developers.zoom.us/docs/api/rest/reference/zoom-api/methods/#operation/meetingCreate).
  2627. private func createZoomMeeting(accessToken: String, topic: String, startTimeUTC: String, durationMinutes: Int, timeZone: TimeZone) async throws -> ZoomCreateMeetingAPIResult {
  2628. let url = URL(string: "https://api.zoom.us/v2/users/me/meetings")!
  2629. var request = URLRequest(url: url)
  2630. request.httpMethod = "POST"
  2631. request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
  2632. request.setValue("application/json", forHTTPHeaderField: "Content-Type")
  2633. let body = ZoomCreateMeetingBody(
  2634. topic: topic,
  2635. type: 2,
  2636. start_time: startTimeUTC,
  2637. duration: durationMinutes,
  2638. timezone: timeZone.identifier
  2639. )
  2640. request.httpBody = try JSONEncoder().encode(body)
  2641. let (data, response) = try await URLSession.shared.data(for: request)
  2642. guard let http = response as? HTTPURLResponse else {
  2643. throw GoogleOAuthError.tokenExchangeFailed("Invalid response from Zoom")
  2644. }
  2645. if http.statusCode == 429 {
  2646. let retryAfterRaw = http.value(forHTTPHeaderField: "Retry-After")
  2647. let seconds = retryAfterRaw.flatMap { Int($0.trimmingCharacters(in: .whitespacesAndNewlines)) }
  2648. throw ZoomOAuthError.rateLimited(retryAfterSeconds: seconds)
  2649. }
  2650. guard (200..<300).contains(http.statusCode) else {
  2651. let raw = String(data: data, encoding: .utf8) ?? "Failed to create meeting"
  2652. if raw.localizedCaseInsensitiveContains("does not contain scopes") {
  2653. throw ZoomOAuthError.missingRequiredScope(raw)
  2654. }
  2655. throw GoogleOAuthError.tokenExchangeFailed(raw)
  2656. }
  2657. return try JSONDecoder().decode(ZoomCreateMeetingAPIResult.self, from: data)
  2658. }
  2659. private func fetchZoomScheduledMeetings(accessToken: String, fallbackTimeZone: TimeZone?) async throws -> [ScheduledMeeting] {
  2660. struct ZoomMeeting: Decodable {
  2661. let id: Int?
  2662. let topic: String?
  2663. let start_time: String?
  2664. let timezone: String?
  2665. let duration: Int?
  2666. let host_id: String?
  2667. let type: Int?
  2668. let status: String?
  2669. let join_url: String?
  2670. }
  2671. struct ZoomMeetingsPage: Decodable {
  2672. let meetings: [ZoomMeeting]
  2673. let next_page_token: String?
  2674. }
  2675. let iso = ISO8601DateFormatter()
  2676. iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
  2677. let fallbackISO = ISO8601DateFormatter()
  2678. fallbackISO.formatOptions = [.withInternetDateTime]
  2679. func inferredTimeZone(from startRaw: String, explicitZoomTimezone: String?) -> TimeZone? {
  2680. if let parsedExplicit = parseZoomTimeZone(explicitZoomTimezone) {
  2681. return parsedExplicit
  2682. }
  2683. if let fallbackTimeZone { return fallbackTimeZone }
  2684. if startRaw.hasSuffix("Z") || startRaw.hasSuffix("z") {
  2685. return TimeZone(secondsFromGMT: 0)
  2686. }
  2687. let startPattern = #"([+-]\d{2}:?\d{2}|[+-]\d{2})$"#
  2688. guard let regex = try? NSRegularExpression(pattern: startPattern) else { return nil }
  2689. let nsRange = NSRange(startRaw.startIndex..<startRaw.endIndex, in: startRaw)
  2690. guard let match = regex.firstMatch(in: startRaw, options: [], range: nsRange),
  2691. let offsetRange = Range(match.range(at: 1), in: startRaw) else {
  2692. return nil
  2693. }
  2694. return parseZoomTimeZone(String(startRaw[offsetRange]))
  2695. }
  2696. func mapMeetings(_ raw: [ZoomMeeting]) -> [ScheduledMeeting] {
  2697. raw.compactMap { meeting in
  2698. guard let startRaw = meeting.start_time else { return nil }
  2699. let start = iso.date(from: startRaw) ?? fallbackISO.date(from: startRaw)
  2700. guard let start else { return nil }
  2701. let end = meeting.duration.map { start.addingTimeInterval(TimeInterval($0 * 60)) }
  2702. let meetingIDString = meeting.id.map(String.init)
  2703. let overrideZone = meetingIDString
  2704. .flatMap { self.meetingTimeZoneOverrides[$0] }
  2705. .flatMap { TimeZone(identifier: $0) }
  2706. let displayZone = overrideZone ?? inferredTimeZone(from: startRaw, explicitZoomTimezone: meeting.timezone)
  2707. let webURL: URL? = {
  2708. func wcJoinURL(meetingId: Int, pwd: String?) -> URL? {
  2709. var components = URLComponents()
  2710. components.scheme = "https"
  2711. components.host = "zoom.us"
  2712. components.path = "/wc/join/\(meetingId)"
  2713. if let pwd, pwd.isEmpty == false {
  2714. components.queryItems = [URLQueryItem(name: "pwd", value: pwd)]
  2715. }
  2716. return components.url
  2717. }
  2718. if let join = meeting.join_url, let url = URL(string: join), url.scheme != nil {
  2719. // Prefer the Zoom Web Client join URL so a click joins in the browser.
  2720. // join_url is often `https://zoom.us/j/<id>?pwd=...`
  2721. if url.path.contains("/wc/join/") {
  2722. return url
  2723. }
  2724. if let id = meeting.id {
  2725. let pwd = URLComponents(url: url, resolvingAgainstBaseURL: false)?
  2726. .queryItems?
  2727. .first(where: { $0.name == "pwd" })?
  2728. .value
  2729. return wcJoinURL(meetingId: id, pwd: pwd)
  2730. }
  2731. return url
  2732. }
  2733. if let id = meeting.id {
  2734. return wcJoinURL(meetingId: id, pwd: nil)
  2735. }
  2736. return nil
  2737. }()
  2738. return ScheduledMeeting(
  2739. title: meeting.topic?.isEmpty == false ? meeting.topic! : "Zoom meeting",
  2740. start: start,
  2741. end: end,
  2742. meetingTimeZone: displayZone,
  2743. host: meeting.host_id ?? "Zoom Host",
  2744. source: "Zoom",
  2745. meetingID: meetingIDString,
  2746. typeLabel: meeting.type == 8 ? "Recurring" : "Scheduled",
  2747. statusLabel: (meeting.status?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? meeting.status! : "scheduled").capitalized,
  2748. webURL: webURL
  2749. )
  2750. }
  2751. }
  2752. var allMeetings: [ZoomMeeting] = []
  2753. var nextPageToken: String?
  2754. repeat {
  2755. var components = URLComponents(string: "https://api.zoom.us/v2/users/me/meetings")!
  2756. var items: [URLQueryItem] = [
  2757. URLQueryItem(name: "type", value: "scheduled"),
  2758. URLQueryItem(name: "page_size", value: "30")
  2759. ]
  2760. if let nextPageToken, nextPageToken.isEmpty == false {
  2761. items.append(URLQueryItem(name: "next_page_token", value: nextPageToken))
  2762. }
  2763. components.queryItems = items
  2764. var request = URLRequest(url: components.url!)
  2765. request.httpMethod = "GET"
  2766. request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
  2767. let (data, response) = try await URLSession.shared.data(for: request)
  2768. guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
  2769. let raw = String(data: data, encoding: .utf8) ?? "Failed to load Zoom meetings"
  2770. if (response as? HTTPURLResponse)?.statusCode == 429 {
  2771. let retryAfterRaw = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "Retry-After")
  2772. let seconds = retryAfterRaw.flatMap { Int($0.trimmingCharacters(in: .whitespacesAndNewlines)) }
  2773. throw ZoomOAuthError.rateLimited(retryAfterSeconds: seconds)
  2774. }
  2775. if raw.localizedCaseInsensitiveContains("does not contain scopes") {
  2776. throw ZoomOAuthError.missingRequiredScope(raw)
  2777. }
  2778. throw GoogleOAuthError.tokenExchangeFailed(raw)
  2779. }
  2780. let decoded = try JSONDecoder().decode(ZoomMeetingsPage.self, from: data)
  2781. allMeetings.append(contentsOf: decoded.meetings)
  2782. let token = decoded.next_page_token?.trimmingCharacters(in: .whitespacesAndNewlines)
  2783. nextPageToken = (token?.isEmpty == false) ? token : nil
  2784. } while nextPageToken != nil
  2785. return mapMeetings(allMeetings)
  2786. }
  2787. private func makeSettingsView() -> NSView {
  2788. let panel = NSView()
  2789. panel.translatesAutoresizingMaskIntoConstraints = false
  2790. let scroll = NSScrollView()
  2791. scroll.translatesAutoresizingMaskIntoConstraints = false
  2792. scroll.drawsBackground = false
  2793. scroll.hasHorizontalScroller = false
  2794. scroll.hasVerticalScroller = true
  2795. scroll.autohidesScrollers = true
  2796. scroll.borderType = .noBorder
  2797. scroll.scrollerStyle = .overlay
  2798. scroll.automaticallyAdjustsContentInsets = false
  2799. let clip = TopAlignedClipView()
  2800. clip.drawsBackground = false
  2801. scroll.contentView = clip
  2802. panel.addSubview(scroll)
  2803. let content = NSView()
  2804. content.translatesAutoresizingMaskIntoConstraints = false
  2805. scroll.documentView = content
  2806. let card = roundedContainer(cornerRadius: 16, color: palette.sectionCard)
  2807. card.translatesAutoresizingMaskIntoConstraints = false
  2808. styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: true)
  2809. content.addSubview(card)
  2810. let stack = NSStackView()
  2811. stack.translatesAutoresizingMaskIntoConstraints = false
  2812. stack.orientation = .vertical
  2813. stack.spacing = 18
  2814. stack.alignment = .leading
  2815. card.addSubview(stack)
  2816. let pageTitle = textLabel("Settings", font: .systemFont(ofSize: 28, weight: .bold), color: primaryText)
  2817. let pageSubtitle = textLabel("Manage appearance, account, and app options.", font: .systemFont(ofSize: 13, weight: .regular), color: secondaryText)
  2818. stack.addArrangedSubview(pageTitle)
  2819. stack.addArrangedSubview(pageSubtitle)
  2820. stack.setCustomSpacing(24, after: pageSubtitle)
  2821. let appearanceTitle = textLabel("Appearance", font: .systemFont(ofSize: 16, weight: .semibold), color: primaryText)
  2822. stack.addArrangedSubview(appearanceTitle)
  2823. let darkModeRow = makeSettingsDarkModeRow()
  2824. stack.addArrangedSubview(darkModeRow)
  2825. darkModeRow.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  2826. stack.setCustomSpacing(24, after: darkModeRow)
  2827. let accountTitle = textLabel("Account", font: .systemFont(ofSize: 16, weight: .semibold), color: primaryText)
  2828. stack.addArrangedSubview(accountTitle)
  2829. let googleAccountRow = makeSettingsGoogleAccountRow()
  2830. stack.addArrangedSubview(googleAccountRow)
  2831. googleAccountRow.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  2832. stack.setCustomSpacing(24, after: googleAccountRow)
  2833. let appTitle = textLabel("App", font: .systemFont(ofSize: 16, weight: .semibold), color: primaryText)
  2834. stack.addArrangedSubview(appTitle)
  2835. let shareButton = makeSettingsActionButton(icon: "⤴︎", title: "Share App", action: .shareApp)
  2836. stack.addArrangedSubview(shareButton)
  2837. shareButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  2838. let upgradeButton = makeSettingsActionButton(icon: "⬆︎", title: "Upgrade", action: .upgrade)
  2839. stack.addArrangedSubview(upgradeButton)
  2840. upgradeButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  2841. settingsUpgradeButton = upgradeButton
  2842. stack.setCustomSpacing(24, after: upgradeButton)
  2843. let legalTitle = textLabel("Help & Legal", font: .systemFont(ofSize: 16, weight: .semibold), color: primaryText)
  2844. stack.addArrangedSubview(legalTitle)
  2845. let privacyButton = makeSettingsActionButton(icon: "🔒", title: "Privacy Policy", action: .privacyPolicy)
  2846. stack.addArrangedSubview(privacyButton)
  2847. privacyButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  2848. let supportButton = makeSettingsActionButton(icon: "💬", title: "Support", action: .support)
  2849. stack.addArrangedSubview(supportButton)
  2850. supportButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  2851. let termsButton = makeSettingsActionButton(icon: "📄", title: "Terms of Services", action: .termsOfServices)
  2852. stack.addArrangedSubview(termsButton)
  2853. termsButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  2854. NSLayoutConstraint.activate([
  2855. scroll.leadingAnchor.constraint(equalTo: panel.leadingAnchor),
  2856. scroll.trailingAnchor.constraint(equalTo: panel.trailingAnchor),
  2857. scroll.topAnchor.constraint(equalTo: panel.topAnchor),
  2858. scroll.bottomAnchor.constraint(equalTo: panel.bottomAnchor),
  2859. content.leadingAnchor.constraint(equalTo: scroll.contentView.leadingAnchor),
  2860. content.trailingAnchor.constraint(equalTo: scroll.contentView.trailingAnchor),
  2861. content.topAnchor.constraint(equalTo: scroll.contentView.topAnchor),
  2862. content.bottomAnchor.constraint(greaterThanOrEqualTo: scroll.contentView.bottomAnchor),
  2863. content.widthAnchor.constraint(equalTo: scroll.contentView.widthAnchor),
  2864. card.centerXAnchor.constraint(equalTo: content.centerXAnchor),
  2865. card.topAnchor.constraint(equalTo: content.topAnchor, constant: 16),
  2866. content.bottomAnchor.constraint(greaterThanOrEqualTo: card.bottomAnchor, constant: 36),
  2867. card.widthAnchor.constraint(lessThanOrEqualToConstant: 620),
  2868. card.widthAnchor.constraint(greaterThanOrEqualToConstant: 460),
  2869. card.leadingAnchor.constraint(greaterThanOrEqualTo: content.leadingAnchor, constant: 30),
  2870. card.trailingAnchor.constraint(lessThanOrEqualTo: content.trailingAnchor, constant: -30),
  2871. stack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 28),
  2872. stack.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -28),
  2873. stack.topAnchor.constraint(equalTo: card.topAnchor, constant: 24),
  2874. stack.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -24)
  2875. ])
  2876. updatePremiumButtons()
  2877. return panel
  2878. }
  2879. private enum SettingsAction: Int {
  2880. case shareApp = 1
  2881. case upgrade = 2
  2882. case privacyPolicy = 4
  2883. case support = 5
  2884. case termsOfServices = 6
  2885. }
  2886. private func makeSettingsDarkModeRow() -> NSView {
  2887. let row = roundedContainer(cornerRadius: 10, color: palette.inputBackground)
  2888. row.translatesAutoresizingMaskIntoConstraints = false
  2889. row.heightAnchor.constraint(equalToConstant: 52).isActive = true
  2890. styleSurface(row, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  2891. let icon = textLabel("◐", font: NSFont.systemFont(ofSize: 18, weight: .medium), color: primaryText)
  2892. let title = textLabel("Dark Mode", font: NSFont.systemFont(ofSize: 15, weight: .semibold), color: primaryText)
  2893. let toggle = NSSwitch()
  2894. toggle.translatesAutoresizingMaskIntoConstraints = false
  2895. toggle.state = darkModeEnabled ? .on : .off
  2896. toggle.target = self
  2897. toggle.action = #selector(settingsDarkModeToggled(_:))
  2898. settingsDarkModeSwitch = toggle
  2899. row.addSubview(icon)
  2900. row.addSubview(title)
  2901. row.addSubview(toggle)
  2902. NSLayoutConstraint.activate([
  2903. icon.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 14),
  2904. icon.centerYAnchor.constraint(equalTo: row.centerYAnchor),
  2905. title.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 10),
  2906. title.centerYAnchor.constraint(equalTo: row.centerYAnchor),
  2907. toggle.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -14),
  2908. toggle.centerYAnchor.constraint(equalTo: row.centerYAnchor)
  2909. ])
  2910. return row
  2911. }
  2912. private func makeSettingsGoogleAccountRow() -> NSView {
  2913. let row = roundedContainer(cornerRadius: 10, color: palette.inputBackground)
  2914. row.translatesAutoresizingMaskIntoConstraints = false
  2915. styleSurface(row, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  2916. let signedIn = isUserLoggedIn()
  2917. let titleText = signedIn ? "Zoom account connected" : "Zoom account not connected"
  2918. let subtitleText = signedIn ? "Signed in" : "Sign in to sync your meetings and calendar."
  2919. let title = textLabel(titleText, font: NSFont.systemFont(ofSize: 15, weight: .semibold), color: primaryText)
  2920. let subtitle = textLabel(subtitleText, font: NSFont.systemFont(ofSize: 13, weight: .regular), color: secondaryText)
  2921. subtitle.maximumNumberOfLines = 2
  2922. subtitle.lineBreakMode = .byTruncatingTail
  2923. let actionButton = NSButton(title: signedIn ? "Sign Out" : "Sign in with Google", target: self, action: #selector(settingsGoogleActionButtonClicked(_:)))
  2924. actionButton.translatesAutoresizingMaskIntoConstraints = false
  2925. actionButton.bezelStyle = .rounded
  2926. actionButton.controlSize = .regular
  2927. settingsGoogleActionButton = actionButton
  2928. row.addSubview(title)
  2929. row.addSubview(subtitle)
  2930. row.addSubview(actionButton)
  2931. NSLayoutConstraint.activate([
  2932. row.heightAnchor.constraint(equalToConstant: 78),
  2933. title.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 14),
  2934. title.topAnchor.constraint(equalTo: row.topAnchor, constant: 12),
  2935. subtitle.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  2936. subtitle.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 4),
  2937. subtitle.trailingAnchor.constraint(lessThanOrEqualTo: actionButton.leadingAnchor, constant: -14),
  2938. actionButton.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -14),
  2939. actionButton.centerYAnchor.constraint(equalTo: row.centerYAnchor)
  2940. ])
  2941. return row
  2942. }
  2943. private func makeSettingsActionButton(icon: String, title: String, action: SettingsAction) -> NSButton {
  2944. let button = HoverButton(title: "", target: self, action: #selector(settingsPageActionButtonClicked(_:)))
  2945. button.translatesAutoresizingMaskIntoConstraints = false
  2946. button.isBordered = false
  2947. button.wantsLayer = true
  2948. button.layer?.cornerRadius = 10
  2949. button.normalColor = palette.inputBackground
  2950. button.hoverColor = palette.isDarkMode ? NSColor.white.withAlphaComponent(0.07) : NSColor.black.withAlphaComponent(0.05)
  2951. styleSurface(button, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  2952. button.heightAnchor.constraint(equalToConstant: 46).isActive = true
  2953. button.tag = action.rawValue
  2954. let iconLabel = textLabel(icon, font: NSFont.systemFont(ofSize: 17, weight: .medium), color: primaryText)
  2955. let titleLabel = textLabel(title, font: NSFont.systemFont(ofSize: 15, weight: .semibold), color: primaryText)
  2956. if action == .upgrade {
  2957. settingsUpgradeTitleLabel = titleLabel
  2958. }
  2959. button.addSubview(iconLabel)
  2960. button.addSubview(titleLabel)
  2961. NSLayoutConstraint.activate([
  2962. iconLabel.leadingAnchor.constraint(equalTo: button.leadingAnchor, constant: 14),
  2963. iconLabel.centerYAnchor.constraint(equalTo: button.centerYAnchor),
  2964. titleLabel.leadingAnchor.constraint(equalTo: iconLabel.trailingAnchor, constant: 10),
  2965. titleLabel.centerYAnchor.constraint(equalTo: button.centerYAnchor)
  2966. ])
  2967. return button
  2968. }
  2969. @objc private func settingsPageActionButtonClicked(_ sender: NSButton) {
  2970. guard let action = SettingsAction(rawValue: sender.tag) else { return }
  2971. switch action {
  2972. case .shareApp:
  2973. showSimpleAlert(title: "Share", message: "Share action placeholder (match reference app behavior next).")
  2974. case .upgrade:
  2975. settingsUpgradePremiumTapped()
  2976. case .privacyPolicy:
  2977. showSimpleAlert(title: "Privacy Policy", message: "Add your Privacy Policy URL in the app and open it here.")
  2978. case .support:
  2979. showSimpleAlert(title: "Support", message: "Add your Support URL/email in the app and open it here.")
  2980. case .termsOfServices:
  2981. showSimpleAlert(title: "Terms", message: "Add your Terms URL in the app and open it here.")
  2982. }
  2983. }
  2984. @objc private func settingsGoogleActionButtonClicked(_ sender: NSButton) {
  2985. if isUserLoggedIn() {
  2986. logoutTapped()
  2987. return
  2988. }
  2989. zoomPrimarySignInTapped()
  2990. }
  2991. private func roundedContainer(cornerRadius: CGFloat, color: NSColor) -> NSView {
  2992. let v = NSView()
  2993. v.wantsLayer = true
  2994. v.layer?.backgroundColor = color.cgColor
  2995. v.layer?.cornerRadius = cornerRadius
  2996. return v
  2997. }
  2998. private func styleSurface(_ view: NSView, borderColor: NSColor, borderWidth: CGFloat, shadow: Bool) {
  2999. view.wantsLayer = true
  3000. view.layer?.borderColor = borderColor.cgColor
  3001. view.layer?.borderWidth = borderWidth
  3002. if shadow {
  3003. view.layer?.shadowColor = NSColor.black.cgColor
  3004. view.layer?.shadowOpacity = palette.isDarkMode ? 0.22 : 0.12
  3005. view.layer?.shadowRadius = 18
  3006. view.layer?.shadowOffset = NSSize(width: 0, height: -2)
  3007. } else {
  3008. view.layer?.shadowOpacity = 0
  3009. }
  3010. }
  3011. private func textLabel(_ text: String, font: NSFont, color: NSColor) -> NSTextField {
  3012. let label = NSTextField(labelWithString: text)
  3013. label.translatesAutoresizingMaskIntoConstraints = false
  3014. label.font = font
  3015. label.textColor = color
  3016. label.backgroundColor = .clear
  3017. label.isBezeled = false
  3018. label.isEditable = false
  3019. label.isSelectable = false
  3020. return label
  3021. }
  3022. private func styledLabel(
  3023. _ text: String,
  3024. font: NSFont,
  3025. color: NSColor,
  3026. kern: CGFloat? = nil,
  3027. lineSpacing: CGFloat? = nil
  3028. ) -> NSTextField {
  3029. let label = textLabel(text, font: font, color: color)
  3030. if kern == nil, lineSpacing == nil {
  3031. return label
  3032. }
  3033. let paragraph = NSMutableParagraphStyle()
  3034. paragraph.alignment = label.alignment
  3035. paragraph.lineBreakMode = .byWordWrapping
  3036. if let lineSpacing { paragraph.lineSpacing = lineSpacing }
  3037. var attrs: [NSAttributedString.Key: Any] = [
  3038. .font: font,
  3039. .foregroundColor: color,
  3040. .paragraphStyle: paragraph
  3041. ]
  3042. if let kern { attrs[.kern] = kern }
  3043. label.attributedStringValue = NSAttributedString(string: text, attributes: attrs)
  3044. return label
  3045. }
  3046. private func startStoreKit() {
  3047. storeKitStartupTask?.cancel()
  3048. storeKitCoordinator.onEntitlementsChanged = { [weak self] _ in
  3049. DispatchQueue.main.async {
  3050. guard let self else { return }
  3051. let newIsPremium = self.storeKitCoordinator.hasPremiumAccess
  3052. let wasPremium = self.lastKnownPremiumAccess
  3053. self.lastKnownPremiumAccess = newIsPremium
  3054. self.updatePremiumButtons()
  3055. if (wasPremium == false) && newIsPremium {
  3056. self.startMeetingsAutoRefresh()
  3057. self.triggerMeetingsRefresh(force: true)
  3058. self.schedulePremiumRatingPromptIfNeeded()
  3059. }
  3060. if wasPremium && !newIsPremium {
  3061. self.premiumRatingPromptWorkItem?.cancel()
  3062. self.premiumRatingPromptWorkItem = nil
  3063. self.showPaywall()
  3064. }
  3065. if newIsPremium {
  3066. self.launchPaywallWorkItem?.cancel()
  3067. self.launchPaywallWorkItem = nil
  3068. self.hasPresentedLaunchPaywall = true
  3069. } else {
  3070. self.scheduleLaunchPaywallIfNeeded()
  3071. }
  3072. }
  3073. }
  3074. storeKitStartupTask = Task { [weak self] in
  3075. await self?.storeKitCoordinator.start()
  3076. await MainActor.run {
  3077. self?.updatePremiumButtons()
  3078. self?.scheduleLaunchPaywallIfNeeded()
  3079. }
  3080. }
  3081. }
  3082. private func scheduleLaunchPaywallIfNeeded() {
  3083. guard hasPresentedLaunchPaywall == false else { return }
  3084. guard storeKitCoordinator.hasPremiumAccess == false else {
  3085. hasPresentedLaunchPaywall = true
  3086. return
  3087. }
  3088. guard launchPaywallWorkItem == nil else { return }
  3089. let workItem = DispatchWorkItem { [weak self] in
  3090. guard let self else { return }
  3091. self.launchPaywallWorkItem = nil
  3092. guard self.hasPresentedLaunchPaywall == false else { return }
  3093. guard self.storeKitCoordinator.hasPremiumAccess == false else {
  3094. self.hasPresentedLaunchPaywall = true
  3095. return
  3096. }
  3097. self.hasPresentedLaunchPaywall = true
  3098. self.showPaywall()
  3099. }
  3100. launchPaywallWorkItem = workItem
  3101. DispatchQueue.main.asyncAfter(deadline: .now() + launchPaywallDelay, execute: workItem)
  3102. }
  3103. @MainActor
  3104. private func updatePremiumButtons() {
  3105. let isPremium = storeKitCoordinator.hasPremiumAccess
  3106. let hasLifetimePremium = storeKitCoordinator.hasLifetimePremiumAccess
  3107. settingsUpgradeButton?.title = ""
  3108. settingsUpgradeTitleLabel?.stringValue = hasLifetimePremium ? "Premium Lifetime" : "Upgrade"
  3109. settingsUpgradeButton?.isHidden = hasLifetimePremium
  3110. settingsUpgradeButton?.isEnabled = hasLifetimePremium == false
  3111. settingsUpgradeButton?.alphaValue = hasLifetimePremium ? 0.6 : 1.0
  3112. updatePremiumLockedSections()
  3113. if isPremium == false, isUserLoggedIn() {
  3114. applyMeetings([])
  3115. applyPremiumLockMessages()
  3116. }
  3117. if isPremium {
  3118. schedulePremiumRatingPromptIfNeeded()
  3119. }
  3120. updateTopBarPremiumButton()
  3121. }
  3122. @MainActor
  3123. private func updateTopBarPremiumButton() {
  3124. guard let button = topBarPremiumButton else { return }
  3125. let isPremium = storeKitCoordinator.hasPremiumAccess
  3126. if isPremium {
  3127. let title = "Manage Subscription"
  3128. let font = NSFont.systemFont(ofSize: 11, weight: .semibold)
  3129. button.attributedTitle = NSAttributedString(string: title, attributes: [
  3130. .foregroundColor: NSColor.white,
  3131. .font: font
  3132. ])
  3133. let symbolConfig = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
  3134. if let base = NSImage(systemSymbolName: "crown.fill", accessibilityDescription: "Premium"),
  3135. let img = base.withSymbolConfiguration(symbolConfig) {
  3136. img.isTemplate = true
  3137. button.image = img
  3138. button.imagePosition = .imageLeading
  3139. button.imageHugsTitle = true
  3140. }
  3141. button.contentTintColor = NSColor.white
  3142. button.toolTip = title
  3143. button.layer?.backgroundColor = NSColor(calibratedRed: 214 / 255, green: 175 / 255, blue: 54 / 255, alpha: 1).cgColor
  3144. } else {
  3145. let title = "Upgrade to Pro"
  3146. let font = NSFont.systemFont(ofSize: 11, weight: .semibold)
  3147. button.attributedTitle = NSAttributedString(string: title, attributes: [
  3148. .foregroundColor: NSColor.white,
  3149. .font: font
  3150. ])
  3151. button.image = nil
  3152. button.toolTip = title
  3153. button.layer?.backgroundColor = accentBlue.cgColor
  3154. button.contentTintColor = .white
  3155. }
  3156. }
  3157. @objc private func settingsDarkModeToggled(_ sender: NSSwitch) {
  3158. setDarkMode(sender.state == .on)
  3159. }
  3160. private func setDarkMode(_ enabled: Bool) {
  3161. darkModeEnabled = enabled
  3162. palette = Palette(isDarkMode: enabled)
  3163. NSApp.appearance = NSAppearance(named: enabled ? .darkAqua : .aqua)
  3164. view.appearance = NSAppearance(named: enabled ? .darkAqua : .aqua)
  3165. if let window = view.window {
  3166. applyWindowBackgroundForCurrentTheme(window)
  3167. }
  3168. let keepSelected = selectedHomeSidebarItem
  3169. selectedHomeSidebarItem = keepSelected
  3170. showHomeView(profile: nil)
  3171. updatePremiumLockedSections()
  3172. }
  3173. private func applyWindowBackgroundForCurrentTheme(_ window: NSWindow) {
  3174. // Keep the window's backing in sync with the app's current theme, otherwise rounded
  3175. // shell corners can briefly show the previous mode's lighter/darker color.
  3176. window.backgroundColor = appBackground
  3177. window.isOpaque = true
  3178. if let contentView = window.contentView {
  3179. contentView.wantsLayer = true
  3180. contentView.layer?.backgroundColor = appBackground.cgColor
  3181. }
  3182. }
  3183. private func systemPrefersDarkMode() -> Bool {
  3184. let global = UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain)
  3185. let style = global?["AppleInterfaceStyle"] as? String
  3186. return style?.lowercased() == "dark"
  3187. }
  3188. @objc private func settingsUpgradePremiumTapped() {
  3189. guard storeKitCoordinator.hasLifetimePremiumAccess == false else { return }
  3190. showPaywall()
  3191. }
  3192. // MARK: - Paywall (ported from meetings_app)
  3193. private func showPaywall() {
  3194. if storeKitCoordinator.hasPremiumAccess && storeKitCoordinator.hasLifetimePremiumAccess == false {
  3195. selectedPremiumPlan = .lifetime
  3196. }
  3197. if let existing = paywallOverlayView {
  3198. refreshPaywallStoreUI()
  3199. if let superview = existing.superview {
  3200. superview.addSubview(existing)
  3201. }
  3202. updatePaywallResponsiveLayout()
  3203. NSApp.activate(ignoringOtherApps: true)
  3204. return
  3205. }
  3206. let overlay = NSView()
  3207. overlay.translatesAutoresizingMaskIntoConstraints = false
  3208. overlay.wantsLayer = true
  3209. overlay.alphaValue = 0
  3210. overlay.layer?.backgroundColor = NSColor.clear.cgColor
  3211. let dim = NSView()
  3212. dim.translatesAutoresizingMaskIntoConstraints = false
  3213. dim.wantsLayer = true
  3214. dim.layer?.backgroundColor = palette.isDarkMode
  3215. ? NSColor.black.withAlphaComponent(0.62).cgColor
  3216. : NSColor.black.withAlphaComponent(0.28).cgColor
  3217. overlay.addSubview(dim)
  3218. // Clicking outside the paywall dismisses it.
  3219. let outsideButton = NSButton(title: "", target: self, action: #selector(closePaywallClicked(_:)))
  3220. outsideButton.translatesAutoresizingMaskIntoConstraints = false
  3221. outsideButton.isBordered = false
  3222. outsideButton.bezelStyle = .regularSquare
  3223. outsideButton.contentTintColor = .clear
  3224. outsideButton.wantsLayer = true
  3225. outsideButton.layer?.backgroundColor = NSColor.clear.cgColor
  3226. overlay.addSubview(outsideButton)
  3227. let content = makePaywallContent()
  3228. overlay.addSubview(content)
  3229. view.addSubview(overlay)
  3230. NSLayoutConstraint.activate([
  3231. overlay.leadingAnchor.constraint(equalTo: view.leadingAnchor),
  3232. overlay.trailingAnchor.constraint(equalTo: view.trailingAnchor),
  3233. overlay.topAnchor.constraint(equalTo: view.topAnchor),
  3234. overlay.bottomAnchor.constraint(equalTo: view.bottomAnchor),
  3235. dim.leadingAnchor.constraint(equalTo: overlay.leadingAnchor),
  3236. dim.trailingAnchor.constraint(equalTo: overlay.trailingAnchor),
  3237. dim.topAnchor.constraint(equalTo: overlay.topAnchor),
  3238. dim.bottomAnchor.constraint(equalTo: overlay.bottomAnchor),
  3239. outsideButton.leadingAnchor.constraint(equalTo: overlay.leadingAnchor),
  3240. outsideButton.trailingAnchor.constraint(equalTo: overlay.trailingAnchor),
  3241. outsideButton.topAnchor.constraint(equalTo: overlay.topAnchor),
  3242. outsideButton.bottomAnchor.constraint(equalTo: overlay.bottomAnchor),
  3243. content.leadingAnchor.constraint(equalTo: overlay.leadingAnchor),
  3244. content.trailingAnchor.constraint(equalTo: overlay.trailingAnchor),
  3245. content.topAnchor.constraint(equalTo: overlay.topAnchor),
  3246. content.bottomAnchor.constraint(equalTo: overlay.bottomAnchor)
  3247. ])
  3248. paywallPanelWidthConstraint = nil
  3249. paywallPanelHeightConstraint = nil
  3250. paywallOverlayView = overlay
  3251. NSAnimationContext.runAnimationGroup { context in
  3252. context.duration = 0.18
  3253. overlay.animator().alphaValue = 1
  3254. }
  3255. Task { [weak self] in
  3256. guard let self else { return }
  3257. await self.storeKitCoordinator.refreshProducts()
  3258. await MainActor.run {
  3259. self.refreshPaywallStoreUI()
  3260. self.updatePaywallResponsiveLayout()
  3261. }
  3262. }
  3263. }
  3264. @objc private func closePaywallClicked(_ sender: Any?) {
  3265. hidePaywall()
  3266. }
  3267. private func hidePaywall() {
  3268. guard let overlay = paywallOverlayView else { return }
  3269. NSAnimationContext.runAnimationGroup { context in
  3270. context.duration = 0.16
  3271. overlay.animator().alphaValue = 0
  3272. } completionHandler: { [weak self] in
  3273. overlay.removeFromSuperview()
  3274. self?.paywallPanelWidthConstraint = nil
  3275. self?.paywallPanelHeightConstraint = nil
  3276. self?.paywallPanelView = nil
  3277. self?.paywallBodyStack = nil
  3278. self?.paywallIsDesktopLayout = false
  3279. }
  3280. }
  3281. private func updatePaywallPanelSize() {
  3282. guard paywallPanelWidthConstraint != nil, paywallPanelHeightConstraint != nil else { return }
  3283. let safeInsetX: CGFloat = 80
  3284. let safeInsetY: CGFloat = 110
  3285. let maxWidth = max(640, view.bounds.width - safeInsetX)
  3286. let maxHeight = max(560, view.bounds.height - safeInsetY)
  3287. let panelWidth = min(1040, maxWidth)
  3288. let panelHeight = min(860, maxHeight)
  3289. if abs(paywallPanelWidthConstraint?.constant ?? 0 - panelWidth) > 1 {
  3290. paywallPanelWidthConstraint?.constant = panelWidth
  3291. }
  3292. if abs(paywallPanelHeightConstraint?.constant ?? 0 - panelHeight) > 1 {
  3293. paywallPanelHeightConstraint?.constant = panelHeight
  3294. }
  3295. }
  3296. private func updatePaywallResponsiveLayout() {
  3297. guard let bodyStack = paywallBodyStack, let panel = paywallPanelView else { return }
  3298. let width = panel.bounds.width
  3299. // Two-column desktop layout needs room for the plan column (520+) + a slimmer benefits column.
  3300. let shouldUseDesktop = width >= 900
  3301. guard shouldUseDesktop != paywallIsDesktopLayout else { return }
  3302. paywallIsDesktopLayout = shouldUseDesktop
  3303. if shouldUseDesktop {
  3304. bodyStack.orientation = .horizontal
  3305. bodyStack.alignment = .top
  3306. bodyStack.spacing = 26
  3307. } else {
  3308. bodyStack.orientation = .vertical
  3309. bodyStack.alignment = .leading
  3310. bodyStack.spacing = 18
  3311. }
  3312. let useWidePlans = width >= 880
  3313. paywallPlansRowWide?.isHidden = useWidePlans == false
  3314. paywallPlansGridNarrow?.isHidden = useWidePlans
  3315. bodyStack.layoutSubtreeIfNeeded()
  3316. }
  3317. private func makePaywallContent() -> NSView {
  3318. paywallPlanViews.removeAll()
  3319. paywallPlanPriceLabels.removeAll()
  3320. paywallPlanSubtitleLabels.removeAll()
  3321. paywallPlanStrikeLabels.removeAll()
  3322. premiumPlanByView.removeAll()
  3323. paywallOfferLabel = nil
  3324. paywallContinueLabel = nil
  3325. paywallContinueButton = nil
  3326. paywallContinueEnabled = true
  3327. paywallPlansRowWide = nil
  3328. paywallPlansGridNarrow = nil
  3329. let root = NSView()
  3330. root.translatesAutoresizingMaskIntoConstraints = false
  3331. root.wantsLayer = true
  3332. root.layer?.backgroundColor = NSColor.clear.cgColor
  3333. let panel = NSView()
  3334. panel.translatesAutoresizingMaskIntoConstraints = false
  3335. panel.wantsLayer = true
  3336. panel.layer?.backgroundColor = NSColor.black.cgColor
  3337. panel.layer?.cornerRadius = 0
  3338. styleSurface(panel, borderColor: NSColor.clear, borderWidth: 0, shadow: false)
  3339. root.addSubview(panel)
  3340. // Full-screen paywall: fill the entire app content area.
  3341. NSLayoutConstraint.activate([
  3342. panel.leadingAnchor.constraint(equalTo: root.leadingAnchor),
  3343. panel.trailingAnchor.constraint(equalTo: root.trailingAnchor),
  3344. panel.topAnchor.constraint(equalTo: root.topAnchor),
  3345. panel.bottomAnchor.constraint(equalTo: root.bottomAnchor)
  3346. ])
  3347. paywallPanelWidthConstraint = nil
  3348. paywallPanelHeightConstraint = nil
  3349. // Responsive logic should measure the panel size (not the full-screen root).
  3350. self.paywallPanelView = panel
  3351. let contentStack = NSStackView()
  3352. contentStack.translatesAutoresizingMaskIntoConstraints = false
  3353. contentStack.orientation = .vertical
  3354. contentStack.spacing = 14
  3355. contentStack.alignment = .leading
  3356. panel.addSubview(contentStack)
  3357. let topRow = NSStackView()
  3358. topRow.translatesAutoresizingMaskIntoConstraints = false
  3359. topRow.orientation = .horizontal
  3360. topRow.alignment = .centerY
  3361. topRow.distribution = .fill
  3362. topRow.spacing = 10
  3363. topRow.addArrangedSubview(textLabel("Meetings Pro", font: NSFont.systemFont(ofSize: 26, weight: .semibold), color: primaryText))
  3364. let topSpacer = NSView()
  3365. topSpacer.translatesAutoresizingMaskIntoConstraints = false
  3366. topRow.addArrangedSubview(topSpacer)
  3367. let closeButton = HoverButton(title: "✕", target: self, action: #selector(closePaywallClicked(_:)))
  3368. closeButton.translatesAutoresizingMaskIntoConstraints = false
  3369. closeButton.isBordered = false
  3370. closeButton.bezelStyle = .regularSquare
  3371. closeButton.wantsLayer = true
  3372. closeButton.layer?.cornerRadius = 14
  3373. closeButton.normalColor = palette.inputBackground
  3374. closeButton.hoverColor = palette.isDarkMode ? NSColor.white.withAlphaComponent(0.10) : NSColor.black.withAlphaComponent(0.06)
  3375. closeButton.layer?.borderColor = palette.inputBorder.cgColor
  3376. closeButton.layer?.borderWidth = 1
  3377. closeButton.font = NSFont.systemFont(ofSize: 13, weight: .bold)
  3378. closeButton.contentTintColor = secondaryText
  3379. closeButton.toolTip = "Continue with free plan"
  3380. panel.addSubview(closeButton)
  3381. NSLayoutConstraint.activate([
  3382. closeButton.widthAnchor.constraint(equalToConstant: 28),
  3383. closeButton.heightAnchor.constraint(equalToConstant: 28),
  3384. closeButton.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -70),
  3385. closeButton.topAnchor.constraint(equalTo: panel.topAnchor, constant: 48)
  3386. ])
  3387. contentStack.addArrangedSubview(topRow)
  3388. topRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
  3389. let hero = paywallHeroCard(
  3390. eyebrow: "PREMIUM EXPERIENCE",
  3391. title: "Elevate your meetings workflow",
  3392. subtitle: "Unlock automation, premium meeting tools, and priority support for every session."
  3393. )
  3394. contentStack.addArrangedSubview(hero)
  3395. hero.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
  3396. let features = paywallFeaturesRow()
  3397. contentStack.addArrangedSubview(features)
  3398. features.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
  3399. let bodyStack = NSStackView()
  3400. bodyStack.translatesAutoresizingMaskIntoConstraints = false
  3401. bodyStack.orientation = .vertical
  3402. bodyStack.spacing = 16
  3403. bodyStack.alignment = .leading
  3404. bodyStack.distribution = .fill
  3405. contentStack.addArrangedSubview(bodyStack)
  3406. paywallBodyStack = bodyStack
  3407. bodyStack.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
  3408. let planColumn = NSStackView()
  3409. planColumn.translatesAutoresizingMaskIntoConstraints = false
  3410. planColumn.orientation = .vertical
  3411. planColumn.spacing = 12
  3412. planColumn.alignment = .leading
  3413. planColumn.distribution = .fill
  3414. bodyStack.addArrangedSubview(planColumn)
  3415. planColumn.widthAnchor.constraint(equalTo: bodyStack.widthAnchor).isActive = true
  3416. let weeklyCard = paywallPlanCard(
  3417. title: "Weekly",
  3418. price: paywallDisplayPrice(for: .weekly),
  3419. badge: "Basic",
  3420. badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
  3421. subtitle: paywallPlanSubtitle(for: .weekly),
  3422. plan: .weekly,
  3423. strikePrice: nil
  3424. )
  3425. let monthlyCard = paywallPlanCard(
  3426. title: "Monthly",
  3427. price: paywallDisplayPrice(for: .monthly),
  3428. badge: "Popular",
  3429. badgeColor: NSColor(calibratedRed: 0.19, green: 0.82, blue: 0.39, alpha: 1),
  3430. subtitle: paywallPlanSubtitle(for: .monthly),
  3431. plan: .monthly,
  3432. strikePrice: nil
  3433. )
  3434. let yearlyCard = paywallPlanCard(
  3435. title: "Yearly",
  3436. price: paywallDisplayPrice(for: .yearly),
  3437. badge: "Save 67%",
  3438. badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
  3439. subtitle: paywallPlanSubtitle(for: .yearly),
  3440. plan: .yearly,
  3441. strikePrice: nil
  3442. )
  3443. let lifetimeCard = paywallPlanCard(
  3444. title: "Lifetime",
  3445. price: paywallDisplayPrice(for: .lifetime),
  3446. badge: "Best Value",
  3447. badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
  3448. subtitle: paywallPlanSubtitle(for: .lifetime),
  3449. plan: .lifetime,
  3450. strikePrice: nil
  3451. )
  3452. let plansRowWide = NSStackView()
  3453. plansRowWide.translatesAutoresizingMaskIntoConstraints = false
  3454. plansRowWide.orientation = .horizontal
  3455. plansRowWide.spacing = 12
  3456. plansRowWide.alignment = .centerY
  3457. plansRowWide.distribution = .fillEqually
  3458. plansRowWide.addArrangedSubview(weeklyCard)
  3459. plansRowWide.addArrangedSubview(monthlyCard)
  3460. plansRowWide.addArrangedSubview(yearlyCard)
  3461. plansRowWide.addArrangedSubview(lifetimeCard)
  3462. paywallPlansRowWide = plansRowWide
  3463. planColumn.addArrangedSubview(plansRowWide)
  3464. plansRowWide.widthAnchor.constraint(equalTo: planColumn.widthAnchor).isActive = true
  3465. let plansGridNarrow = NSStackView()
  3466. plansGridNarrow.translatesAutoresizingMaskIntoConstraints = false
  3467. plansGridNarrow.orientation = .vertical
  3468. plansGridNarrow.spacing = 12
  3469. plansGridNarrow.alignment = .leading
  3470. plansGridNarrow.distribution = .fillEqually
  3471. let plansRow1 = NSStackView()
  3472. plansRow1.translatesAutoresizingMaskIntoConstraints = false
  3473. plansRow1.orientation = .horizontal
  3474. plansRow1.spacing = 12
  3475. plansRow1.alignment = .centerY
  3476. plansRow1.distribution = .fillEqually
  3477. plansRow1.addArrangedSubview(paywallPlanCard(
  3478. title: "Weekly",
  3479. price: paywallDisplayPrice(for: .weekly),
  3480. badge: "Basic Deal",
  3481. badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
  3482. subtitle: paywallPlanSubtitle(for: .weekly),
  3483. plan: .weekly,
  3484. strikePrice: nil
  3485. ))
  3486. plansRow1.addArrangedSubview(paywallPlanCard(
  3487. title: "Monthly",
  3488. price: paywallDisplayPrice(for: .monthly),
  3489. badge: "Popular",
  3490. badgeColor: NSColor(calibratedRed: 0.19, green: 0.82, blue: 0.39, alpha: 1),
  3491. subtitle: paywallPlanSubtitle(for: .monthly),
  3492. plan: .monthly,
  3493. strikePrice: nil
  3494. ))
  3495. let plansRow2 = NSStackView()
  3496. plansRow2.translatesAutoresizingMaskIntoConstraints = false
  3497. plansRow2.orientation = .horizontal
  3498. plansRow2.spacing = 12
  3499. plansRow2.alignment = .centerY
  3500. plansRow2.distribution = .fillEqually
  3501. plansRow2.addArrangedSubview(paywallPlanCard(
  3502. title: "Yearly",
  3503. price: paywallDisplayPrice(for: .yearly),
  3504. badge: "Best Deal",
  3505. badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
  3506. subtitle: paywallPlanSubtitle(for: .yearly),
  3507. plan: .yearly,
  3508. strikePrice: nil
  3509. ))
  3510. plansRow2.addArrangedSubview(paywallPlanCard(
  3511. title: "Lifetime",
  3512. price: paywallDisplayPrice(for: .lifetime),
  3513. badge: "Save 50%",
  3514. badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
  3515. subtitle: paywallPlanSubtitle(for: .lifetime),
  3516. plan: .lifetime,
  3517. strikePrice: nil
  3518. ))
  3519. plansGridNarrow.addArrangedSubview(plansRow1)
  3520. plansGridNarrow.addArrangedSubview(plansRow2)
  3521. plansGridNarrow.isHidden = true
  3522. paywallPlansGridNarrow = plansGridNarrow
  3523. planColumn.addArrangedSubview(plansGridNarrow)
  3524. plansGridNarrow.widthAnchor.constraint(equalTo: planColumn.widthAnchor).isActive = true
  3525. plansRow1.widthAnchor.constraint(equalTo: plansGridNarrow.widthAnchor).isActive = true
  3526. plansRow2.widthAnchor.constraint(equalTo: plansGridNarrow.widthAnchor).isActive = true
  3527. updatePaywallPlanSelection()
  3528. planColumn.setCustomSpacing(16, after: plansGridNarrow)
  3529. let trustRow = paywallTrustRow()
  3530. planColumn.addArrangedSubview(trustRow)
  3531. trustRow.widthAnchor.constraint(equalTo: planColumn.widthAnchor).isActive = true
  3532. planColumn.setCustomSpacing(14, after: trustRow)
  3533. let offer = textLabel(paywallOfferText(for: selectedPremiumPlan), font: NSFont.systemFont(ofSize: 13, weight: .semibold), color: primaryText)
  3534. offer.alignment = .center
  3535. paywallOfferLabel = offer
  3536. let offerWrap = NSView()
  3537. offerWrap.translatesAutoresizingMaskIntoConstraints = false
  3538. offerWrap.addSubview(offer)
  3539. NSLayoutConstraint.activate([
  3540. offer.centerXAnchor.constraint(equalTo: offerWrap.centerXAnchor),
  3541. offer.topAnchor.constraint(equalTo: offerWrap.topAnchor, constant: 6),
  3542. offer.bottomAnchor.constraint(equalTo: offerWrap.bottomAnchor, constant: -2)
  3543. ])
  3544. planColumn.addArrangedSubview(offerWrap)
  3545. offerWrap.widthAnchor.constraint(equalTo: planColumn.widthAnchor).isActive = true
  3546. planColumn.setCustomSpacing(14, after: offerWrap)
  3547. let continueButton = HoverButton(title: "", target: self, action: #selector(paywallContinueClicked(_:)))
  3548. continueButton.translatesAutoresizingMaskIntoConstraints = false
  3549. continueButton.isBordered = false
  3550. continueButton.bezelStyle = .regularSquare
  3551. continueButton.wantsLayer = true
  3552. continueButton.layer?.cornerRadius = 14
  3553. continueButton.normalColor = NSColor(calibratedRed: 0.16, green: 0.49, blue: 0.98, alpha: 1)
  3554. continueButton.hoverColor = NSColor(calibratedRed: 0.16, green: 0.49, blue: 0.98, alpha: 0.92)
  3555. continueButton.heightAnchor.constraint(equalToConstant: 36).isActive = true
  3556. styleSurface(continueButton, borderColor: NSColor.black.withAlphaComponent(0.12), borderWidth: 1, shadow: true)
  3557. let continueLabel = textLabel("Continue", font: NSFont.systemFont(ofSize: 16, weight: .bold), color: .white)
  3558. continueButton.addSubview(continueLabel)
  3559. NSLayoutConstraint.activate([
  3560. continueLabel.centerXAnchor.constraint(equalTo: continueButton.centerXAnchor),
  3561. continueLabel.centerYAnchor.constraint(equalTo: continueButton.centerYAnchor)
  3562. ])
  3563. paywallContinueButton = continueButton
  3564. paywallContinueLabel = continueLabel
  3565. let continueButtonWrap = NSView()
  3566. continueButtonWrap.translatesAutoresizingMaskIntoConstraints = false
  3567. continueButtonWrap.addSubview(continueButton)
  3568. planColumn.addArrangedSubview(continueButtonWrap)
  3569. NSLayoutConstraint.activate([
  3570. continueButtonWrap.widthAnchor.constraint(equalTo: planColumn.widthAnchor),
  3571. continueButton.topAnchor.constraint(equalTo: continueButtonWrap.topAnchor),
  3572. continueButton.bottomAnchor.constraint(equalTo: continueButtonWrap.bottomAnchor),
  3573. continueButton.centerXAnchor.constraint(equalTo: continueButtonWrap.centerXAnchor),
  3574. continueButton.widthAnchor.constraint(equalToConstant: 400),
  3575. continueButton.widthAnchor.constraint(lessThanOrEqualTo: continueButtonWrap.widthAnchor, constant: -40)
  3576. ])
  3577. planColumn.setCustomSpacing(16, after: continueButtonWrap)
  3578. let secure = textLabel("Secured by Apple. Cancel anytime.", font: NSFont.systemFont(ofSize: 12, weight: .semibold), color: secondaryText)
  3579. secure.alignment = .center
  3580. let secureWrap = NSView()
  3581. secureWrap.translatesAutoresizingMaskIntoConstraints = false
  3582. secureWrap.addSubview(secure)
  3583. NSLayoutConstraint.activate([
  3584. secure.centerXAnchor.constraint(equalTo: secureWrap.centerXAnchor),
  3585. secure.topAnchor.constraint(equalTo: secureWrap.topAnchor, constant: 4),
  3586. secure.bottomAnchor.constraint(equalTo: secureWrap.bottomAnchor, constant: -8)
  3587. ])
  3588. planColumn.addArrangedSubview(secureWrap)
  3589. secureWrap.widthAnchor.constraint(equalTo: planColumn.widthAnchor).isActive = true
  3590. planColumn.setCustomSpacing(8, after: secureWrap)
  3591. let primaryFooterButton = paywallFooterButton(
  3592. title: storeKitCoordinator.hasPremiumAccess ? "Manage Subscription" : "Continue with free plan",
  3593. action: storeKitCoordinator.hasPremiumAccess
  3594. ? #selector(paywallManageSubscriptionClicked(_:))
  3595. : #selector(closePaywallClicked(_:))
  3596. )
  3597. let restoreButton = paywallFooterButton(
  3598. title: "Restore Purchase",
  3599. action: #selector(paywallRestorePurchaseClicked(_:))
  3600. )
  3601. let privacyButton = paywallFooterButton(
  3602. title: "Privacy Policy",
  3603. action: #selector(paywallPrivacyPolicyClicked(_:))
  3604. )
  3605. let supportButton = paywallFooterButton(
  3606. title: "Support",
  3607. action: #selector(paywallSupportClicked(_:))
  3608. )
  3609. let termsButton = paywallFooterButton(
  3610. title: "Terms of Services",
  3611. action: #selector(paywallTermsOfServicesClicked(_:))
  3612. )
  3613. // Footer: evenly spaced links across the paywall width.
  3614. let footerContent = NSStackView()
  3615. footerContent.translatesAutoresizingMaskIntoConstraints = false
  3616. footerContent.orientation = .horizontal
  3617. footerContent.alignment = .centerY
  3618. footerContent.distribution = .equalSpacing
  3619. footerContent.spacing = 36
  3620. footerContent.addArrangedSubview(primaryFooterButton)
  3621. footerContent.addArrangedSubview(restoreButton)
  3622. footerContent.addArrangedSubview(privacyButton)
  3623. footerContent.addArrangedSubview(supportButton)
  3624. footerContent.addArrangedSubview(termsButton)
  3625. let footerWrap = NSView()
  3626. footerWrap.translatesAutoresizingMaskIntoConstraints = false
  3627. footerWrap.addSubview(footerContent)
  3628. NSLayoutConstraint.activate([
  3629. footerContent.leadingAnchor.constraint(equalTo: footerWrap.leadingAnchor, constant: 44),
  3630. footerContent.trailingAnchor.constraint(equalTo: footerWrap.trailingAnchor, constant: -44),
  3631. footerContent.topAnchor.constraint(equalTo: footerWrap.topAnchor),
  3632. footerContent.bottomAnchor.constraint(equalTo: footerWrap.bottomAnchor)
  3633. ])
  3634. // Push footer to the bottom of the paywall.
  3635. let footerPushSpacer = NSView()
  3636. footerPushSpacer.translatesAutoresizingMaskIntoConstraints = false
  3637. footerPushSpacer.setContentHuggingPriority(.defaultLow, for: .vertical)
  3638. footerPushSpacer.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
  3639. planColumn.addArrangedSubview(footerPushSpacer)
  3640. planColumn.addArrangedSubview(footerWrap)
  3641. footerWrap.widthAnchor.constraint(equalTo: planColumn.widthAnchor).isActive = true
  3642. // Keep a comfortable top margin, but center the paywall content vertically when there's room.
  3643. let contentTopMin = contentStack.topAnchor.constraint(greaterThanOrEqualTo: panel.topAnchor, constant: 84)
  3644. let contentBottomMax = contentStack.bottomAnchor.constraint(lessThanOrEqualTo: panel.bottomAnchor, constant: -18)
  3645. let contentCenterY = contentStack.centerYAnchor.constraint(equalTo: panel.centerYAnchor)
  3646. contentCenterY.priority = .defaultLow
  3647. let contentBottomEq = contentStack.bottomAnchor.constraint(equalTo: panel.bottomAnchor, constant: -18)
  3648. contentBottomEq.priority = .defaultLow
  3649. NSLayoutConstraint.activate([
  3650. contentStack.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 70),
  3651. contentStack.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -70),
  3652. contentTopMin,
  3653. contentBottomMax,
  3654. contentCenterY,
  3655. contentBottomEq
  3656. ])
  3657. refreshPaywallStoreUI()
  3658. updatePaywallResponsiveLayout()
  3659. return root
  3660. }
  3661. private func paywallBenefitsSection() -> NSView {
  3662. let stack = NSStackView()
  3663. stack.translatesAutoresizingMaskIntoConstraints = false
  3664. stack.orientation = .vertical
  3665. stack.spacing = 8
  3666. stack.alignment = .leading
  3667. stack.widthAnchor.constraint(greaterThanOrEqualToConstant: 320).isActive = true
  3668. let rowOne = NSStackView()
  3669. rowOne.translatesAutoresizingMaskIntoConstraints = false
  3670. rowOne.orientation = .horizontal
  3671. rowOne.spacing = 10
  3672. rowOne.distribution = .fillEqually
  3673. rowOne.alignment = .centerY
  3674. rowOne.addArrangedSubview(paywallBenefitItem(icon: "📅", text: "Manage meetings"))
  3675. rowOne.addArrangedSubview(paywallBenefitItem(icon: "🖼️", text: "Virtual backgrounds"))
  3676. let rowTwo = NSStackView()
  3677. rowTwo.translatesAutoresizingMaskIntoConstraints = false
  3678. rowTwo.orientation = .horizontal
  3679. rowTwo.spacing = 10
  3680. rowTwo.distribution = .fillEqually
  3681. rowTwo.alignment = .centerY
  3682. rowTwo.addArrangedSubview(paywallBenefitItem(icon: "⚡", text: "Tools for productivity"))
  3683. rowTwo.addArrangedSubview(paywallBenefitItem(icon: "🛟", text: "24/7 support"))
  3684. stack.addArrangedSubview(rowOne)
  3685. stack.addArrangedSubview(rowTwo)
  3686. return stack
  3687. }
  3688. private func paywallHeroCard(eyebrow: String, title: String, subtitle: String) -> NSView {
  3689. let card = NSView()
  3690. card.translatesAutoresizingMaskIntoConstraints = false
  3691. card.wantsLayer = true
  3692. card.layer?.cornerRadius = 14
  3693. card.layer?.backgroundColor = palette.secondaryCardBackground.cgColor
  3694. styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  3695. let eyebrowLabel = styledLabel(
  3696. eyebrow,
  3697. font: NSFont.systemFont(ofSize: 11, weight: .semibold),
  3698. color: NSColor(calibratedRed: 0.19, green: 0.82, blue: 0.39, alpha: 0.92),
  3699. kern: 0.8
  3700. )
  3701. eyebrowLabel.alignment = .left
  3702. let titleLabel = styledLabel(
  3703. title,
  3704. font: NSFont.systemFont(ofSize: 18, weight: .semibold),
  3705. color: primaryText,
  3706. lineSpacing: 1
  3707. )
  3708. titleLabel.alignment = .left
  3709. let subtitleLabel = styledLabel(
  3710. subtitle,
  3711. font: NSFont.systemFont(ofSize: 13, weight: .regular),
  3712. color: secondaryText,
  3713. lineSpacing: 2
  3714. )
  3715. subtitleLabel.maximumNumberOfLines = 2
  3716. subtitleLabel.lineBreakMode = .byTruncatingTail
  3717. subtitleLabel.alignment = .left
  3718. let stack = NSStackView()
  3719. stack.translatesAutoresizingMaskIntoConstraints = false
  3720. stack.orientation = .vertical
  3721. stack.alignment = .leading
  3722. stack.spacing = 6
  3723. stack.addArrangedSubview(eyebrowLabel)
  3724. stack.addArrangedSubview(titleLabel)
  3725. stack.addArrangedSubview(subtitleLabel)
  3726. card.addSubview(stack)
  3727. NSLayoutConstraint.activate([
  3728. stack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16),
  3729. stack.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -16),
  3730. stack.topAnchor.constraint(equalTo: card.topAnchor, constant: 12),
  3731. stack.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -12)
  3732. ])
  3733. return card
  3734. }
  3735. private func paywallFeaturesRow() -> NSView {
  3736. let row = NSStackView()
  3737. row.translatesAutoresizingMaskIntoConstraints = false
  3738. row.orientation = .horizontal
  3739. row.spacing = 10
  3740. row.alignment = .centerY
  3741. row.distribution = .fillEqually
  3742. row.addArrangedSubview(paywallBenefitItem(icon: "📅", text: "Manage meetings"))
  3743. row.addArrangedSubview(paywallBenefitItem(icon: "💬", text: "Smart scheduling"))
  3744. row.addArrangedSubview(paywallBenefitItem(icon: "⚡", text: "Faster workflow"))
  3745. row.addArrangedSubview(paywallBenefitItem(icon: "🔔", text: "Reminder notifications"))
  3746. return row
  3747. }
  3748. private func paywallTrustRow() -> NSView {
  3749. let row = NSStackView()
  3750. row.translatesAutoresizingMaskIntoConstraints = false
  3751. row.orientation = .horizontal
  3752. row.spacing = 10
  3753. row.alignment = .centerY
  3754. row.distribution = .fillEqually
  3755. row.addArrangedSubview(paywallTrustItem(title: "Cancel anytime", subtitle: "No lock-in"))
  3756. row.addArrangedSubview(paywallTrustItem(title: "Instant access", subtitle: "Unlock all tools"))
  3757. row.addArrangedSubview(paywallTrustItem(title: "Secure billing", subtitle: "Handled by Apple"))
  3758. return row
  3759. }
  3760. private func paywallTrustItem(title: String, subtitle: String) -> NSView {
  3761. let card = NSView()
  3762. card.translatesAutoresizingMaskIntoConstraints = false
  3763. card.wantsLayer = true
  3764. card.layer?.cornerRadius = 12
  3765. card.layer?.backgroundColor = palette.inputBackground.cgColor
  3766. styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  3767. let titleLabel = textLabel(title, font: NSFont.systemFont(ofSize: 12, weight: .semibold), color: primaryText)
  3768. let subtitleLabel = textLabel(subtitle, font: NSFont.systemFont(ofSize: 11, weight: .regular), color: secondaryText)
  3769. let stack = NSStackView()
  3770. stack.translatesAutoresizingMaskIntoConstraints = false
  3771. stack.orientation = .vertical
  3772. stack.alignment = .centerX
  3773. stack.spacing = 2
  3774. stack.addArrangedSubview(titleLabel)
  3775. stack.addArrangedSubview(subtitleLabel)
  3776. card.addSubview(stack)
  3777. NSLayoutConstraint.activate([
  3778. card.heightAnchor.constraint(equalToConstant: 44),
  3779. stack.centerXAnchor.constraint(equalTo: card.centerXAnchor),
  3780. stack.centerYAnchor.constraint(equalTo: card.centerYAnchor)
  3781. ])
  3782. return card
  3783. }
  3784. private func paywallBenefitItem(icon: String, text: String) -> NSView {
  3785. let card = NSView()
  3786. card.translatesAutoresizingMaskIntoConstraints = false
  3787. card.wantsLayer = true
  3788. card.layer?.cornerRadius = 10
  3789. card.layer?.backgroundColor = palette.inputBackground.cgColor
  3790. card.heightAnchor.constraint(equalToConstant: 36).isActive = true
  3791. styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  3792. let iconWrap = roundedContainer(cornerRadius: 8, color: palette.inputBackground)
  3793. iconWrap.translatesAutoresizingMaskIntoConstraints = false
  3794. iconWrap.widthAnchor.constraint(equalToConstant: 24).isActive = true
  3795. iconWrap.heightAnchor.constraint(equalToConstant: 24).isActive = true
  3796. styleSurface(iconWrap, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  3797. let iconLabel = textLabel(icon, font: NSFont.systemFont(ofSize: 13, weight: .medium), color: accentBlue)
  3798. iconWrap.addSubview(iconLabel)
  3799. NSLayoutConstraint.activate([
  3800. iconLabel.centerXAnchor.constraint(equalTo: iconWrap.centerXAnchor),
  3801. iconLabel.centerYAnchor.constraint(equalTo: iconWrap.centerYAnchor)
  3802. ])
  3803. let title = textLabel(text, font: NSFont.systemFont(ofSize: 12, weight: .medium), color: primaryText)
  3804. card.addSubview(iconWrap)
  3805. card.addSubview(title)
  3806. NSLayoutConstraint.activate([
  3807. iconWrap.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 8),
  3808. iconWrap.centerYAnchor.constraint(equalTo: card.centerYAnchor),
  3809. title.leadingAnchor.constraint(equalTo: iconWrap.trailingAnchor, constant: 10),
  3810. title.centerYAnchor.constraint(equalTo: card.centerYAnchor),
  3811. title.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -8)
  3812. ])
  3813. return card
  3814. }
  3815. private func paywallPlanCard(
  3816. title: String,
  3817. price: String,
  3818. badge: String,
  3819. badgeColor: NSColor,
  3820. subtitle: String?,
  3821. plan: PremiumPlan,
  3822. strikePrice: String?
  3823. ) -> NSView {
  3824. let wrapper = HoverButton(title: "", target: self, action: #selector(paywallPlanButtonClicked(_:)))
  3825. wrapper.translatesAutoresizingMaskIntoConstraints = false
  3826. wrapper.isBordered = false
  3827. wrapper.bezelStyle = .regularSquare
  3828. wrapper.wantsLayer = true
  3829. wrapper.layer?.backgroundColor = NSColor.clear.cgColor
  3830. wrapper.heightAnchor.constraint(equalToConstant: 138).isActive = true
  3831. wrapper.tag = PremiumPlan.allCases.firstIndex(of: plan) ?? 0
  3832. let card = NSView()
  3833. card.translatesAutoresizingMaskIntoConstraints = false
  3834. card.wantsLayer = true
  3835. card.layer?.cornerRadius = 16
  3836. card.layer?.backgroundColor = palette.sectionCard.cgColor
  3837. wrapper.addSubview(card)
  3838. NSLayoutConstraint.activate([
  3839. card.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor),
  3840. card.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor),
  3841. card.topAnchor.constraint(equalTo: wrapper.topAnchor, constant: 14),
  3842. card.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor)
  3843. ])
  3844. styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  3845. let badgeLabel = styledLabel(
  3846. badge.uppercased(),
  3847. font: NSFont.systemFont(ofSize: 10.5, weight: .semibold),
  3848. color: .white,
  3849. kern: 0.6
  3850. )
  3851. let badgeWrap = roundedContainer(cornerRadius: 10, color: badgeColor)
  3852. badgeWrap.translatesAutoresizingMaskIntoConstraints = false
  3853. badgeWrap.wantsLayer = true
  3854. badgeWrap.layer?.borderColor = NSColor(calibratedWhite: 1, alpha: 0.22).cgColor
  3855. badgeWrap.layer?.borderWidth = 1
  3856. badgeWrap.layer?.shadowColor = NSColor.black.cgColor
  3857. badgeWrap.layer?.shadowOpacity = 0.20
  3858. badgeWrap.layer?.shadowOffset = CGSize(width: 0, height: -1)
  3859. badgeWrap.layer?.shadowRadius = 3
  3860. badgeWrap.addSubview(badgeLabel)
  3861. NSLayoutConstraint.activate([
  3862. badgeLabel.leadingAnchor.constraint(equalTo: badgeWrap.leadingAnchor, constant: 8),
  3863. badgeLabel.trailingAnchor.constraint(equalTo: badgeWrap.trailingAnchor, constant: -8),
  3864. badgeLabel.topAnchor.constraint(equalTo: badgeWrap.topAnchor, constant: 2),
  3865. badgeLabel.bottomAnchor.constraint(equalTo: badgeWrap.bottomAnchor, constant: -2)
  3866. ])
  3867. wrapper.addSubview(badgeWrap)
  3868. let titleLabel = textLabel(title, font: NSFont.systemFont(ofSize: 16, weight: .semibold), color: accentBlue)
  3869. card.addSubview(titleLabel)
  3870. let priceLabel = textLabel(price, font: NSFont.systemFont(ofSize: 15, weight: .bold), color: primaryText)
  3871. card.addSubview(priceLabel)
  3872. paywallPlanPriceLabels[plan, default: []].append(priceLabel)
  3873. NSLayoutConstraint.activate([
  3874. badgeWrap.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 14),
  3875. badgeWrap.centerYAnchor.constraint(equalTo: card.topAnchor),
  3876. titleLabel.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16),
  3877. titleLabel.topAnchor.constraint(equalTo: card.topAnchor, constant: 22),
  3878. priceLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
  3879. priceLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8)
  3880. ])
  3881. if let subtitle {
  3882. let sub = textLabel(subtitle, font: NSFont.systemFont(ofSize: 11, weight: .regular), color: secondaryText)
  3883. card.addSubview(sub)
  3884. paywallPlanSubtitleLabels[plan, default: []].append(sub)
  3885. NSLayoutConstraint.activate([
  3886. sub.leadingAnchor.constraint(equalTo: priceLabel.leadingAnchor),
  3887. sub.topAnchor.constraint(equalTo: priceLabel.bottomAnchor, constant: 2),
  3888. sub.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -12)
  3889. ])
  3890. }
  3891. if let strikePrice {
  3892. let strike = textLabel(strikePrice, font: NSFont.systemFont(ofSize: 12, weight: .medium), color: NSColor.systemRed)
  3893. card.addSubview(strike)
  3894. paywallPlanStrikeLabels[plan, default: []].append(strike)
  3895. NSLayoutConstraint.activate([
  3896. strike.trailingAnchor.constraint(equalTo: priceLabel.trailingAnchor),
  3897. strike.topAnchor.constraint(equalTo: priceLabel.bottomAnchor, constant: 4)
  3898. ])
  3899. }
  3900. paywallPlanViews[plan, default: []].append(card)
  3901. premiumPlanByView[ObjectIdentifier(card)] = plan
  3902. wrapper.onHoverChanged = { [weak self, weak card] hovering in
  3903. guard let self, let card else { return }
  3904. self.applyPaywallPlanStyle(card, isSelected: plan == self.selectedPremiumPlan, hovering: hovering)
  3905. }
  3906. wrapper.onHoverChanged?(false)
  3907. return wrapper
  3908. }
  3909. @objc private func paywallPlanButtonClicked(_ sender: NSButton) {
  3910. guard let plan = PremiumPlan.allCases[safe: sender.tag] else { return }
  3911. selectedPremiumPlan = plan
  3912. updatePaywallPlanSelection()
  3913. }
  3914. private func updatePaywallPlanSelection() {
  3915. for (plan, views) in paywallPlanViews {
  3916. for view in views {
  3917. applyPaywallPlanStyle(view, isSelected: plan == selectedPremiumPlan)
  3918. }
  3919. }
  3920. paywallOfferLabel?.stringValue = paywallOfferText(for: selectedPremiumPlan)
  3921. updatePaywallContinueState(isLoading: false)
  3922. }
  3923. private func paywallOfferText(for plan: PremiumPlan) -> String {
  3924. if storeKitCoordinator.hasLifetimePremiumAccess {
  3925. return "Lifetime premium is active on this Apple ID."
  3926. }
  3927. guard let product = storeKitCoordinator.productsByID[plan.rawValue] else {
  3928. let price = paywallDisplayPrice(for: plan)
  3929. switch plan {
  3930. case .weekly: return "\(price)/week"
  3931. case .monthly: return "\(price)/month"
  3932. case .yearly: return "\(price)/year"
  3933. case .lifetime: return "\(price) one-time purchase"
  3934. }
  3935. }
  3936. if let offer = product.subscription?.introductoryOffer,
  3937. offer.paymentMode == .freeTrial,
  3938. let trial = paywallSubscriptionPeriodDescription(offer.period) {
  3939. let renewal = paywallSubscriptionPeriodDescription(product.subscription?.subscriptionPeriod)
  3940. if let renewal {
  3941. return "Free for \(trial), then \(product.displayPrice)/\(renewal)"
  3942. }
  3943. return "Free for \(trial), then \(product.displayPrice)"
  3944. }
  3945. if plan == .lifetime {
  3946. return "\(product.displayPrice) one-time purchase"
  3947. }
  3948. if let renewal = paywallSubscriptionPeriodDescription(product.subscription?.subscriptionPeriod) {
  3949. return "\(product.displayPrice)/\(renewal)"
  3950. }
  3951. let fallbackPrice = paywallDisplayPrice(for: plan)
  3952. switch plan {
  3953. case .weekly: return "\(fallbackPrice)/week"
  3954. case .monthly: return "\(fallbackPrice)/month"
  3955. case .yearly: return "\(fallbackPrice)/year"
  3956. case .lifetime: return "\(fallbackPrice) one-time purchase"
  3957. }
  3958. }
  3959. private func paywallFreeTrialText(for plan: PremiumPlan) -> String? {
  3960. guard let product = storeKitCoordinator.productsByID[plan.rawValue],
  3961. let offer = product.subscription?.introductoryOffer,
  3962. offer.paymentMode == .freeTrial,
  3963. let trial = paywallSubscriptionPeriodDescription(offer.period) else {
  3964. return nil
  3965. }
  3966. return trial
  3967. }
  3968. private func paywallPlanSubtitle(for plan: PremiumPlan) -> String {
  3969. if plan == .lifetime { return "One-time purchase" }
  3970. guard let product = storeKitCoordinator.productsByID[plan.rawValue] else {
  3971. switch plan {
  3972. case .weekly: return "Billed weekly"
  3973. case .monthly: return "Billed monthly"
  3974. case .yearly: return "Billed yearly"
  3975. case .lifetime: return "One-time purchase"
  3976. }
  3977. }
  3978. if let period = product.subscription?.subscriptionPeriod,
  3979. let unit = paywallSubscriptionPeriodDescription(period),
  3980. let trial = paywallFreeTrialText(for: plan) {
  3981. return "\(product.displayPrice)/\(unit) • \(trial) free trial"
  3982. }
  3983. if let period = product.subscription?.subscriptionPeriod,
  3984. let unit = paywallSubscriptionPeriodDescription(period) {
  3985. return "Billed \(unit)"
  3986. }
  3987. switch plan {
  3988. case .weekly: return "Billed weekly"
  3989. case .monthly: return "Billed monthly"
  3990. case .yearly: return "Billed yearly"
  3991. case .lifetime: return "One-time purchase"
  3992. }
  3993. }
  3994. private func paywallSubscriptionPeriodDescription(_ period: Product.SubscriptionPeriod?) -> String? {
  3995. guard let period else { return nil }
  3996. let value = period.value
  3997. switch period.unit {
  3998. case .day:
  3999. return value == 1 ? "1 day" : "\(value) days"
  4000. case .week:
  4001. return value == 1 ? "1 week" : "\(value) weeks"
  4002. case .month:
  4003. return value == 1 ? "1 month" : "\(value) months"
  4004. case .year:
  4005. return value == 1 ? "1 year" : "\(value) years"
  4006. @unknown default:
  4007. return nil
  4008. }
  4009. }
  4010. private func refreshPaywallStoreUI() {
  4011. refreshPaywallPlanPrices()
  4012. updatePaywallPlanSelection()
  4013. updatePaywallContinueState(isLoading: false)
  4014. }
  4015. private func refreshPaywallPlanPrices() {
  4016. for plan in PremiumPlan.allCases {
  4017. let price = paywallDisplayPrice(for: plan)
  4018. paywallPlanPriceLabels[plan]?.forEach { $0.stringValue = price }
  4019. let subtitle = paywallPlanSubtitle(for: plan)
  4020. paywallPlanSubtitleLabels[plan]?.forEach { $0.stringValue = subtitle }
  4021. }
  4022. }
  4023. private func paywallDisplayPrice(for plan: PremiumPlan) -> String {
  4024. if let product = storeKitCoordinator.productsByID[plan.rawValue] {
  4025. return product.displayPrice
  4026. }
  4027. switch plan {
  4028. case .weekly: return "3.99"
  4029. case .monthly: return "7.99"
  4030. case .yearly: return "24.99"
  4031. case .lifetime: return "44.99"
  4032. }
  4033. }
  4034. @objc private func paywallContinueClicked(_ sender: Any?) {
  4035. startSelectedPlanPurchase()
  4036. }
  4037. private func paywallFooterButton(title: String, action: Selector) -> NSButton {
  4038. let button = NSButton(title: title, target: self, action: action)
  4039. button.translatesAutoresizingMaskIntoConstraints = false
  4040. button.isBordered = false
  4041. button.bezelStyle = .shadowlessSquare
  4042. button.font = NSFont.systemFont(ofSize: 11, weight: .semibold)
  4043. button.contentTintColor = secondaryText
  4044. button.alignment = .center
  4045. button.heightAnchor.constraint(equalToConstant: 24).isActive = true
  4046. return button
  4047. }
  4048. @objc private func paywallPrivacyPolicyClicked(_ sender: Any?) {
  4049. showSimpleAlert(
  4050. title: "Privacy Policy",
  4051. message: "Placeholder: Privacy Policy content/link will be added before publishing."
  4052. )
  4053. }
  4054. @objc private func paywallSupportClicked(_ sender: Any?) {
  4055. showSimpleAlert(
  4056. title: "Support",
  4057. message: "Placeholder: Support contact details/link will be added before publishing."
  4058. )
  4059. }
  4060. @objc private func paywallTermsOfServicesClicked(_ sender: Any?) {
  4061. showSimpleAlert(
  4062. title: "Terms of Services",
  4063. message: "Placeholder: Terms of Services content/link will be added before publishing."
  4064. )
  4065. }
  4066. @objc private func paywallManageSubscriptionClicked(_ sender: Any?) {
  4067. openManageSubscriptions()
  4068. }
  4069. @objc private func paywallRestorePurchaseClicked(_ sender: Any?) {
  4070. Task { [weak self] in
  4071. guard let self else { return }
  4072. let message = await self.storeKitCoordinator.restorePurchases()
  4073. await MainActor.run {
  4074. self.refreshPaywallStoreUI()
  4075. self.updatePremiumButtons()
  4076. self.showSimpleAlert(title: "Restore Purchase", message: message)
  4077. }
  4078. }
  4079. }
  4080. private func startSelectedPlanPurchase() {
  4081. guard paywallContinueEnabled else { return }
  4082. paywallPurchaseTask?.cancel()
  4083. updatePaywallContinueState(isLoading: true)
  4084. let selectedPlan = selectedPremiumPlan
  4085. let hadPremiumBeforePurchase = storeKitCoordinator.hasPremiumAccess
  4086. let hadLifetimeBeforePurchase = storeKitCoordinator.hasLifetimePremiumAccess
  4087. paywallPurchaseTask = Task { [weak self] in
  4088. guard let self else { return }
  4089. if self.storeKitCoordinator.productsByID[selectedPlan.rawValue] == nil {
  4090. await self.storeKitCoordinator.refreshProducts()
  4091. }
  4092. let result = await self.storeKitCoordinator.purchase(plan: selectedPlan)
  4093. await MainActor.run {
  4094. self.updatePaywallContinueState(isLoading: false)
  4095. self.refreshPaywallStoreUI()
  4096. self.updatePremiumButtons()
  4097. switch result {
  4098. case .success:
  4099. if self.storeKitCoordinator.hasPremiumAccess {
  4100. // Ensure QA can repeatedly verify rating flow right after upgrading.
  4101. UserDefaults.standard.set(false, forKey: self.ratingCompletedKey)
  4102. UserDefaults.standard.set(false, forKey: self.ratingPromptShownKey)
  4103. self.schedulePremiumRatingPromptIfNeeded()
  4104. }
  4105. if selectedPlan == .lifetime, hadLifetimeBeforePurchase == false, hadPremiumBeforePurchase {
  4106. self.showSimpleAlert(
  4107. title: "Lifetime Activated",
  4108. message: "Lifetime premium is now active. If you previously subscribed (weekly/monthly/yearly), please cancel that subscription in your Apple ID subscriptions to avoid future renewals."
  4109. )
  4110. } else {
  4111. self.showSimpleAlert(title: "Purchase Complete", message: "Premium has been unlocked successfully.")
  4112. }
  4113. self.hidePaywall()
  4114. if self.storeKitCoordinator.hasPremiumAccess {
  4115. DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) { [weak self] in
  4116. self?.presentRatingPrompt(source: "premium")
  4117. }
  4118. }
  4119. case .pending:
  4120. self.showSimpleAlert(title: "Purchase Pending", message: "Your purchase is pending approval. You can continue once it completes.")
  4121. case .cancelled:
  4122. break
  4123. case .failed(let message):
  4124. let fallbackMessage = "StoreKit did not return products for this plan yet. Wait a few seconds and try again, or reopen the app."
  4125. if message.contains("Product not available") {
  4126. self.showSimpleAlert(title: "Purchase Failed", message: fallbackMessage)
  4127. } else {
  4128. self.showSimpleAlert(title: "Purchase Failed", message: message)
  4129. }
  4130. }
  4131. }
  4132. }
  4133. }
  4134. private func updatePaywallContinueState(isLoading: Bool) {
  4135. if isLoading {
  4136. paywallContinueEnabled = false
  4137. paywallContinueLabel?.stringValue = "Processing..."
  4138. paywallContinueButton?.alphaValue = 0.75
  4139. return
  4140. }
  4141. if storeKitCoordinator.hasLifetimePremiumAccess {
  4142. paywallContinueEnabled = false
  4143. paywallContinueLabel?.stringValue = "Lifetime Premium Active"
  4144. paywallContinueButton?.alphaValue = 0.75
  4145. } else if storeKitCoordinator.productsByID[selectedPremiumPlan.rawValue] == nil {
  4146. paywallContinueEnabled = false
  4147. paywallContinueLabel?.stringValue = "Store Unavailable"
  4148. paywallContinueButton?.alphaValue = 0.75
  4149. } else {
  4150. paywallContinueEnabled = true
  4151. if let trial = paywallFreeTrialText(for: selectedPremiumPlan) {
  4152. paywallContinueLabel?.stringValue = "Start with \(trial) trial"
  4153. } else {
  4154. paywallContinueLabel?.stringValue = "Continue"
  4155. }
  4156. paywallContinueButton?.alphaValue = 1.0
  4157. }
  4158. }
  4159. private func applyPaywallPlanStyle(_ card: NSView, isSelected: Bool, hovering: Bool = false) {
  4160. let selectedBorder = NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1)
  4161. let idleBorder = palette.inputBorder
  4162. let hoverBlend = palette.isDarkMode ? NSColor.white : NSColor.black
  4163. let hoverIdleBackground =
  4164. palette.sectionCard.blended(withFraction: 0.10, of: hoverBlend) ?? palette.sectionCard
  4165. let selectedBackground = palette.isDarkMode
  4166. ? NSColor(calibratedRed: 30.0 / 255.0, green: 34.0 / 255.0, blue: 42.0 / 255.0, alpha: 1)
  4167. : NSColor(calibratedRed: 255.0 / 255.0, green: 246.0 / 255.0, blue: 236.0 / 255.0, alpha: 1)
  4168. card.layer?.backgroundColor = (isSelected ? selectedBackground : (hovering ? hoverIdleBackground : palette.sectionCard)).cgColor
  4169. card.layer?.borderColor = (isSelected ? selectedBorder : (hovering ? selectedBorder.withAlphaComponent(0.55) : idleBorder)).cgColor
  4170. card.layer?.borderWidth = isSelected ? 2 : 1
  4171. card.layer?.shadowColor = NSColor.black.cgColor
  4172. card.layer?.shadowOpacity = isSelected ? (palette.isDarkMode ? 0.26 : 0.10) : (hovering ? 0.18 : 0.12)
  4173. card.layer?.shadowOffset = CGSize(width: 0, height: -1)
  4174. card.layer?.shadowRadius = isSelected ? (palette.isDarkMode ? 10 : 6) : (hovering ? 7 : 5)
  4175. }
  4176. // MARK: - Home UI
  4177. private func makeHomeView(profile: GoogleUserProfile?) -> NSView {
  4178. let root = NSView()
  4179. let shell = NSView()
  4180. shell.wantsLayer = true
  4181. shell.layer?.backgroundColor = appShellBackground.cgColor
  4182. shell.layer?.cornerRadius = appShellCornerRadius
  4183. shell.layer?.borderWidth = 1
  4184. shell.layer?.borderColor = NSColor.white.withAlphaComponent(0.06).cgColor
  4185. let chromeColumn = NSView()
  4186. chromeColumn.wantsLayer = true
  4187. chromeColumn.layer?.backgroundColor = chromeUnifiedBackground.cgColor
  4188. chromeColumn.layer?.cornerRadius = appShellCornerRadius
  4189. chromeColumn.layer?.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner]
  4190. chromeColumn.layer?.masksToBounds = true
  4191. let chromeDivider = NSView()
  4192. chromeDivider.wantsLayer = true
  4193. chromeDivider.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.32).cgColor
  4194. let chromeHeader = NSView()
  4195. chromeHeader.wantsLayer = true
  4196. chromeHeader.layer?.backgroundColor = chromeUnifiedBackground.cgColor
  4197. homeSidebarRowViews = [:]
  4198. homeSidebarIconViews = [:]
  4199. homeSidebarLabels = [:]
  4200. let sidebar = makeSidebar(items: ["Home", "Meetings", "Scheduler"], selected: selectedHomeSidebarItem, style: .home)
  4201. let content = NSView()
  4202. content.wantsLayer = true
  4203. content.layer?.backgroundColor = NSColor.clear.cgColor
  4204. root.addSubview(shell)
  4205. shell.addSubview(chromeColumn)
  4206. shell.addSubview(content)
  4207. chromeColumn.addSubview(chromeDivider)
  4208. chromeColumn.addSubview(chromeHeader)
  4209. chromeColumn.addSubview(sidebar)
  4210. shell.translatesAutoresizingMaskIntoConstraints = false
  4211. chromeColumn.translatesAutoresizingMaskIntoConstraints = false
  4212. chromeDivider.translatesAutoresizingMaskIntoConstraints = false
  4213. chromeHeader.translatesAutoresizingMaskIntoConstraints = false
  4214. sidebar.translatesAutoresizingMaskIntoConstraints = false
  4215. content.translatesAutoresizingMaskIntoConstraints = false
  4216. NSLayoutConstraint.activate([
  4217. shell.leadingAnchor.constraint(equalTo: root.leadingAnchor),
  4218. shell.trailingAnchor.constraint(equalTo: root.trailingAnchor),
  4219. shell.topAnchor.constraint(equalTo: root.topAnchor, constant: 0),
  4220. shell.bottomAnchor.constraint(equalTo: root.bottomAnchor),
  4221. chromeColumn.leadingAnchor.constraint(equalTo: shell.leadingAnchor),
  4222. chromeColumn.topAnchor.constraint(equalTo: shell.topAnchor),
  4223. chromeColumn.bottomAnchor.constraint(equalTo: shell.bottomAnchor),
  4224. chromeColumn.widthAnchor.constraint(equalToConstant: 82),
  4225. chromeDivider.topAnchor.constraint(equalTo: chromeColumn.topAnchor),
  4226. chromeDivider.bottomAnchor.constraint(equalTo: chromeColumn.bottomAnchor),
  4227. chromeDivider.trailingAnchor.constraint(equalTo: chromeColumn.trailingAnchor),
  4228. chromeDivider.widthAnchor.constraint(equalToConstant: 1),
  4229. chromeHeader.topAnchor.constraint(equalTo: chromeColumn.topAnchor),
  4230. chromeHeader.leadingAnchor.constraint(equalTo: chromeColumn.leadingAnchor),
  4231. chromeHeader.trailingAnchor.constraint(equalTo: chromeColumn.trailingAnchor),
  4232. chromeHeader.heightAnchor.constraint(equalToConstant: homeChromeHeaderHeight),
  4233. sidebar.leadingAnchor.constraint(equalTo: chromeColumn.leadingAnchor),
  4234. sidebar.trailingAnchor.constraint(equalTo: chromeColumn.trailingAnchor),
  4235. sidebar.topAnchor.constraint(equalTo: chromeHeader.bottomAnchor),
  4236. sidebar.bottomAnchor.constraint(equalTo: chromeColumn.bottomAnchor),
  4237. content.leadingAnchor.constraint(equalTo: chromeColumn.trailingAnchor),
  4238. content.trailingAnchor.constraint(equalTo: shell.trailingAnchor),
  4239. content.topAnchor.constraint(equalTo: shell.topAnchor),
  4240. content.bottomAnchor.constraint(equalTo: shell.bottomAnchor)
  4241. ])
  4242. let brandStack = NSStackView()
  4243. brandStack.orientation = .vertical
  4244. brandStack.spacing = 0
  4245. brandStack.alignment = .leading
  4246. let brandTop = makeLabel("zoom", size: 14, color: primaryText, weight: .semibold, centered: false)
  4247. let brandBottom = makeLabel("Workplace", size: 27, color: primaryText, weight: .bold, centered: false)
  4248. brandTop.font = .systemFont(ofSize: 12, weight: .semibold)
  4249. brandBottom.font = .systemFont(ofSize: 12, weight: .bold)
  4250. [brandTop, brandBottom].forEach { brandStack.addArrangedSubview($0) }
  4251. let topBar = NSView()
  4252. topBar.wantsLayer = true
  4253. topBar.layer?.backgroundColor = chromeUnifiedBackground.cgColor
  4254. let topBarDivider = NSView()
  4255. topBarDivider.wantsLayer = true
  4256. topBarDivider.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.06).cgColor
  4257. let searchPill = NSView()
  4258. searchPill.wantsLayer = true
  4259. searchPill.layer?.backgroundColor = searchPillBackground.cgColor
  4260. searchPill.layer?.cornerRadius = 10
  4261. searchPill.layer?.borderWidth = 0
  4262. let searchIcon = NSImageView()
  4263. searchIcon.image = NSImage(systemSymbolName: "magnifyingglass", accessibilityDescription: "Search")
  4264. searchIcon.contentTintColor = mutedText.withAlphaComponent(0.9)
  4265. searchIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 16, weight: .regular)
  4266. searchIcon.imageScaling = .scaleProportionallyUpOrDown
  4267. let searchHintLabel = makeLabel("Search ⌘ + E", size: 13, color: mutedText, weight: .regular, centered: true)
  4268. searchHintLabel.isHidden = false
  4269. let searchField = SearchPillTextField()
  4270. searchField.isBordered = false
  4271. searchField.drawsBackground = false
  4272. searchField.backgroundColor = .clear
  4273. searchField.focusRingType = .none
  4274. searchField.isEditable = false
  4275. searchField.isSelectable = false
  4276. searchField.font = .systemFont(ofSize: 13, weight: .regular)
  4277. searchField.textColor = primaryText
  4278. searchField.alignment = .left
  4279. searchField.placeholderString = nil
  4280. if let cell = searchField.cell as? NSTextFieldCell {
  4281. cell.isBezeled = false
  4282. cell.isBordered = false
  4283. cell.backgroundColor = .clear
  4284. }
  4285. let updateSearchHintVisibility = { [weak searchField, weak searchHintLabel] in
  4286. guard let searchField, let searchHintLabel else { return }
  4287. let shouldShow = searchField.isSearchFocused == false && searchField.stringValue.isEmpty
  4288. searchHintLabel.isHidden = shouldShow == false
  4289. }
  4290. searchField.onFocusChange = { [weak self] focused in
  4291. self?.applySearchPillFocusBorder(focused: focused)
  4292. updateSearchHintVisibility()
  4293. }
  4294. updateSearchHintVisibility()
  4295. let searchTap = NSClickGestureRecognizer(target: self, action: #selector(topSearchPillTapped))
  4296. searchPill.addGestureRecognizer(searchTap)
  4297. searchField.addGestureRecognizer(NSClickGestureRecognizer(target: self, action: #selector(topSearchPillTapped)))
  4298. searchIcon.addGestureRecognizer(NSClickGestureRecognizer(target: self, action: #selector(topSearchPillTapped)))
  4299. searchHintLabel.addGestureRecognizer(NSClickGestureRecognizer(target: self, action: #selector(topSearchPillTapped)))
  4300. let searchRow = NSStackView()
  4301. searchRow.orientation = .horizontal
  4302. searchRow.spacing = 14
  4303. searchRow.alignment = .centerY
  4304. searchRow.addArrangedSubview(searchPill)
  4305. let rightTopBarCluster = NSStackView()
  4306. rightTopBarCluster.orientation = .horizontal
  4307. rightTopBarCluster.spacing = 10
  4308. rightTopBarCluster.alignment = .centerY
  4309. let upgradeToProButton = makeUpgradeToProButton(action: #selector(upgradeToProTapped))
  4310. topBarPremiumButton = upgradeToProButton
  4311. updateTopBarPremiumButton()
  4312. let authButton = NSButton(title: "", target: self, action: #selector(topBarAuthButtonTapped))
  4313. authButton.isBordered = false
  4314. authButton.wantsLayer = true
  4315. authButton.layer?.cornerRadius = 11
  4316. authButton.contentTintColor = .white
  4317. authButton.font = .systemFont(ofSize: 12, weight: .semibold)
  4318. authButton.bezelStyle = .shadowlessSquare
  4319. self.topBarAuthButton = authButton
  4320. [upgradeToProButton, authButton].forEach { rightTopBarCluster.addArrangedSubview($0) }
  4321. let timeTitle = makeLabel("--:--", size: 56, color: primaryText, weight: .bold, centered: true)
  4322. let dateTitle = makeLabel("-", size: 16, color: secondaryText, weight: .regular, centered: true)
  4323. let actions = NSStackView(views: [
  4324. makeActionTile(title: "New meeting", symbol: "video.fill", color: accentOrange, action: #selector(newMeetingTapped)),
  4325. makeActionTile(title: "Join", symbol: "plus", color: accentBlue, action: #selector(joinMeetingTapped)),
  4326. makeActionTile(title: "Schedule", symbol: "calendar", color: accentBlue, action: #selector(scheduleMeetingTapped))
  4327. ])
  4328. actions.orientation = .horizontal
  4329. actions.spacing = 12
  4330. actions.alignment = .centerY
  4331. actions.distribution = .fillEqually
  4332. let panel = NSView()
  4333. panel.wantsLayer = true
  4334. panel.layer?.backgroundColor = secondaryCardBackground.withAlphaComponent(0.94).cgColor
  4335. panel.layer?.cornerRadius = 16
  4336. panel.layer?.borderWidth = 1
  4337. panel.layer?.borderColor = palette.inputBorder.cgColor
  4338. let todaysDateFormatter = DateFormatter()
  4339. todaysDateFormatter.dateFormat = "EEEE, MMM d"
  4340. let panelHeader = makeLabel(todaysDateFormatter.string(from: Date()), size: 18, color: primaryText, weight: .semibold, centered: false)
  4341. let meetingsStatus = makeLabel("Upcoming meetings", size: 11, color: secondaryText, weight: .medium, centered: false)
  4342. let meetingsDayNav = NSStackView()
  4343. meetingsDayNav.orientation = .horizontal
  4344. meetingsDayNav.spacing = 4
  4345. meetingsDayNav.alignment = .centerY
  4346. let prevDayButton = makeNavGlyphButton(symbol: "chevron.left", action: #selector(meetingsPrevDayTapped), dimension: 14, pointSize: 7, toolTip: "Previous day")
  4347. let todayButton = makeMeetingsDayChipButton(title: "Today", symbol: "calendar", action: #selector(meetingsTodayTapped))
  4348. let nextDayButton = makeNavGlyphButton(symbol: "chevron.right", action: #selector(meetingsNextDayTapped), dimension: 14, pointSize: 7, toolTip: "Next day")
  4349. [todayButton, prevDayButton, nextDayButton].forEach { meetingsDayNav.addArrangedSubview($0) }
  4350. let noMeeting = makeLabel("No meetings scheduled for today.", size: 15, color: secondaryText, weight: .regular, centered: true)
  4351. let meetingsScrollView = NSScrollView()
  4352. meetingsScrollView.drawsBackground = false
  4353. meetingsScrollView.hasVerticalScroller = true
  4354. meetingsScrollView.hasHorizontalScroller = false
  4355. meetingsScrollView.autohidesScrollers = true
  4356. let meetingsDocument = FlippedView()
  4357. let meetingsStack = NSStackView()
  4358. meetingsStack.orientation = .vertical
  4359. meetingsStack.spacing = 14
  4360. meetingsStack.alignment = .leading
  4361. // Keep meeting cards pinned to the top of the scroll content.
  4362. meetingsStack.setContentHuggingPriority(.required, for: .vertical)
  4363. meetingsStack.setContentCompressionResistancePriority(.required, for: .vertical)
  4364. let refreshMeetingsButton = NSButton(title: "Refresh", target: self, action: #selector(refreshMeetingsTapped))
  4365. refreshMeetingsButton.isBordered = false
  4366. refreshMeetingsButton.font = .systemFont(ofSize: 14, weight: .semibold)
  4367. refreshMeetingsButton.contentTintColor = primaryText
  4368. let refreshSymbolConfig = NSImage.SymbolConfiguration(pointSize: 13, weight: .semibold)
  4369. if let base = NSImage(systemSymbolName: "arrow.clockwise", accessibilityDescription: "Refresh"),
  4370. let image = base.withSymbolConfiguration(refreshSymbolConfig) {
  4371. image.isTemplate = true
  4372. refreshMeetingsButton.image = image
  4373. refreshMeetingsButton.imagePosition = .imageLeading
  4374. refreshMeetingsButton.imageHugsTitle = true
  4375. refreshMeetingsButton.imageScaling = .scaleNone
  4376. }
  4377. refreshMeetingsButton.wantsLayer = true
  4378. refreshMeetingsButton.layer?.backgroundColor = (palette.isDarkMode
  4379. ? NSColor(calibratedRed: 36 / 255, green: 39 / 255, blue: 46 / 255, alpha: 1)
  4380. : NSColor.black.withAlphaComponent(0.06)
  4381. ).cgColor
  4382. refreshMeetingsButton.layer?.cornerRadius = 11
  4383. refreshMeetingsButton.layer?.borderWidth = 1
  4384. refreshMeetingsButton.layer?.borderColor = palette.inputBorder.cgColor
  4385. self.refreshMeetingsButton = refreshMeetingsButton
  4386. let placeholder = makeLabel("Coming soon", size: 22, color: secondaryText, weight: .semibold, centered: true)
  4387. placeholder.isHidden = true
  4388. let settingsView = makeSettingsView()
  4389. settingsView.isHidden = selectedHomeSidebarItem != "Settings"
  4390. let meetingsPageView = makeMeetingsPageView()
  4391. meetingsPageView.isHidden = selectedHomeSidebarItem != "Meetings"
  4392. let searchOverlay = NSView()
  4393. searchOverlay.wantsLayer = true
  4394. searchOverlay.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.45).cgColor
  4395. searchOverlay.isHidden = true
  4396. searchOverlay.translatesAutoresizingMaskIntoConstraints = false
  4397. let searchPanel = NSView()
  4398. searchPanel.wantsLayer = true
  4399. searchPanel.layer?.backgroundColor = secondaryCardBackground.cgColor
  4400. searchPanel.layer?.cornerRadius = 14
  4401. searchPanel.layer?.borderWidth = 1
  4402. searchPanel.layer?.borderColor = palette.inputBorder.cgColor
  4403. searchPanel.translatesAutoresizingMaskIntoConstraints = false
  4404. let popupTitle = makeLabel("Search Meetings", size: 18, color: primaryText, weight: .semibold, centered: false)
  4405. popupTitle.translatesAutoresizingMaskIntoConstraints = false
  4406. let popupSubtitle = makeLabel("Find any scheduled meeting instantly", size: 12, color: secondaryText, weight: .regular, centered: false)
  4407. popupSubtitle.translatesAutoresizingMaskIntoConstraints = false
  4408. let popupSearchChrome = NSView()
  4409. popupSearchChrome.translatesAutoresizingMaskIntoConstraints = false
  4410. popupSearchChrome.wantsLayer = true
  4411. popupSearchChrome.layer?.backgroundColor = searchPillBackground.cgColor
  4412. popupSearchChrome.layer?.cornerRadius = 10
  4413. popupSearchChrome.layer?.borderWidth = 1
  4414. popupSearchChrome.layer?.borderColor = palette.inputBorder.cgColor
  4415. let popupSearchIcon = NSImageView()
  4416. popupSearchIcon.translatesAutoresizingMaskIntoConstraints = false
  4417. popupSearchIcon.image = NSImage(systemSymbolName: "magnifyingglass", accessibilityDescription: "Search meetings")
  4418. popupSearchIcon.contentTintColor = secondaryText
  4419. popupSearchIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 14, weight: .regular)
  4420. let popupSearchField = NSTextField()
  4421. popupSearchField.translatesAutoresizingMaskIntoConstraints = false
  4422. popupSearchField.isBordered = false
  4423. popupSearchField.drawsBackground = false
  4424. popupSearchField.backgroundColor = .clear
  4425. popupSearchField.focusRingType = .none
  4426. popupSearchField.font = .systemFont(ofSize: 15, weight: .regular)
  4427. popupSearchField.textColor = primaryText
  4428. popupSearchField.placeholderString = "Search all meetings by title, host, source, ID..."
  4429. let popupResultsScroll = NSScrollView()
  4430. popupResultsScroll.translatesAutoresizingMaskIntoConstraints = false
  4431. popupResultsScroll.drawsBackground = false
  4432. popupResultsScroll.hasVerticalScroller = true
  4433. popupResultsScroll.hasHorizontalScroller = false
  4434. popupResultsScroll.autohidesScrollers = true
  4435. let popupResultsDocument = FlippedView()
  4436. popupResultsDocument.translatesAutoresizingMaskIntoConstraints = false
  4437. let popupResultsStack = NSStackView()
  4438. popupResultsStack.translatesAutoresizingMaskIntoConstraints = false
  4439. popupResultsStack.orientation = .vertical
  4440. popupResultsStack.spacing = 10
  4441. popupResultsStack.alignment = .leading
  4442. let popupEmpty = makeLabel("No meetings match your search.", size: 13, color: secondaryText, weight: .regular, centered: true)
  4443. popupEmpty.translatesAutoresizingMaskIntoConstraints = false
  4444. popupEmpty.isHidden = true
  4445. root.addSubview(searchOverlay)
  4446. searchOverlay.addSubview(searchPanel)
  4447. searchPanel.addSubview(popupTitle)
  4448. searchPanel.addSubview(popupSubtitle)
  4449. searchPanel.addSubview(popupSearchChrome)
  4450. searchPanel.addSubview(popupResultsScroll)
  4451. searchPanel.addSubview(popupEmpty)
  4452. popupSearchChrome.addSubview(popupSearchIcon)
  4453. popupSearchChrome.addSubview(popupSearchField)
  4454. popupResultsScroll.documentView = popupResultsDocument
  4455. popupResultsDocument.addSubview(popupResultsStack)
  4456. let schedulerRoot = NSView()
  4457. schedulerRoot.isHidden = selectedHomeSidebarItem != "Scheduler"
  4458. let schedulerSplit = NSStackView()
  4459. schedulerSplit.orientation = .horizontal
  4460. schedulerSplit.spacing = 14
  4461. schedulerSplit.distribution = .fillEqually
  4462. schedulerSplit.alignment = .top
  4463. let schedulerCalendarPanel = NSView()
  4464. schedulerCalendarPanel.wantsLayer = true
  4465. schedulerCalendarPanel.layer?.backgroundColor = secondaryCardBackground.withAlphaComponent(0.94).cgColor
  4466. schedulerCalendarPanel.layer?.cornerRadius = 16
  4467. schedulerCalendarPanel.layer?.borderWidth = 1
  4468. schedulerCalendarPanel.layer?.borderColor = palette.inputBorder.cgColor
  4469. let schedulerMeetingsPanel = NSView()
  4470. schedulerMeetingsPanel.wantsLayer = true
  4471. schedulerMeetingsPanel.layer?.backgroundColor = secondaryCardBackground.withAlphaComponent(0.94).cgColor
  4472. schedulerMeetingsPanel.layer?.cornerRadius = 16
  4473. schedulerMeetingsPanel.layer?.borderWidth = 1
  4474. schedulerMeetingsPanel.layer?.borderColor = palette.inputBorder.cgColor
  4475. let schedulerCalendarTopRow = NSStackView()
  4476. schedulerCalendarTopRow.orientation = .horizontal
  4477. schedulerCalendarTopRow.alignment = .centerY
  4478. schedulerCalendarTopRow.distribution = .fill
  4479. schedulerCalendarTopRow.spacing = 10
  4480. let schedulerConnectLabel = makeLabel("Zoom calendar", size: 18, color: accentBlue, weight: .semibold, centered: false)
  4481. let schedulerPlusButton = NSButton(title: "", target: self, action: #selector(scheduleMeetingTapped))
  4482. schedulerPlusButton.isBordered = false
  4483. schedulerPlusButton.bezelStyle = .shadowlessSquare
  4484. schedulerPlusButton.focusRingType = .none
  4485. schedulerPlusButton.wantsLayer = true
  4486. schedulerPlusButton.layer?.backgroundColor = accentBlue.cgColor
  4487. schedulerPlusButton.layer?.cornerRadius = 14
  4488. schedulerPlusButton.contentTintColor = .white
  4489. schedulerPlusButton.toolTip = "Connect calendar"
  4490. if let image = NSImage(systemSymbolName: "plus", accessibilityDescription: "Connect calendar") {
  4491. schedulerPlusButton.image = image
  4492. schedulerPlusButton.imagePosition = .imageOnly
  4493. }
  4494. schedulerPlusButton.translatesAutoresizingMaskIntoConstraints = false
  4495. schedulerPlusButton.widthAnchor.constraint(equalToConstant: 28).isActive = true
  4496. schedulerPlusButton.heightAnchor.constraint(equalToConstant: 28).isActive = true
  4497. let schedulerMonthRow = NSStackView()
  4498. schedulerMonthRow.orientation = .horizontal
  4499. schedulerMonthRow.alignment = .centerY
  4500. schedulerMonthRow.spacing = 8
  4501. let schedulerPrevMonthButton = makeNavGlyphButton(symbol: "chevron.left", action: #selector(schedulerPrevMonthTapped), dimension: 18, pointSize: 10, toolTip: "Previous month")
  4502. let schedulerMonthLabel = makeLabel("-", size: 30, color: primaryText, weight: .semibold, centered: false)
  4503. let schedulerNextMonthButton = makeNavGlyphButton(symbol: "chevron.right", action: #selector(schedulerNextMonthTapped), dimension: 18, pointSize: 10, toolTip: "Next month")
  4504. [schedulerPrevMonthButton, schedulerMonthLabel, schedulerNextMonthButton].forEach { schedulerMonthRow.addArrangedSubview($0) }
  4505. let schedulerWeekdaysRow = NSStackView()
  4506. schedulerWeekdaysRow.orientation = .horizontal
  4507. schedulerWeekdaysRow.distribution = .fillEqually
  4508. // Match date-row spacing so each date column aligns under weekday labels.
  4509. schedulerWeekdaysRow.spacing = 6
  4510. let weekdaySymbols = ["S", "M", "T", "W", "T", "F", "S"]
  4511. for symbol in weekdaySymbols {
  4512. let weekdayLabel = makeLabel(symbol, size: 12, color: mutedText, weight: .medium, centered: true)
  4513. schedulerWeekdaysRow.addArrangedSubview(weekdayLabel)
  4514. }
  4515. let schedulerCalendarGrid = NSStackView()
  4516. schedulerCalendarGrid.orientation = .vertical
  4517. schedulerCalendarGrid.distribution = .fillEqually
  4518. schedulerCalendarGrid.alignment = .leading
  4519. schedulerCalendarGrid.spacing = 6
  4520. let schedulerRightTopRow = NSStackView()
  4521. schedulerRightTopRow.orientation = .horizontal
  4522. schedulerRightTopRow.alignment = .centerY
  4523. schedulerRightTopRow.spacing = 10
  4524. let schedulerSelectedDateLabel = makeLabel("-", size: 30, color: primaryText, weight: .semibold, centered: false)
  4525. let schedulerTodayButton = makeMeetingsDayChipButton(title: "Today", symbol: "calendar", action: #selector(schedulerTodayTapped))
  4526. schedulerTodayButton.toolTip = "Jump to today"
  4527. [schedulerTodayButton].forEach { schedulerRightTopRow.addArrangedSubview($0) }
  4528. let schedulerMeetingsHeader = makeLabel("Scheduled meetings", size: 13, color: secondaryText, weight: .medium, centered: false)
  4529. let schedulerMeetingsScroll = NSScrollView()
  4530. schedulerMeetingsScroll.drawsBackground = false
  4531. schedulerMeetingsScroll.hasVerticalScroller = true
  4532. schedulerMeetingsScroll.hasHorizontalScroller = false
  4533. schedulerMeetingsScroll.autohidesScrollers = true
  4534. let schedulerMeetingsDocument = FlippedView()
  4535. let schedulerMeetingsStack = NSStackView()
  4536. schedulerMeetingsStack.orientation = .vertical
  4537. schedulerMeetingsStack.spacing = 12
  4538. schedulerMeetingsStack.alignment = .leading
  4539. schedulerMeetingsStack.setContentHuggingPriority(.required, for: .vertical)
  4540. schedulerMeetingsStack.setContentCompressionResistancePriority(.required, for: .vertical)
  4541. schedulerMeetingsScroll.documentView = schedulerMeetingsDocument
  4542. schedulerMeetingsDocument.addSubview(schedulerMeetingsStack)
  4543. let schedulerEmptyLabel = makeLabel("No meetings scheduled for this date.", size: 15, color: secondaryText, weight: .regular, centered: true)
  4544. schedulerEmptyLabel.isHidden = true
  4545. let contentColumn = NSView()
  4546. contentColumn.translatesAutoresizingMaskIntoConstraints = false
  4547. content.addSubview(topBar)
  4548. content.addSubview(topBarDivider)
  4549. content.addSubview(contentColumn)
  4550. [brandStack, searchRow, rightTopBarCluster, searchPill, searchField, searchIcon, searchHintLabel].forEach {
  4551. $0.translatesAutoresizingMaskIntoConstraints = false
  4552. }
  4553. [brandStack].forEach {
  4554. shell.addSubview($0)
  4555. }
  4556. [searchRow, rightTopBarCluster].forEach {
  4557. topBar.addSubview($0)
  4558. }
  4559. [searchIcon, searchField, searchHintLabel].forEach {
  4560. searchPill.addSubview($0)
  4561. }
  4562. [timeTitle, dateTitle, actions, panel, panelHeader, meetingsStatus, meetingsDayNav, noMeeting, meetingsScrollView, refreshMeetingsButton, placeholder, settingsView, meetingsPageView, schedulerRoot].forEach {
  4563. $0.translatesAutoresizingMaskIntoConstraints = false
  4564. contentColumn.addSubview($0)
  4565. }
  4566. [schedulerSplit, schedulerCalendarPanel, schedulerMeetingsPanel, schedulerCalendarTopRow, schedulerConnectLabel, schedulerPlusButton, schedulerMonthRow, schedulerWeekdaysRow, schedulerCalendarGrid, schedulerRightTopRow, schedulerSelectedDateLabel, schedulerTodayButton, schedulerMeetingsHeader, schedulerMeetingsScroll, schedulerEmptyLabel, schedulerMeetingsDocument, schedulerMeetingsStack].forEach {
  4567. $0.translatesAutoresizingMaskIntoConstraints = false
  4568. }
  4569. schedulerRoot.addSubview(schedulerSplit)
  4570. [schedulerCalendarPanel, schedulerMeetingsPanel].forEach { schedulerSplit.addArrangedSubview($0) }
  4571. [schedulerCalendarTopRow, schedulerMonthRow, schedulerWeekdaysRow, schedulerCalendarGrid].forEach { schedulerCalendarPanel.addSubview($0) }
  4572. schedulerCalendarTopRow.addArrangedSubview(schedulerConnectLabel)
  4573. schedulerCalendarTopRow.addArrangedSubview(NSView())
  4574. schedulerCalendarTopRow.addArrangedSubview(schedulerPlusButton)
  4575. [schedulerSelectedDateLabel, schedulerRightTopRow, schedulerMeetingsHeader, schedulerMeetingsScroll, schedulerEmptyLabel].forEach { schedulerMeetingsPanel.addSubview($0) }
  4576. topBar.translatesAutoresizingMaskIntoConstraints = false
  4577. topBarDivider.translatesAutoresizingMaskIntoConstraints = false
  4578. meetingsDocument.translatesAutoresizingMaskIntoConstraints = false
  4579. meetingsStack.translatesAutoresizingMaskIntoConstraints = false
  4580. meetingsScrollView.documentView = meetingsDocument
  4581. meetingsDocument.addSubview(meetingsStack)
  4582. let searchRowCenterX = searchRow.centerXAnchor.constraint(equalTo: topBar.centerXAnchor)
  4583. searchRowCenterX.priority = .defaultHigh
  4584. let brandLeading = brandStack.leadingAnchor.constraint(equalTo: shell.leadingAnchor, constant: brandLeadingInset)
  4585. brandLeadingConstraint = brandLeading
  4586. NSLayoutConstraint.activate([
  4587. brandLeading,
  4588. brandStack.trailingAnchor.constraint(lessThanOrEqualTo: searchRow.leadingAnchor, constant: -12),
  4589. brandStack.centerYAnchor.constraint(equalTo: chromeHeader.centerYAnchor, constant: -1),
  4590. topBar.topAnchor.constraint(equalTo: content.topAnchor),
  4591. topBar.leadingAnchor.constraint(equalTo: content.leadingAnchor),
  4592. topBar.trailingAnchor.constraint(equalTo: content.trailingAnchor),
  4593. topBar.heightAnchor.constraint(equalToConstant: 56),
  4594. topBarDivider.topAnchor.constraint(equalTo: topBar.bottomAnchor),
  4595. topBarDivider.leadingAnchor.constraint(equalTo: content.leadingAnchor),
  4596. topBarDivider.trailingAnchor.constraint(equalTo: content.trailingAnchor),
  4597. topBarDivider.heightAnchor.constraint(equalToConstant: 1),
  4598. contentColumn.topAnchor.constraint(equalTo: topBarDivider.bottomAnchor, constant: 14),
  4599. contentColumn.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -10),
  4600. contentColumn.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 8),
  4601. contentColumn.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -8),
  4602. searchRowCenterX,
  4603. searchRow.centerYAnchor.constraint(equalTo: topBar.centerYAnchor),
  4604. searchRow.leadingAnchor.constraint(greaterThanOrEqualTo: topBar.leadingAnchor, constant: 40),
  4605. searchRow.leadingAnchor.constraint(greaterThanOrEqualTo: brandStack.trailingAnchor, constant: 16),
  4606. searchRow.trailingAnchor.constraint(lessThanOrEqualTo: rightTopBarCluster.leadingAnchor, constant: -12),
  4607. rightTopBarCluster.trailingAnchor.constraint(equalTo: topBar.trailingAnchor, constant: -12),
  4608. rightTopBarCluster.centerYAnchor.constraint(equalTo: topBar.centerYAnchor),
  4609. searchPill.heightAnchor.constraint(equalToConstant: 32),
  4610. searchPill.widthAnchor.constraint(equalToConstant: 320),
  4611. searchIcon.leadingAnchor.constraint(equalTo: searchPill.leadingAnchor, constant: 12),
  4612. searchIcon.centerYAnchor.constraint(equalTo: searchPill.centerYAnchor),
  4613. searchIcon.widthAnchor.constraint(equalToConstant: 16),
  4614. searchIcon.heightAnchor.constraint(equalToConstant: 16),
  4615. searchHintLabel.centerXAnchor.constraint(equalTo: searchPill.centerXAnchor),
  4616. searchHintLabel.centerYAnchor.constraint(equalTo: searchPill.centerYAnchor),
  4617. searchHintLabel.leadingAnchor.constraint(greaterThanOrEqualTo: searchIcon.trailingAnchor, constant: 8),
  4618. searchField.leadingAnchor.constraint(equalTo: searchIcon.trailingAnchor, constant: 8),
  4619. searchField.trailingAnchor.constraint(equalTo: searchPill.trailingAnchor, constant: -10),
  4620. searchField.centerYAnchor.constraint(equalTo: searchPill.centerYAnchor),
  4621. authButton.heightAnchor.constraint(equalToConstant: 30),
  4622. timeTitle.topAnchor.constraint(equalTo: contentColumn.topAnchor, constant: 20),
  4623. timeTitle.centerXAnchor.constraint(equalTo: contentColumn.centerXAnchor),
  4624. dateTitle.topAnchor.constraint(equalTo: timeTitle.bottomAnchor, constant: 6),
  4625. dateTitle.centerXAnchor.constraint(equalTo: contentColumn.centerXAnchor),
  4626. actions.topAnchor.constraint(equalTo: dateTitle.bottomAnchor, constant: 28),
  4627. actions.centerXAnchor.constraint(equalTo: contentColumn.centerXAnchor),
  4628. actions.leadingAnchor.constraint(greaterThanOrEqualTo: contentColumn.leadingAnchor, constant: 12),
  4629. actions.trailingAnchor.constraint(lessThanOrEqualTo: contentColumn.trailingAnchor, constant: -12),
  4630. actions.heightAnchor.constraint(equalToConstant: 100),
  4631. panel.topAnchor.constraint(equalTo: actions.bottomAnchor, constant: 18),
  4632. panel.centerXAnchor.constraint(equalTo: contentColumn.centerXAnchor),
  4633. panel.widthAnchor.constraint(equalToConstant: 610),
  4634. panel.leadingAnchor.constraint(greaterThanOrEqualTo: contentColumn.leadingAnchor, constant: 6),
  4635. panel.trailingAnchor.constraint(lessThanOrEqualTo: contentColumn.trailingAnchor, constant: -6),
  4636. panel.heightAnchor.constraint(greaterThanOrEqualToConstant: 280),
  4637. panel.bottomAnchor.constraint(equalTo: contentColumn.bottomAnchor, constant: -14),
  4638. panelHeader.topAnchor.constraint(equalTo: panel.topAnchor, constant: 20),
  4639. panelHeader.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 16),
  4640. meetingsStatus.centerYAnchor.constraint(equalTo: panelHeader.centerYAnchor),
  4641. meetingsDayNav.centerYAnchor.constraint(equalTo: panelHeader.centerYAnchor),
  4642. meetingsDayNav.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -16),
  4643. meetingsStatus.trailingAnchor.constraint(equalTo: meetingsDayNav.leadingAnchor, constant: -10),
  4644. noMeeting.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 18),
  4645. noMeeting.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -18),
  4646. noMeeting.centerYAnchor.constraint(equalTo: panel.centerYAnchor),
  4647. meetingsScrollView.topAnchor.constraint(equalTo: panelHeader.bottomAnchor, constant: 12),
  4648. meetingsScrollView.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 14),
  4649. meetingsScrollView.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -14),
  4650. meetingsScrollView.bottomAnchor.constraint(equalTo: refreshMeetingsButton.topAnchor, constant: -14),
  4651. meetingsDocument.widthAnchor.constraint(equalTo: meetingsScrollView.contentView.widthAnchor),
  4652. meetingsStack.topAnchor.constraint(equalTo: meetingsDocument.topAnchor),
  4653. meetingsStack.leadingAnchor.constraint(equalTo: meetingsDocument.leadingAnchor),
  4654. meetingsStack.trailingAnchor.constraint(equalTo: meetingsDocument.trailingAnchor),
  4655. meetingsStack.bottomAnchor.constraint(lessThanOrEqualTo: meetingsDocument.bottomAnchor),
  4656. refreshMeetingsButton.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 14),
  4657. refreshMeetingsButton.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -14),
  4658. refreshMeetingsButton.bottomAnchor.constraint(equalTo: panel.bottomAnchor, constant: -12),
  4659. refreshMeetingsButton.heightAnchor.constraint(equalToConstant: 40),
  4660. placeholder.centerXAnchor.constraint(equalTo: contentColumn.centerXAnchor),
  4661. placeholder.centerYAnchor.constraint(equalTo: contentColumn.centerYAnchor),
  4662. settingsView.topAnchor.constraint(equalTo: contentColumn.topAnchor),
  4663. settingsView.leadingAnchor.constraint(equalTo: contentColumn.leadingAnchor),
  4664. settingsView.trailingAnchor.constraint(equalTo: contentColumn.trailingAnchor),
  4665. settingsView.bottomAnchor.constraint(equalTo: contentColumn.bottomAnchor),
  4666. meetingsPageView.topAnchor.constraint(equalTo: contentColumn.topAnchor),
  4667. meetingsPageView.leadingAnchor.constraint(equalTo: contentColumn.leadingAnchor),
  4668. meetingsPageView.trailingAnchor.constraint(equalTo: contentColumn.trailingAnchor),
  4669. meetingsPageView.bottomAnchor.constraint(equalTo: contentColumn.bottomAnchor),
  4670. schedulerRoot.topAnchor.constraint(equalTo: contentColumn.topAnchor, constant: 8),
  4671. schedulerRoot.leadingAnchor.constraint(equalTo: contentColumn.leadingAnchor, constant: 8),
  4672. schedulerRoot.trailingAnchor.constraint(equalTo: contentColumn.trailingAnchor, constant: -8),
  4673. schedulerRoot.bottomAnchor.constraint(equalTo: contentColumn.bottomAnchor, constant: -8),
  4674. schedulerSplit.topAnchor.constraint(equalTo: schedulerRoot.topAnchor),
  4675. schedulerSplit.leadingAnchor.constraint(equalTo: schedulerRoot.leadingAnchor),
  4676. schedulerSplit.trailingAnchor.constraint(equalTo: schedulerRoot.trailingAnchor),
  4677. schedulerSplit.bottomAnchor.constraint(equalTo: schedulerRoot.bottomAnchor),
  4678. schedulerCalendarTopRow.topAnchor.constraint(equalTo: schedulerCalendarPanel.topAnchor, constant: 18),
  4679. schedulerCalendarTopRow.leadingAnchor.constraint(equalTo: schedulerCalendarPanel.leadingAnchor, constant: 16),
  4680. schedulerCalendarTopRow.trailingAnchor.constraint(equalTo: schedulerCalendarPanel.trailingAnchor, constant: -16),
  4681. schedulerMonthRow.topAnchor.constraint(equalTo: schedulerCalendarTopRow.bottomAnchor, constant: 18),
  4682. schedulerMonthRow.leadingAnchor.constraint(equalTo: schedulerCalendarPanel.leadingAnchor, constant: 16),
  4683. schedulerMonthRow.trailingAnchor.constraint(lessThanOrEqualTo: schedulerCalendarPanel.trailingAnchor, constant: -16),
  4684. schedulerWeekdaysRow.topAnchor.constraint(equalTo: schedulerMonthRow.bottomAnchor, constant: 14),
  4685. schedulerWeekdaysRow.leadingAnchor.constraint(equalTo: schedulerCalendarPanel.leadingAnchor, constant: 16),
  4686. schedulerWeekdaysRow.trailingAnchor.constraint(equalTo: schedulerCalendarPanel.trailingAnchor, constant: -16),
  4687. schedulerCalendarGrid.topAnchor.constraint(equalTo: schedulerWeekdaysRow.bottomAnchor, constant: 10),
  4688. schedulerCalendarGrid.leadingAnchor.constraint(equalTo: schedulerCalendarPanel.leadingAnchor, constant: 16),
  4689. schedulerCalendarGrid.trailingAnchor.constraint(equalTo: schedulerCalendarPanel.trailingAnchor, constant: -16),
  4690. schedulerCalendarGrid.bottomAnchor.constraint(equalTo: schedulerCalendarPanel.bottomAnchor, constant: -16),
  4691. schedulerCalendarGrid.heightAnchor.constraint(greaterThanOrEqualToConstant: 280),
  4692. schedulerSelectedDateLabel.topAnchor.constraint(equalTo: schedulerMeetingsPanel.topAnchor, constant: 18),
  4693. schedulerSelectedDateLabel.leadingAnchor.constraint(equalTo: schedulerMeetingsPanel.leadingAnchor, constant: 16),
  4694. schedulerSelectedDateLabel.trailingAnchor.constraint(lessThanOrEqualTo: schedulerRightTopRow.leadingAnchor, constant: -10),
  4695. schedulerRightTopRow.centerYAnchor.constraint(equalTo: schedulerSelectedDateLabel.centerYAnchor),
  4696. schedulerRightTopRow.trailingAnchor.constraint(equalTo: schedulerMeetingsPanel.trailingAnchor, constant: -16),
  4697. schedulerMeetingsHeader.topAnchor.constraint(equalTo: schedulerSelectedDateLabel.bottomAnchor, constant: 12),
  4698. schedulerMeetingsHeader.leadingAnchor.constraint(equalTo: schedulerMeetingsPanel.leadingAnchor, constant: 16),
  4699. schedulerMeetingsHeader.trailingAnchor.constraint(equalTo: schedulerMeetingsPanel.trailingAnchor, constant: -16),
  4700. schedulerMeetingsScroll.topAnchor.constraint(equalTo: schedulerMeetingsHeader.bottomAnchor, constant: 10),
  4701. schedulerMeetingsScroll.leadingAnchor.constraint(equalTo: schedulerMeetingsPanel.leadingAnchor, constant: 14),
  4702. schedulerMeetingsScroll.trailingAnchor.constraint(equalTo: schedulerMeetingsPanel.trailingAnchor, constant: -14),
  4703. schedulerMeetingsScroll.bottomAnchor.constraint(equalTo: schedulerMeetingsPanel.bottomAnchor, constant: -14),
  4704. schedulerMeetingsDocument.widthAnchor.constraint(equalTo: schedulerMeetingsScroll.contentView.widthAnchor),
  4705. schedulerMeetingsStack.topAnchor.constraint(equalTo: schedulerMeetingsDocument.topAnchor),
  4706. schedulerMeetingsStack.leadingAnchor.constraint(equalTo: schedulerMeetingsDocument.leadingAnchor),
  4707. schedulerMeetingsStack.trailingAnchor.constraint(equalTo: schedulerMeetingsDocument.trailingAnchor),
  4708. schedulerMeetingsStack.bottomAnchor.constraint(lessThanOrEqualTo: schedulerMeetingsDocument.bottomAnchor),
  4709. schedulerEmptyLabel.centerXAnchor.constraint(equalTo: schedulerMeetingsPanel.centerXAnchor),
  4710. schedulerEmptyLabel.centerYAnchor.constraint(equalTo: schedulerMeetingsPanel.centerYAnchor),
  4711. searchOverlay.leadingAnchor.constraint(equalTo: root.leadingAnchor),
  4712. searchOverlay.trailingAnchor.constraint(equalTo: root.trailingAnchor),
  4713. searchOverlay.topAnchor.constraint(equalTo: root.topAnchor),
  4714. searchOverlay.bottomAnchor.constraint(equalTo: root.bottomAnchor),
  4715. searchPanel.centerXAnchor.constraint(equalTo: searchOverlay.centerXAnchor),
  4716. searchPanel.centerYAnchor.constraint(equalTo: searchOverlay.centerYAnchor),
  4717. searchPanel.widthAnchor.constraint(equalToConstant: 640),
  4718. searchPanel.heightAnchor.constraint(equalToConstant: 460),
  4719. popupTitle.topAnchor.constraint(equalTo: searchPanel.topAnchor, constant: 18),
  4720. popupTitle.leadingAnchor.constraint(equalTo: searchPanel.leadingAnchor, constant: 18),
  4721. popupTitle.trailingAnchor.constraint(equalTo: searchPanel.trailingAnchor, constant: -18),
  4722. popupSubtitle.topAnchor.constraint(equalTo: popupTitle.bottomAnchor, constant: 4),
  4723. popupSubtitle.leadingAnchor.constraint(equalTo: popupTitle.leadingAnchor),
  4724. popupSubtitle.trailingAnchor.constraint(equalTo: popupTitle.trailingAnchor),
  4725. popupSearchChrome.topAnchor.constraint(equalTo: popupSubtitle.bottomAnchor, constant: 12),
  4726. popupSearchChrome.leadingAnchor.constraint(equalTo: searchPanel.leadingAnchor, constant: 18),
  4727. popupSearchChrome.trailingAnchor.constraint(equalTo: searchPanel.trailingAnchor, constant: -18),
  4728. popupSearchChrome.heightAnchor.constraint(equalToConstant: 40),
  4729. popupSearchIcon.leadingAnchor.constraint(equalTo: popupSearchChrome.leadingAnchor, constant: 12),
  4730. popupSearchIcon.centerYAnchor.constraint(equalTo: popupSearchChrome.centerYAnchor),
  4731. popupSearchIcon.widthAnchor.constraint(equalToConstant: 16),
  4732. popupSearchIcon.heightAnchor.constraint(equalToConstant: 16),
  4733. popupSearchField.leadingAnchor.constraint(equalTo: popupSearchIcon.trailingAnchor, constant: 8),
  4734. popupSearchField.trailingAnchor.constraint(equalTo: popupSearchChrome.trailingAnchor, constant: -12),
  4735. popupSearchField.centerYAnchor.constraint(equalTo: popupSearchChrome.centerYAnchor),
  4736. popupResultsScroll.topAnchor.constraint(equalTo: popupSearchChrome.bottomAnchor, constant: 12),
  4737. popupResultsScroll.leadingAnchor.constraint(equalTo: searchPanel.leadingAnchor, constant: 18),
  4738. popupResultsScroll.trailingAnchor.constraint(equalTo: searchPanel.trailingAnchor, constant: -18),
  4739. popupResultsScroll.bottomAnchor.constraint(equalTo: searchPanel.bottomAnchor, constant: -18),
  4740. popupResultsStack.topAnchor.constraint(equalTo: popupResultsDocument.topAnchor),
  4741. popupResultsStack.leadingAnchor.constraint(equalTo: popupResultsDocument.leadingAnchor),
  4742. popupResultsStack.trailingAnchor.constraint(equalTo: popupResultsDocument.trailingAnchor),
  4743. popupResultsStack.bottomAnchor.constraint(lessThanOrEqualTo: popupResultsDocument.bottomAnchor),
  4744. popupResultsStack.widthAnchor.constraint(equalTo: popupResultsScroll.contentView.widthAnchor),
  4745. popupEmpty.centerXAnchor.constraint(equalTo: popupResultsScroll.centerXAnchor),
  4746. popupEmpty.centerYAnchor.constraint(equalTo: popupResultsScroll.centerYAnchor)
  4747. ])
  4748. timeLabel = timeTitle
  4749. dateLabel = dateTitle
  4750. homeWelcomeLabel = nil
  4751. homeTimeLabelView = timeTitle
  4752. homeDateLabelView = dateTitle
  4753. homeActionsRow = actions
  4754. homeMeetingsPanel = panel
  4755. homePlaceholderLabel = placeholder
  4756. homeSettingsView = settingsView
  4757. meetingsPageRootView = meetingsPageView
  4758. schedulerRootView = schedulerRoot
  4759. self.schedulerMonthLabel = schedulerMonthLabel
  4760. schedulerCalendarGridStack = schedulerCalendarGrid
  4761. self.schedulerSelectedDateLabel = schedulerSelectedDateLabel
  4762. schedulerMeetingsListStack = schedulerMeetingsStack
  4763. schedulerMeetingsEmptyLabel = schedulerEmptyLabel
  4764. self.schedulerTodayButton = schedulerTodayButton
  4765. meetingsDayHeaderLabel = panelHeader
  4766. meetingsListStack = meetingsStack
  4767. meetingsStatusLabel = meetingsStatus
  4768. emptyMeetingLabel = noMeeting
  4769. meetingsPrevDayButton = prevDayButton
  4770. meetingsTodayButton = todayButton
  4771. meetingsNextDayButton = nextDayButton
  4772. observeMeetingsScrollEdges(in: meetingsScrollView)
  4773. updateClock()
  4774. updateMeetingsDayUI()
  4775. applyFilteredMeetings()
  4776. applyMeetingsPageFilters(resetVisible: true)
  4777. renderSchedulerCalendarGrid()
  4778. updateSchedulerDateHeader()
  4779. applySchedulerMeetingsForSelectedDate()
  4780. homeSearchField = searchField
  4781. homeSearchPill = searchPill
  4782. meetingSearchOverlayView = searchOverlay
  4783. meetingSearchPanelView = searchPanel
  4784. meetingSearchField = popupSearchField
  4785. meetingSearchResultsStack = popupResultsStack
  4786. meetingSearchEmptyLabel = popupEmpty
  4787. removeMeetingSearchObserver()
  4788. meetingSearchTextObserver = NotificationCenter.default.addObserver(
  4789. forName: NSControl.textDidChangeNotification,
  4790. object: popupSearchField,
  4791. queue: .main
  4792. ) { [weak self] _ in
  4793. guard let self else { return }
  4794. self.applyGlobalMeetingSearch(query: popupSearchField.stringValue)
  4795. }
  4796. applyGlobalMeetingSearch(query: "")
  4797. topBarAuthButtonWidthConstraint = authButton.widthAnchor.constraint(equalToConstant: 120)
  4798. topBarAuthButtonWidthConstraint?.isActive = true
  4799. updateTopBarAuthButton()
  4800. applyAuthenticationUIState(triggerInitialRefresh: false)
  4801. updatePremiumLockedSections()
  4802. return root
  4803. }
  4804. @objc private func premiumLockedAreaTapped(_ sender: NSButton) {
  4805. showPaywall()
  4806. }
  4807. @MainActor
  4808. private func updatePremiumLockedSections() {
  4809. let shouldLock = isUserLoggedIn() && (storeKitCoordinator.hasPremiumAccess == false)
  4810. homePremiumLockOverlay = updatePremiumLockOverlay(
  4811. existing: homePremiumLockOverlay,
  4812. in: homeMeetingsPanel,
  4813. locked: shouldLock,
  4814. message: "Upgrade to Premium to access meetings"
  4815. )
  4816. meetingsPremiumLockOverlay = updatePremiumLockOverlay(
  4817. existing: meetingsPremiumLockOverlay,
  4818. in: meetingsPageRootView,
  4819. locked: shouldLock,
  4820. message: "Upgrade to Premium to unlock Meetings"
  4821. )
  4822. schedulerPremiumLockOverlay = updatePremiumLockOverlay(
  4823. existing: schedulerPremiumLockOverlay,
  4824. in: schedulerRootView,
  4825. locked: shouldLock,
  4826. message: "Upgrade to Premium to unlock Scheduler"
  4827. )
  4828. }
  4829. @MainActor
  4830. private func updatePremiumLockOverlay(
  4831. existing: NSButton?,
  4832. in target: NSView?,
  4833. locked: Bool,
  4834. message: String
  4835. ) -> NSButton? {
  4836. guard let target else { return existing }
  4837. let button: NSButton
  4838. if let existing, existing.superview === target {
  4839. button = existing
  4840. } else {
  4841. let created = NSButton(title: message, target: self, action: #selector(premiumLockedAreaTapped(_:)))
  4842. created.translatesAutoresizingMaskIntoConstraints = false
  4843. created.isBordered = false
  4844. created.bezelStyle = .shadowlessSquare
  4845. created.alignment = .center
  4846. created.wantsLayer = true
  4847. created.layer?.cornerRadius = 12
  4848. created.layer?.masksToBounds = true
  4849. created.layer?.backgroundColor = NSColor.clear.cgColor
  4850. created.font = .systemFont(ofSize: 15, weight: .semibold)
  4851. created.contentTintColor = .white
  4852. let blur = NSVisualEffectView()
  4853. blur.translatesAutoresizingMaskIntoConstraints = false
  4854. blur.material = .hudWindow
  4855. blur.blendingMode = .withinWindow
  4856. blur.state = .active
  4857. created.addSubview(blur, positioned: .below, relativeTo: nil)
  4858. let dim = NSView()
  4859. dim.translatesAutoresizingMaskIntoConstraints = false
  4860. dim.wantsLayer = true
  4861. dim.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.72).cgColor
  4862. created.addSubview(dim, positioned: .above, relativeTo: blur)
  4863. target.addSubview(created)
  4864. NSLayoutConstraint.activate([
  4865. created.leadingAnchor.constraint(equalTo: target.leadingAnchor),
  4866. created.trailingAnchor.constraint(equalTo: target.trailingAnchor),
  4867. created.topAnchor.constraint(equalTo: target.topAnchor),
  4868. created.bottomAnchor.constraint(equalTo: target.bottomAnchor),
  4869. blur.leadingAnchor.constraint(equalTo: created.leadingAnchor),
  4870. blur.trailingAnchor.constraint(equalTo: created.trailingAnchor),
  4871. blur.topAnchor.constraint(equalTo: created.topAnchor),
  4872. blur.bottomAnchor.constraint(equalTo: created.bottomAnchor),
  4873. dim.leadingAnchor.constraint(equalTo: created.leadingAnchor),
  4874. dim.trailingAnchor.constraint(equalTo: created.trailingAnchor),
  4875. dim.topAnchor.constraint(equalTo: created.topAnchor),
  4876. dim.bottomAnchor.constraint(equalTo: created.bottomAnchor)
  4877. ])
  4878. button = created
  4879. }
  4880. let blurView = button.subviews.compactMap { $0 as? NSVisualEffectView }.first
  4881. let dimView = button.subviews.first { $0 is NSVisualEffectView == false }
  4882. if palette.isDarkMode {
  4883. blurView?.material = .hudWindow
  4884. button.contentTintColor = .white
  4885. dimView?.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.72).cgColor
  4886. } else {
  4887. // Lighter, cleaner lock treatment in light mode.
  4888. blurView?.material = .popover
  4889. button.contentTintColor = NSColor(calibratedWhite: 0.09, alpha: 1.0)
  4890. dimView?.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.38).cgColor
  4891. }
  4892. button.title = message
  4893. button.isHidden = locked == false
  4894. return button
  4895. }
  4896. private func makeMeetingsPageView() -> NSView {
  4897. let root = NSView()
  4898. root.wantsLayer = true
  4899. root.layer?.backgroundColor = NSColor.clear.cgColor
  4900. let container = NSView()
  4901. container.translatesAutoresizingMaskIntoConstraints = false
  4902. container.wantsLayer = true
  4903. container.layer?.backgroundColor = secondaryCardBackground.withAlphaComponent(0.94).cgColor
  4904. container.layer?.cornerRadius = 16
  4905. container.layer?.borderWidth = 1
  4906. container.layer?.borderColor = palette.inputBorder.cgColor
  4907. root.addSubview(container)
  4908. let title = makeLabel("Meetings", size: 28, color: primaryText, weight: .bold, centered: false)
  4909. let subtitle = makeLabel("All scheduled Zoom meetings", size: 13, color: secondaryText, weight: .regular, centered: false)
  4910. let count = makeLabel("0 scheduled", size: 12, color: secondaryText, weight: .medium, centered: false)
  4911. let titleStack = NSStackView(views: [title, subtitle, count])
  4912. titleStack.orientation = .vertical
  4913. titleStack.spacing = 4
  4914. titleStack.alignment = .leading
  4915. titleStack.translatesAutoresizingMaskIntoConstraints = false
  4916. let categoryFilter = NSPopUpButton()
  4917. categoryFilter.translatesAutoresizingMaskIntoConstraints = false
  4918. categoryFilter.addItems(withTitles: ["All", "Today", "This week", "This month", "Custom range"])
  4919. categoryFilter.selectItem(withTitle: "All")
  4920. categoryFilter.target = self
  4921. categoryFilter.action = #selector(meetingsPageRangePresetChanged(_:))
  4922. let fromDatePicker = NSDatePicker()
  4923. fromDatePicker.translatesAutoresizingMaskIntoConstraints = false
  4924. fromDatePicker.datePickerStyle = .textFieldAndStepper
  4925. fromDatePicker.datePickerMode = .single
  4926. fromDatePicker.datePickerElements = [.yearMonthDay]
  4927. fromDatePicker.dateValue = Date()
  4928. fromDatePicker.font = .systemFont(ofSize: 13, weight: .regular)
  4929. let toDatePicker = NSDatePicker()
  4930. toDatePicker.translatesAutoresizingMaskIntoConstraints = false
  4931. toDatePicker.datePickerStyle = .textFieldAndStepper
  4932. toDatePicker.datePickerMode = .single
  4933. toDatePicker.datePickerElements = [.yearMonthDay]
  4934. toDatePicker.dateValue = Date()
  4935. toDatePicker.font = .systemFont(ofSize: 13, weight: .regular)
  4936. let applyFiltersButton = NSButton(title: "Apply", target: self, action: #selector(meetingsPageApplyFiltersTapped))
  4937. applyFiltersButton.translatesAutoresizingMaskIntoConstraints = false
  4938. applyFiltersButton.isBordered = false
  4939. applyFiltersButton.wantsLayer = true
  4940. applyFiltersButton.layer?.backgroundColor = accentBlue.cgColor
  4941. applyFiltersButton.layer?.cornerRadius = 10
  4942. applyFiltersButton.contentTintColor = .white
  4943. applyFiltersButton.font = .systemFont(ofSize: 13, weight: .semibold)
  4944. let resetFiltersButton = NSButton(title: "Reset", target: self, action: #selector(meetingsPageResetFiltersTapped))
  4945. resetFiltersButton.translatesAutoresizingMaskIntoConstraints = false
  4946. resetFiltersButton.isBordered = false
  4947. resetFiltersButton.wantsLayer = true
  4948. resetFiltersButton.layer?.backgroundColor = palette.inputBackground.cgColor
  4949. resetFiltersButton.layer?.cornerRadius = 10
  4950. resetFiltersButton.layer?.borderWidth = 1
  4951. resetFiltersButton.layer?.borderColor = palette.inputBorder.cgColor
  4952. resetFiltersButton.contentTintColor = primaryText
  4953. resetFiltersButton.font = .systemFont(ofSize: 13, weight: .semibold)
  4954. let refreshButton = NSButton(title: "", target: self, action: #selector(refreshMeetingsTapped))
  4955. refreshButton.translatesAutoresizingMaskIntoConstraints = false
  4956. refreshButton.isBordered = false
  4957. refreshButton.wantsLayer = true
  4958. refreshButton.layer?.backgroundColor = palette.inputBackground.cgColor
  4959. refreshButton.layer?.cornerRadius = 10
  4960. refreshButton.layer?.borderWidth = 1
  4961. refreshButton.layer?.borderColor = palette.inputBorder.cgColor
  4962. refreshButton.contentTintColor = primaryText
  4963. refreshButton.font = .systemFont(ofSize: 13, weight: .semibold)
  4964. if let image = NSImage(systemSymbolName: "arrow.clockwise", accessibilityDescription: "Refresh") {
  4965. refreshButton.image = image
  4966. refreshButton.imagePosition = .imageOnly
  4967. }
  4968. let filterRow = NSStackView(views: [categoryFilter, fromDatePicker, toDatePicker, applyFiltersButton, resetFiltersButton, refreshButton])
  4969. filterRow.orientation = .horizontal
  4970. filterRow.spacing = 10
  4971. filterRow.alignment = .centerY
  4972. filterRow.translatesAutoresizingMaskIntoConstraints = false
  4973. let statusLabel = makeLabel("Showing 0 meetings", size: 12, color: secondaryText, weight: .medium, centered: false)
  4974. statusLabel.translatesAutoresizingMaskIntoConstraints = false
  4975. let listScroll = NSScrollView()
  4976. listScroll.translatesAutoresizingMaskIntoConstraints = false
  4977. listScroll.drawsBackground = false
  4978. listScroll.hasVerticalScroller = true
  4979. listScroll.hasHorizontalScroller = false
  4980. listScroll.autohidesScrollers = true
  4981. let listDocument = FlippedView()
  4982. listDocument.translatesAutoresizingMaskIntoConstraints = false
  4983. let listStack = NSStackView()
  4984. listStack.translatesAutoresizingMaskIntoConstraints = false
  4985. listStack.orientation = .vertical
  4986. listStack.spacing = 12
  4987. listStack.alignment = .leading
  4988. listStack.setContentHuggingPriority(.required, for: .vertical)
  4989. listStack.setContentCompressionResistancePriority(.required, for: .vertical)
  4990. listScroll.documentView = listDocument
  4991. listDocument.addSubview(listStack)
  4992. let empty = makeLabel("No meetings match the current filters.", size: 14, color: secondaryText, weight: .regular, centered: true)
  4993. empty.translatesAutoresizingMaskIntoConstraints = false
  4994. empty.isHidden = true
  4995. let loadMoreButton = NSButton(title: "", target: nil, action: nil)
  4996. loadMoreButton.translatesAutoresizingMaskIntoConstraints = false
  4997. loadMoreButton.isBordered = false
  4998. loadMoreButton.wantsLayer = true
  4999. loadMoreButton.layer?.backgroundColor = palette.inputBackground.cgColor
  5000. loadMoreButton.layer?.cornerRadius = 10
  5001. loadMoreButton.layer?.borderWidth = 1
  5002. loadMoreButton.layer?.borderColor = palette.inputBorder.cgColor
  5003. loadMoreButton.contentTintColor = primaryText
  5004. loadMoreButton.font = .systemFont(ofSize: 13, weight: .semibold)
  5005. loadMoreButton.isHidden = true
  5006. [titleStack, filterRow, statusLabel, listScroll, empty, loadMoreButton].forEach { container.addSubview($0) }
  5007. NSLayoutConstraint.activate([
  5008. container.topAnchor.constraint(equalTo: root.topAnchor, constant: 8),
  5009. container.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 8),
  5010. container.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -8),
  5011. container.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -8),
  5012. titleStack.topAnchor.constraint(equalTo: container.topAnchor, constant: 20),
  5013. titleStack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 18),
  5014. titleStack.trailingAnchor.constraint(lessThanOrEqualTo: container.trailingAnchor, constant: -18),
  5015. filterRow.topAnchor.constraint(equalTo: titleStack.bottomAnchor, constant: 14),
  5016. filterRow.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 18),
  5017. filterRow.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -18),
  5018. categoryFilter.widthAnchor.constraint(equalToConstant: 170),
  5019. fromDatePicker.widthAnchor.constraint(equalToConstant: 170),
  5020. toDatePicker.widthAnchor.constraint(equalToConstant: 170),
  5021. applyFiltersButton.widthAnchor.constraint(equalToConstant: 96),
  5022. resetFiltersButton.widthAnchor.constraint(equalToConstant: 96),
  5023. refreshButton.widthAnchor.constraint(equalToConstant: 44),
  5024. applyFiltersButton.heightAnchor.constraint(equalToConstant: 34),
  5025. resetFiltersButton.heightAnchor.constraint(equalToConstant: 34),
  5026. refreshButton.heightAnchor.constraint(equalToConstant: 34),
  5027. statusLabel.topAnchor.constraint(equalTo: filterRow.bottomAnchor, constant: 12),
  5028. statusLabel.leadingAnchor.constraint(equalTo: filterRow.leadingAnchor),
  5029. statusLabel.trailingAnchor.constraint(equalTo: filterRow.trailingAnchor),
  5030. listScroll.topAnchor.constraint(equalTo: statusLabel.bottomAnchor, constant: 10),
  5031. listScroll.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 16),
  5032. listScroll.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -16),
  5033. listScroll.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -14),
  5034. listDocument.widthAnchor.constraint(equalTo: listScroll.contentView.widthAnchor),
  5035. listStack.topAnchor.constraint(equalTo: listDocument.topAnchor),
  5036. listStack.leadingAnchor.constraint(equalTo: listDocument.leadingAnchor),
  5037. listStack.trailingAnchor.constraint(equalTo: listDocument.trailingAnchor),
  5038. // Pin bottom edge so document height grows with arranged subviews.
  5039. listStack.bottomAnchor.constraint(equalTo: listDocument.bottomAnchor),
  5040. empty.centerXAnchor.constraint(equalTo: listScroll.centerXAnchor),
  5041. empty.centerYAnchor.constraint(equalTo: listScroll.centerYAnchor),
  5042. loadMoreButton.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 16),
  5043. loadMoreButton.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -16),
  5044. loadMoreButton.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -14),
  5045. loadMoreButton.heightAnchor.constraint(equalToConstant: 1)
  5046. ])
  5047. meetingsPageCategoryFilter = categoryFilter
  5048. meetingsPageFromDatePicker = fromDatePicker
  5049. meetingsPageToDatePicker = toDatePicker
  5050. meetingsPageRefreshButton = refreshButton
  5051. meetingsPageCountLabel = count
  5052. meetingsPageStatusLabel = statusLabel
  5053. meetingsPageListStack = listStack
  5054. meetingsPageEmptyLabel = empty
  5055. meetingsPageScrollView = listScroll
  5056. meetingsPageLoadMoreButton = loadMoreButton
  5057. observeMeetingsPageInfiniteScroll(in: listScroll)
  5058. updateMeetingsDatePickersEnabledState()
  5059. return root
  5060. }
  5061. deinit {
  5062. premiumRatingPromptWorkItem?.cancel()
  5063. usageRatingPromptWorkItem?.cancel()
  5064. removeSearchFieldObserver()
  5065. removeMeetingSearchObserver()
  5066. clearMeetingsPageScrollObserver()
  5067. removeSearchShortcutMonitor()
  5068. for observer in windowFullScreenObservers {
  5069. NotificationCenter.default.removeObserver(observer)
  5070. }
  5071. windowFullScreenObservers.removeAll()
  5072. }
  5073. private func ensureUsageSessionStartDate() {
  5074. if UserDefaults.standard.object(forKey: usageSessionStartedAtKey) == nil {
  5075. UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: usageSessionStartedAtKey)
  5076. }
  5077. }
  5078. private func usageSessionStartedAt() -> Date {
  5079. let storedValue = UserDefaults.standard.double(forKey: usageSessionStartedAtKey)
  5080. if storedValue > 0 {
  5081. return Date(timeIntervalSince1970: storedValue)
  5082. }
  5083. let now = Date()
  5084. UserDefaults.standard.set(now.timeIntervalSince1970, forKey: usageSessionStartedAtKey)
  5085. return now
  5086. }
  5087. private func hasCompletedOrSeenRatingFlow() -> Bool {
  5088. UserDefaults.standard.bool(forKey: ratingCompletedKey)
  5089. }
  5090. private func scheduleUsageBasedRatingPromptIfNeeded() {
  5091. usageRatingPromptWorkItem?.cancel()
  5092. guard hasCompletedOrSeenRatingFlow() == false else { return }
  5093. let elapsed = Date().timeIntervalSince(usageSessionStartedAt())
  5094. let delay = max(0, usageRatingPromptDelay - elapsed)
  5095. let workItem = DispatchWorkItem { [weak self] in
  5096. self?.presentRatingPrompt(source: "usage")
  5097. }
  5098. usageRatingPromptWorkItem = workItem
  5099. DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
  5100. }
  5101. private func schedulePremiumRatingPromptIfNeeded() {
  5102. guard hasCompletedOrSeenRatingFlow() == false else { return }
  5103. guard premiumRatingPromptWorkItem == nil else { return }
  5104. let workItem = DispatchWorkItem { [weak self] in
  5105. self?.premiumRatingPromptWorkItem = nil
  5106. self?.presentRatingPrompt(source: "premium")
  5107. }
  5108. premiumRatingPromptWorkItem = workItem
  5109. DispatchQueue.main.asyncAfter(deadline: .now() + premiumRatingPromptDelay, execute: workItem)
  5110. }
  5111. private func presentRatingPrompt(source: String) {
  5112. guard hasCompletedOrSeenRatingFlow() == false else { return }
  5113. UserDefaults.standard.set(true, forKey: ratingPromptShownKey)
  5114. NSApp.activate(ignoringOtherApps: true)
  5115. let alert = NSAlert()
  5116. alert.alertStyle = .informational
  5117. alert.messageText = "Enjoying Zoom App?"
  5118. if source == "premium" {
  5119. alert.informativeText = "You unlocked Premium. If you're enjoying the app, please rate us."
  5120. } else {
  5121. alert.informativeText = "You've been using the app for a while. If it's helping you, please rate us."
  5122. }
  5123. alert.addButton(withTitle: "Rate Now")
  5124. alert.addButton(withTitle: "Later")
  5125. let response = alert.runModal()
  5126. if response == .alertFirstButtonReturn {
  5127. UserDefaults.standard.set(true, forKey: ratingCompletedKey)
  5128. SKStoreReviewController.requestReview()
  5129. }
  5130. }
  5131. private func startClock() {
  5132. clockTimer?.invalidate()
  5133. clockTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
  5134. self?.updateClock()
  5135. }
  5136. updateClock()
  5137. }
  5138. private func updateClock() {
  5139. let now = Date()
  5140. let timeFormatter = DateFormatter()
  5141. timeFormatter.dateFormat = "h:mm a"
  5142. let dateFormatter = DateFormatter()
  5143. dateFormatter.dateFormat = "EEEE, d MMM"
  5144. timeLabel?.stringValue = timeFormatter.string(from: now)
  5145. dateLabel?.stringValue = dateFormatter.string(from: now)
  5146. }
  5147. // MARK: - Shared UI
  5148. private func makeSidebar(items: [String], selected: String, style: SidebarStyle = .home) -> NSView {
  5149. let sidebar = NSView()
  5150. sidebar.wantsLayer = true
  5151. sidebar.layer?.backgroundColor = (style == .home ? chromeUnifiedBackground : sidebarBackground).cgColor
  5152. let stack = NSStackView()
  5153. stack.orientation = .vertical
  5154. stack.spacing = style == .home ? 12 : 16
  5155. stack.alignment = .centerX
  5156. stack.distribution = .fill
  5157. // Keep sidebar items pinned to the top; don't let extra height stretch/shift them.
  5158. stack.setContentHuggingPriority(.required, for: .vertical)
  5159. stack.setContentCompressionResistancePriority(.required, for: .vertical)
  5160. stack.translatesAutoresizingMaskIntoConstraints = false
  5161. sidebar.addSubview(stack)
  5162. for item in items {
  5163. let row = NSView()
  5164. row.translatesAutoresizingMaskIntoConstraints = false
  5165. row.wantsLayer = true
  5166. let selectedRow = item == selected
  5167. row.layer?.backgroundColor = selectedRow ? sidebarActiveBackground.withAlphaComponent(0.95).cgColor : NSColor.clear.cgColor
  5168. row.layer?.cornerRadius = style == .home ? 12 : 10
  5169. row.widthAnchor.constraint(equalToConstant: style == .home ? 68 : 70).isActive = true
  5170. // Prevent rows from stretching/collapsing when the window resizes.
  5171. row.setContentHuggingPriority(.required, for: .vertical)
  5172. row.setContentCompressionResistancePriority(.required, for: .vertical)
  5173. if style == .home {
  5174. // Must be tall enough for icon (26) + paddings + label without clipping.
  5175. row.heightAnchor.constraint(equalToConstant: 66).isActive = true
  5176. }
  5177. if style == .home {
  5178. let iconContainer = NSView()
  5179. iconContainer.translatesAutoresizingMaskIntoConstraints = false
  5180. row.addSubview(iconContainer)
  5181. let iconView = NSImageView()
  5182. iconView.translatesAutoresizingMaskIntoConstraints = false
  5183. iconView.contentTintColor = selectedRow ? primaryText : secondaryText
  5184. iconView.imageScaling = .scaleProportionallyUpOrDown
  5185. iconView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 20, weight: .regular)
  5186. iconView.image = NSImage(systemSymbolName: sidebarSymbolName(for: item, filled: selectedRow), accessibilityDescription: item)
  5187. iconContainer.addSubview(iconView)
  5188. let label = makeLabel(item, size: 10, color: selectedRow ? primaryText : secondaryText, weight: .regular, centered: true)
  5189. label.translatesAutoresizingMaskIntoConstraints = false
  5190. row.addSubview(label)
  5191. let hit = NSButton(title: "", target: self, action: #selector(homeSidebarItemTapped(_:)))
  5192. hit.identifier = NSUserInterfaceItemIdentifier(item)
  5193. hit.isBordered = false
  5194. hit.bezelStyle = .shadowlessSquare
  5195. hit.focusRingType = .none
  5196. hit.translatesAutoresizingMaskIntoConstraints = false
  5197. row.addSubview(hit, positioned: .above, relativeTo: nil)
  5198. NSLayoutConstraint.activate([
  5199. iconContainer.topAnchor.constraint(equalTo: row.topAnchor, constant: 9),
  5200. iconContainer.centerXAnchor.constraint(equalTo: row.centerXAnchor),
  5201. iconContainer.widthAnchor.constraint(equalToConstant: 26),
  5202. iconContainer.heightAnchor.constraint(equalToConstant: 26),
  5203. iconView.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor),
  5204. iconView.centerYAnchor.constraint(equalTo: iconContainer.centerYAnchor),
  5205. iconView.widthAnchor.constraint(equalToConstant: 22),
  5206. iconView.heightAnchor.constraint(equalToConstant: 22),
  5207. label.topAnchor.constraint(equalTo: iconContainer.bottomAnchor, constant: 6),
  5208. label.centerXAnchor.constraint(equalTo: row.centerXAnchor),
  5209. label.bottomAnchor.constraint(equalTo: row.bottomAnchor, constant: -8),
  5210. hit.leadingAnchor.constraint(equalTo: row.leadingAnchor),
  5211. hit.trailingAnchor.constraint(equalTo: row.trailingAnchor),
  5212. hit.topAnchor.constraint(equalTo: row.topAnchor),
  5213. hit.bottomAnchor.constraint(equalTo: row.bottomAnchor)
  5214. ])
  5215. homeSidebarRowViews[item] = row
  5216. homeSidebarIconViews[item] = iconView
  5217. homeSidebarLabels[item] = label
  5218. if item == "Hub" {
  5219. let badge = makeSidebarBadge(text: "1")
  5220. badge.translatesAutoresizingMaskIntoConstraints = false
  5221. iconContainer.addSubview(badge)
  5222. NSLayoutConstraint.activate([
  5223. badge.centerXAnchor.constraint(equalTo: iconContainer.trailingAnchor, constant: -2),
  5224. badge.centerYAnchor.constraint(equalTo: iconContainer.topAnchor, constant: 5)
  5225. ])
  5226. } else if item == "More" {
  5227. let dot = NSView()
  5228. dot.translatesAutoresizingMaskIntoConstraints = false
  5229. dot.wantsLayer = true
  5230. dot.layer?.backgroundColor = NSColor.systemRed.cgColor
  5231. dot.layer?.cornerRadius = 4
  5232. row.addSubview(dot)
  5233. NSLayoutConstraint.activate([
  5234. dot.widthAnchor.constraint(equalToConstant: 8),
  5235. dot.heightAnchor.constraint(equalToConstant: 8),
  5236. dot.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor),
  5237. dot.bottomAnchor.constraint(equalTo: iconContainer.topAnchor, constant: -4)
  5238. ])
  5239. }
  5240. } else {
  5241. let icon = makeLabel(selectedRow ? "⌂" : "◻︎", size: 15, color: primaryText, weight: .regular, centered: true)
  5242. let label = makeLabel(item, size: 11, color: selectedRow ? primaryText : secondaryText, weight: .regular, centered: true)
  5243. [icon, label].forEach {
  5244. $0.translatesAutoresizingMaskIntoConstraints = false
  5245. row.addSubview($0)
  5246. }
  5247. NSLayoutConstraint.activate([
  5248. icon.topAnchor.constraint(equalTo: row.topAnchor, constant: 10),
  5249. icon.centerXAnchor.constraint(equalTo: row.centerXAnchor),
  5250. label.topAnchor.constraint(equalTo: icon.bottomAnchor, constant: 5),
  5251. label.centerXAnchor.constraint(equalTo: row.centerXAnchor),
  5252. label.bottomAnchor.constraint(equalTo: row.bottomAnchor, constant: -8)
  5253. ])
  5254. }
  5255. stack.addArrangedSubview(row)
  5256. }
  5257. if style == .home {
  5258. let spacer = NSView()
  5259. spacer.translatesAutoresizingMaskIntoConstraints = false
  5260. // Keep Settings in a stable position (no vertical shifting on resize).
  5261. spacer.heightAnchor.constraint(equalToConstant: 12).isActive = true
  5262. spacer.setContentHuggingPriority(.required, for: .vertical)
  5263. spacer.setContentCompressionResistancePriority(.required, for: .vertical)
  5264. stack.addArrangedSubview(spacer)
  5265. let settingsRow = NSView()
  5266. settingsRow.translatesAutoresizingMaskIntoConstraints = false
  5267. settingsRow.wantsLayer = true
  5268. let settingsSelected = selected == "Settings"
  5269. settingsRow.layer?.backgroundColor = settingsSelected ? sidebarActiveBackground.withAlphaComponent(0.95).cgColor : NSColor.clear.cgColor
  5270. settingsRow.layer?.cornerRadius = 12
  5271. settingsRow.widthAnchor.constraint(equalToConstant: 68).isActive = true
  5272. settingsRow.heightAnchor.constraint(equalToConstant: 66).isActive = true
  5273. let iconContainer = NSView()
  5274. iconContainer.translatesAutoresizingMaskIntoConstraints = false
  5275. settingsRow.addSubview(iconContainer)
  5276. let iconView = NSImageView()
  5277. iconView.translatesAutoresizingMaskIntoConstraints = false
  5278. iconView.contentTintColor = settingsSelected ? primaryText : secondaryText
  5279. iconView.imageScaling = .scaleProportionallyUpOrDown
  5280. iconView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 20, weight: .regular)
  5281. let settingsSymbolPreferred = settingsSelected ? "gearshape.fill" : "gearshape"
  5282. iconView.image = NSImage(systemSymbolName: settingsSymbolPreferred, accessibilityDescription: "Settings")
  5283. ?? NSImage(systemSymbolName: "gearshape", accessibilityDescription: "Settings")
  5284. iconContainer.addSubview(iconView)
  5285. let label = makeLabel("Settings", size: 10, color: settingsSelected ? primaryText : secondaryText, weight: .regular, centered: true)
  5286. label.translatesAutoresizingMaskIntoConstraints = false
  5287. settingsRow.addSubview(label)
  5288. let hit = NSButton(title: "", target: self, action: #selector(homeSidebarItemTapped(_:)))
  5289. hit.identifier = NSUserInterfaceItemIdentifier("Settings")
  5290. hit.isBordered = false
  5291. hit.bezelStyle = .shadowlessSquare
  5292. hit.focusRingType = .none
  5293. hit.translatesAutoresizingMaskIntoConstraints = false
  5294. settingsRow.addSubview(hit, positioned: .above, relativeTo: nil)
  5295. NSLayoutConstraint.activate([
  5296. iconContainer.topAnchor.constraint(equalTo: settingsRow.topAnchor, constant: 9),
  5297. iconContainer.centerXAnchor.constraint(equalTo: settingsRow.centerXAnchor),
  5298. iconContainer.widthAnchor.constraint(equalToConstant: 26),
  5299. iconContainer.heightAnchor.constraint(equalToConstant: 26),
  5300. iconView.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor),
  5301. iconView.centerYAnchor.constraint(equalTo: iconContainer.centerYAnchor),
  5302. iconView.widthAnchor.constraint(equalToConstant: 22),
  5303. iconView.heightAnchor.constraint(equalToConstant: 22),
  5304. label.topAnchor.constraint(equalTo: iconContainer.bottomAnchor, constant: 6),
  5305. label.centerXAnchor.constraint(equalTo: settingsRow.centerXAnchor),
  5306. label.bottomAnchor.constraint(equalTo: settingsRow.bottomAnchor, constant: -8),
  5307. hit.leadingAnchor.constraint(equalTo: settingsRow.leadingAnchor),
  5308. hit.trailingAnchor.constraint(equalTo: settingsRow.trailingAnchor),
  5309. hit.topAnchor.constraint(equalTo: settingsRow.topAnchor),
  5310. hit.bottomAnchor.constraint(equalTo: settingsRow.bottomAnchor)
  5311. ])
  5312. homeSidebarRowViews["Settings"] = settingsRow
  5313. homeSidebarIconViews["Settings"] = iconView
  5314. homeSidebarLabels["Settings"] = label
  5315. stack.addArrangedSubview(settingsRow)
  5316. }
  5317. NSLayoutConstraint.activate([
  5318. stack.leadingAnchor.constraint(equalTo: sidebar.leadingAnchor, constant: 4),
  5319. stack.trailingAnchor.constraint(equalTo: sidebar.trailingAnchor, constant: -4)
  5320. ])
  5321. if style == .home {
  5322. NSLayoutConstraint.activate([
  5323. stack.topAnchor.constraint(equalTo: sidebar.topAnchor, constant: 10)
  5324. ])
  5325. }
  5326. stack.bottomAnchor.constraint(lessThanOrEqualTo: sidebar.bottomAnchor, constant: -18).isActive = true
  5327. return sidebar
  5328. }
  5329. @objc private func homeSidebarItemTapped(_ sender: NSButton) {
  5330. guard let item = sender.identifier?.rawValue else { return }
  5331. selectedHomeSidebarItem = item
  5332. updateHomeSidebarHighlight()
  5333. updateSelectedHomeSectionUI()
  5334. }
  5335. @objc private func newMeetingTapped() {
  5336. menuBarTriggerNewMeeting()
  5337. }
  5338. func menuBarIsUserLoggedIn() -> Bool {
  5339. isUserLoggedIn()
  5340. }
  5341. func menuBarShowSection(_ section: HomeSection) {
  5342. selectedHomeSidebarItem = section.rawValue
  5343. updateHomeSidebarHighlight()
  5344. updateSelectedHomeSectionUI()
  5345. }
  5346. func menuBarTriggerNewMeeting() {
  5347. guard isUserLoggedIn() else {
  5348. showSimpleAlert(title: "Sign in required", message: "Please sign in with Zoom to start a new meeting.")
  5349. return
  5350. }
  5351. guard let url = URL(string: "https://zoom.us/start/videomeeting"), NSWorkspace.shared.open(url) else {
  5352. showSimpleAlert(title: "Unable to open", message: "Could not start a new Zoom meeting right now.")
  5353. return
  5354. }
  5355. }
  5356. func menuBarTriggerJoinMeeting() {
  5357. joinMeetingTapped()
  5358. }
  5359. func menuBarTriggerScheduleMeeting() {
  5360. scheduleMeetingTapped()
  5361. }
  5362. func menuBarTriggerAuthAction() {
  5363. if isUserLoggedIn() {
  5364. logoutTapped()
  5365. } else {
  5366. zoomPrimarySignInTapped()
  5367. }
  5368. }
  5369. @MainActor
  5370. private func updateHomeSidebarHighlight() {
  5371. for (item, row) in homeSidebarRowViews {
  5372. let selected = item == selectedHomeSidebarItem
  5373. row.layer?.backgroundColor = selected ? sidebarActiveBackground.withAlphaComponent(0.95).cgColor : NSColor.clear.cgColor
  5374. homeSidebarLabels[item]?.textColor = selected ? primaryText : secondaryText
  5375. homeSidebarIconViews[item]?.contentTintColor = selected ? primaryText : secondaryText
  5376. let symbolName = sidebarSymbolName(for: item, filled: selected)
  5377. if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: item) {
  5378. homeSidebarIconViews[item]?.image = image
  5379. }
  5380. }
  5381. }
  5382. @MainActor
  5383. private func updateSelectedHomeSectionUI() {
  5384. let isHome = selectedHomeSidebarItem == "Home"
  5385. let isMeetings = selectedHomeSidebarItem == "Meetings"
  5386. let isSettings = selectedHomeSidebarItem == "Settings"
  5387. let isScheduler = selectedHomeSidebarItem == "Scheduler"
  5388. let premiumLocked = isUserLoggedIn() && (storeKitCoordinator.hasPremiumAccess == false)
  5389. homeWelcomeLabel?.stringValue = "Home"
  5390. homeWelcomeLabel?.isHidden = (isHome == false) || isSettings || isMeetings
  5391. let dashboardViews: [NSView?] = [
  5392. homeTimeLabelView,
  5393. homeDateLabelView,
  5394. homeActionsRow,
  5395. homeMeetingsPanel,
  5396. meetingsDayHeaderLabel,
  5397. meetingsStatusLabel,
  5398. meetingsPrevDayButton,
  5399. meetingsTodayButton,
  5400. meetingsNextDayButton,
  5401. meetingsScrollView,
  5402. refreshMeetingsButton
  5403. ]
  5404. let hideDashboard = isHome == false || isSettings || isMeetings || isScheduler
  5405. dashboardViews.forEach { $0?.isHidden = hideDashboard }
  5406. // Do not toggle emptyMeetingLabel with other dashboard views — that overrode applyFilteredMeetings()
  5407. // and showed "No meetings…" on top of meeting cards when returning to Home.
  5408. if hideDashboard {
  5409. emptyMeetingLabel?.isHidden = true
  5410. } else {
  5411. let hasMeetingCards = (meetingsListStack?.arrangedSubviews.isEmpty == false)
  5412. emptyMeetingLabel?.isHidden = premiumLocked || hasMeetingCards
  5413. }
  5414. homeSettingsView?.isHidden = isSettings == false
  5415. meetingsPageRootView?.isHidden = isMeetings == false
  5416. schedulerRootView?.isHidden = isScheduler == false
  5417. if isHome {
  5418. homePlaceholderLabel?.isHidden = true
  5419. } else {
  5420. // Keep non-Home pages empty for now.
  5421. homePlaceholderLabel?.isHidden = true
  5422. }
  5423. if isScheduler {
  5424. updateSchedulerDateHeader()
  5425. applySchedulerMeetingsForSelectedDate()
  5426. renderSchedulerCalendarGrid()
  5427. }
  5428. if isMeetings {
  5429. applyMeetingsPageFilters(resetVisible: false)
  5430. }
  5431. }
  5432. private func sidebarSymbolName(for item: String, filled: Bool = false) -> String {
  5433. switch item {
  5434. case "Home":
  5435. return filled ? "house.fill" : "house"
  5436. case "Meetings":
  5437. return filled ? "video.fill" : "video"
  5438. case "Chat":
  5439. return filled ? "message.fill" : "message"
  5440. case "Scheduler":
  5441. // `calendar.badge.clock.fill` is not available on macOS; keep a stable symbol.
  5442. return "calendar.badge.clock"
  5443. case "Settings":
  5444. // `gearshape.fill` may not exist on all macOS versions; handled via safe image assignment.
  5445. return filled ? "gearshape.fill" : "gearshape"
  5446. case "Hub":
  5447. return "square.grid.3x3"
  5448. case "More":
  5449. return "ellipsis"
  5450. default:
  5451. return "circle"
  5452. }
  5453. }
  5454. private func makeSidebarBadge(text: String) -> NSView {
  5455. let badge = NSView()
  5456. badge.wantsLayer = true
  5457. badge.layer?.backgroundColor = NSColor.systemRed.cgColor
  5458. badge.layer?.cornerRadius = 12 / 2
  5459. badge.widthAnchor.constraint(equalToConstant: 12).isActive = true
  5460. badge.heightAnchor.constraint(equalToConstant: 12).isActive = true
  5461. let label = makeLabel(text, size: 8, color: .white, weight: .bold, centered: true)
  5462. label.translatesAutoresizingMaskIntoConstraints = false
  5463. badge.addSubview(label)
  5464. NSLayoutConstraint.activate([
  5465. label.centerXAnchor.constraint(equalTo: badge.centerXAnchor),
  5466. label.centerYAnchor.constraint(equalTo: badge.centerYAnchor, constant: -0.3)
  5467. ])
  5468. return badge
  5469. }
  5470. @MainActor
  5471. private func alignNativeTrafficLights() {
  5472. guard let window = view.window else { return }
  5473. guard let closeButton = window.standardWindowButton(.closeButton),
  5474. let miniButton = window.standardWindowButton(.miniaturizeButton),
  5475. let zoomButton = window.standardWindowButton(.zoomButton) else { return }
  5476. guard let buttonContainer = closeButton.superview else { return }
  5477. let buttons = [closeButton, miniButton, zoomButton]
  5478. // Compute from top inset so moving "down" is stable in titlebar coordinates.
  5479. let containerHeight = buttonContainer.bounds.height
  5480. let targetY = max(0, containerHeight - closeButton.frame.height - nativeTrafficLightsTopInset)
  5481. var nextX = nativeTrafficLightsLeading
  5482. for button in buttons {
  5483. button.setFrameOrigin(NSPoint(x: nextX, y: targetY))
  5484. nextX += button.frame.width + 8
  5485. }
  5486. }
  5487. private func makeUpgradeToProButton(action: Selector?) -> NSButton {
  5488. let title = "Upgrade to Pro"
  5489. let button = NSButton(title: title, target: action == nil ? nil : self, action: action)
  5490. button.isBordered = false
  5491. button.focusRingType = .none
  5492. button.wantsLayer = true
  5493. button.layer?.backgroundColor = accentBlue.cgColor
  5494. button.layer?.cornerRadius = 14
  5495. let font = NSFont.systemFont(ofSize: 11, weight: .semibold)
  5496. button.attributedTitle = NSAttributedString(string: title, attributes: [
  5497. .foregroundColor: NSColor.white,
  5498. .font: font
  5499. ])
  5500. button.toolTip = title
  5501. button.translatesAutoresizingMaskIntoConstraints = false
  5502. button.heightAnchor.constraint(equalToConstant: 28).isActive = true
  5503. button.widthAnchor.constraint(greaterThanOrEqualToConstant: 124).isActive = true
  5504. return button
  5505. }
  5506. /// Back / forward / history: icon-only, no background or border. Back/forward use smaller `dimension` / `pointSize` than history.
  5507. private func makeNavGlyphButton(symbol: String, action: Selector?, dimension: CGFloat = 18, pointSize: CGFloat = 9, toolTip: String? = nil) -> NSButton {
  5508. let button = NSButton(title: "", target: action == nil ? nil : self, action: action)
  5509. button.isBordered = false
  5510. button.bezelStyle = .shadowlessSquare
  5511. button.focusRingType = .none
  5512. button.contentTintColor = palette.isDarkMode
  5513. ? NSColor(calibratedWhite: 0.84, alpha: 1)
  5514. : NSColor(calibratedWhite: 0.22, alpha: 1)
  5515. if let toolTip {
  5516. button.toolTip = toolTip
  5517. }
  5518. let symbolConfig = NSImage.SymbolConfiguration(pointSize: pointSize, weight: .medium)
  5519. if let base = NSImage(systemSymbolName: symbol, accessibilityDescription: symbol),
  5520. let image = base.withSymbolConfiguration(symbolConfig) {
  5521. image.isTemplate = true
  5522. button.image = image
  5523. }
  5524. button.imageScaling = .scaleProportionallyUpOrDown
  5525. button.imagePosition = .imageOnly
  5526. button.translatesAutoresizingMaskIntoConstraints = false
  5527. button.widthAnchor.constraint(equalToConstant: dimension).isActive = true
  5528. button.heightAnchor.constraint(equalToConstant: dimension).isActive = true
  5529. return button
  5530. }
  5531. private func makeMeetingsDayChipButton(title: String, symbol: String? = nil, action: Selector?) -> NSButton {
  5532. let button = NSButton(title: title, target: action == nil ? nil : self, action: action)
  5533. button.isBordered = false
  5534. button.bezelStyle = .shadowlessSquare
  5535. button.focusRingType = .none
  5536. button.wantsLayer = true
  5537. button.layer?.backgroundColor = (palette.isDarkMode
  5538. ? NSColor.white.withAlphaComponent(0.06)
  5539. : NSColor.black.withAlphaComponent(0.06)
  5540. ).cgColor
  5541. button.layer?.cornerRadius = 10
  5542. button.layer?.borderWidth = 1
  5543. button.layer?.borderColor = (palette.isDarkMode
  5544. ? NSColor.white.withAlphaComponent(0.08)
  5545. : NSColor.black.withAlphaComponent(0.12)
  5546. ).cgColor
  5547. let tint = palette.isDarkMode
  5548. ? NSColor(calibratedWhite: 0.9, alpha: 1)
  5549. : NSColor(calibratedWhite: 0.18, alpha: 1)
  5550. let font = NSFont.systemFont(ofSize: 11, weight: .semibold)
  5551. button.contentTintColor = tint
  5552. button.font = font
  5553. button.attributedTitle = NSAttributedString(string: title, attributes: [
  5554. .foregroundColor: tint,
  5555. .font: font
  5556. ])
  5557. if let symbol {
  5558. let symbolConfig = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
  5559. if let base = NSImage(systemSymbolName: symbol, accessibilityDescription: title),
  5560. let image = base.withSymbolConfiguration(symbolConfig) {
  5561. image.isTemplate = true
  5562. button.image = image
  5563. button.imagePosition = .imageLeading
  5564. }
  5565. button.imageHugsTitle = true
  5566. button.imageScaling = .scaleNone
  5567. }
  5568. button.translatesAutoresizingMaskIntoConstraints = false
  5569. button.heightAnchor.constraint(equalToConstant: 24).isActive = true
  5570. button.widthAnchor.constraint(greaterThanOrEqualToConstant: 92).isActive = true
  5571. return button
  5572. }
  5573. private func makeActionTile(title: String, symbol: String, color: NSColor, action: Selector? = nil) -> NSView {
  5574. let root = HoverTileView()
  5575. root.translatesAutoresizingMaskIntoConstraints = false
  5576. root.widthAnchor.constraint(equalToConstant: 88).isActive = true
  5577. // Give caption text a little more vertical room so descenders (e.g. "g") are never clipped.
  5578. root.heightAnchor.constraint(equalToConstant: 76).isActive = true
  5579. let iconButton = NSButton(title: "", target: action == nil ? nil : self, action: action)
  5580. iconButton.isBordered = false
  5581. iconButton.wantsLayer = true
  5582. iconButton.layer?.backgroundColor = color.cgColor
  5583. iconButton.layer?.cornerRadius = 15
  5584. iconButton.layer?.shadowOpacity = 0.2
  5585. iconButton.layer?.shadowRadius = 7
  5586. iconButton.layer?.shadowOffset = NSSize(width: 0, height: -1)
  5587. let symbolConfig = NSImage.SymbolConfiguration(pointSize: 22, weight: .semibold)
  5588. if let baseImage = NSImage(systemSymbolName: symbol, accessibilityDescription: title),
  5589. let configured = baseImage.withSymbolConfiguration(symbolConfig) {
  5590. iconButton.image = configured
  5591. }
  5592. iconButton.contentTintColor = .white
  5593. iconButton.imageScaling = .scaleNone
  5594. let label = makeLabel(title, size: 12, color: secondaryText, weight: .regular, centered: true)
  5595. [iconButton, label].forEach {
  5596. $0.translatesAutoresizingMaskIntoConstraints = false
  5597. root.addSubview($0)
  5598. }
  5599. let iconTopConstraint = iconButton.topAnchor.constraint(equalTo: root.topAnchor)
  5600. NSLayoutConstraint.activate([
  5601. iconTopConstraint,
  5602. iconButton.centerXAnchor.constraint(equalTo: root.centerXAnchor),
  5603. iconButton.widthAnchor.constraint(equalToConstant: 50),
  5604. iconButton.heightAnchor.constraint(equalToConstant: 50),
  5605. label.topAnchor.constraint(equalTo: iconButton.bottomAnchor, constant: 8),
  5606. label.centerXAnchor.constraint(equalTo: root.centerXAnchor),
  5607. label.bottomAnchor.constraint(equalTo: root.bottomAnchor)
  5608. ])
  5609. root.onHoverChanged = { [weak iconButton, weak label] hovering in
  5610. NSAnimationContext.runAnimationGroup { context in
  5611. context.duration = hovering ? 0.16 : 0.14
  5612. context.timingFunction = CAMediaTimingFunction(name: hovering ? .easeOut : .easeInEaseOut)
  5613. iconTopConstraint.animator().constant = hovering ? -3 : 0
  5614. label?.animator().alphaValue = hovering ? 1.0 : 0.96
  5615. }
  5616. guard let iconButton else { return }
  5617. iconButton.layer?.shadowOpacity = hovering ? 0.28 : 0.2
  5618. iconButton.layer?.shadowRadius = hovering ? 10 : 7
  5619. iconButton.layer?.shadowOffset = hovering ? NSSize(width: 0, height: -3) : NSSize(width: 0, height: -1)
  5620. }
  5621. return root
  5622. }
  5623. private func makeMeetingRowCard(_ meeting: ScheduledMeeting) -> NSView {
  5624. let hoverBackground = palette.isDarkMode
  5625. ? NSColor.white.withAlphaComponent(0.075)
  5626. : NSColor.black.withAlphaComponent(0.10)
  5627. let hoverBorder = palette.isDarkMode
  5628. ? NSColor.white.withAlphaComponent(0.85)
  5629. : NSColor.black.withAlphaComponent(0.22)
  5630. let card = MeetingCardView(url: meeting.webURL, hoverBackground: hoverBackground, hoverBorder: hoverBorder)
  5631. card.wantsLayer = true
  5632. card.layer?.backgroundColor = meetingCardBackground.cgColor
  5633. card.layer?.cornerRadius = 13
  5634. card.layer?.borderWidth = 1
  5635. card.layer?.borderColor = (palette.isDarkMode ? NSColor.white.withAlphaComponent(0.06) : NSColor.black.withAlphaComponent(0.10)).cgColor
  5636. card.translatesAutoresizingMaskIntoConstraints = false
  5637. card.heightAnchor.constraint(equalToConstant: 116).isActive = true
  5638. let dateFormatter = DateFormatter()
  5639. dateFormatter.dateFormat = "EEE, MMM d"
  5640. let timeFormatter = DateFormatter()
  5641. timeFormatter.dateFormat = "h:mm a"
  5642. let displayTimeZone = meeting.meetingTimeZone ?? TimeZone.current
  5643. dateFormatter.timeZone = displayTimeZone
  5644. timeFormatter.timeZone = displayTimeZone
  5645. let startText = timeFormatter.string(from: meeting.start)
  5646. let endText = meeting.end.map { timeFormatter.string(from: $0) } ?? ""
  5647. let range = endText.isEmpty ? startText : "\(startText) - \(endText)"
  5648. let title = makeLabel(meeting.title, size: 18, color: primaryText, weight: .semibold, centered: false)
  5649. let detail = makeLabel("\(dateFormatter.string(from: meeting.start))\n\(range)", size: 12, color: secondaryText, weight: .regular, centered: false)
  5650. detail.maximumNumberOfLines = 2
  5651. let host = makeLabel("Host: \(meeting.host) • \(meeting.source)", size: 11, color: secondaryText, weight: .regular, centered: false)
  5652. [title, detail, host].forEach {
  5653. $0.translatesAutoresizingMaskIntoConstraints = false
  5654. card.addSubview($0)
  5655. }
  5656. NSLayoutConstraint.activate([
  5657. title.topAnchor.constraint(equalTo: card.topAnchor, constant: 11),
  5658. title.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16),
  5659. title.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -14),
  5660. detail.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 3),
  5661. detail.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  5662. detail.trailingAnchor.constraint(equalTo: title.trailingAnchor),
  5663. host.topAnchor.constraint(equalTo: detail.bottomAnchor, constant: 7),
  5664. host.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  5665. host.trailingAnchor.constraint(equalTo: title.trailingAnchor),
  5666. host.bottomAnchor.constraint(lessThanOrEqualTo: card.bottomAnchor, constant: -12)
  5667. ])
  5668. return card
  5669. }
  5670. private func makeLabel(_ text: String, size: CGFloat, color: NSColor, weight: NSFont.Weight, centered: Bool) -> NSTextField {
  5671. let label = NSTextField(labelWithString: text)
  5672. label.font = .systemFont(ofSize: size, weight: weight)
  5673. label.textColor = color
  5674. label.alignment = centered ? .center : .left
  5675. return label
  5676. }
  5677. private func makeSocialButton(icon: String, text: String, action: Selector? = nil) -> (container: NSView, button: NSButton?) {
  5678. let wrapper = NSView()
  5679. let button = NSButton(title: icon, target: action == nil ? nil : self, action: action)
  5680. button.font = .systemFont(ofSize: 20, weight: .medium)
  5681. button.isBordered = false
  5682. button.wantsLayer = true
  5683. button.layer?.cornerRadius = 12
  5684. button.layer?.backgroundColor = cardBackground.cgColor
  5685. button.contentTintColor = primaryText
  5686. button.translatesAutoresizingMaskIntoConstraints = false
  5687. let label = makeLabel(text, size: 12, color: secondaryText, weight: .regular, centered: true)
  5688. label.translatesAutoresizingMaskIntoConstraints = false
  5689. wrapper.addSubview(button)
  5690. wrapper.addSubview(label)
  5691. NSLayoutConstraint.activate([
  5692. button.topAnchor.constraint(equalTo: wrapper.topAnchor),
  5693. button.centerXAnchor.constraint(equalTo: wrapper.centerXAnchor),
  5694. button.widthAnchor.constraint(equalToConstant: 52),
  5695. button.heightAnchor.constraint(equalToConstant: 52),
  5696. label.topAnchor.constraint(equalTo: button.bottomAnchor, constant: 6),
  5697. label.centerXAnchor.constraint(equalTo: wrapper.centerXAnchor),
  5698. label.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor)
  5699. ])
  5700. return (wrapper, action == nil ? nil : button)
  5701. }
  5702. }
  5703. extension ViewController: NSWindowDelegate {
  5704. func windowWillClose(_ notification: Notification) {
  5705. guard let window = notification.object as? NSWindow else { return }
  5706. if window === joinMeetingWindow {
  5707. resetJoinMeetingPanelReferences()
  5708. }
  5709. if window === scheduleMeetingWindow {
  5710. resetScheduleMeetingPanelReferences()
  5711. }
  5712. }
  5713. }
  5714. private extension Array {
  5715. subscript(safe index: Int) -> Element? {
  5716. guard index >= 0, index < count else { return nil }
  5717. return self[index]
  5718. }
  5719. }
  5720. /// Vertical centering without changing `drawingRect` (so the field editor keeps a full-height frame and typing works).
  5721. private final class JoinPanelVerticallyCenteredTextFieldCell: NSTextFieldCell {
  5722. private func lineHeight() -> CGFloat {
  5723. guard let font = font else { return 0 }
  5724. return ceil(font.ascender - font.descender + font.leading)
  5725. }
  5726. private func verticalMargin(forBoundsHeight h: CGFloat) -> CGFloat {
  5727. let lh = lineHeight()
  5728. guard lh > 0, h > lh else { return 0 }
  5729. return max(0, floor((h - lh) / 2))
  5730. }
  5731. private func verticallyCenteredInteriorFrame(_ cellFrame: NSRect) -> NSRect {
  5732. let lh = lineHeight()
  5733. guard lh > 0 else { return cellFrame }
  5734. let m = verticalMargin(forBoundsHeight: cellFrame.height)
  5735. var r = cellFrame
  5736. r.origin.y += m
  5737. r.size.height = lh
  5738. return r
  5739. }
  5740. override func drawInterior(withFrame cellFrame: NSRect, in controlView: NSView) {
  5741. super.drawInterior(withFrame: verticallyCenteredInteriorFrame(cellFrame), in: controlView)
  5742. }
  5743. override func setUpFieldEditorAttributes(_ textObj: NSText) -> NSText {
  5744. let text = super.setUpFieldEditorAttributes(textObj)
  5745. guard let tv = text as? NSTextView else { return text }
  5746. // Bounds can be 0 on first editor setup; join fields are laid out at 42pt tall inside the pill.
  5747. let h = max(controlView?.bounds.height ?? 0, 42)
  5748. let margin = verticalMargin(forBoundsHeight: h)
  5749. if margin > 0 {
  5750. // NSTextView still uses `NSSize` here: horizontal inset, vertical inset from bounds origin (symmetric padding).
  5751. tv.textContainerInset = NSSize(width: 0, height: margin)
  5752. }
  5753. return text
  5754. }
  5755. }
  5756. /// Pill-shaped chrome with inset text; focus ring handled via edit notifications (reliable vs. cell overrides).
  5757. private final class JoinPanelFieldContainer: NSView {
  5758. let textField: NSTextField
  5759. private let normalBorder: NSColor
  5760. private let focusBorder: NSColor
  5761. private let fill: NSColor
  5762. private var beginObserver: NSObjectProtocol?
  5763. private var endObserver: NSObjectProtocol?
  5764. init(
  5765. placeholder: String,
  5766. normalBorder: NSColor,
  5767. focusBorder: NSColor,
  5768. fill: NSColor,
  5769. primaryText: NSColor,
  5770. mutedText: NSColor
  5771. ) {
  5772. self.normalBorder = normalBorder
  5773. self.focusBorder = focusBorder
  5774. self.fill = fill
  5775. textField = NSTextField()
  5776. textField.cell = JoinPanelVerticallyCenteredTextFieldCell(textCell: "")
  5777. super.init(frame: .zero)
  5778. translatesAutoresizingMaskIntoConstraints = false
  5779. wantsLayer = true
  5780. // Do not clip subviews: `masksToBounds` + corner radius can clip the field editor (NSTextView) and block typing.
  5781. layer?.masksToBounds = false
  5782. layer?.cornerRadius = 23
  5783. layer?.backgroundColor = fill.cgColor
  5784. applyBorder(focused: false)
  5785. textField.font = .systemFont(ofSize: 14, weight: .regular)
  5786. textField.textColor = primaryText
  5787. textField.alignment = .center
  5788. textField.isEditable = true
  5789. textField.isSelectable = true
  5790. textField.refusesFirstResponder = false
  5791. textField.focusRingType = .none
  5792. textField.isBordered = false
  5793. textField.drawsBackground = false
  5794. textField.translatesAutoresizingMaskIntoConstraints = false
  5795. if let cell = textField.cell as? NSTextFieldCell {
  5796. cell.isBezeled = false
  5797. cell.drawsBackground = false
  5798. cell.alignment = .center
  5799. cell.usesSingleLineMode = true
  5800. cell.lineBreakMode = .byTruncatingTail
  5801. let placeholderParagraph = NSMutableParagraphStyle()
  5802. placeholderParagraph.alignment = .center
  5803. cell.placeholderAttributedString = NSAttributedString(
  5804. string: placeholder,
  5805. attributes: [
  5806. .foregroundColor: mutedText.withAlphaComponent(0.88),
  5807. .font: NSFont.systemFont(ofSize: 14, weight: .regular),
  5808. .paragraphStyle: placeholderParagraph
  5809. ]
  5810. )
  5811. }
  5812. addSubview(textField)
  5813. NSLayoutConstraint.activate([
  5814. textField.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 14),
  5815. textField.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -14),
  5816. textField.topAnchor.constraint(equalTo: topAnchor, constant: 2),
  5817. textField.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -2),
  5818. heightAnchor.constraint(equalToConstant: 46)
  5819. ])
  5820. beginObserver = NotificationCenter.default.addObserver(
  5821. forName: NSControl.textDidBeginEditingNotification,
  5822. object: textField,
  5823. queue: .main
  5824. ) { [weak self] _ in
  5825. self?.applyBorder(focused: true)
  5826. }
  5827. endObserver = NotificationCenter.default.addObserver(
  5828. forName: NSControl.textDidEndEditingNotification,
  5829. object: textField,
  5830. queue: .main
  5831. ) { [weak self] _ in
  5832. self?.applyBorder(focused: false)
  5833. }
  5834. }
  5835. required init?(coder: NSCoder) {
  5836. fatalError("init(coder:) has not been implemented")
  5837. }
  5838. deinit {
  5839. if let beginObserver {
  5840. NotificationCenter.default.removeObserver(beginObserver)
  5841. }
  5842. if let endObserver {
  5843. NotificationCenter.default.removeObserver(endObserver)
  5844. }
  5845. }
  5846. private func applyBorder(focused: Bool) {
  5847. layer?.borderColor = (focused ? focusBorder : normalBorder).cgColor
  5848. layer?.borderWidth = focused ? 1.5 : 1
  5849. }
  5850. override func viewDidMoveToWindow() {
  5851. super.viewDidMoveToWindow()
  5852. if window == nil {
  5853. applyBorder(focused: false)
  5854. }
  5855. }
  5856. }
  5857. private final class SearchPillTextField: NSTextField {
  5858. var onFocusChange: ((Bool) -> Void)?
  5859. private(set) var isSearchFocused = false
  5860. func forceClearFocusState() {
  5861. isSearchFocused = false
  5862. onFocusChange?(false)
  5863. }
  5864. override func becomeFirstResponder() -> Bool {
  5865. let ok = super.becomeFirstResponder()
  5866. if ok {
  5867. isSearchFocused = true
  5868. onFocusChange?(true)
  5869. }
  5870. return ok
  5871. }
  5872. override func resignFirstResponder() -> Bool {
  5873. let ok = super.resignFirstResponder()
  5874. if ok {
  5875. isSearchFocused = false
  5876. onFocusChange?(false)
  5877. }
  5878. return ok
  5879. }
  5880. }
  5881. private final class FlippedView: NSView {
  5882. override var isFlipped: Bool { true }
  5883. }
  5884. private final class MeetingCardView: NSView {
  5885. private let url: URL?
  5886. private let hoverBackground: NSColor
  5887. private let hoverBorder: NSColor
  5888. private var tracking: NSTrackingArea?
  5889. private var isHovering = false
  5890. private var normalBackgroundColor: CGColor?
  5891. private var normalBorderColor: CGColor?
  5892. private var normalBorderWidth: CGFloat?
  5893. init(url: URL?, hoverBackground: NSColor, hoverBorder: NSColor) {
  5894. self.url = url
  5895. self.hoverBackground = hoverBackground
  5896. self.hoverBorder = hoverBorder
  5897. super.init(frame: .zero)
  5898. }
  5899. required init?(coder: NSCoder) {
  5900. self.url = nil
  5901. self.hoverBackground = NSColor.white.withAlphaComponent(0.075)
  5902. self.hoverBorder = NSColor.white.withAlphaComponent(0.85)
  5903. super.init(coder: coder)
  5904. }
  5905. override func updateTrackingAreas() {
  5906. super.updateTrackingAreas()
  5907. if let tracking { removeTrackingArea(tracking) }
  5908. let options: NSTrackingArea.Options = [.mouseEnteredAndExited, .activeInKeyWindow, .inVisibleRect]
  5909. let area = NSTrackingArea(rect: .zero, options: options, owner: self, userInfo: nil)
  5910. addTrackingArea(area)
  5911. tracking = area
  5912. }
  5913. override func resetCursorRects() {
  5914. super.resetCursorRects()
  5915. if url != nil {
  5916. addCursorRect(bounds, cursor: .pointingHand)
  5917. }
  5918. }
  5919. override func mouseEntered(with event: NSEvent) {
  5920. super.mouseEntered(with: event)
  5921. guard url != nil else { return }
  5922. isHovering = true
  5923. applyHoverState()
  5924. }
  5925. override func mouseExited(with event: NSEvent) {
  5926. super.mouseExited(with: event)
  5927. guard url != nil else { return }
  5928. isHovering = false
  5929. applyHoverState()
  5930. }
  5931. override func mouseUp(with event: NSEvent) {
  5932. super.mouseUp(with: event)
  5933. guard let url else { return }
  5934. NSWorkspace.shared.open(url)
  5935. }
  5936. private func applyHoverState() {
  5937. guard let layer else { return }
  5938. if normalBackgroundColor == nil { normalBackgroundColor = layer.backgroundColor }
  5939. if normalBorderColor == nil { normalBorderColor = layer.borderColor }
  5940. if normalBorderWidth == nil { normalBorderWidth = layer.borderWidth }
  5941. if isHovering {
  5942. layer.backgroundColor = hoverBackground.cgColor
  5943. layer.borderColor = hoverBorder.cgColor
  5944. layer.borderWidth = max(1.5, layer.borderWidth)
  5945. } else {
  5946. layer.backgroundColor = normalBackgroundColor
  5947. layer.borderColor = normalBorderColor
  5948. if let normalBorderWidth { layer.borderWidth = normalBorderWidth }
  5949. }
  5950. }
  5951. }
  5952. struct GoogleOAuthTokens: Codable, Equatable {
  5953. var accessToken: String
  5954. var refreshToken: String?
  5955. var expiresAt: Date
  5956. var scope: String?
  5957. var tokenType: String?
  5958. }
  5959. struct GoogleUserProfile: Codable, Equatable {
  5960. var name: String?
  5961. var email: String?
  5962. var picture: String?
  5963. }
  5964. struct ZoomOAuthTokens: Codable, Equatable {
  5965. var accessToken: String
  5966. var refreshToken: String?
  5967. var expiresAt: Date
  5968. var scope: String?
  5969. var tokenType: String?
  5970. }
  5971. enum ZoomOAuthError: Error {
  5972. case missingClientId
  5973. case missingClientSecret
  5974. case invalidCallbackURL
  5975. case missingAuthorizationCode
  5976. case tokenExchangeFailed(String)
  5977. case missingRequiredScope(String)
  5978. case rateLimited(retryAfterSeconds: Int?)
  5979. case unableToOpenBrowser
  5980. case authenticationTimedOut
  5981. }
  5982. final class ZoomOAuthTokenStore {
  5983. private let defaultsKey: String
  5984. private let defaults: UserDefaults
  5985. init(service: String = Bundle.main.bundleIdentifier ?? "zoom_app",
  5986. account: String = "zoomOAuthTokens",
  5987. defaults: UserDefaults = .standard) {
  5988. self.defaultsKey = "\(service).\(account)"
  5989. self.defaults = defaults
  5990. }
  5991. func readTokens() throws -> ZoomOAuthTokens? {
  5992. guard let data = defaults.data(forKey: defaultsKey) else { return nil }
  5993. return try JSONDecoder().decode(ZoomOAuthTokens.self, from: data)
  5994. }
  5995. func writeTokens(_ tokens: ZoomOAuthTokens) throws {
  5996. let data = try JSONEncoder().encode(tokens)
  5997. defaults.set(data, forKey: defaultsKey)
  5998. }
  5999. func clearTokens() {
  6000. defaults.removeObject(forKey: defaultsKey)
  6001. }
  6002. }
  6003. final class ZoomOAuthService: NSObject {
  6004. static let shared = ZoomOAuthService()
  6005. private var inAppOAuthWindowController: InAppOAuthWindowController?
  6006. private let tokenStore = ZoomOAuthTokenStore()
  6007. private let clientIdDefaultsKey = "zoom.oauth.clientId"
  6008. private let clientSecretDefaultsKey = "zoom.oauth.clientSecret"
  6009. private let infoPlistClientIdKey = "ZoomOAuthClientId"
  6010. private let envClientSecretKey = "ZOOM_OAUTH_CLIENT_SECRET"
  6011. // Optional: put OAuth app credentials here for local-only testing (do not ship secrets in release builds).
  6012. /// Fallback if Info.plist `ZoomOAuthClientId` is missing (e.g. mis-quoted build setting).
  6013. private let bundledClientId = "isvIAKPhSPOhBxFUkiY2A"
  6014. /// Prefer `ZOOM_OAUTH_CLIENT_SECRET` env or UserDefaults when distributing; rotate if this value is ever leaked.
  6015. private let bundledClientSecret = "jPfbdvt14CKH48vKEg3NjDpTIgCd2rDq"
  6016. func setClientCredentials(clientId: String, clientSecret: String) {
  6017. UserDefaults.standard.set(clientId, forKey: clientIdDefaultsKey)
  6018. UserDefaults.standard.set(clientSecret, forKey: clientSecretDefaultsKey)
  6019. }
  6020. func configuredClientId() -> String? {
  6021. if let plist = Bundle.main.object(forInfoDictionaryKey: infoPlistClientIdKey) as? String {
  6022. let trimmed = plist.trimmingCharacters(in: .whitespacesAndNewlines)
  6023. if trimmed.isEmpty == false { return trimmed }
  6024. }
  6025. let value = UserDefaults.standard.string(forKey: clientIdDefaultsKey)?
  6026. .trimmingCharacters(in: .whitespacesAndNewlines)
  6027. if let value, value.isEmpty == false { return value }
  6028. return bundledClientId.isEmpty ? nil : bundledClientId
  6029. }
  6030. func configuredClientSecret() -> String? {
  6031. if let env = ProcessInfo.processInfo.environment[envClientSecretKey] {
  6032. let trimmed = env.trimmingCharacters(in: .whitespacesAndNewlines)
  6033. if trimmed.isEmpty == false { return trimmed }
  6034. }
  6035. let value = UserDefaults.standard.string(forKey: clientSecretDefaultsKey)?
  6036. .trimmingCharacters(in: .whitespacesAndNewlines)
  6037. if let value, value.isEmpty == false { return value }
  6038. return bundledClientSecret.isEmpty ? nil : bundledClientSecret
  6039. }
  6040. func clearSavedTokens() {
  6041. tokenStore.clearTokens()
  6042. }
  6043. func validAccessToken(presentingWindow: NSWindow?) async throws -> String {
  6044. if let tokens = try tokenStore.readTokens(),
  6045. tokens.expiresAt.timeIntervalSinceNow > 60,
  6046. tokenHasRequiredScope(tokens.scope) {
  6047. return tokens.accessToken
  6048. } else if var tokens = try tokenStore.readTokens(),
  6049. let refreshed = try await refreshTokens(tokens) {
  6050. tokens = refreshed
  6051. try tokenStore.writeTokens(tokens)
  6052. return tokens.accessToken
  6053. }
  6054. let tokens = try await interactiveSignIn(presentingWindow: presentingWindow)
  6055. try tokenStore.writeTokens(tokens)
  6056. return tokens.accessToken
  6057. }
  6058. private func interactiveSignIn(presentingWindow: NSWindow?) async throws -> ZoomOAuthTokens {
  6059. _ = presentingWindow
  6060. let oauthWindow = await MainActor.run { makeOAuthWindowController() }
  6061. guard let clientId = configuredClientId() else { throw ZoomOAuthError.missingClientId }
  6062. guard let clientSecret = configuredClientSecret() else { throw ZoomOAuthError.missingClientSecret }
  6063. // Keep a provider-compatible redirect URI while capturing it in-app (no local server required).
  6064. let redirectURI = "http://127.0.0.1:8742/oauth2redirect"
  6065. let state = UUID().uuidString
  6066. var components = URLComponents(string: "https://zoom.us/oauth/authorize")!
  6067. // Omit `scope` so Zoom uses the OAuth app’s enabled scopes from the Marketplace (avoids mismatch errors).
  6068. components.queryItems = [
  6069. URLQueryItem(name: "response_type", value: "code"),
  6070. URLQueryItem(name: "client_id", value: clientId),
  6071. URLQueryItem(name: "redirect_uri", value: redirectURI),
  6072. URLQueryItem(name: "state", value: state)
  6073. ]
  6074. guard let authURL = components.url else { throw ZoomOAuthError.invalidCallbackURL }
  6075. do {
  6076. let callbackURL = try await oauthWindow.authenticate(startURL: authURL, callbackURLPrefix: redirectURI)
  6077. let queryItems = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?.queryItems
  6078. guard queryItems?.first(where: { $0.name == "state" })?.value == state else { throw ZoomOAuthError.invalidCallbackURL }
  6079. guard let code = queryItems?.first(where: { $0.name == "code" })?.value, code.isEmpty == false else {
  6080. throw ZoomOAuthError.missingAuthorizationCode
  6081. }
  6082. return try await exchangeCodeForTokens(code: code, redirectURI: redirectURI, clientId: clientId, clientSecret: clientSecret)
  6083. } catch GoogleOAuthError.authenticationTimedOut {
  6084. throw ZoomOAuthError.authenticationTimedOut
  6085. } catch {
  6086. throw error
  6087. }
  6088. }
  6089. @MainActor
  6090. private func makeOAuthWindowController() -> InAppOAuthWindowController {
  6091. if let existing = inAppOAuthWindowController { return existing }
  6092. let controller = InAppOAuthWindowController(title: "Zoom Sign-In")
  6093. inAppOAuthWindowController = controller
  6094. return controller
  6095. }
  6096. private func exchangeCodeForTokens(code: String, redirectURI: String, clientId: String, clientSecret: String) async throws -> ZoomOAuthTokens {
  6097. var request = URLRequest(url: URL(string: "https://zoom.us/oauth/token")!)
  6098. request.httpMethod = "POST"
  6099. request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
  6100. request.setValue("Basic \(Self.basicAuth(clientId: clientId, clientSecret: clientSecret))", forHTTPHeaderField: "Authorization")
  6101. request.httpBody = Self.formURLEncoded([
  6102. "grant_type": "authorization_code",
  6103. "code": code,
  6104. "redirect_uri": redirectURI
  6105. ])
  6106. let (data, response) = try await URLSession.shared.data(for: request)
  6107. guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
  6108. throw ZoomOAuthError.tokenExchangeFailed(String(data: data, encoding: .utf8) ?? "Failed")
  6109. }
  6110. struct TokenResponse: Decodable {
  6111. let access_token: String
  6112. let refresh_token: String?
  6113. let expires_in: Double
  6114. let scope: String?
  6115. let token_type: String?
  6116. }
  6117. let decoded = try JSONDecoder().decode(TokenResponse.self, from: data)
  6118. return ZoomOAuthTokens(
  6119. accessToken: decoded.access_token,
  6120. refreshToken: decoded.refresh_token,
  6121. expiresAt: Date().addingTimeInterval(decoded.expires_in),
  6122. scope: decoded.scope,
  6123. tokenType: decoded.token_type
  6124. )
  6125. }
  6126. private func refreshTokens(_ tokens: ZoomOAuthTokens) async throws -> ZoomOAuthTokens? {
  6127. guard let refreshToken = tokens.refreshToken else { return nil }
  6128. guard let clientId = configuredClientId() else { throw ZoomOAuthError.missingClientId }
  6129. guard let clientSecret = configuredClientSecret() else { throw ZoomOAuthError.missingClientSecret }
  6130. var request = URLRequest(url: URL(string: "https://zoom.us/oauth/token")!)
  6131. request.httpMethod = "POST"
  6132. request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
  6133. request.setValue("Basic \(Self.basicAuth(clientId: clientId, clientSecret: clientSecret))", forHTTPHeaderField: "Authorization")
  6134. request.httpBody = Self.formURLEncoded([
  6135. "grant_type": "refresh_token",
  6136. "refresh_token": refreshToken
  6137. ])
  6138. let (data, response) = try await URLSession.shared.data(for: request)
  6139. guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
  6140. return nil
  6141. }
  6142. struct RefreshResponse: Decodable {
  6143. let access_token: String
  6144. let refresh_token: String?
  6145. let expires_in: Double
  6146. let scope: String?
  6147. let token_type: String?
  6148. }
  6149. let decoded = try JSONDecoder().decode(RefreshResponse.self, from: data)
  6150. return ZoomOAuthTokens(
  6151. accessToken: decoded.access_token,
  6152. refreshToken: decoded.refresh_token ?? refreshToken,
  6153. expiresAt: Date().addingTimeInterval(decoded.expires_in),
  6154. scope: decoded.scope ?? tokens.scope,
  6155. tokenType: decoded.token_type ?? tokens.tokenType
  6156. )
  6157. }
  6158. private func tokenHasRequiredScope(_ scopeValue: String?) -> Bool {
  6159. guard let scopeValue, scopeValue.isEmpty == false else { return false }
  6160. let parts = scopeValue.split { $0 == " " || $0 == "," }.map(String.init)
  6161. return parts.contains { part in
  6162. part == "meeting:read"
  6163. || part == "meeting:read:admin"
  6164. || part == "meeting:write"
  6165. || part == "meeting:write:admin"
  6166. || part.contains("meeting:read")
  6167. || part.contains("meeting:write")
  6168. || part.contains("list_meetings")
  6169. || part.contains("list_user_meetings")
  6170. }
  6171. }
  6172. private static func basicAuth(clientId: String, clientSecret: String) -> String {
  6173. let joined = "\(clientId):\(clientSecret)"
  6174. return Data(joined.utf8).base64EncodedString()
  6175. }
  6176. private static func formURLEncoded(_ params: [String: String]) -> Data {
  6177. let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~")
  6178. let pairs = params.map { key, value in
  6179. let k = key.addingPercentEncoding(withAllowedCharacters: allowed) ?? key
  6180. let v = value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
  6181. return "\(k)=\(v)"
  6182. }.joined(separator: "&")
  6183. return Data(pairs.utf8)
  6184. }
  6185. }
  6186. enum GoogleOAuthError: Error {
  6187. case missingClientId
  6188. case missingClientSecret
  6189. case invalidCallbackURL
  6190. case missingAuthorizationCode
  6191. case tokenExchangeFailed(String)
  6192. case unableToOpenBrowser
  6193. case authenticationTimedOut
  6194. }
  6195. final class GoogleOAuthService: NSObject {
  6196. static let shared = GoogleOAuthService()
  6197. private var inAppOAuthWindowController: InAppOAuthWindowController?
  6198. private let clientId = "1058191714408-i7dlicarppj0rt0ghn9loou606lmm0dr.apps.googleusercontent.com"
  6199. private let clientSecret = "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"
  6200. private let requiredCalendarScope = "https://www.googleapis.com/auth/calendar.readonly"
  6201. private let scopes = ["openid", "email", "profile", "https://www.googleapis.com/auth/calendar.readonly"]
  6202. private lazy var tokenStore = KeychainTokenStore(account: "googleOAuthTokens.\(clientId)")
  6203. func loadTokens() -> GoogleOAuthTokens? { try? tokenStore.readTokens() }
  6204. func clearSavedTokens() {
  6205. tokenStore.clearTokens()
  6206. }
  6207. func fetchUserProfile(accessToken: String) async throws -> GoogleUserProfile {
  6208. var request = URLRequest(url: URL(string: "https://openidconnect.googleapis.com/v1/userinfo")!)
  6209. request.httpMethod = "GET"
  6210. request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
  6211. let (data, response) = try await URLSession.shared.data(for: request)
  6212. guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
  6213. throw GoogleOAuthError.tokenExchangeFailed(String(data: data, encoding: .utf8) ?? "Failed")
  6214. }
  6215. return try JSONDecoder().decode(GoogleUserProfile.self, from: data)
  6216. }
  6217. func validAccessToken(presentingWindow: NSWindow?) async throws -> String {
  6218. if let tokens = try tokenStore.readTokens(),
  6219. tokens.expiresAt.timeIntervalSinceNow > 60,
  6220. tokenHasCalendarScope(tokens.scope) {
  6221. return tokens.accessToken
  6222. }
  6223. let tokens = try await interactiveSignIn(presentingWindow: presentingWindow)
  6224. try tokenStore.writeTokens(tokens)
  6225. return tokens.accessToken
  6226. }
  6227. private func interactiveSignIn(presentingWindow: NSWindow?) async throws -> GoogleOAuthTokens {
  6228. _ = presentingWindow
  6229. let oauthWindow = await MainActor.run { makeOAuthWindowController() }
  6230. let codeVerifier = Self.randomURLSafeString(length: 64)
  6231. let codeChallenge = Self.pkceChallenge(for: codeVerifier)
  6232. let state = Self.randomURLSafeString(length: 32)
  6233. // Google OAuth app settings often already include this callback from prior loopback setup.
  6234. let redirectURI = "http://127.0.0.1:8742/oauth2redirect"
  6235. var components = URLComponents(string: "https://accounts.google.com/o/oauth2/v2/auth")!
  6236. components.queryItems = [
  6237. URLQueryItem(name: "client_id", value: clientId),
  6238. URLQueryItem(name: "redirect_uri", value: redirectURI),
  6239. URLQueryItem(name: "response_type", value: "code"),
  6240. URLQueryItem(name: "scope", value: scopes.joined(separator: " ")),
  6241. URLQueryItem(name: "state", value: state),
  6242. URLQueryItem(name: "code_challenge", value: codeChallenge),
  6243. URLQueryItem(name: "code_challenge_method", value: "S256"),
  6244. URLQueryItem(name: "access_type", value: "offline"),
  6245. URLQueryItem(name: "prompt", value: "consent")
  6246. ]
  6247. guard let authURL = components.url else { throw GoogleOAuthError.invalidCallbackURL }
  6248. let callbackURL = try await oauthWindow.authenticate(startURL: authURL, callbackURLPrefix: redirectURI)
  6249. let query = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?.queryItems
  6250. guard query?.first(where: { $0.name == "state" })?.value == state else { throw GoogleOAuthError.invalidCallbackURL }
  6251. guard let code = query?.first(where: { $0.name == "code" })?.value, code.isEmpty == false else {
  6252. throw GoogleOAuthError.missingAuthorizationCode
  6253. }
  6254. return try await exchangeCodeForTokens(code: code, codeVerifier: codeVerifier, redirectURI: redirectURI)
  6255. }
  6256. @MainActor
  6257. private func makeOAuthWindowController() -> InAppOAuthWindowController {
  6258. if let existing = inAppOAuthWindowController { return existing }
  6259. let controller = InAppOAuthWindowController(title: "Google Sign-In")
  6260. inAppOAuthWindowController = controller
  6261. return controller
  6262. }
  6263. private func exchangeCodeForTokens(code: String, codeVerifier: String, redirectURI: String) async throws -> GoogleOAuthTokens {
  6264. var request = URLRequest(url: URL(string: "https://oauth2.googleapis.com/token")!)
  6265. request.httpMethod = "POST"
  6266. request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
  6267. request.httpBody = Self.formURLEncoded([
  6268. "client_id": clientId,
  6269. "client_secret": clientSecret,
  6270. "code": code,
  6271. "code_verifier": codeVerifier,
  6272. "redirect_uri": redirectURI,
  6273. "grant_type": "authorization_code"
  6274. ])
  6275. let (data, response) = try await URLSession.shared.data(for: request)
  6276. guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
  6277. throw GoogleOAuthError.tokenExchangeFailed(String(data: data, encoding: .utf8) ?? "Failed")
  6278. }
  6279. struct TokenResponse: Decodable {
  6280. let access_token: String
  6281. let expires_in: Double
  6282. let refresh_token: String?
  6283. let scope: String?
  6284. let token_type: String?
  6285. }
  6286. let decoded = try JSONDecoder().decode(TokenResponse.self, from: data)
  6287. return GoogleOAuthTokens(
  6288. accessToken: decoded.access_token,
  6289. refreshToken: decoded.refresh_token,
  6290. expiresAt: Date().addingTimeInterval(decoded.expires_in),
  6291. scope: decoded.scope,
  6292. tokenType: decoded.token_type
  6293. )
  6294. }
  6295. private static func pkceChallenge(for verifier: String) -> String {
  6296. let digest = SHA256.hash(data: Data(verifier.utf8))
  6297. return Data(digest).base64URLEncodedString()
  6298. }
  6299. private static func randomURLSafeString(length: Int) -> String {
  6300. var bytes = [UInt8](repeating: 0, count: length)
  6301. _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
  6302. return Data(bytes).base64URLEncodedString()
  6303. }
  6304. private static func formURLEncoded(_ params: [String: String]) -> Data {
  6305. let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~")
  6306. let pairs = params.map { key, value in
  6307. let k = key.addingPercentEncoding(withAllowedCharacters: allowed) ?? key
  6308. let v = value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
  6309. return "\(k)=\(v)"
  6310. }.joined(separator: "&")
  6311. return Data(pairs.utf8)
  6312. }
  6313. private func tokenHasCalendarScope(_ scopeValue: String?) -> Bool {
  6314. guard let scopeValue else { return false }
  6315. return scopeValue.split(separator: " ").contains(where: { $0 == Substring(requiredCalendarScope) })
  6316. }
  6317. }
  6318. private extension Data {
  6319. func base64URLEncodedString() -> String {
  6320. base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "")
  6321. }
  6322. }
  6323. final class KeychainTokenStore {
  6324. private let defaultsKey: String
  6325. private let defaults: UserDefaults
  6326. init(service: String = Bundle.main.bundleIdentifier ?? "zoom_app",
  6327. account: String = "googleOAuthTokens",
  6328. defaults: UserDefaults = .standard) {
  6329. self.defaultsKey = "\(service).\(account)"
  6330. self.defaults = defaults
  6331. }
  6332. func readTokens() throws -> GoogleOAuthTokens? {
  6333. guard let data = defaults.data(forKey: defaultsKey) else { return nil }
  6334. return try JSONDecoder().decode(GoogleOAuthTokens.self, from: data)
  6335. }
  6336. func writeTokens(_ tokens: GoogleOAuthTokens) throws {
  6337. let data = try JSONEncoder().encode(tokens)
  6338. defaults.set(data, forKey: defaultsKey)
  6339. }
  6340. func clearTokens() {
  6341. defaults.removeObject(forKey: defaultsKey)
  6342. }
  6343. }
  6344. @MainActor
  6345. private final class OAuthWebViewContainerView: NSView {
  6346. private let webView: WKWebView
  6347. init(webView: WKWebView) {
  6348. self.webView = webView
  6349. super.init(frame: .zero)
  6350. addSubview(webView)
  6351. }
  6352. @available(*, unavailable) required init?(coder: NSCoder) { nil }
  6353. override func layout() {
  6354. super.layout()
  6355. webView.frame = bounds
  6356. }
  6357. }
  6358. @MainActor
  6359. private final class InAppOAuthWindowController: NSWindowController, WKNavigationDelegate {
  6360. private let webView: WKWebView
  6361. private var callbackPrefix: String?
  6362. private var callbackContinuation: CheckedContinuation<URL, Error>?
  6363. init(title: String) {
  6364. let configuration = WKWebViewConfiguration()
  6365. configuration.websiteDataStore = .nonPersistent()
  6366. self.webView = WKWebView(frame: .zero, configuration: configuration)
  6367. let container = OAuthWebViewContainerView(webView: webView)
  6368. let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 980, height: 760), styleMask: [.titled, .closable, .miniaturizable, .resizable], backing: .buffered, defer: false)
  6369. window.title = title
  6370. window.contentView = container
  6371. super.init(window: window)
  6372. webView.navigationDelegate = self
  6373. }
  6374. @available(*, unavailable) required init?(coder: NSCoder) { nil }
  6375. func authenticate(startURL: URL, callbackURLPrefix: String, timeoutSeconds: Double = 120) async throws -> URL {
  6376. self.callbackPrefix = callbackURLPrefix
  6377. showWindow(nil)
  6378. window?.makeKeyAndOrderFront(nil)
  6379. window?.center()
  6380. webView.load(URLRequest(url: startURL))
  6381. return try await withThrowingTaskGroup(of: URL.self) { group in
  6382. group.addTask { [weak self] in
  6383. guard let self else { throw GoogleOAuthError.invalidCallbackURL }
  6384. return try await self.awaitCallback()
  6385. }
  6386. group.addTask {
  6387. try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000))
  6388. throw GoogleOAuthError.authenticationTimedOut
  6389. }
  6390. let url = try await group.next()!
  6391. group.cancelAll()
  6392. self.webView.stopLoading()
  6393. self.close()
  6394. return url
  6395. }
  6396. }
  6397. private func awaitCallback() async throws -> URL {
  6398. try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<URL, Error>) in
  6399. callbackContinuation = continuation
  6400. }
  6401. }
  6402. func webView(_ webView: WKWebView,
  6403. decidePolicyFor navigationAction: WKNavigationAction,
  6404. decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
  6405. guard let targetURL = navigationAction.request.url,
  6406. let callbackPrefix,
  6407. targetURL.absoluteString.hasPrefix(callbackPrefix) else {
  6408. decisionHandler(.allow)
  6409. return
  6410. }
  6411. callbackContinuation?.resume(returning: targetURL)
  6412. callbackContinuation = nil
  6413. decisionHandler(.cancel)
  6414. }
  6415. }
  6416. extension GoogleOAuthError: LocalizedError {
  6417. var errorDescription: String? {
  6418. switch self {
  6419. case .missingClientId: return "Missing Google OAuth Client ID."
  6420. case .missingClientSecret: return "Missing Google OAuth Client Secret."
  6421. case .invalidCallbackURL: return "Invalid OAuth callback URL."
  6422. case .missingAuthorizationCode: return "Google did not return an authorization code."
  6423. case .tokenExchangeFailed(let details): return "Token exchange failed: \(details)"
  6424. case .unableToOpenBrowser: return "Could not open browser for Google sign-in."
  6425. case .authenticationTimedOut: return "Google sign-in timed out."
  6426. }
  6427. }
  6428. }
  6429. extension ZoomOAuthError: LocalizedError {
  6430. var errorDescription: String? {
  6431. switch self {
  6432. case .missingClientId:
  6433. return "Zoom OAuth Client ID is not set (Info.plist ZoomOAuthClientId, UserDefaults, or the setup prompt)."
  6434. case .missingClientSecret:
  6435. return "Zoom OAuth Client Secret is not set (environment ZOOM_OAUTH_CLIENT_SECRET, UserDefaults, or the setup prompt)."
  6436. case .invalidCallbackURL:
  6437. return "The OAuth redirect URL was invalid. In your Zoom app OAuth allow list, add exactly http://127.0.0.1:8742/oauth2redirect."
  6438. case .missingAuthorizationCode:
  6439. return "Zoom did not return an authorization code."
  6440. case .tokenExchangeFailed(let details):
  6441. return details
  6442. case .missingRequiredScope(let details):
  6443. return "The Zoom access token is missing required scopes. \(details)"
  6444. case .rateLimited(let retryAfterSeconds):
  6445. if let retryAfterSeconds {
  6446. return "Zoom rate limit reached. Try again in \(retryAfterSeconds) seconds."
  6447. }
  6448. return "Zoom rate limit reached. Try again later."
  6449. case .unableToOpenBrowser:
  6450. return "Could not open the system browser for Zoom sign-in."
  6451. case .authenticationTimedOut:
  6452. return "Zoom sign-in timed out waiting for the browser redirect."
  6453. }
  6454. }
  6455. }