D0VA5710O7>Z|i8G(!Xw3ezW_PDs
z-1aklZ{Nn+=o}xO>D;sU8hXWO!lS*~QboHRL2OilYpm89R+>{4CoO1oXMacP6
z&pRqEUwFh)>KpBQ!Qn;5G~daQCV$v}J*-(8sKD_=I_wh1xo*9GpJr%V&&xc@t|s^P
zr(seFlX<4@TG74RoP?&@FM4%0>HqZ!Cfe(HoSqyz*w(ZvjJ?!rs$sz&-AMl6`t(=F
z)^tAY5}c8JJM;yzF%jYji0sAmzMS1)tzBAMFe=KmFrzX_3
zr_k;G5qo}JaLI6#OKI_fo)?fx1)Nu6|2{elt}o`Pw#ux3EbTAz^8#0M7({StgS^_^d0oSe;UCz+G=83(N+ukoGpWt*UMRXE?_j6=)x!~ntI
zD+_Gj9{&CE{t5lnRxj3ENLk%gePO@JMLqj|otKlB&zQMKV?r~_uVpHh$$}?#9pg_<
z`*60^L^b`s^;HM+*xA>0o-j{7G`DYO{MA>BznV_@@j-U#i!)V)yCCJ-?!8NAT`xD9
zCVV1n^X!)~LJpspK+kqf8E&fwf)s+_enXI4hlrFK40q?bhI
z@$dQ}5fLsReW$SK!D8WkdxUJY)i=zYs29Z;{4!E{*Zw1EYj(}7n9wkJ&*g|2hA$NV
ztSdDEHE=*}sm1;xc5GhlrB_bxX<+#G-!#uoZH=u+wI*M8bgKK3rlUo+#(gh(SDCCT
z`XgsuYSeTxp|JjB@&6{R1}mL2Gk&QFPW<#V>6=cHR;tNusrda%LN;|g*=RAtH_|`+
zv07$KddoGPbp6gO`Qk`BKGrZY%dd5NF&7NxZrB6Km
z#NC}=y7R-tL%XiW##{R*hBnAZujW5pzhuA6!hlC_w#*W0+$FHGBq_|iw=YGos@tWf
z)4rz5*Tm$>ua=*Y+Ud1R7wz2Q$;h-UdBxYCCw8-#PI>Qp@%hX90^*;(e^f4+5oHK#Rc5bTv_DY>?`r?mk*QoADsB9=^3k|Axy0y?m<%W;9VqXMLPLXSI
zwo_d>&+{WCXH}&d4!Y?%dOQp8T)}nyi-k@O^SpCi8$f&M)
zvTfxDpOSg!KGqZmvI}&tTUEdL*OL7kMKvv@58K7g<^RdK@OoYGWxf9Y8*4rK1$Vsa
z2{hKUQ>c*Hy>*vE?!yNHM^|6lEXMh@a`i&aut?iNN2kuRL+$tKq$Q1@
zd&!4BH@63@r}>t5wts#&t&D+bf}ilK9gn8RzfR^!
zFSz99X!Yz2_~G9y7A_S(iFs1>$&QBg`Z8<7j>%kFv1*fplVekHq+FQPro$ZGO)Di>
zO_c+Ku2^DSH;tcHvUSd
zw3$DCzS-t*>3kGtH)Q%SsvVd`CR?Ti5P`V?X{+7q)j!omOtueUc2SGzF+GD>;2WMFLT-^
zp0U_+apCHDpF0Cy8vC7Q#p{}J7)*gGxBa_&6dSuC|aFV1}UKILlj
z7BLRL*Vh}*6v;m?%J;XMWXS(rZ;iz5NuM^{d;ctLaiB}xns4=)+UIQ-En3)I_O^6+
z*E89qyG%3V#1rkT%s<|Ai{RWV?;~ixS9a#xHH((TW^s~giU!{7o-(m5E7AiPXWIWiIW^*8tI5?s)$5Y!7m}{N
ze5_;LJ}1Xh*ZHBSv3vPOK{4Jnb33Pfy3X__>5|Nv*#-%wOw7CfZJDDI$d;9wWj#S>
z_2b4v3Bnp{toHmm8W%C;(VbW)=Q9d^R_lGY^Sz8`*|zG8?xMg2fyl?9POnx+&-|-~x>@(*zO^-N5od20N!T#neLCUl;+0Low|5(Cj@Y^V`18W(bso3B
zG!%0R*5}+Wp7S(r4QuwfFT$7Db7%f|c(gXTo$b=HIWL&~_i+18TeVr($Ryl6XQl0r
zncfC(i*6UraAWKKz~9d4v&TQrGO%cQ&_{zqYaXwD(%)ODDX{4O+=sXH<^KpL+h^Zk
zyz;s<)oJc7lW_h>7I)D}`j@8dkt~tfnVvAC(WSvz#Qw4KyT;fk(T!3oqZ-0l$Bmd0%k$%5>mfG#rd-qpsm7>t(J?s9jE=oR-!Y|5tYyH75t1PuQ%Knp`
z`jubwPVDpS2{(-Vzjr@OENLvRVmg;Rn^TxgnRn}HPe1QO|1Z7S_tvH@D_i2EeENL7
zjo$AD!B;;oHB7BF_vqO%Noz)Y$}}scsjH`yTr$3(?k{t&;Xu_9i^$}|mjaIKo_ki*
zUE5ciyk(DUk=VPmi&=&~e~%d4h+TBgbf!YVrJyNw4B>%CO^?H1!t-;lBQ?XDf!vtI^##oTih64bxNnd%c0q3@$KnIqqAZ=j28=S3^`
z%=%}sPgkhzH|Tv;sGR!xmWz}^#!51IvYzpGB6hf8gTp
z#5iG-xD?xr3iqQYHqKi1R)u>f)6aE#cs4(gYcbeW=eH&(Q|-l$<%zs!KPA4&+tkz?
z&m$@}W5s>7U-f6Vnb}_8bbauv{N@4q1Ad=uW}Z31U3KwE^-j&lS)9SuHgO#VX+B3(
zmkP;cU%Yns_{56rrj`Xq+)po2)~>75Qhon%d*=1?HxC7N_vZ+)nLTjVyl7T`;bWEe
zfq4$Qu6#
zS}8x9r+Q*tiN|d&CbgD5qMkRW_{`hCDp5}8`6bTnu2>J_-5&0Z%a{L(a{A|(A}On^
zq91x{pG3~J1;0Xchh;(DCxQNsf^xg
zH9eM<{ytAk=OkL5Z@fCq@0FoYJACy|u!^_*V
z%0ew`g~v*r1UJPqocUW~KYX579Q3b77+njcqh9u$PPQhYSJcnF&MO8;)Lj*FIIV{@Zk$m$~=|u{
z{a{_8pNgcnZ2gV7wX4JrupCI<=@G|jJD1NuAnVUoj~N^9vz*?;d!X?B!R!1llTP=l
zeMouAryiiH;(n*_d2~GI`@%fc&GIeNN?xAXTFqNi1I4^#AWe#BMpP&Xqj0Eo|w37N02wvo<_c
zHhXa{WN%bt*6kE6o|0{m8y#~UVr(AQb+6=cxURn|w`N9Mjq8+lQQL#3HH&WX&Rm_k
z?)+!Td7sToL*Jefh)wLc_UgQUrb=$&$t9B=?dpEbk~tT@CN;!trBSu{5A~~CzlbFo
z8prRxxh0_V{@m2d5eql<{Yh#wmNrainiQL$FnekKoi~{chQIkjKdqEsTqr0Zx$5ou
z{`GMW+Y%eIpT3k!6=d?!31|GaZ_c8(4_;lHGfU>>Efx34!O~A%ZoCavV%Vr0{N&Oy
z6&Jl3huc>an&vN-o0YR*SuwBdu9&ah9Zc6!qrI-l3d(k!lZ(8(rg?+>j*~lOi(d3b
z_#95??8tmry)i96B){9*f_YL>KuK@-4f(1aqNSQY)}&2Wc%;kTu0cRH~l=c
zd8ZL$P|lga^mFCwuUXWw^YJ>RUSaw8E-$~Sd$U`GZpS>~&iDO|&wrg}(<~LpY;(LX
zyVg@lp|x#$)r*#AM(chV%Y?0#KCo-?8H3z!clS0gIiIaB6uZ;p#(eqfe7b9S-)#T&
zu{+`4{x$2rDYfR#JsBPpd1w04*wwRlv-TQ158i0s{*Os3OgiUA$xPLhH`TQ-D_+iB
zlq?a*;r~>{$an2e<0HDwKLhodw{AFcj9uEmeA3DZ4k23HEB~EY+tyiD>
zv+mE|D~BssbhaMunY!QXVGy750_&Rn?rn{(&z}3H_IU1k@#vPxp{w3|UoG!2ES(;IFuTCfAKAQh5Tziho
z{Lo+i6SVz~>|8rR>b}?&!G5-%_SG70hxZpn*Wc39Uv%e&((|`+e32d$~v`{zpEOR;%z-o>4)yN
zn(x6DFC+Fkn_0T|fLYL4`5)#t0^&ZeioVVBrBaK-CVanm{Nv^gedUwf4oGKTd1Bvr
zafJ+n)=K?z8mt%Yv&R0}UOM54aE&3)h2jZ^lzWUfXk0axw!I&8((!R)rR5ywhOA4M
zGYcI)NC;#uKkAclLMrXX49+sAk|5o^Qco6s)ZJowP4`)Hdw460ro*g5Qr2fxYHiz&
z?)F?Zr#zi~mGkn_+5>Nsg%~F$K3Y4MXYJS0-O=x)l8^pNYp!9uUgI^2$g)gbiC(B(|ZtzI*Qf%56
zr4TzQ$U)ic_N-5<4-5TCSvq~k4}p-GzE^^t7hMR>NVUH*<42CiQGdI$6Lb7pvr1BC
zFKb~wJ$E6uWC!E4<+m;{a@Cog`u}{V)wcU;2Zc3s`ZiQ=^OoI~@}VG{`PgEXQ(M2i
zH86a>P^Cb2n!07R2V>r~U&<3jBF}3~(^9paCo_+Oi)H26Cu@|GH++6pQ_-$=(f;>G
zjj%q)fa_m0m~LAqSiSQ&st|Ks{QJYl$=NJ=EgxL%11>v-oRZs7y+G}kZ3$=D)6lJ+
zT|a$fHXK~fFpVK!Zf#-NieHs+)s0j6l6sA+Pi-@|n=r_Wjc8SC3Zt$bGYh(16|)!z9oKPs@Rtj
z9pAh6txnsW?KPJf46pqAw$U#3O8thZ{zvAy-`@UOo#BCr<9mnq8}ySiF764Qr!R75
zt*y71#s{vd-FKe}yo;6CDU`H+ZPlDV@c}abCu*eu_&cWK36-Y=F~>lh@(_V(Gy*IRu=>I$|xo4j!dtyOsZ%m2-Wn&6~3
zjp$8Hx1M$1P@9s)tLtrU<$q}AV{7f|gIuL0ahs0cQasCQ&Y)!dq1Ft@D-?$Uj;Xm{I{;5GM
zTjv%3N!7}4`l2$kX1I7#`BI&4{L7OcXJAlshyMp2dl({L(z5?>yO%fmCXM1gq1xR|RA9(T67_o->?1yzQ6CJOQPU
zpgP$d8Y}iMNoQxa?@CoE$>B-caW^WRLD6kqT=SHT#SdQkhx@7|}YlT-IE
zetvSTtfNoM!?%Amb|edbw62S;DP6#}a{Gj6<>OHc6d%@$%L!Uv``M%RG^OvN)%v+F
z*05Mu;Yt5|AqL$Upk;
zcjTQD3cK!}m8c0b{wdBE8fwna^*Hapxj>$Z<%*-OtyA(GuE=m_ojp9;bCYI|rP`mf
zR;LxN^B&Z`>BV|^`l^{b&ZX3RcD#45$3UN<{P(xJMe1U+9(S!~Shcdo=Ek>Asgi#T
zo2C_5`YDL=lnDvNz54tnAla*1&Xc?3NKf#k74r{naFpHmTi;QvzbX6t*!bWiQd43R3Xp63N8<6@;EXKH+rmXtBx-!n6@
zzv9iW>8qF%YizZZPB%>dzVdHc(Dq4>HcwKnd&U`a=;-r=lMbum*qJ8%N`L%S<$2^5
zujE^Ko|}0#Z)OSAHgbGy`~66Srv3SU>`m5fUvDVxKl9Gb_tl!t7rvkG-+orll*heY
zf7SJz)pMu4R^%*?|E{u)!Si1A1@SGnYO8K5+~>HzI@zmZs_d2TzwEXz40-hUN7BTv
zDXNk=|Je9{?rta+_$hLw{?5jeJpcC3=2P^}5WBGD$i0jkPZH$*OK43nYUVrA`GfWP
zrKx!v5}$3z=-2ytRpIaRUuSoaJh-Rra2Il5i!moI};3#-=;Q6U}q
zxIKHW1Z}zd#a-swBbkXa9>(r;-M{{5;J$s+nKrAn*iSdiIdT5?c8=H8Gp@g!-@J+U
zy2(Yw`cIbvXY5>;cRq`ookihFW8_?gXv>Hh#Sfp{J-PP6k#%wZPRv~xUoAP`V(O}o
z#_6X&Pd|I|=kMskIwkQd#`y*h5(GcUEEIS(=T&M0^OMCNO4p~j-n05{ygMwUY?t2C
zPYx5;AKrMi@fd$&>V&|gojZ=le!uuGBzI1Tam8Er&mCI*7j9R*w0d^n)0rFWGc;`P
zPFLQWRq(u>cmFQa&mK?y8m>LQ-b}wgLw%`r(-ghK+`%#BMa%7DZg&(;lDP4Bh4j1W
z(lG@l_ZjAGwF=!M>ahJqnQN-#1IcxLs#hNy&Q82r_&HBj`Hj_Q
ztI|7+*S~!>DpZMLddTmuB^u^mdPIu<-J@L}ehZmS3-7(UH)BEV^x6oG7Yo%e0J
znZd%~`(1W1*XJoa_;mKo`5{#|eR0YDS%uRB9Kt>%Gw!>4d|mXW%3rptg*&FdJgC+p
zKZ||&3LCE2BNs&Xwq}bw+dgyEx5MnAYVUtU9kf4s(^F)ZQoHKQbb0OrT)Se~AI@Ht
zJ^g}}qO9Pt@Jr&rl
zJYfI3XokzYZ5$6KolN}k`%U4Jo&{?fqF2Rjt~FWtfz9p1PsvNoyuaZ*X6zxzv5y`~O>KKIK?)z<18+
z_2-X@Z)>PCI=;*LO;p~R^r?C0?swiPw@O^~GUjfrcWdpfoyu-|9Hvz=hgLpP-{CFr
zGw}G_F4uRr{3pfjSbBjYsQBGV1FeuOX}em*k7k$M&z=3d&r|pk-@Sba8Yb{+Rn16Elj0T?DliY9%r&Em|Lwn$%ZItQ{_3%KvO2%4
zu&e9F;VmMaT0agne-FKXT->!C|eZ14R+z>xz4C^dyT{_R+-nHb0Wo#dGJ4FU|^j3gwd~pfphw8
z_Iq+$6nmGMPM1`k^1;Po((Z)EYd-#P&0ekh^6ji2IsPvAxy``hHxXi@E;yE&JCj9={V0zfdn*#Jwb4yiHut;Im^JHD^?>JQt;}wvz92U%ejtfql=c%oM*J
zU9cptce4yf-8q^3CyXVt4qjt>IQ41X7f03yhZnrweskfClFfcsEnfe+t9T-pZMD>g
z=zUs?0>U;(-OT4&7Cdckoc75>O!>rSxfi
zn{YmAE>GsI#cPV9fBkH0WE0t3R1kq{VO~
zbmOsw_oP2tcc#zdHBU}@pz!VP)Y``}u?Ahc9zTuyDav`&;z6xRpnAyrmEW&>^o!|S
zDf6FQW0wE(M#jT=$Gn?@l73xnEwjvBv>_@(HoEI;`y<73te1-q%B~X$>8*+Qkrd3I
zztP!v>D-;|3pua&TxtyUzIAoGKfh*qz|rg7g}y4}e%fa`r&b(+Qlh5mDe&y;rZ@2Obo
z5LLMCpE$>ko7z(gCx7_)N$cZwT~CWS4HXBz*&nvtpl14#?{VAm7m4q_HJ|DGaavf)
z=FBAP*#aNlSfp;@zcyvTVzb9$yQcB%+2^k!v-#sxrsZ6ZZF>SFaybs3TEbu^80hc$
z(&YEKi~KG}cqIF>vJNf{h~8(Zz{s%TkJJo-JJyF1!V9j49l7K9Y7Ybd)EhtdEIj>G
z)R47SdFi&T;T!5(OAgK6{rZ;t*}dK`HZMF7GvCGhfL~@0^LD8tv$BuAeQ;vxw7u>}
zmNe?hZn;18`11{X9-=JoHTX*-o@PmPv>7*dZHl~e`qpOInaBU$usD?0kZ|Yoyu6a$
z5cRt0g7YLhC);K4nEy16hV9W(uaJ${NRmZ07?JDGJ3S~N0o5WSJNIo@ot^FQyUuZL^UgTewX>R8UO(UZ
zU|ZMAB#yq@8@(3Ee(o%(+h*}TOmX83%)9fir7j~);^krGKbo_S
zuiq(g{G@c>yXir7g}w7`_v&l!>})!}QTCatM4giY=V}(6<1Lquocb+QzBbCD@Dtzs
z*AALWo#C^lE8KtcdeI5SZl?sjIXBjuiDZ{Eu<6MN0Ro3tizzD(}0l)l36_uBu)Js*)Ep$SS${%)0S5%1jn{(VJ{6WiPm
zZfUHUy4s~qmtJ=6&yIEEQu%r;R#K|rqu=khO{o*KWoDdR_Ez`O(f58DA+FIL*=K+5
zb=(xkWZL`d+SH{Mx&2RMZad|6fM4G
zR=D^4q63%CRc=?}4XdnnK5^{s$$0^rR~gT;S}z`6f9+3j%@d0^a=$lahg4sF^DLV4
z0K2tW`1HLCmPmM?S8bRRyWq~H^NH&+n~v~3e74&_$<+6`Vw%st32SGc^($QdXw~X_
z9EVRy?e>tGcBc>-5L({>ItLUD7_YOfG9OHKy0y?3g^2an)`{kp~TGt1I??u+Q?|
z_jAX)nf|hjR(*E__o#b1PKnLkKIcU9*1Ml(cA2K%`&IkCA)Fy|;*9LKnx_^Vp8cs{
zesdDjO5@yVjZ+rQu{V`kRB@3*DpHI4_4(giZjRoAtqE*>zSnQ=@cK|=V{R5yS@h^KF(b1>(6vpPud=NCVy5VkH(v{W6rI
zdnO|;a=uf&^P}AI$tqiFAN{zq=Wm;arQc`eZB2_V8LU^nx%=w%(@%?kH|Lm5ZVeP)
zZ+Yj5@xxiwf4F;|uD{5V;j&6Vieq+f_}4#Y3qLuY*Kf*mSl7Gyq>c9H$@-TU*-SdP
z;_CBf)jdaegSZoK{f#@X=CI(`(XPzS)oU3$7B$G|bNZ%~^8R`nFaBHnvDxOLtfN_f
zS2Sv`iY{L3HaTClBao$btHcb`{2e{F=OJulvVi-)bf%Z*>fFa69b4~wp51mh?u7HJIdhHv
zKUix$tEOY|Gx=*GEcLv8d(}#B9(ylc7yhJogTna-_IgY=R_(p*>K@-YncG)uX+rf4
z#`Cjuq%IaRT$x>eS2uS`#vPx{`Cr2>FdpSNpPsJApmJQ{`Gr?JvKDZC`l_cOhVj${MysV7@yqV~}m&Un3brCW`EOq%t)#%{0Gd=BrgLRX}AXFqpl`Br2v7vbx2am(N5
zn;$)!6Swg5I_v14cCVE6ABwV?DA@{{oGM)A-LD~b`PfSx-uO&3G^h{}XY0Rh*f_dr3vt;m+;dRh8R+6yA5;`uTh8
zrO3iqfv?N2ubudGtz_$tHrw)y^+SJUMrT-Nc`H65KNN
z3bx|!CKm|??0ogD(0q8u6^zLp^}>){6;M?
zY~IgmyS4XBSKQHwxm!E=P4ii=ow7#{oJdi+Jt^-6AH&*;i5K0kn2S0~ymk9ir;)$=
z;-}M(wwDNgy5cPO+2LuAYqav=85g-NR1EdHPixKnJ<*9d{o`DLdv`nze=k;8SI{)s
zCS|3xbHcPucNGE}w|Cq$nATg>xnfd)@8+!JW7eV4wvn6G-@LGJ`?Z79Pufh2t4dmQ
z&$X!L`4g%{*RP70pc9VHa|_u#$fmsTuhjXlA*rwL9{
zzGurA$0C@p&Xm?`7eAH#lD>SSFk*c~r1sOA8Jt!eD^;vB(~Kjp
z{Y*UY+~M_{Fgd<%O`#PwlRl}2N&XJXT-L{XtH(q4)pXuyRlV2COuq45Dq6aAeuV8(
z2fc@Pou^kQd=k`&?_cz!Qncdxn&2wV8+q4H)y40K`0P><_#(dSiaOlL7lnH?_TS*)S-tn0>8#n|n>=2I7zHi+u=&))a(37i@})8-FSkBD>2l!FODSugF-@H;ANF$*=ZYp#K0E7mlHa!!
z1pnb{wm8<(Y%ry2_L6z|C+E*!vnXoBPJ+=<=T$ho{RJ
zhRy8xy;D3PSlc#foeS@*V;TQTd|EbTr~g%YwP5v~>qVdWK0JM%<62(U7ry*TRnCip
z2~QhVSX(am?l#T8#`eI*X={XSisv^rzq0u{u{^T#n!d}MHknkr&ZCwZ^LMRT|3!NK
zr$#9z)tD8rZ%dXQQ7aLImMpcy3lLcmD}eu7QOEKy}W#)*`|P`jNY0@@;9r)
zUYdW=zx9^o0~>qu&nFjO&TKI`+j^_m@9I3R*}BWBAMQx`w@3Lu^Gr{kFV_vvw05TR
zJehyv&soQsMU5Pt7QnP?3F8Yf(q$XZMVQzxtKdX-R!3_*>OeyR-1H!(D0huc=>^6D--ERYYbTec&TF
z`QD*G!HxMCmYDe0Nii_KO2F@MIDl?AMG=fCN>czaTlgL$K;$I-Zdsk>&Z{@UDr
zQ7h}HiI!g5^M4+l%n|$7ir)0UCmyn%X^Ob|-uBH(lPbUK|J%0l?96*&>Ab!`?=1|*!gqa2(&nP8-SviR><8!^v)d$`m55LizD)D3bTEA%)Eo)V_
z1@5eO)AuW%8)W2G^tPAlj`3>uy0Fi(tN;3be=l=2Ihp14)&DWhRVqb?O{PrRVW=T^
z<1g>kj<(4mZ8z$o@9;X89`=_pbg+oc*3e_VIPbdbLk58~?)4vTMB7hf&UqXrlJK*B
z_5afw3l<#se7mly@1K5CN|3p1<&n&YOG!85&bK{(?DFd0-;WvUJnP>dWRJ=FdVX_y
z$deN;)}Ojlb`^%}IoNC}XgEH}V|jzsZAtADm$%<~wC~@puYQkHT=$&Vp>fCB2Rm72I&44n
zzcowuo%qzGUyBsC?v9M_m@>KDf$81!^Veo_6(5c|d0>;?ihV4)i$Y@q)RdP_O;aw9
zaxUHXj3F`a?UWCCXHB_go#|jJb^d;%+Jm_-{H95M-jN3f|8B1Q^?>{H*6&G8yEksg
z+8iZwhN-klA^Fszh^?l&Auqc>&Lv#14Rxxw&xgt_+J*oSY
z>#yxuBD+vR?3+;ku5V8!Ju$Ane*Vvqf~XQse~0dye9<#Ge=d9DX>fhR#?bCKliSH0
ztA22VF!cxu4=#dBMW5?#GE^^;l%YXbIk4c@7>Y%;GXQoGqdJtZcMhk
z!1y|Zm9MG5m&fX7q=I0W_$%$BUyOdTH9T7Ijbo$k{=9|Eg5IrZg|&q&5r$g-SRY=h
zzi=)~VlVf+tLNDG(
zUoF=poZ7UDwkxnjM5lM`f3&lx#6N3G{63kU*64|*jd`-?<_Q(8jr#G#>PSPr;^%pc
zyNY+s;s6Kt9iAa+vlwI3F4)DrPU<=UHEjAYk?`p^$q=}n2#R`
z(p|jy%T5!|N(22FckJiRUSWT;kjJqxoyn@%+W+*c{jRE)c9sWhef_-L!|=SL@!<=s
z_q;ZLv*1;$x#S+7yCe6hVCa%VKIZj{*a{VHuK(oevujy(qtkYaEpNVYsQ%Ahk+s`U
zxPR-;!%49j#@j^_mxNgz*v!+hx2sP3PP(zAu7c=grhtPRy1q|5JTn_e{_|Wsvi_e)yL|sEyE-|zgSpS7(^QRq03RZsF
zW*BwfRV5&1;w#_ynR3bdWahs))ZSGyeg4bjb+5FZe0%znUw=p5H=~C8-OCc34{x@<
z5?kBF_4!Cihw_QoH1-QV2vBi6ZjE&hD=hQKSPC2!Y9
zPc@AXogG({9Dg@KN2Mhy(coT!*?*o^Yx#+);qJB>=G>NToW7?ST~jR;K`?vFYl?Q
z%oU!@9G&XEibH$BQ?Yyf+doY{xL7G(`+v=)qIuKHH2k8a7qohKiTl)0y$Kaewl>D3E6IXNlLzj5zv^6FpI6fmy5D=*5^PzPTDx0+aYw=n=4cYw5v9-Z$<SW9`{|@zcyzzt
zj+=V~X7_)L=kr;)Zd>onyr+qCdgb}`YQiqq_W14NzTalAk(SXCq>rh&7sCW^yT
zPHa7+8{2QaUZ>wu!mrN+a9vXg3b?C&ZC~N_I+e%!u9Vzi?9-zT)xG
z7gdr|8SQk}Wj&WTuF7ks_J3d3qjSH+A0B>DQyw8!k`gPuF~jQe#K7r}I;U>U*}cPf
zhMB6?lAtpc*3R#qUOp&!aF&k|J7aIU)SDg7k_-8=)t>Bq#658(zjWfotF^N~hSqEp
zZb}c|B)2{~Ns-r9;qMdAo)8yP&XehBshiU8M}1|y5g>Sz`|@jz4__)zdEQB%s`2^Y
z1=e}9Zu5kxyU1ibb=7Tg3*Hv1#XBMCMD)V6r)EVu`~1K3PXFP&Ky8zJ>-SBW`&NoH
zhHtxSCgtNQ#nNKMV6Ff6?6I?a0kM3X+W+sQ?A@`I`Nl5k%DYEeX6y=?8)S34^ngx%
z(X_d9r}oC{ME^d`IZO7xe(5%Yl?D-yrN!M?XSM$ioOH~S_xa@=^B;D7e7SLUdBE{A
zrVm&3)-yU~v?)}s%UJYs&p)dPWuog}JxmQ2oLk5*w=+}GpJ&03)iq8HPHk__{d>6{`w4Nmf*THMEw*~L==hU62Xz13yY0FzF-llMtip5hk<5wVBCB`!?{t#W
z{-kzW^Hf>Tg~)`Ltr}j>mdz=P-+C!X;Mcv%vqyd8tOFYDAEeGo^S!DN!CB_DSmW^O
zj}J1IEUXMMPEy%?|4BjJN40Q^lQO>l-%LHfbfx+%yALtD{|3EWv9bHP)w937ZoA8-
z)t&gU{PC*o-dFvX%zJpgt)TY}^UDqI(+-sYsU9byRKlUTe
zOd^SY*=2FZa?>08gLgF>{$)5I@pS&nyP+3ul|M31J9?`tDqJDp|IL@@nr_Q)(D?Xt
z+w1Dsjdr3P@!h9Pm2A0|mCWhfJSkjDCSP!YUf0a-v+v7wv=3}N)N$`-lCi+e`+O^$
zIEotr!!3HaR^G8O|69#u(01U?UG@(iLO*#~JPu8odb|J1!6|QLZthmKU-c$AN^RW3x
z>3`XteeBGUw)?K7eDsx__T!VrnZ^*msdc~8qs1RO+wZe=l6<7y$p7|9T*X|^6zdng
zlGdBf#9CGU3A$aKRTo%Pn@7+T)A(Cpj!j*cJM-==g&gO>c(sq}Qs;%TB)B
zVAB4s_j?O_m38Ny2N{tZH#8k|0|YK+d3`c{A?u-UQ+1rF?Z(6V+#+cmv-d8U#H*5f
z&oRrFpY??2(uaEv{x6JnJl@n9ye)fTYZrUk+*0mq6TftY1e`6~({VguL(uoUrE9+~
zt*J~^OI!a)O{gH|;`ESyqf-molmAWWv!1wMOJ?|tOQG>suU+)9eXDK0ly|bch<|bs
zr^Km4PpfS*jIs_TpY-5)a^%Ruj(?v+ox>HM{CJbKKU+-lGGqJ3je$SnF1ImB`325d
z^G7FNP9;z0yk-E~CI7o0xe8+E^-L+0UFo~^g#7U>PJwx;EqCm%ZqZyl)xMT3A@1T!
z17@|O>sw!G7~Rmc&&*piW&h@?4U1jedXL4gh+$ulWNu;QrdexY{`y3*Zuw2lU)h%=
z1g$uyX4g4GZ_d+kZA%tQ>+L*T%N|gF
zVbU7O^Z?VxJ_2^y>i~Hx0m7C80K3X(&_MOuHE3bpPuCGk}J(W{0c&Tta+x7jcJpP5&{hGUK>pZ6E
zQAZh4H@NK0;{VXH?^*Hm!;-rDm-)s!Z_-%7TYvVq&<%&L>0(QQX4qQiYQL4^cpsQI
z`M4Hu;nTld*Lqz)Tz8yg%*iePgL!$krtZ%-Q&knTuKzQhD{2yXMzmS}|b%0n3A@cYJ@Vr4q3!rkmU2_MOnh
zAqQ8yI~OeXM8b7f!POAvC5_BK8ZtdQ9xDGS%GqdnwBtmg{7Ug_=Uk4bJ&UqCV6|n}
ztHQ8`KLrPu3ruN9Ih%G>ehK@`+@+Na
zP}XZ2zxTg5`9eN^@AUEH?s2^-vZ6`TjqaX
zT;uxr{Ur4bRyH46Ofe
zHh(w%^XH$c<&DGD#;bWgl}o*q%)h7qOq7ZB@B4tn`G)aP_r%-h+X?^v>!%c&_+Z_K
z$wfIGbFw@B9zJf#vLUG0)a|Oy{+PPZrfqRKpJ#5(aSP$nY#02zFGhD#_%YY360egk
zeNriZdeh7Q!2EOBaz~cTI^USK)HeG5Yll$F%SJ-Swl6xqes4FYaBNn`Yxijn3L9##
z-uXF2F6;5S+r}}|YQ1+IwRF;2d&8r9Q+0ts^ICDy{4KiEgU{dZS|*>X_218ewb(BC
zX5Y;-tP^LPn_7@t&86`Af7|94CcoOZ&i~-hu4jFegGbUcY94=(+oPlWJxm|poHaYB
z_#<^r&355Cx`DQNRqL1EzrTRvcY9CKyG^OwQNFk$ro!|3;i9v-aH`}!W!sB
zoM(Q<{?Ce-zG03>-)xQT>=Cn#Vit;P_OR9IJbI~kq-w|BchkRrG#6budt;hygxqG^
z2Nzl$miT!jD@8F)C{0<)@U1eg`fcF%uABaXd@l7Sk+MC}8}94>73IHWJbg)*!sc6L
z#{O%kuPom(u~YWRq-xE|-%KuNjVq^T7?e7?fi2G!=}VO(tYAV#hRE7+fP2J0m0RpYdf|z$~A8eM525P7}j`=*!I4w!V}1kSxCUq9sDIpJPkf
zt99J3S4@8M=$z%+wO5&Zi?7_lsp
zN~Rb#ADlDmiR90^maL1B?0vOyXR7+y&)DR8hnb%K@Aztp{J*{Li!`)%{Cu)*KZnnb
zsWWywcKjE->FBLvH~(xij(Q>Eut`fN-8<_(n~Uib2NPHJ=oNzEKV;@ySo3t#+@n)J
zXn*Alcx3#zoF~r0cF%@VG$Zm!TvU;RUV
zTz9gMUfz1tV9j1W<{eJ=v!2>LUFTi=(3K&-H)_H&VHU+N$&NV^`x|qXP4_U_dSOov
z?{dE!alsuus<*x}2Q{6W8_W@WDrwfTyQ`Rl)Hx6M9ha}%QCy~%pHpWPzI=f#%lg;_
zFLj>me&Bkr!ZZ6$Y(3wDvuAm7cGM+Y*tG7_*}Z`q__r5l#OD334_%w*CgwByVoJdW
zzl8lPCTbm#(}TpOx4Fd~=yvL#b5ZDRN{Q0a!uoj!)eJMPacvcRX1V!N-Mgi#H@!NK
zUNO|ywKv%OWzz1Vhc0siINLu=`>^fck%)~u7VN!m$+LadR8PZgchz_nq^0m5nO~X1
zC|joZxPE?F;DSuUOTVP=oUAp8x%5ECeU9CYu!YQte1Eq5p0{WBleC+h|CR+!S#nqM
zj;;G~GpFK-r&QK^iMLAL?F|i+IOX$d*Na&5+buqCN=jG8tgicRGqdC3jSCzbm&e;A
z?3~W{5kcCL~|b=IfJs#g52zgvYCtn(J|%AB}Z
zg*&19SINJ5&1aNmTjfvN{CnRr7dP<c^-R%oPjnuMJXW0T
zHi!S*%87;c^5?H>`qhrHX|~=
z%`-;1A=S-qTVU$t;sQoXS5r%HzdwfRoUJ#`tyjI$1|D}QZY9{Oh;NAr`6
zkT*Z{wtV!x-n2jP%#R6fm2)(2uWG(Dq3Wdlzcq{BHXM8)U$OPiwc?-~yLUD#Nc_;!
zIWx&exmaTFfkP72C092w3lyfCUO&pod0qFLi<|5B7PPOdv%N_oEy8eQCIQhaK|RgM|{8VyICj;%fYSW3lvcE8d>8!nTSIm=?R
zub<93?Uldy1@}8i;Vp-5pA~V;tSH#pygd37qsUgRb8~xxe&|<-?0mI6UfAmU&+yj!
zx4wErH@{(;-NvlRWhPoL`E_o_(d&n1W_lkCPO9sCD{`$aHgDSoK22L8<15M`^KANB
zcZ)BanlfqC6#0Gk8DDk#yP4cR{wr1Pn+lWvi7Sex>%W*k@Oym4;iZss_-dOeB#Xi1-#xO{clwJT~3A0*ZKOOdV#cb|D7w>w@#0n
zxUj7Hihuo^Pe(V3w@Ll$FSzwihDndv`P4dr&`*Yc_Rsubd&ysFZD-v7tn}9_m(?8=
z+vq7&GUsoD0pHaQxgU2VWM?w5ygE8f@o}c_*Z-Zm3qQoK*9nq4x*~$9@sRh4{aIcN
M?4oxjFfcFx0J!Y(>Hq)$
literal 0
HcmV?d00001
diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go
index ef796920d..755f2fa3f 100644
--- a/bskyweb/cmd/bskyweb/server.go
+++ b/bskyweb/cmd/bskyweb/server.go
@@ -258,6 +258,7 @@ func serve(cctx *cli.Context) error {
e.GET("/feeds", server.WebGeneric)
e.GET("/notifications", server.WebGeneric)
e.GET("/notifications/settings", server.WebGeneric)
+ e.GET("/notifications/activity", server.WebGeneric)
e.GET("/lists", server.WebGeneric)
e.GET("/moderation", server.WebGeneric)
e.GET("/moderation/modlists", server.WebGeneric)
@@ -275,6 +276,7 @@ func serve(cctx *cli.Context) error {
e.GET("/settings/appearance", server.WebGeneric)
e.GET("/settings/account", server.WebGeneric)
e.GET("/settings/privacy-and-security", server.WebGeneric)
+ e.GET("/settings/privacy-and-security/activity", server.WebGeneric)
e.GET("/settings/content-and-media", server.WebGeneric)
e.GET("/settings/interests", server.WebGeneric)
e.GET("/settings/about", server.WebGeneric)
diff --git a/modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandler.kt b/modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandler.kt
index 9fdfcfd89..4f8a6b892 100644
--- a/modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandler.kt
+++ b/modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandler.kt
@@ -45,10 +45,10 @@ class BackgroundNotificationHandler(
private fun mutateWithOtherReason(remoteMessage: RemoteMessage) {
// If oreo or higher
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
- // If one of "like", "repost", "follow", "mention", "reply", "quote", "like-via-repost", "repost-via-repost"
+ // If one of "like", "repost", "follow", "mention", "reply", "quote", "like-via-repost", "repost-via-repost", "subscribed-post"
// assign to it's eponymous channel. otherwise do nothing, let expo handle it
when (remoteMessage.data["reason"]) {
- "like", "repost", "follow", "mention", "reply", "quote", "like-via-repost", "repost-via-repost" -> {
+ "like", "repost", "follow", "mention", "reply", "quote", "like-via-repost", "repost-via-repost", "subscribed-post" -> {
remoteMessage.data["channelId"] = remoteMessage.data["reason"]
}
}
diff --git a/package.json b/package.json
index 7c1c3dee9..aae35f96a 100644
--- a/package.json
+++ b/package.json
@@ -69,7 +69,7 @@
"icons:optimize": "svgo -f ./assets/icons"
},
"dependencies": {
- "@atproto/api": "^0.15.16",
+ "@atproto/api": "^0.15.21",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
@@ -218,7 +218,7 @@
"zod": "^3.20.2"
},
"devDependencies": {
- "@atproto/dev-env": "^0.3.144",
+ "@atproto/dev-env": "^0.3.150",
"@babel/core": "^7.26.0",
"@babel/preset-env": "^7.26.0",
"@babel/runtime": "^7.26.0",
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index f1a9c569d..26a2b2a2a 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -84,6 +84,7 @@ import {SearchScreen} from '#/screens/Search'
import {AboutSettingsScreen} from '#/screens/Settings/AboutSettings'
import {AccessibilitySettingsScreen} from '#/screens/Settings/AccessibilitySettings'
import {AccountSettingsScreen} from '#/screens/Settings/AccountSettings'
+import {ActivityPrivacySettingsScreen} from '#/screens/Settings/ActivityPrivacySettings'
import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings'
import {AppIconSettingsScreen} from '#/screens/Settings/AppIconSettings'
import {AppPasswordsScreen} from '#/screens/Settings/AppPasswords'
@@ -109,8 +110,10 @@ import {
} from '#/components/dialogs/EmailDialog'
import {router} from '#/routes'
import {Referrer} from '../modules/expo-bluesky-swiss-army'
+import {NotificationsActivityListScreen} from './screens/Notifications/ActivityList'
import {LegacyNotificationSettingsScreen} from './screens/Settings/LegacyNotificationSettings'
import {NotificationSettingsScreen} from './screens/Settings/NotificationSettings'
+import {ActivityNotificationSettingsScreen} from './screens/Settings/NotificationSettings/ActivityNotificationSettings'
import {LikeNotificationSettingsScreen} from './screens/Settings/NotificationSettings/LikeNotificationSettings'
import {LikesOnRepostsNotificationSettingsScreen} from './screens/Settings/NotificationSettings/LikesOnRepostsNotificationSettings'
import {MentionNotificationSettingsScreen} from './screens/Settings/NotificationSettings/MentionNotificationSettings'
@@ -390,6 +393,14 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
requireAuth: true,
}}
/>
+ ActivityPrivacySettingsScreen}
+ options={{
+ title: title(msg`Privacy and Security`),
+ requireAuth: true,
+ }}
+ />
NotificationSettingsScreen}
@@ -459,6 +470,14 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
requireAuth: true,
}}
/>
+ ActivityNotificationSettingsScreen}
+ options={{
+ title: title(msg`Activity notifications`),
+ requireAuth: true,
+ }}
+ />
MiscellaneousNotificationSettingsScreen}
@@ -524,6 +543,11 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
getComponent={() => MessagesInboxScreen}
options={{title: title(msg`Chat request inbox`), requireAuth: true}}
/>
+ NotificationsActivityListScreen}
+ options={{title: title(msg`Notifications`), requireAuth: true}}
+ />
LegacyNotificationSettingsScreen}
diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx
index 30b26bead..4aec74880 100644
--- a/src/components/ProfileCard.tsx
+++ b/src/components/ProfileCard.tsx
@@ -11,6 +11,8 @@ import {useLingui} from '@lingui/react'
import {useActorStatus} from '#/lib/actor-status'
import {getModerationCauseKey} from '#/lib/moderation'
import {type LogEvents} from '#/lib/statsig/statsig'
+import {forceLTR} from '#/lib/strings/bidi'
+import {NON_BREAKING_SPACE} from '#/lib/strings/constants'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {useProfileShadow} from '#/state/cache/profile-shadow'
@@ -18,7 +20,7 @@ import {useProfileFollowMutationQueue} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import {PreviewableUserAvatar, UserAvatar} from '#/view/com/util/UserAvatar'
-import {atoms as a, useTheme} from '#/alf'
+import {atoms as a, platform, useTheme} from '#/alf'
import {
Button,
ButtonIcon,
@@ -183,14 +185,77 @@ export function AvatarPlaceholder() {
export function NameAndHandle({
profile,
moderationOpts,
+ inline = false,
}: {
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
+ inline?: boolean
}) {
+ if (inline) {
+ return (
+
+ )
+ } else {
+ return (
+
+
+
+
+ )
+ }
+}
+
+function InlineNameAndHandle({
+ profile,
+ moderationOpts,
+}: {
+ profile: bsky.profile.AnyProfileView
+ moderationOpts: ModerationOpts
+}) {
+ const t = useTheme()
+ const verification = useSimpleVerificationState({profile})
+ const moderation = moderateProfile(profile, moderationOpts)
+ const name = sanitizeDisplayName(
+ profile.displayName || sanitizeHandle(profile.handle),
+ moderation.ui('displayName'),
+ )
+ const handle = sanitizeHandle(profile.handle, '@')
return (
-
-
-
+
+
+ {forceLTR(name)}
+
+ {verification.showBadge && (
+
+
+
+ )}
+
+ {NON_BREAKING_SPACE + handle}
+
)
}
diff --git a/src/components/Tooltip/index.tsx b/src/components/Tooltip/index.tsx
index 446cf18fc..fbdb969db 100644
--- a/src/components/Tooltip/index.tsx
+++ b/src/components/Tooltip/index.tsx
@@ -3,6 +3,7 @@ import {
createContext,
useCallback,
useContext,
+ useEffect,
useMemo,
useRef,
useState,
@@ -30,31 +31,33 @@ const BUBBLE_SHADOW_OFFSET = ARROW_SIZE / 3 // vibes-based, provide more shadow
type TooltipContextType = {
position: 'top' | 'bottom'
- ready: boolean
+ visible: boolean
onVisibleChange: (visible: boolean) => void
}
+type TargetMeasurements = {
+ x: number
+ y: number
+ width: number
+ height: number
+}
+
type TargetContextType = {
- targetMeasurements:
- | {
- x: number
- y: number
- width: number
- height: number
- }
- | undefined
- targetRef: React.RefObject
+ targetMeasurements: TargetMeasurements | undefined
+ setTargetMeasurements: (measurements: TargetMeasurements) => void
+ shouldMeasure: boolean
}
const TooltipContext = createContext({
position: 'bottom',
- ready: false,
+ visible: false,
onVisibleChange: () => {},
})
const TargetContext = createContext({
targetMeasurements: undefined,
- targetRef: {current: null},
+ setTargetMeasurements: () => {},
+ shouldMeasure: false,
})
export function Outer({
@@ -68,21 +71,12 @@ export function Outer({
visible: boolean
onVisibleChange: (visible: boolean) => void
}) {
- /**
- * Whether we have measured the target and are ready to show the tooltip.
- */
- const [ready, setReady] = useState(false)
/**
* Lagging state to track the externally-controlled visibility of the
- * tooltip.
+ * tooltip, which needs to wait for the target to be measured before
+ * actually being shown.
*/
- const [prevRequestVisible, setPrevRequestVisible] = useState<
- boolean | undefined
- >()
- /**
- * Needs to reference the element this Tooltip is attached to.
- */
- const targetRef = useRef(null)
+ const [visible, setVisible] = useState(false)
const [targetMeasurements, setTargetMeasurements] = useState<
| {
x: number
@@ -93,33 +87,24 @@ export function Outer({
| undefined
>(undefined)
- if (requestVisible && !prevRequestVisible) {
- setPrevRequestVisible(true)
-
- if (targetRef.current) {
- /*
- * Once opened, measure the dimensions and position of the target
- */
- targetRef.current.measure((_x, _y, width, height, pageX, pageY) => {
- if (pageX !== undefined && pageY !== undefined && width && height) {
- setTargetMeasurements({x: pageX, y: pageY, width, height})
- setReady(true)
- }
- })
- }
- } else if (!requestVisible && prevRequestVisible) {
- setPrevRequestVisible(false)
+ if (requestVisible && !visible && targetMeasurements) {
+ setVisible(true)
+ } else if (!requestVisible && visible) {
+ setVisible(false)
setTargetMeasurements(undefined)
- setReady(false)
}
const ctx = useMemo(
- () => ({position, ready, onVisibleChange}),
- [position, ready, onVisibleChange],
+ () => ({position, visible, onVisibleChange}),
+ [position, visible, onVisibleChange],
)
const targetCtx = useMemo(
- () => ({targetMeasurements, targetRef}),
- [targetMeasurements, targetRef],
+ () => ({
+ targetMeasurements,
+ setTargetMeasurements,
+ shouldMeasure: requestVisible,
+ }),
+ [requestVisible, targetMeasurements, setTargetMeasurements],
)
return (
@@ -132,7 +117,20 @@ export function Outer({
}
export function Target({children}: {children: React.ReactNode}) {
- const {targetRef} = useContext(TargetContext)
+ const {shouldMeasure, setTargetMeasurements} = useContext(TargetContext)
+ const targetRef = useRef(null)
+
+ useEffect(() => {
+ if (!shouldMeasure) return
+ /*
+ * Once opened, measure the dimensions and position of the target
+ */
+ targetRef.current?.measure((_x, _y, width, height, pageX, pageY) => {
+ if (pageX !== undefined && pageY !== undefined && width && height) {
+ setTargetMeasurements({x: pageX, y: pageY, width, height})
+ }
+ })
+ }, [shouldMeasure, setTargetMeasurements])
return (
@@ -148,13 +146,13 @@ export function Content({
children: React.ReactNode
label: string
}) {
- const {position, ready, onVisibleChange} = useContext(TooltipContext)
+ const {position, visible, onVisibleChange} = useContext(TooltipContext)
const {targetMeasurements} = useContext(TargetContext)
const requestClose = useCallback(() => {
onVisibleChange(false)
}, [onVisibleChange])
- if (!ready || !targetMeasurements) return null
+ if (!visible || !targetMeasurements) return null
return (
diff --git a/src/components/Tooltip/index.web.tsx b/src/components/Tooltip/index.web.tsx
index 739a714cd..fc5808d7a 100644
--- a/src/components/Tooltip/index.web.tsx
+++ b/src/components/Tooltip/index.web.tsx
@@ -13,10 +13,12 @@ import {Text} from '#/components/Typography'
type TooltipContextType = {
position: 'top' | 'bottom'
+ onVisibleChange: (open: boolean) => void
}
const TooltipContext = createContext({
position: 'bottom',
+ onVisibleChange: () => {},
})
export function Outer({
@@ -30,7 +32,10 @@ export function Outer({
visible: boolean
onVisibleChange: (visible: boolean) => void
}) {
- const ctx = useMemo(() => ({position}), [position])
+ const ctx = useMemo(
+ () => ({position, onVisibleChange}),
+ [position, onVisibleChange],
+ )
return (
{children}
@@ -54,7 +59,7 @@ export function Content({
label: string
}) {
const t = useTheme()
- const {position} = useContext(TooltipContext)
+ const {position, onVisibleChange} = useContext(TooltipContext)
return (
onVisibleChange(false)}
style={flatten([
a.rounded_sm,
select(t.name, {
diff --git a/src/components/activity-notifications/SubscribeProfileButton.tsx b/src/components/activity-notifications/SubscribeProfileButton.tsx
new file mode 100644
index 000000000..71253dca9
--- /dev/null
+++ b/src/components/activity-notifications/SubscribeProfileButton.tsx
@@ -0,0 +1,89 @@
+import {useCallback} from 'react'
+import {type ModerationOpts} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
+import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
+import {Button, ButtonIcon} from '#/components/Button'
+import {useDialogControl} from '#/components/Dialog'
+import {BellPlus_Stroke2_Corner0_Rounded as BellPlusIcon} from '#/components/icons/BellPlus'
+import {BellRinging_Filled_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging'
+import * as Tooltip from '#/components/Tooltip'
+import {Text} from '#/components/Typography'
+import {useActivitySubscriptionsNudged} from '#/storage/hooks/activity-subscriptions-nudged'
+import type * as bsky from '#/types/bsky'
+import {SubscribeProfileDialog} from './SubscribeProfileDialog'
+
+export function SubscribeProfileButton({
+ profile,
+ moderationOpts,
+}: {
+ profile: bsky.profile.AnyProfileView
+ moderationOpts: ModerationOpts
+}) {
+ const {_} = useLingui()
+ const requireEmailVerification = useRequireEmailVerification()
+ const subscribeDialogControl = useDialogControl()
+ const [activitySubscriptionsNudged, setActivitySubscriptionsNudged] =
+ useActivitySubscriptionsNudged()
+
+ const onDismissTooltip = () => {
+ setActivitySubscriptionsNudged(true)
+ }
+
+ const onPress = useCallback(() => {
+ subscribeDialogControl.open()
+ }, [subscribeDialogControl])
+
+ const name = createSanitizedDisplayName(profile, true)
+
+ const wrappedOnPress = requireEmailVerification(onPress, {
+ instructions: [
+
+ Before you can get notifications for {name}'s posts, you must first
+ verify your email.
+ ,
+ ],
+ })
+
+ const isSubscribed =
+ profile.viewer?.activitySubscription?.post ||
+ profile.viewer?.activitySubscription?.reply
+
+ const Icon = isSubscribed ? BellRingingIcon : BellPlusIcon
+
+ return (
+ <>
+
+
+
+
+
+
+ Get notified about new posts
+
+
+
+
+
+ >
+ )
+}
diff --git a/src/components/activity-notifications/SubscribeProfileDialog.tsx b/src/components/activity-notifications/SubscribeProfileDialog.tsx
new file mode 100644
index 000000000..d1ab2842d
--- /dev/null
+++ b/src/components/activity-notifications/SubscribeProfileDialog.tsx
@@ -0,0 +1,309 @@
+import {useMemo, useState} from 'react'
+import {View} from 'react-native'
+import {
+ type AppBskyNotificationDefs,
+ type AppBskyNotificationListActivitySubscriptions,
+ type ModerationOpts,
+ type Un$Typed,
+} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {
+ type InfiniteData,
+ useMutation,
+ useQueryClient,
+} from '@tanstack/react-query'
+
+import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
+import {cleanError} from '#/lib/strings/errors'
+import {sanitizeHandle} from '#/lib/strings/handles'
+import {logger} from '#/logger'
+import {isWeb} from '#/platform/detection'
+import {updateProfileShadow} from '#/state/cache/profile-shadow'
+import {RQKEY_getActivitySubscriptions} from '#/state/queries/activity-subscriptions'
+import {useAgent} from '#/state/session'
+import * as Toast from '#/view/com/util/Toast'
+import {platform, useTheme, web} from '#/alf'
+import {atoms as a} from '#/alf'
+import {Admonition} from '#/components/Admonition'
+import {
+ Button,
+ ButtonIcon,
+ type ButtonProps,
+ ButtonText,
+} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import * as Toggle from '#/components/forms/Toggle'
+import {Loader} from '#/components/Loader'
+import * as ProfileCard from '#/components/ProfileCard'
+import {Text} from '#/components/Typography'
+import type * as bsky from '#/types/bsky'
+
+export function SubscribeProfileDialog({
+ control,
+ profile,
+ moderationOpts,
+ includeProfile,
+}: {
+ control: Dialog.DialogControlProps
+ profile: bsky.profile.AnyProfileView
+ moderationOpts: ModerationOpts
+ includeProfile?: boolean
+}) {
+ return (
+
+
+
+
+ )
+}
+
+function DialogInner({
+ profile,
+ moderationOpts,
+ includeProfile,
+}: {
+ profile: bsky.profile.AnyProfileView
+ moderationOpts: ModerationOpts
+ includeProfile?: boolean
+}) {
+ const {_} = useLingui()
+ const t = useTheme()
+ const agent = useAgent()
+ const control = Dialog.useDialogContext()
+ const queryClient = useQueryClient()
+ const initialState = parseActivitySubscription(
+ profile.viewer?.activitySubscription,
+ )
+ const [state, setState] = useState(initialState)
+
+ const values = useMemo(() => {
+ const {post, reply} = state
+ const res = []
+ if (post) res.push('post')
+ if (reply) res.push('reply')
+ return res
+ }, [state])
+
+ const onChange = (newValues: string[]) => {
+ setState(oldValues => {
+ // ensure you can't have reply without post
+ if (!oldValues.reply && newValues.includes('reply')) {
+ return {
+ post: true,
+ reply: true,
+ }
+ }
+
+ if (oldValues.post && !newValues.includes('post')) {
+ return {
+ post: false,
+ reply: false,
+ }
+ }
+
+ return {
+ post: newValues.includes('post'),
+ reply: newValues.includes('reply'),
+ }
+ })
+ }
+
+ const {
+ mutate: saveChanges,
+ isPending: isSaving,
+ error,
+ } = useMutation({
+ mutationFn: async (
+ activitySubscription: Un$Typed,
+ ) => {
+ await agent.app.bsky.notification.putActivitySubscription({
+ subject: profile.did,
+ activitySubscription,
+ })
+ },
+ onSuccess: (_data, activitySubscription) => {
+ control.close(() => {
+ updateProfileShadow(queryClient, profile.did, {
+ activitySubscription,
+ })
+
+ if (!activitySubscription.post && !activitySubscription.reply) {
+ logger.metric('activitySubscription:disable', {})
+ Toast.show(
+ _(
+ msg`You will no longer receive notifications for ${sanitizeHandle(profile.handle, '@')}`,
+ ),
+ 'check',
+ )
+
+ // filter out the subscription
+ queryClient.setQueryData(
+ RQKEY_getActivitySubscriptions,
+ (
+ old?: InfiniteData,
+ ) => {
+ if (!old) return old
+ return {
+ ...old,
+ pages: old.pages.map(page => ({
+ ...page,
+ subscriptions: page.subscriptions.filter(
+ item => item.did !== profile.did,
+ ),
+ })),
+ }
+ },
+ )
+ } else {
+ logger.metric('activitySubscription:enable', {
+ setting: activitySubscription.reply ? 'posts_and_replies' : 'posts',
+ })
+ if (!initialState.post && !initialState.reply) {
+ Toast.show(
+ _(
+ msg`You'll start receiving notifications for ${sanitizeHandle(profile.handle, '@')}!`,
+ ),
+ 'check',
+ )
+ } else {
+ Toast.show(_(msg`Changes saved`), 'check')
+ }
+ }
+ })
+ },
+ onError: err => {
+ logger.error('Could not save activity subscription', {message: err})
+ },
+ })
+
+ const buttonProps: Omit = useMemo(() => {
+ const isDirty =
+ state.post !== initialState.post || state.reply !== initialState.reply
+ const hasAny = state.post || state.reply
+
+ if (isDirty) {
+ return {
+ label: _(msg`Save changes`),
+ color: hasAny ? 'primary' : 'negative',
+ onPress: () => saveChanges(state),
+ disabled: isSaving,
+ }
+ } else {
+ // on web, a disabled save button feels more natural than a massive close button
+ if (isWeb) {
+ return {
+ label: _(msg`Save changes`),
+ color: 'secondary',
+ disabled: true,
+ }
+ } else {
+ return {
+ label: _(msg`Cancel`),
+ color: 'secondary',
+ onPress: () => control.close(),
+ }
+ }
+ }
+ }, [state, initialState, control, _, isSaving, saveChanges])
+
+ const name = createSanitizedDisplayName(profile, false)
+
+ return (
+
+
+
+
+ Keep me posted
+
+
+ Get notified of this account’s activity
+
+
+
+ {includeProfile && (
+
+
+
+
+ )}
+
+
+
+
+
+ Posts
+
+
+
+
+
+ Replies
+
+
+
+
+
+
+ {error && (
+
+ Could not save changes: {cleanError(error)}
+
+ )}
+
+
+
+
+
+
+ )
+}
+
+function parseActivitySubscription(
+ sub?: AppBskyNotificationDefs.ActivitySubscription,
+): Un$Typed {
+ if (!sub) return {post: false, reply: false}
+ const {post, reply} = sub
+ return {post, reply}
+}
diff --git a/src/components/dialogs/nuxs/ActivitySubscriptions.tsx b/src/components/dialogs/nuxs/ActivitySubscriptions.tsx
new file mode 100644
index 000000000..b9f3979ed
--- /dev/null
+++ b/src/components/dialogs/nuxs/ActivitySubscriptions.tsx
@@ -0,0 +1,177 @@
+import {useCallback} from 'react'
+import {View} from 'react-native'
+import {Image} from 'expo-image'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {isWeb} from '#/platform/detection'
+import {atoms as a, useTheme, web} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import {useNuxDialogContext} from '#/components/dialogs/nuxs'
+import {Sparkle_Stroke2_Corner0_Rounded as SparkleIcon} from '#/components/icons/Sparkle'
+import {Text} from '#/components/Typography'
+
+export function ActivitySubscriptionsNUX() {
+ const t = useTheme()
+ const {_} = useLingui()
+ const nuxDialogs = useNuxDialogContext()
+ const control = Dialog.useDialogControl()
+
+ Dialog.useAutoOpen(control)
+
+ const onClose = useCallback(() => {
+ nuxDialogs.dismissActiveNux()
+ }, [nuxDialogs])
+
+ return (
+
+
+
+
+
+
+
+
+ New Feature
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Get notified when someone posts
+
+
+
+ You can now choose to be notified when specific people post. If
+ there’s someone you want timely updates from, go to their
+ profile and find the new bell icon near the follow button.
+
+
+
+
+ {!isWeb && (
+
+ )}
+
+
+
+
+
+ )
+}
diff --git a/src/components/dialogs/nuxs/index.tsx b/src/components/dialogs/nuxs/index.tsx
index 11377e1de..8096a0141 100644
--- a/src/components/dialogs/nuxs/index.tsx
+++ b/src/components/dialogs/nuxs/index.tsx
@@ -11,12 +11,12 @@ import {
import {useProfileQuery} from '#/state/queries/profile'
import {type SessionAccount, useSession} from '#/state/session'
import {useOnboardingState} from '#/state/shell'
-import {InitialVerificationAnnouncement} from '#/components/dialogs/nuxs/InitialVerificationAnnouncement'
+import {ActivitySubscriptionsNUX} from '#/components/dialogs/nuxs/ActivitySubscriptions'
/*
* NUXs
*/
import {isSnoozed, snooze, unsnooze} from '#/components/dialogs/nuxs/snoozing'
-import {isDaysOld} from '#/components/dialogs/nuxs/utils'
+import {isExistingUserAsOf} from '#/components/dialogs/nuxs/utils'
type Context = {
activeNux: Nux | undefined
@@ -33,9 +33,12 @@ const queuedNuxs: {
}) => boolean
}[] = [
{
- id: Nux.InitialVerificationAnnouncement,
+ id: Nux.ActivitySubscriptions,
enabled: ({currentProfile}) => {
- return isDaysOld(2, currentProfile.createdAt)
+ return isExistingUserAsOf(
+ '2025-07-03T00:00:00.000Z',
+ currentProfile.createdAt,
+ )
},
},
]
@@ -111,7 +114,7 @@ function Inner({
}
React.useEffect(() => {
- if (snoozed) return
+ if (snoozed) return // comment this out to test
if (!nuxs) return
for (const {id, enabled} of queuedNuxs) {
@@ -119,7 +122,7 @@ function Inner({
// check if completed first
if (nux && nux.completed) {
- continue
+ continue // comment this out to test
}
// then check gate (track exposure)
@@ -172,9 +175,7 @@ function Inner({
return (
{/*For example, activeNux === Nux.NeueTypography && */}
- {activeNux === Nux.InitialVerificationAnnouncement && (
-
- )}
+ {activeNux === Nux.ActivitySubscriptions && }
)
}
diff --git a/src/components/dialogs/nuxs/utils.ts b/src/components/dialogs/nuxs/utils.ts
index 0cc510484..ba8f0169d 100644
--- a/src/components/dialogs/nuxs/utils.ts
+++ b/src/components/dialogs/nuxs/utils.ts
@@ -16,3 +16,18 @@ export function isDaysOld(days: number, createdAt?: string) {
if (isOldEnough) return true
return false
}
+
+export function isExistingUserAsOf(date: string, createdAt?: string) {
+ /*
+ * Should never happen because we gate NUXs to only accounts with a valid
+ * profile and a `createdAt` (see `nuxs/index.tsx`). But if it ever did, the
+ * account is either old enough to be pre-onboarding, or some failure happened
+ * during account creation. Fail closed. - esb
+ */
+ if (!createdAt) return false
+
+ const threshold = Date.parse(date)
+ const then = new Date(createdAt).getTime()
+
+ return then < threshold
+}
diff --git a/src/components/icons/BellPlus.tsx b/src/components/icons/BellPlus.tsx
new file mode 100644
index 000000000..cd29de197
--- /dev/null
+++ b/src/components/icons/BellPlus.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const BellPlus_Stroke2_Corner0_Rounded = createSinglePathSVG({
+ path: 'M12 2a1 1 0 0 1 0 2 5.85 5.85 0 0 0-5.802 5.08L5.143 17h13.715l-.382-2.868-.01-.102a1 1 0 0 1 1.973-.262l.02.1.532 4a1 1 0 0 1-.99 1.132h-3.357c-.905 1.747-2.606 3-4.644 3s-3.74-1.253-4.643-3H4a1 1 0 0 1-.991-1.132l1.207-9.053A7.85 7.85 0 0 1 12 2ZM9.78 19c.61.637 1.397 1 2.22 1s1.611-.363 2.22-1H9.78ZM17 2.5a1 1 0 0 1 1 1V6h2.5a1 1 0 0 1 0 2H18v2.5a1 1 0 0 1-2 0V8h-2.5a1 1 0 1 1 0-2H16V3.5a1 1 0 0 1 1-1Z',
+})
diff --git a/src/components/icons/BellRinging.tsx b/src/components/icons/BellRinging.tsx
index b174fcedc..11981a7a3 100644
--- a/src/components/icons/BellRinging.tsx
+++ b/src/components/icons/BellRinging.tsx
@@ -3,3 +3,7 @@ import {createSinglePathSVG} from './TEMPLATE'
export const BellRinging_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M12 2a7.854 7.854 0 0 1 7.785 6.815l1.055 7.92.018.224a2 2 0 0 1-2 2.041h-2.215c-.904 1.747-2.605 3-4.643 3s-3.739-1.253-4.643-3H5.142a2 2 0 0 1-1.982-2.265l1.056-7.92.057-.363A7.854 7.854 0 0 1 12 2ZM9.78 19c.609.637 1.398 1 2.22 1s1.611-.363 2.22-1H9.78ZM12 4a5.854 5.854 0 0 0-5.76 4.81l-.041.27L5.142 17h13.716l-1.056-7.92A5.854 5.854 0 0 0 12 4ZM2.718 7.464a1 1 0 1 1-1.953-.427l1.953.427Zm20.518-.427a1 1 0 0 1-1.954.427l1.954-.427ZM3.193 2.105a1 1 0 0 1 1.531 1.287 9.47 9.47 0 0 0-2.006 4.072L.765 7.037a11.46 11.46 0 0 1 2.428-4.932Zm16.205-.123a1 1 0 0 1 1.34.047l.069.076.217.265a11.46 11.46 0 0 1 2.212 4.667l-.978.213-.976.214a9.46 9.46 0 0 0-1.826-3.853l-.18-.22-.062-.081a1 1 0 0 1 .184-1.328Z',
})
+
+export const BellRinging_Filled_Corner0_Rounded = createSinglePathSVG({
+ path: 'M12 2a7.854 7.854 0 0 1 7.784 6.815l1.207 9.053a1 1 0 0 1-.99 1.132h-3.354c-.904 1.748-2.608 3-4.647 3-2.038 0-3.742-1.252-4.646-3H4a1.002 1.002 0 0 1-.991-1.132l1.207-9.053A7.85 7.85 0 0 1 12 2ZM9.78 19c.608.637 1.398 1 2.221 1s1.613-.363 2.222-1H9.779ZM3.193 2.104a1 1 0 0 1 1.53 1.288A9.47 9.47 0 0 0 2.72 7.464a1 1 0 0 1-1.954-.427 11.46 11.46 0 0 1 2.428-4.933Zm16.205-.122a1 1 0 0 1 1.409.122 11.47 11.47 0 0 1 2.429 4.933 1 1 0 0 1-1.954.427 9.47 9.47 0 0 0-2.006-4.072 1 1 0 0 1 .122-1.41Z',
+})
diff --git a/src/lib/api/feed/list.ts b/src/lib/api/feed/list.ts
index 9744e3d4c..9697b0aaf 100644
--- a/src/lib/api/feed/list.ts
+++ b/src/lib/api/feed/list.ts
@@ -1,20 +1,20 @@
import {
- AppBskyFeedDefs,
- AppBskyFeedGetListFeed as GetListFeed,
- BskyAgent,
+ type Agent,
+ type AppBskyFeedDefs,
+ type AppBskyFeedGetListFeed as GetListFeed,
} from '@atproto/api'
-import {FeedAPI, FeedAPIResponse} from './types'
+import {type FeedAPI, type FeedAPIResponse} from './types'
export class ListFeedAPI implements FeedAPI {
- agent: BskyAgent
+ agent: Agent
params: GetListFeed.QueryParams
constructor({
agent,
feedParams,
}: {
- agent: BskyAgent
+ agent: Agent
feedParams: GetListFeed.QueryParams
}) {
this.agent = agent
diff --git a/src/lib/api/feed/posts.ts b/src/lib/api/feed/posts.ts
new file mode 100644
index 000000000..33eff5099
--- /dev/null
+++ b/src/lib/api/feed/posts.ts
@@ -0,0 +1,52 @@
+import {
+ type Agent,
+ type AppBskyFeedDefs,
+ type AppBskyFeedGetPosts,
+} from '@atproto/api'
+
+import {logger} from '#/logger'
+import {type FeedAPI, type FeedAPIResponse} from './types'
+
+export class PostListFeedAPI implements FeedAPI {
+ agent: Agent
+ params: AppBskyFeedGetPosts.QueryParams
+ peek: AppBskyFeedDefs.FeedViewPost | null = null
+
+ constructor({
+ agent,
+ feedParams,
+ }: {
+ agent: Agent
+ feedParams: AppBskyFeedGetPosts.QueryParams
+ }) {
+ this.agent = agent
+ if (feedParams.uris.length > 25) {
+ logger.warn(
+ `Too many URIs provided - expected 25, got ${feedParams.uris.length}`,
+ )
+ }
+ this.params = {
+ uris: feedParams.uris.slice(0, 25),
+ }
+ }
+
+ async peekLatest(): Promise {
+ if (this.peek) return this.peek
+ throw new Error('Has not fetched yet')
+ }
+
+ async fetch({}: {}): Promise {
+ const res = await this.agent.app.bsky.feed.getPosts({
+ ...this.params,
+ })
+ if (res.success) {
+ this.peek = {post: res.data.posts[0]}
+ return {
+ feed: res.data.posts.map(post => ({post})),
+ }
+ }
+ return {
+ feed: [],
+ }
+ }
+}
diff --git a/src/lib/hooks/useNotificationHandler.ts b/src/lib/hooks/useNotificationHandler.ts
index 311f38a79..6c3e7deb8 100644
--- a/src/lib/hooks/useNotificationHandler.ts
+++ b/src/lib/hooks/useNotificationHandler.ts
@@ -1,6 +1,6 @@
import {useEffect} from 'react'
import * as Notifications from 'expo-notifications'
-import {type AppBskyNotificationListNotifications} from '@atproto/api'
+import {AtUri} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {CommonActions, useNavigation} from '@react-navigation/native'
@@ -32,6 +32,7 @@ export type NotificationReason =
| 'repost-via-repost'
| 'verified'
| 'unverified'
+ | 'subscribed-post'
/**
* Manually overridden type, but retains the possibility of
@@ -112,61 +113,68 @@ export function useNotificationsHandler() {
})
Notifications.setNotificationChannelAsync(
- 'like' satisfies AppBskyNotificationListNotifications.Notification['reason'],
+ 'like' satisfies NotificationReason,
{
name: _(msg`Likes`),
importance: Notifications.AndroidImportance.HIGH,
},
)
Notifications.setNotificationChannelAsync(
- 'repost' satisfies AppBskyNotificationListNotifications.Notification['reason'],
+ 'repost' satisfies NotificationReason,
{
name: _(msg`Reposts`),
importance: Notifications.AndroidImportance.HIGH,
},
)
Notifications.setNotificationChannelAsync(
- 'reply' satisfies AppBskyNotificationListNotifications.Notification['reason'],
+ 'reply' satisfies NotificationReason,
{
name: _(msg`Replies`),
importance: Notifications.AndroidImportance.HIGH,
},
)
Notifications.setNotificationChannelAsync(
- 'mention' satisfies AppBskyNotificationListNotifications.Notification['reason'],
+ 'mention' satisfies NotificationReason,
{
name: _(msg`Mentions`),
importance: Notifications.AndroidImportance.HIGH,
},
)
Notifications.setNotificationChannelAsync(
- 'quote' satisfies AppBskyNotificationListNotifications.Notification['reason'],
+ 'quote' satisfies NotificationReason,
{
name: _(msg`Quotes`),
importance: Notifications.AndroidImportance.HIGH,
},
)
Notifications.setNotificationChannelAsync(
- 'follow' satisfies AppBskyNotificationListNotifications.Notification['reason'],
+ 'follow' satisfies NotificationReason,
{
name: _(msg`New followers`),
importance: Notifications.AndroidImportance.HIGH,
},
)
Notifications.setNotificationChannelAsync(
- 'like-via-repost' satisfies AppBskyNotificationListNotifications.Notification['reason'],
+ 'like-via-repost' satisfies NotificationReason,
{
name: _(msg`Likes of your reposts`),
importance: Notifications.AndroidImportance.HIGH,
},
)
Notifications.setNotificationChannelAsync(
- 'repost-via-repost' satisfies AppBskyNotificationListNotifications.Notification['reason'],
+ 'repost-via-repost' satisfies NotificationReason,
{
name: _(msg`Reposts of your reposts`),
importance: Notifications.AndroidImportance.HIGH,
},
)
+ Notifications.setNotificationChannelAsync(
+ 'subscribed-post' satisfies NotificationReason,
+ {
+ name: _(msg`Activity from others`),
+ importance: Notifications.AndroidImportance.HIGH,
+ },
+ )
}, [_])
useEffect(() => {
@@ -220,6 +228,23 @@ export function useNotificationsHandler() {
}
} else {
switch (payload.reason) {
+ case 'subscribed-post':
+ const urip = new AtUri(payload.uri)
+ if (urip.collection === 'app.bsky.feed.post') {
+ setTimeout(() => {
+ // @ts-expect-error types are weird here
+ navigation.navigate('HomeTab', {
+ screen: 'PostThread',
+ params: {
+ name: urip.host,
+ rkey: urip.rkey,
+ },
+ })
+ }, 500)
+ } else {
+ resetToTab('NotificationsTab')
+ }
+ break
case 'like':
case 'repost':
case 'follow':
@@ -231,6 +256,7 @@ export function useNotificationsHandler() {
case 'repost-via-repost':
case 'verified':
case 'unverified':
+ default:
resetToTab('NotificationsTab')
break
// TODO implement these after we have an idea of how to handle each individual case
diff --git a/src/lib/moderation/create-sanitized-display-name.ts b/src/lib/moderation/create-sanitized-display-name.ts
index 4f9584f91..4c62a5c03 100644
--- a/src/lib/moderation/create-sanitized-display-name.ts
+++ b/src/lib/moderation/create-sanitized-display-name.ts
@@ -1,12 +1,9 @@
-import {AppBskyActorDefs} from '@atproto/api'
-
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
+import type * as bsky from '#/types/bsky'
export function createSanitizedDisplayName(
- profile:
- | AppBskyActorDefs.ProfileViewBasic
- | AppBskyActorDefs.ProfileViewDetailed,
+ profile: bsky.profile.AnyProfileView,
noAt = false,
) {
if (profile.displayName != null && profile.displayName !== '') {
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index c92be34c2..b1db5caa6 100644
--- a/src/lib/routes/types.ts
+++ b/src/lib/routes/types.ts
@@ -51,6 +51,7 @@ export type CommonNavigatorParams = {
AppearanceSettings: undefined
AccountSettings: undefined
PrivacyAndSecuritySettings: undefined
+ ActivityPrivacySettings: undefined
ContentAndMediaSettings: undefined
NotificationSettings: undefined
ReplyNotificationSettings: undefined
@@ -72,6 +73,7 @@ export type CommonNavigatorParams = {
MessagesConversation: {conversation: string; embed?: string; accept?: true}
MessagesSettings: undefined
MessagesInbox: undefined
+ NotificationsActivityList: {posts: string}
LegacyNotificationSettings: undefined
Feeds: undefined
Start: {name: string; rkey: string}
diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts
index fca3f609a..3b1106480 100644
--- a/src/lib/statsig/gates.ts
+++ b/src/lib/statsig/gates.ts
@@ -7,7 +7,6 @@ export type Gate =
| 'old_postonboarding'
| 'onboarding_add_video_feed'
| 'post_threads_v2_unspecced'
- | 'reengagement_features'
| 'remove_show_latest_button'
| 'test_gate_1'
| 'test_gate_2'
diff --git a/src/logger/metrics.ts b/src/logger/metrics.ts
index 31af1be2b..d18e69122 100644
--- a/src/logger/metrics.ts
+++ b/src/logger/metrics.ts
@@ -443,4 +443,17 @@ export type MetricEvents = {
[key: string]: any
}
'thread:click:headerMenuOpen': {}
+ 'activitySubscription:enable': {
+ setting: 'posts' | 'posts_and_replies'
+ }
+ 'activitySubscription:disable': {}
+ 'activityPreference:changeChannels': {
+ name: string
+ push: boolean
+ list: boolean
+ }
+ 'activityPreference:changeFilter': {
+ name: string
+ value: string
+ }
}
diff --git a/src/routes.ts b/src/routes.ts
index b66a0ae53..7fc673e2b 100644
--- a/src/routes.ts
+++ b/src/routes.ts
@@ -11,6 +11,7 @@ export const router = new Router({
Search: '/search',
Feeds: '/feeds',
Notifications: '/notifications',
+ NotificationsActivityList: '/notifications/activity',
LegacyNotificationSettings: '/notifications/settings',
Settings: '/settings',
Lists: '/lists',
@@ -50,6 +51,7 @@ export const router = new Router({
SavedFeeds: '/settings/saved-feeds',
AccountSettings: '/settings/account',
PrivacyAndSecuritySettings: '/settings/privacy-and-security',
+ ActivityPrivacySettings: '/settings/privacy-and-security/activity',
ContentAndMediaSettings: '/settings/content-and-media',
InterestsSettings: '/settings/interests',
AboutSettings: '/settings/about',
diff --git a/src/screens/Notifications/ActivityList.tsx b/src/screens/Notifications/ActivityList.tsx
new file mode 100644
index 000000000..f87e34008
--- /dev/null
+++ b/src/screens/Notifications/ActivityList.tsx
@@ -0,0 +1,44 @@
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {type NativeStackScreenProps} from '@react-navigation/native-stack'
+
+import {type AllNavigatorParams} from '#/lib/routes/types'
+import {PostFeed} from '#/view/com/posts/PostFeed'
+import {EmptyState} from '#/view/com/util/EmptyState'
+import * as Layout from '#/components/Layout'
+import {ListFooter} from '#/components/Lists'
+
+type Props = NativeStackScreenProps<
+ AllNavigatorParams,
+ 'NotificationsActivityList'
+>
+export function NotificationsActivityListScreen({
+ route: {
+ params: {posts},
+ },
+}: Props) {
+ const uris = decodeURIComponent(posts)
+ const {_} = useLingui()
+
+ return (
+
+
+
+
+
+ Notifications
+
+
+
+
+ (
+
+ )}
+ renderEndOfFeed={() => }
+ />
+
+ )
+}
diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx
index 1639abaf0..5dbf32c57 100644
--- a/src/screens/Profile/Header/ProfileHeaderStandard.tsx
+++ b/src/screens/Profile/Header/ProfileHeaderStandard.tsx
@@ -15,7 +15,6 @@ import {sanitizeHandle} from '#/lib/strings/handles'
import {logger} from '#/logger'
import {isIOS} from '#/platform/detection'
import {useProfileShadow} from '#/state/cache/profile-shadow'
-import {type Shadow} from '#/state/cache/types'
import {
useProfileBlockMutationQueue,
useProfileFollowMutationQueue,
@@ -24,6 +23,7 @@ import {useRequireAuth, useSession} from '#/state/session'
import {ProfileMenu} from '#/view/com/profile/ProfileMenu'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, platform, useBreakpoints, useTheme} from '#/alf'
+import {SubscribeProfileButton} from '#/components/activity-notifications/SubscribeProfileButton'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
import {MessageProfileButton} from '#/components/dms/MessageProfileButton'
@@ -58,8 +58,8 @@ let ProfileHeaderStandard = ({
}: Props): React.ReactNode => {
const t = useTheme()
const {gtMobile} = useBreakpoints()
- const profile: Shadow =
- useProfileShadow(profileUnshadowed)
+ const profile =
+ useProfileShadow(profileUnshadowed)
const {currentAccount, hasSession} = useSession()
const {_} = useLingui()
const moderation = useMemo(
@@ -134,13 +134,26 @@ let ProfileHeaderStandard = ({
}
}, [_, queueUnblock])
- const isMe = React.useMemo(
+ const isMe = useMemo(
() => currentAccount?.did === profile.did,
[currentAccount, profile],
)
const {isActive: live} = useActorStatus(profile)
+ const subscriptionsAllowed = useMemo(() => {
+ switch (profile.associated?.activitySubscription?.allowSubscriptions) {
+ case 'followers':
+ case undefined:
+ return !!profile.viewer?.following
+ case 'mutuals':
+ return !!profile.viewer?.following && !!profile.viewer.followedBy
+ case 'none':
+ default:
+ return false
+ }
+ }, [profile])
+
return (
+ {hasSession && subscriptionsAllowed && (
+
+ )}
{hasSession && }