From 67e3f6048f52583f0db3a0d61dfd4fc0ef4c670c Mon Sep 17 00:00:00 2001
From: Daniel Supernault Tag people Add license NEW
+ Add license NEW
+
+ {{licenseTitle}}
+
+
+ Add location Last updated: You are viewing a profile from a remote server, it may not contain up-to-date information.
No notifications yet Refresh
+ {{profileUsername}} is not following yet
+ {{profileUsername}} has no followers yet
+ {{user.acct.split('@')[0]}}@{{user.acct.split('@')[1]}}
+
+ {{user.display_name ? user.display_name : user.username}}
+ Load more Top Posts Most Recent
+
How do I share a post with multiple photos or videos?
@@ -43,7 +43,7 @@
+
How do I add a caption before sharing my photos or videos on Pixelfed?
@@ -54,7 +54,7 @@
+
+
+
+ {{--
What is the limit for photo and video file sizes?
@@ -122,7 +122,7 @@
+ {{--
When I share a photo, what's the image resolution?
@@ -133,7 +133,7 @@
+ {{--
Can I edit my post captions, photos or videos after sharing them? @@ -144,7 +144,7 @@
--}} -+
How can I disable comments/replies on my post? @@ -159,7 +159,7 @@
-+
How many people can I tag or mention in my comments or posts? @@ -171,4 +171,55 @@
-@endsection \ No newline at end of file ++ + + What does archive mean? + +
+ + + How can I archive my posts? + +
+ + + How do I unarchive my posts? + +
`2E0O7l+6BPV{C~7ijcNrb7` rbR=ev>bbyH zL3rlmY6v$8$bb0=RTIk>t nK(xOYvxkxm39wUM4ywRG0V@rU}V+DM286iUpnhQvn~#<4;lH3g_v z(M$k_{J-~J@hF;NB5KNpI?NUMP+wgWwwdH4J;%&E){gJ4exWJ7Z(n7@e08&5&*-35 z{y1yslnN-C7Ow;qLQ0rqS=@Rs-44q_OGqOkY{s-m*y69Uea=s8c3mBYRm5T?h**Aj zh-?mi>2MwY;^fkZHL5}NM{}?gLP@g#B6R;mZ8IP33>Cm2ut%bu{L-C`D^pN`knHJ6 zdRmqxb+7QBezbxA sNTHj{oq&2IsFXvFcSJp`Ma3nK*Nrt^*;-34zfcdC!K;@?pMSFSU#ChejLMAC@Li zEEYSLJ7?2>{OLpW{Oh^urTK|ySBUR )bA^+kyxp>UiZPGUkd%%6Q{HUHvAmaaSwJ`V{h{@TfU{>(e8R^+h-%CtJb_pYwn zs@s~8flwR(9a_a~Rx3!!7jIshx3RtchK=zjKDcq|4WJOQSL<9#60DSi7@p=&e60RW_j&0@A%5qdT*p6D zteT0A>RQ1L4QtWNNGMe-SY{!bHw{dzK%~4)QH6~|*oCPkCMH)Ahr)BG<15CridL3v zmDX}2dO<0Xf2kF?HW!~tWsKAq|LUW)TS}_e-*712>1pUAiKq~mn?FBT%OCv7(&etO z=IhSXREnv%lc(A8XOk^0ZiAahS 1e6x zd|@GXRQ4j7a---3#F8LMM~z&@$m#PrHif+>Niz~19|aE!iu&5wzHM(&Xsn+jG!#(B zx)ZHSh+6-DN`~5sa>>B6zf;G1UteD7$vaPX@V=>rTeK+flp34Ex}Ny!(zDRB0$uVI zak6*^qaZ3ICcU{)FvPjh8~K()8#?uZZ44VJST#Y)BdQQXMZ3T~b111vNkL4&BY|d+ z*cZ*S!A@Yk4a)@UO>^yc+j(q!8Q-N>tq<;YET7R*I$!s`nxz)(?u_%&MOKpzvWce& z$jOK+9cjaY?Wq?!L`SPbY4^EEyn&t6uFQXHWr`zEmNvwkau_a^ZSW`WYT_-2Hr ;33PWe1&%&@ROh))G+~4tUEoiUfReWPmw669= zs{*Dx{%8aL!aHm0@{qU3VX6!6AQ7p7dkPBw_MdKQhQc3E@}s70UQwS{9$b2Ywg}=_ zG>qmwSP;veT9yvChk7ZGJbwcc%loO?=Ez7!PmS}+ueG#xkwh9)yCQJ~-h+PCDJ0w} zh=$iZh2U`42Zj?1iEEi;F?+RjFBOCG-(B8t iBHy*mbY`tQpZ)w$?^HzP0 z6L8laTJbj5zNl-OGrO6CjCky~TAfcVW5@Z$N7f#O&P9}HVMkKWX~~QZC(NTL>1MVG zGuqRV0nd|^1i}EW85vMM-p{0IBZLvkPr?|Oqm=WoFG!O`h%2*n%}{#b6~dLuV%MpH&1m4W|5M(Q+wHeG!TL7WhMqIeHHu8;QxwUSMCmi9$)50DNg|2K> z#3iI>U#L^0_2Yx0KGCG@Duy(~>J^|jToF3&dcCIEH9@Q)$a_H=y%4r=%}OJ~9TGmZ zr?N1TKMPt=rMsYh*-Wm<>$H$*L*rK28p4g~ )N#JA@{^A?E_WR-o;_B#RhmHMj#77L&`}ak{KMx$oqI`> zd61;n)F(VFnjhJF@P`N0Zv22`$0qPgw+Q=WA8lcJC^_kO4)X8ZZ!P32yWv&815xlQ z0t4zzM!SIKrm!F(p++Sglc9f@poNl?o=+s4C+){k{<9BO!wXur5*kO&g|io$R`?Hn z@2TG)!l^DZcpqXWf(Hewmya9G?OHBP_Hjw{cvH7cTg%8$)ew}RZBLQ~#-IH9+J_&h z*m5oHBQSh~hX>!SVTB?`Rh)}6thJVW>K5R{om#-9-s}c%%J|q1gh)={PkpSmgZ!cp z1VB2PMLc{CQIeH3oH57a3ZfcmJqHeWNNX&)w|j4`Sq45%rVakkBXwk}@Sp$sQoh(} zsPd^0K7{LQ@$ioozWFxcKvu{pM_n(9f9O3M7jUek_^Mh (|W2G(ICfAZwA3Sn#(^Lf2M;bb1N8p-=zGlNyoAlirt z3q{5<0SyU;DKggLN)o=knX!#LQUcoeQSt6#)PIXWHBc3kDnn7TW*XKos3SflK2VxL ze#5Wn=|RsRMKDRHVdFFY9;UDta8QRd8^OV%ZAOcEYNf-%$A4o3p=fP6iWGk0V|6oR zB!YWD;{PFtnwj!3^&f($&DahXQ{~b&kSOE#-KSJh5T+z@p1Vvo^|QInf-{~xxwNPB zZ6C-l9fGdUoB3h}Zas+*yC8$M2!@KN1iF_*=GRZwQJPG;|CBvbr-VP_zN6@fuP7vU z`F_8jMf1g{G`AI(3*B&@-` $MQ(Lk9oLW;Lx5(1+4-3Tn^GgBo7&pk zM2p+npWh8>J<-w?5g7oaPbiw~LG;_C^ I}u!y)Hyu65Euq zk)dr5QUG)FKM35ZIT?rc5iK!#^@Z%G9Sh9zKixtCa|`5O=kLk+c?5=~Tme)6Sr!?{ zTT`B(v_7QLTq+`xDCHMek-wzYZ3=#r^dhNF3L2H@D&*6Zd?`|Z5*_jde@fjD?=2&} zAYc2)lergAp hVD?OgqRX
Afl|&t`+;u{N!OHH-^otUF)Gn&bQ~`{hK0z&2b^j0dTW<+9c~>Apn%t z?G_Xvx8h6z&IuH#z(D|)f%y=)hhrP(PK6-t5dB;&a1EetGBYq;X z<{iDweBHKMKC@|Ai9#eA{4xqOw~=lR5 }p*-45!g8;!>TG0WY<`-I#GHPaZM6_U7 zK~w6aN)TYt(|AFDVyM1>!5I$ouKR0dN+=_@ql~ `EAud?Kh5$ &3(ui5rT%X1A&QLm6@)z3*f;3_5;w$ zhdO&LLrGLEIFbbH4~6hYTD)>DRj}$ALJMly=3@u>9~7pccxB3u{|Wm(B;ldz0dbeO z;+pt=2}@{PLmt^S5ltcW93sH*?kpSTzX2mZu8hkZv!W)8w(UgkX0-?BR-hOt-|L-I z+alq02!D?t$8A+qYbpdBuu=Os&0pP8chr>;ghbLzqW(tCIdlpx2PvPkdT3O;NOd=! zO3F-P!YQ3%C$bPn4i`hfdRut#7>Y!x-)YzrI#y`GPeqB2Rn*;OlJ4?X8Ys|#4v6b> zR6xmy5}Rf%#BXy56AiDj!T1zl=mlxujpQRc!1wOoFz8d-)#zD_wQz%e>EF2@;ay9P zl?s<1>#vIkrNo>?%2sln9$yFm5-4@Q;VRv|vAwR6{Y!mk=Jzf0Imu&9l#w$iR F%5Vm3fqCXnH z6l++&u$i*WNDK31p>Ci--!ut)5A$_9Y8phLU=V#`Pryqk2Rww`C-+$}S+FNL27%{( z4Rw5(g|&o0hZ`seJKr(cR`Lf>ln0y3Gp+n6x~SJv5OR|xdS0z8<|ry*wRAd#A{$h` zUQrOCNl5czNnl=4;X)G1XV_ln$9cAqSx&>0S?bmY2ijP!8ADRwj+KywP#7yCaLp z2Z|ugLWj&GQCe5jAIEi-N}+}zl*IhSgOpa5@PZWQr2^Y>{!~!@V4e^@K%%I`@uj3N zhlOoNfpxElj|>-6ZiCnzvcT{x)g~lgKWz|gXB_pLn`f?}t+28qkshkf8beADg~2Fz zek0^kO*8~4P&Rkw6xH)E%xvdh^Q`&57>2v90c&p)!$lP2Kzm)4eq|dUm!wGN>t$!{ z-E8H*D}Et)YHAJXB*{NzPLeLAk8p!rCG1i=^k^B~%k_cur6Lc|`!x}Ged`-H6QYYM znYYfAQ#hiy$bF(V>}mppYFZ@vNh*a%`-j|)y`jELCZtbb6UhXi==E@s%Dcs4tkAfD z&q+PxuW=i31#%r!SVUIDLZZp*q~+;&M>-aiPMLof>EzY~^T#`@hpw<|vd|~w%4 g!2r%9z141LtW2%*7a}K-Pb}!k4C-Z|7f~AMKR|x^zkB)l zZPgnsaz&AF@N86&rX@wPf(RN~EGy=y4gc5eC#VkI`LM-4?d&yKwe$W8wvs=*BYb-& zlq{)4QF!cDBO|`k)`kym)x4`1!&lZ(QrVX#(yK&xVS9fEbdMf$OW&pDC>0&UK#!=d zap~#+AT!Co7h3vnTUfodw7vxjtKF32anmX}6g_Wd?Jj__#V??5^Tvdp8P?OJcqJ-C zahj-7b3rI5Wj{7gdA}J1%1~1=_qG@1gLpg3@_{ggJex?6)X9rLg2W0Gots|KIb4db z+)VZ6EBMf_R5<1(wrm?vhFV8ijg~}~8xbHsb>>Ed{Ja^=76qmR|K{w?{KCiEc-LoY zoWH)0Rq$tiXH}<6bIFQPf)~s=my+;-q%$fL(6Ms4Ter&1_jhg~Lq+)0GHi;_r=K5J zZZq`p5Xi~qno%2iE8r<2owwY` 5fGm-eQY9Gu1q zI0&M`!TI7lSmW|OS?%DhGKlhPnLuEMS0UHn_^VLf SQn=&^P9iSWOttmWoTMdsrxmJ+48*-Q{ z6d*eIU(6nyCl`3*$<|6yC5Y@ls^LW4AAf49US$0ryS0uETr6*prTAVgj8Y<4r(%kU zGYztQnZNe=rU;6pP_lFir)*L-+zCBo4nv&kf#A1W0D|)h)%>Y RHHN$CN>sSTc> zAYiZkb~q^X2tfT7;jae!_rX1#+nK86=Z$~e469%1+N)GrFj`%Ige+5-UZ=d*RG5-P zg)*uwcF6iVAJy)f+e7_luWYa?{D*h9m5mJYC+dMwRgyc>xeP914gp;++m@nI3s6xy zYGN-73o4oaG{jWvDy=!Cul(^p>27naI*>4eq(Nl3m?jx%Dc)9I1ycBmkV@= {SQuR&JNvx9TNIVOn=_?kkPAqR)QP Dh4=kLQ2r#Z|sAOM~c>cB6+k|%52vN-U1;DkWQPFKzd3mm0iikN_c>Rt-o$fP2Q z%jRPRj2|WfBs{o@-Ln0mmmx~`N{+9N&Z&;3gW_#%r9(wVZV1uf%;b=5BBC=-PhvRF zCJ$va!S)D2IkCiRG;&u`Jdn!`1;=mrl1dsCGZ3LK# zO~|Tn(_<>?mT+dlf 5RAz+vU(NQB@w3u1 zeiVM!nff>Oe3nVi$NA7ZSu@GSx9R4r;;&p1hWb_PO2tq0<8}y&&X)N= z_ZC*f0;rul4vAk&kQQ39M|9ZuR}lG!5yM&Ckom~`4*jVUxxbR zB}+a|uR>2Z2BD(XZc<*LQ0A+6Z$pQ2!v!Q(^ehTDt_64*u^L$%aIwss2#A%D)}gX5 z*WjYH2PbW%=CTrA5SiXo(28k)nsezY7N9HNQIi!#S0gTp#3DXGrS v>jTygypy8MFg{iiiEbi;tS(j=PEhix2}bf4tSChmhgAXNG& zk5*7%Y0jxE_o!fDBnhjxtGiq6-mxPRYQd(XI0!XJSaO*+lH{B2k8cUO+LDVGz&-!( z=XUkE2RXb+cE0K1dFB0w$QJhxMYy|IKA^j^;)u&2zkgk5y_PTo=S8}2$!Gbx*Y-R$ zeJfK?B7p$U?Po7^%QHywwi&98aIc%OW{1A=J8brk{tsvT!(XlVz*kx2?180!!I(MQ zxctK6#g=OFcdz=bT_#MEc-TsG;w~C9hI`EPiDSdKLa0ND^qTRxZ}BqMZpMvvlc%q1 z$4Qk3uRC6Q#ms{~YyRwKjK8>Q^#>*q33#ybr?dC2-M$D@cxv5e7oqoP^=I+#rkcOU zzbk8>#=l>wO*4~6>yB prm(N5k9z@UHsT zSJKrF;%p1w((t(^)%_$c$Mb$4hy03n^Y1k@@>kUB_`f#bcT;qcn&ShFz4dx*RI|cL z(NHKBn?*e^h$xZJ`KKHAH0JKTcg}a+F3`Dqov&&-#&mvXlY)Pzn>OIzUu$YQqQ`L6 z6^_$E?zkPHei0!jj?2Ofv4C}PWX%e@P4OKKBu`QcxS9e4-t(7R8~COzUA%YK(zc{& zj#2j2LIWHK)8M%IuiX1DP@zOuepQM}9svu?Xh1YgZGrKzrIvqn%MOCo3MQi9DqP3_ zw7^S($2%eo?Q M? zJ*oySU8)Kx%$nU9x_(gwK@zw!6MB!ub4ug&Ap10Jf+T$cO4v8WMT3-`cdxnvbIpEQ zS-NNj4!%Sp78~)Go3ML?V@+TET_Ak8TkXGd&p-!y2HFc4Qv@S`dpg5;@4YRqjNa&P zln2SZ-HvV<3-itw>-m%WYm|1SH;b$1Y@o^8kwh8L>Dk6M&K3h94jV@D=1W`GEym4N z^5NyJP2k2SHq`Kq;Z^t*4sTdv#>8D~bfyG2JrQmIBaekQZ>sZI3`*L_p~OjWV1a8b z_5Ad=EBW^}teRPf4AiMSt~ATyRA`>~QOwh$3gS@2A{Hoeps0-1BX`loKyMflOm{Ov zx)l*S<`RJg3dqI!XwN~ZrA9PBdpl?+Bg6%*3Thcbhc5SmWR_V~P0HEr7mn~pZ{7gW z fAU=``G@m2EO%$mU(8SYzqPxY>E!x#DAb4Hgp;azj(;(C zaz)?C{)2~ZJ8+6m-oKNF{(3nN6;ASx9c`X{cj4qx{=M-H{GkW_kiYAe^|Rlacw$Kn z<Pr)~{_mOTP5i;hCH&l-t8X=jhjBzBira&_ zL&(o`QhvtDB2VMhqR^4z9b_fIF35&=2BSfEL#OGzEfPoQ|7?0iN?bD~BD&&!A_R-P z` hrvEQ%XEt64L8?gihxkPB5>U?noMI4yM z r#LwMQq#%kHZ{Kark-Zq~z!M%94w?lt$u z9bCVscZ?-FC>9Q*7MH)$eQhEu P>uc89AOGf5K^-MnGUe!db6Qc$= zWjd(h*c3&fRAt8v`c52I*}=c?^bf$lzwyOi0`Xq|%(>O2u;75jAAaT&ZP5Ka)^33j z_hv(@z1@#kVm<@b;6NvT{Mo9Q3Atztn1iS}j7%f$LUsf7b=k1T@2DkgK!5z%-^0+} zk=8qe&GE`}_cRii6s%4 4697o)i5kYD=4XO>vFIFcXu(z$B<2xk(7-oSAa zi~ru2ZiX!X>6i8{2JAgwsasWM^31MU>Dx^F;5Uv3##d(l=^Nck785JCe4}NxIDU&- zRkESz#q6!$I @sc>aYbf9wyc_=7)MGOPUck6=Ub+V5@^%h~(gbSq8M zO5o~Vp>zn55Qrm1vKlN9v4x0g!Fg&UtDXJMci+L*SqXE%fRY)cNU;@}viRLE)mGy| zMcjGjT`1Y3VGAt&=u4dy-Uo5t;yC~IOZ(Q3hYi_!KpRBrB+>(m>HGT!uCy75J7WuR z6QLeUqL{PS#JRq9`8!U()k0z_g*)NQfv!PZ&r3H2n*-g0s)cJPQ~D7tA2( yG$^YhWn(^<_zdh199u`M$MZ8=!RM?iZHU!8ETF#>QoDg0X7K_&{ zVUxe(Zw(;v<-cvOn~Wu)dBcKebE*ac2F4PTv$4PHXR%~hhYS$B6H#&7Bo?dc(_#E; zB*w}*;+4fe`uBa6{lVAt^0mKz=TbW1H_i91te$=UKg_Vr5>dGYs5S>g^@IxvlgD0G zx5{?Lymkdz9ht^xX@Jfzy}YN<94OG9B7QzCP8Uz8Ly7Q20v9rKt+Hmt#DG1R(A5e4 ze_!5OIY}!~b1Duu2xZ2juXL>&!dk6})c^F> 9C1Kd8`rY=dZo;mDVu{S;EBy*D>rXRR+cUy@c8S;y-R?*2AwhRhVSw7>Rt?ATs5V zSF_k$=hgIL*S$D 2t z$#M*}t3O-CE{s?ZsUh4zDv*%?cUbzNoDGfvCEE}}AY1>X7NIR^F|4E7QQ!qRJNdH^ ziRil%kT>RTRBnfJ2@{l~MW(&`fDv20{I%=Ydoc~?&I;DUtjiy)U^{6Jf3}u2tg{C1 zy*EtUgN=j)9 +tpQSIGa!F+BYQ9YoNs1#0MCEW%uX#{1?GbbifbcHr%qwJ@cFpH zeuh5BHnVd}LF$ SoDUU#}% z+5ZNhEn8Siy@kqjjFYxh2$O-XZrsw*g~ObdbJrGDdkZ`sBZmtoEp;d4&cF@0GuZu( z8$*f(RZ;ME*Us(qntF-1mbz_cCpGW7p?jD2#d&%QgNf?= Qlib~9W9;e4l?-CSiM zVW>#Oj7cinP@(MX+Quei+oR0-;C9xCho cON;XTxsR6gCslE}YCj8X>KprQ2$e5T#eyaA6=f7$J2w z9M(XTL%Z4bjU@c+X@~|fbr3H#@)qJextkT1k<4vtbDq45H97qWb`Yz-JHcw5k0sdT z8Z$8w&OyJnwVeu^m&1F&w`|h+<6ib|G&`}Eh481aSD@_Cy)3eLJYhM1w3m&wnc;#8 zfu4&b_TUO-vaNAteF8VT({9ouTzqxhxxJ5#uyN;^J{G=VoNk4Ps3Y)1akt>X(?<>` z@;#DeClVvEyNh~ZQdU60@N#eai>O1+rhTlYaSSIwaIfoOl%L0Nd<1etT &Fd|ULj#9kvyzcS~g#WxR v*h37RqIu{&AQB0R3`XU(D^bxe3YXb$un*7iIA)T5O?UNkeXqGV#eG{kho z#Y#Wjo8P>sr{DQpKMRM(z4v2@ejGq3SoVR82p0=&*)!%W-p|fsVh;^MKuqjsYw-J* z_Ol&}&G49TdYu>cvt1QQd6tGO23k%10k&!BWOxie`_8f|=hy+Zwq|@<8AL53-JyLx zB1{$^#=8%&JELQfxJKrnud2r)(>+pPPFYl{Da<9gx0X|J6MF#U{gIp4E(AJk!71kD z@7~1rz|wS@53*ldYSE@TuieTvmVQ>RMy-cB0?qAxtac6`X7JXXy@y!+Dmsp%s2A~_ zo)fJ9@-G|$!IIhPJbnwi37q zF&rmtHtTl}eYvWHoP_xs_)>@;wmRkLzmetwu;K;XvLvXQAFXZ#3z64+_E znKjq8n3$RFUBONVpjx_M6C-+2=dwZX7?=^NRv3cQJrYCCV@KJl06v{RJjzZkGF9hO zr`TpEevGYU6V5HiSR8+T@feF@db7ub;ss>q^Ty A84xu(2o|=SKOSVAH3*kN zF>025M%)O7kT6Nqio3*ceRr{z^+RA{H66=AeM#|=bnLr;aLdUJu^9e*c!+&vg9Y18 ztg;w(V-b{oM3gr&WjdG6W6#XX&z@)hjhHc;B&8w)f-t`$fKJXUluC?F_8}PQJe_3c z0Q#Z3SiQ3?C6ub2Vuu!43FpKxbo*bV*dsK9G(+aMy#N~DI|PAr^Z={8{HZiM!4_dh z`i9W5W|&=AI|*f$rTRD_7A)sihRH-rV6Lc5%OsJH#691Z^USc&MK29QXe6D`2unb$ z*^+(i5mvc;99|RVOSG*xpBiBw#%gXICELk)_b6*+L(V5h*-1?7=c8;8e+CUf@b5F& z&T53vXmR+z%Xm@@M&vgQc3#MmT^Cqvg{2}=Iu FHOh+4j=YpH#Dv($kGKY c+k;OoO-&^- zjh}gH+hi(Lm@bhhPDN3uLpOX{WOl76j2kCN71VWmFLoNG)TNiw`0h2Oc*2D>b8?Ad zh{8WT;42j*x#zU_)D@a_^p|iG+V`&=1??xEo|uA%XZ-ul6kPZFbvyAt{LDrCU%dYJ z_&;KU4`}>n8>-^_oq6#A8@I>zZTu?!&ZZsgkvbS1Ke$;0Gk$q<8EEl>EfvE|N4-i# zA*>5hNr@030nIQ4MKR-{E%)VXt*y6>Yi5Yp!*6eyjr-(p74To#T8RITZ7s+&9d(*1 z3Z@{%--lfG`ZJK({?2oMMKLJ`TT=zY;>WfR!b_grUWEUD-!1@Y7oH!Rl7-_ApK7Z3 zaLrBPkG+^b7NzLxk)(+&yrD=PrXJAzx}iCOVOlSmD6963FQ%uk-#!ePKX#k-I&}ny zPg53mYoWW3$|hHnP#4mDf-KY!(hyKX4V1qqxvr!Hm1VcOhaG;+s59$JO5#i2%8hq! zEhsgm7R}K;u9%6t_v)HSRG)6tDaGY+&$A;6lUsU;-;Xw=6NC2NbVQszTb6Hc+G|GF zwlPU+|J85fkdWaO7#g44c1OIpEib;LtpEsnZ$ISp_mvsM(9o $^VPl2qhf+v8oUsS=O0aS8$iQ!dE(L-2A6>P6VUvrZwwx$yLag)+gsijd3 z6;WG(9#ZXbR=Wiw9H`MkT*uL%Mrw;Fnvw3I)k?8biq(=3_gcPFmqvQFQ56#pyEz+v zhhlz;!M#|#k(VN{?746c_k8x?Txibc`*|IUsh0$`89qhec3jq2CX`Zkb}sBsBPAt- zZJh^;hGI?hE1` 9qs( Ao|Rx~-v=C{C@ z?8RIZGD;yK4#HNpK>6T^sL4cfYksPAc_`~>g<)L0El@ccO~<2EOD@EwU{EuBmG!#c zCsHXE>y~r_o7D
QMIg#$Xg~T^)mhEXt#Xt^`G$8zp-p z1_6}U^%(e)-nO8*bjJVTZAz+T{T#x5P=95s;8EPSHx&GN6_EcYa_{L2)LQX0WN>Th zwbuF@0|V&=^ZW*u){!FJD8dksz%O(Ts?9KvZAfUvCZZLZY >(jXW@~ zfnD*!y~9WuT^3eDnx_k4yTe7nbr`{pT!X=VZ|Pcl9fsV}Gp?sK?>cPmX?=4YCib)@ zeF1ent!-bxl%CebFQC^hMPI@jy~e(TmY$aXD>&5Ca@~MPPiyxLSk%)R@ipAn)AD@{ zp)?NRHgsoT5a1GscaD>9HJ{X=`5@c)9SmdYmhYi^=N7n{l0p>QL)nezU?7US&o)@e zrM?XoqD6kb4d$n2A|hj4rDhRj4?GW(arp7)k!upR`~_H)jyQp8IR63^-$6=|kC570 zs+lvjS`)j6yaX_ aN{x73BQ)~Lmuz*|lrX4VC070w}kSaD{Fl1YH!BDnu7Yt K6cIA1LG1ai`j-fFaYZ>?14qu z_AWH!MHvW1qcQyE?uB0tzrB|q#pw~wDjLU*w $Zm^mhKBNXUE zS_C;hCJaKcqTJS7L9ErU1LjRl*6@8W5ek}+dJ?LMlB;|IPS;9GCXDB0YipC`-w(?G zYS`v8D3UMVh3p}2zh)L0iXb4W;~zzC#ugohyO{e;I655rf?Qi9j0DMr2-nj@a36gD z?@HOM15hARrL26c=AeGvr!CNZX1(OHy8>d!V^m9=1k{R{Z?LBhz{KGMPcvf(s`iV} zocW~W``Gyd5bDFj7joP4pF#mM55fbf2+i#AgJ@OX9)!N^PX}SyK&nJV1KCMQ$$XJo z3;zpI4i>X-4#6)3+bDEX3*g1wrk%yyj~q6{k8)O5)FW|!_OLDLqlZCCtx=E;9fpPy zDu!eXo`$5{*J5-p!|ArlFc%UquGO%AJpy$o$Ic_TMGeb;3-Sx2N}Xd_SPMnxYdq;d zqAN6_FwnH+2%Ga3 =x|%U}6#YBuAV!m_n>?*C@iI|lWMQiL zaHx{)c?<3=|K4`~L>7cMjLCdC4=swwUlI|cl5C$c94guI^H7kIXbxc;-iH46Y`$7a zF5{>2x>x;QcS$T8&1W?7*rj>vRijSpIV>i*6FAqvs+-_W)^QlpQ``;gZ*PNGRFfR6 zR2m$$dWf1o^lO~R+C0Qv$tJ!7OHwNxly#zMrON?+ck3OvJC)+w*I-V@QjfyqtZqw> z#rTXFM`2|8VzCmdfukVg)Wn2phl B=u z$aS0zC5O{l>?C9+<_yaWkVZ|A8@nbb&|vL&ABr#mMQ=HA9Dal>_|6khm>E^7?9@tZ zVihM~QdUIqJ0eq@a)Y9akqWl?P4tx2Z$dhI_5_R>)T~61s5G>;7TYEJ;|Uls3Jt5c zxuQmlNG{9{Wa@~>6P!-8XjN$>y06HIm#jDm1- rYM0Vg$MVR$*TM1I$aIGCO-3MzMxdFbJZo %dR$Y86^!sCc_1JA*TVxnk>X$g*sf+byo zMVThK#_>xSVLDDib{c1^tmzyuw(bKA&IKRaw%T?M$M63b#;4=w3t3AT>Z*?+JC!2p z7M}-!?f)3=MC>??lc0eOJP(gx%Xc0rPP5f<9_o42ee^GO?)KBaKrWzke)9<|!QO8^ z0cQ?Dbqe@N#C$l2bdsYUhtY~pVf;iY`eobK5U70oYsn0RZHyHhh0(E%*479bf$Vb_ z7zPs%)5NxYiV8sDaRD4y9J>HV3JG#%TL@uM2x$q&7o^r!bYjY0{R}EHsM4bQTn2mP zEDW=<|H`8#Wg9Q@fRy;Wg(;AP2XVrS0m)&<4~z^Q7hw^eGV_w1k8HaHGqBZhiBk!- z>2t_UC#4}Y{bd&rnSOE 4wKKq!gXm_gXS%bedScuTM9_t&Ui z{33?d*m)U>(s`Jv Lk=56YbN9aLds~?*vC* zB6ALh8TqD8B(Au=R}PgH4e4I~-D9Ga^$na##jlF5{HS2 _zk^K2FJS$x*HThKL-9RwUdm(LH?Z(y(7#OiA7P8K AbN~PV diff --git a/public/js/rempos.js b/public/js/rempos.js index 4c2f90a8ca4e0f107eeeadc46f852a99614eadbd..b636824515eb431c8797d61c3d9e671080cd9bdd 100644 GIT binary patch delta 1063 zcmah|-A|Hn6y8C;{6N%93Bu&90m^57CA8M8i`JS$Qqc)DUU^>^6G7qC y5{cNZwM8{{}6Z>|~$ML3perP_?~ zkYQI)F>Vc$I~(BCTE}g^-kvVs4WlP$4zsk6^M`^FH$!u@Y0gyYl5;6gle!d~k{&FL zmJvyTfk$ZGQj(n%T;eOloQB6`lubi#8P#XNRz}w|&{IaS40M#yrwlwVqv&V2$RkW{ zdL+eRa{ew7&4QKhlGj-<#ocDv=^A4(x_l(K_oW%m UrJv|LK; 2N<5s0qk{}ORVoq#Xu^eB5m~tj$~w**@K3Ul!WyG_okjf_yr!?hmpV1t^bNSI zKZd{cEs1Xk!EXLw69XTiUjResO{&SSBs?HLv+$-O_hb^Q0HyG3?g!&y^1g6B?Dys8 PIfbiR#}k{IP}}kc=_GDT delta 816 zcmaKpPe_w-7{_^RO<}q$|HLNE*Y_~Dw)gGb+{z6tilReEbSvE--@R`7vhlv-ecwsp z76mhq>L9 N(f4Nt bc zYG>O#I9>!BZs=gv`#k~BSD8&P4WG@@xb+(xlQA os~sNODc51D@)-qAtMUuu*PP@bW-h;hxiQHdeMfq z2j!clrormkE6?XSw>#?gYHWPeMN^erM2cv5X|7!0!vhw~--iw?7N7;G>tG>Q?N;~y z-RcOh{3AbDVWx5_mCY~#8Yicr6_JGo+aCRPwCcy{EF3NQAHjuSozU$|3CUD k(+X@r3M*s87CqN+80``mNSJ&*66b1rW^Q*z{klB3Uav#Dx}LdVeUaY2%GILUp5 zqp9AaJ;k+DbrdZUp$XGb5;n0+Ek=;y-``&Zr%P{MsVl=48O*1#3|UuknA(m@OQi9L zW)oqZ5U_}ouuPr;U)IsAw+U<7?YL&H6@o6ophaVOH1V=8ayq x~{hO&Ap|Kb-oq(P?eDg&PzI<|h>gCx# zY~lsYiVW)`Br3GvG~3afgsqI|wjS0E-5KkK k5y9q?Z(g-7HCaN+p{ zvvxb%aGxZ~quNp@<@v>^w*P3~S5rczaheLP+gi$G8P;u-<=AI}MY;+Xy6d50!#WhJ zaa{l&+O!v{9xH>dY^j6F&%WysG{Ay8)=xuFzwum#!_rMkj-Y}ngM>s?n!3DcDVHJX z{@fE;lA%;`%ZAc&+1n}{-NwV|r=_y=Yt@lP_|B%1rEz^9F$PJreWbJ9(?h$^#x&U` zw5OFMz}6$R@Qq%jGASp_LUt9|fi%O`nHXk>i2#qRTYSqF8r2M2l#-H^OeWEV&>uJ7 zfh$Y0soojhOCpY_E1D$I)S2x;uE1*3nGK%$Nei4FZ-9Guig51^0S@k(d!v&|p?2qz z8=bfwCp5w9JDY^o)_m8xhLForU)WX4!NI z~&+*(Wbg8;1Fzm~mfMg&{{77!idY z&%5EkE&;yU*W8jXl{$k^jCotuNLOXQGwlQf7vLe@ciWAL58wIu>{Qd|d*;?ql_-v8 z4G{;8-_k;X?(M+Pe`@a}dhvz01`}p!>hJD(j=LJ=q1b~SWop9w)>WyV+Perw<70>v z7vgW#V7R_^;ENp2TX7#>mi900jqh@)%U?cJ4C?*uC9CgVy&Zb)=O=$tRymk3BaTiP zguQZqC(bH*;K%s<#|Qo!pHF}N1$>4dyj7yANk;h|%hJX~A_eU=Bxtv_Ni<~ls}4Sb z86Zsk(}SD233-qj1|21q9SHSjU)Coqn_7X6?7$XkkwA+-`~EqqMRjv1H^g4D4DjVs z9B`zN1r9JC{cK*AuS fq^`c@7 zXV`PNrJR9@t?Dr6P*X7#b@=9Cp8ZYjJhTptka~2os0eNT_+g=$ikc+JqK+QQuuW3p z342)4;&Ee4)Mc7_{qW^E)t{0K&aA5CGs pxpy*dsI}Ha6 z3kpcDKGU_ATu7bASY_BP$;i0c1)8;fX?#Spc&fsak2H1?CB2W0EWOKmd^dwYwWAfC zS{2&tptet^wt}a!Na4)Z+L|n38sOmQk-D8Ehh8|GCCL-UlXd!UuApAtmy|VC#LTbH ziEK~3{k^rbp!t?6c=VYX@JE~H6*5MIOD5rkR4+KG&C}4Kr?>*Rbg36o&vEeK%T@5= zKg^kkXjYUDQ!7d=pFsv4U)<7Tnl;9=556#sj`4O(GYmdH=Ii7gvd{6w5-iOhq^9E= zCHl~?gJedOw}-VT9rc;iBpHSdjc0y_spHUC=FMW$grpqW<7H-d(*& $dZNJj zm*-D5&Vw)feO2nna~q59ax%tlZOsFRZq#(x7h@2K0g5L{-ofpvPLMUn!L(#tkLG>y zW`O_h<|aF0A=+zUg9HRYc65jR*pN9B;k~dB81r@U@qIqOAPZ4lGw6^nVraH4WEiH` z$DAJ%)~tmYut4vN^;m7)T*}O_5;_v~Q2kOBw7*o3b(jdwOEoa3b`k6$m7fu0R i#Vxj*GI{P|Fk2H#NV3C@rQxhXgtNol zE3;e7iw>FWi(ACD(RLZN6oLt~3^h$;WlKAqA6>yWNFxb^*R-9Su{aulIjkVZ4J|^3 z(MgFF5WLKU&zeS26x>ae# 2`>);3fBpyAlgssULd#>Usu@{NzU#GdZ?MWGtf7nR3QV SKW}$1sh1yocLTv=|IZM{Cp+K^I zjyB=Vdmp!X@0%cx>QQGnAawcT`-G_<&E?iYeRXZAe>wUV9MhHptty@8frpD2j)m0% zMkZ{N7s8zfOQGf2hT;|ReN%rf zHNw`=kU=myqv(JLp(euz_Q7>q)~La69)2?YzL1jO&X=p|JRS;)t~(<3l%$ZqUqVlM zs%DYfnE83loVDw+dt~e7({?ag_g`LAIts2%tPB)9TF%u%{9sLSRI}jWgNxyN&n%s= zbDoGuN=8t;+r9pin9F9}km+#?L^K`tpQxNy2<6x0*)SAbVKAr$i0J;rW3WIpFP4@S zqCsmbCJcG8ng+9U%cHE!3kL94H6S?F%+AGzgD{HTxGIj0@A_y2H$p5HFMi%2T9hH2 zhgVxEzn$3FV6zc45@ytulm%>VOX+1 ymcpBdDOaS%17La*cYcd6FMg |#jWtzOBb=R_Rzl^z!qio ziD&Wo{0Rj|-Z|mJ-}NVheyWaR`GL7$Nbq|9LjR0yc+lS1llrfda>XpX;Rtp0vpTkg zLgTBxGF=rlmBO}H@4JaAHceO&VnYoqgp04v!8OZ%*@bHce);qSuF2N$2lX{>8S544 zK^^OMw^TLV;y0xC<0&5$%tS1VxQ|C4X1R2bH?dZxA+uj?@k>c8ey~~Sa!P|$j*W%_ z>eDbN*N|^(D}~5wTS_+HzWVc1_uj-UxYfN2yUSepB|!?JE-XBJbBj{8s2ajk61$MH zAoTZ3K~`1kaQbVvEupe51*w;s*{qaTFtewYW2>1#34Ziidp#8$naJ1^lXcmYHQDm^ zYseA!=rw5~qFBVlyj>-7#J!QyBir494W_NyI5vQnS#jOKJ0IM;%w&ovnZ#K$+(D)t zB(x(mPBunWFS#1=32mk%Yu%!uX6Qvn8W$ba*}vbl!O{^Ep8VC5C3 -$?~GnW&vohI~sscj?PF$i$( z_bcI_&n}+r`UMLPY== WnZ%4X0!QE7 zjBd^@D= 7h(y zY8c?@32t9s4C5}7=UP`yLuX|vvTS7{Nx`&GD(g*9c;j4`kdG#rmGYq^q=Wyx^*lux zyV=9z=drh>fbIRF0Pn3tot}8_G1O-G{IX)&P2qv_O?X%IgYySCyvs1a_x^Hpvz_n% z0ZF!g&^V7pAl{R+YDd9dP=uhR{^o=GiqVcxa`F31BK!A?5p@VlPmES|y%N^VsOa9U z*ckn+*{`R5adCi~Pt`329|^mT@A +6)U<41*# p zQ6@IJ`+|y5=C4E^g~$HbUXASm7kO2?TM*C(e)Y#&N(ZoQphM}$%g{V+ACI9A6D5Yt zT#X5dR>E?SI6Y`5J&X-KQE 7>tl}_=u=nQdpgm!m z8aM@;!}k9Dri2EkYFwmfP!^haA^0a9*Ia$60bOF(rLUs5{Pxn%ig4)Lm-E2Ly;%hA z_@yH5@iYkS2yZuT_@tQ$gr*uRx!eBI^ZXO87#lC}(Jz)9ub9m}g#V$z#m*usPd!-8 oEjoUthWpPV4?TOV9b0c$@QxYKGktDiT_}e_on5lOpPjD%1IL1V)c^nh delta 3152 zcmbtWU2GKB71r^3jlnS(|A38+y*mbHCLWJ}sDXO9I2Pg8fdBzqvMgpiJGL(!&s}C_ z`N^_L9Mq79)`Z-KL8PtJ5|zHRAsUF4N)1HHQxi%n`qHQ^q^TOEqC_ca0wN?mGwXFs zf@rJm!|cqx=bm%!Ip6o)`_lEo_kLD*`d3g&i7g84Ll30{NoM@l@C;CIQ^Bs#97-HT z)ijni9VKn+mZ^^DGIQrwLk+(YF6m^-u%!=9HL?tu5gcZ=6Bz4KMpJEFcu?rFbSG_@ z5y~S)?G3D1Sk`K%RCBoy3k(J=HZsK~RkRZrzgW}&l&6YXdHuBFx~XfD`k LHFEdV+P+4OmBf9ByG4J~&AHxd{tl(r@v3IHTWIexrv67$f z5A)NlVP2w?d2h-K!Hcz475H^%-(OZpr402h^lS+GzK5xez8hD^(0BjpI6jZ`bn!2G zcYFP7W h=Yj(}#k8XR9*FUw4e@I1s^>0=Dw|%92@965_+xeEr z(?hxUMcZrn+3E^Dyj|dLz5iogwqpkGd+Y+Al~@AZd~}rXN8SzdAMUuHPye9QyPW7L zFQY_P9Mu}q9c*hfN_ktV9Xrynw}xj@2Qbac_MjKva~AGp`EA?A)?BtfzlYWHfiKmo z*!Of9R{hxFoqjp~^=#yhEssYQ!u*i?ixA9oUtSDl-k$GXfpEvbMpba%P@rmhfaL9a z2YTI&B~V#Io0>#Q;&H@-Ol3hxB&670)rv4TTLMcOsm!DpRSc%7hK;6 J%dts* zP~scLI>Jjv9^NjvPn1El(2vD4V@t6JXT~}(#rMZv#pjQXe1OkqpL-pjtB)?m*(Z+9 zDW`EeK^%O<^4Rx&dbAh33;dme5F<8^o`_;lnzRX%9c6@?{WM9|tIn`8qV8AJRLVFY z`kyit8Q*at62gCPj{6|i#_Ky?fcWH(oe&zRs7jJ7GQ_ l8d zk4I+}FHt)?zR{WN !DIg{3Ezh#i!VyV=%5&-{FzT%iWm`9LiuZ-9zy@*h7Un@ zV-ZF HsDt` z(9NaKx}Ic`tVvklsga}%dZXrUU2`6F4j8&kw16#XlHWos|3W6cV*#Yh1iZCe99@#r zeqp3+RyS-tXhITw4Cq*NeTAi~PDqa#{K)OPFeV(=`03lVVDPtZ`)Ysnb_WVT!{@C9 zz+QL+?i7Ht&w*G7)P1cG+VF4rVpuia7KXV+__3x0>iz@cYwyAM@rxyJ5yr_(AO%H2 z-{Z||bCh>~QwE)dfu{7#gr)8~rO-A>0=Il7G`peMP+J;cz=tBTjJnOUVaGJT6^u}K zY&KK_b5G5Nb)dPwn+FwcxEwZN&VA+3ihqa8LF%EXIvO(_Jf$9DTG~IYlwn;>W;NB( zhD8U@AY!HsBdf=q1fki9qWfRjtSHHb`%yXcA{~6iK|+{uiqaC$i1`@6liF@>j`_g% zY2p640&04gl2TFd;%Y*qldn=>6jPT?4CM);BBdr41QH_-;7(t!fTpE)F|;O4CjBBL zq}W7SBtw&3)UB(8MZ#1D=VMK!Z=AV%Dq%&0Vw&ClBj+&}z>>P`+#7X&UkRO9;TctM z9IJY=3KoWFH+A2tf_k9if3Jd3K;6|`b1vME4LLR!ZlK+fYM5W)(+)2?j$_%1$xKbh zzMZRvLm}*|yRrsOH5vyGiUt`HaT78nE0h6hCPlv?C;+M)ZI@&=K5HKILnS5a@0dkG zF!$+N=x(9pz| y;ci5N>O77QpsmhgpK#r!Sk>b&osZozIuIK z?^I&PSt2G{hyc|`fE2X8rA4N0v>xi7%q_$t?khrR`HI^{2*ITkUF6nDnLJ`N45%%` zzWRvCuhtL~6GL$=Axg2NZs?94G2@mWJBz Y;TO zUbupDn%LcffH0Zc01t%siT>qtRRb)<-Wd&W0I{wsBYKjme&*OgN3nIM2YW~d(vB_) z!JL5mE{(v8{^dZ?Y`na=uSB3-VKGtn3Ab0boGq+pz-9(6+G-j0!GorRaG)e5Qf#(Q zW9pVgfnwapqfm#Kaiee;A=MzjIn*il@`A4jm48!tb$aeTvSnbi@!#A>?xjxd&Q@6R yzZ|G5{ma3w>y6`UJK%dE%-EOA0xG*NhoEL$SPXwH$Ys}KZQ1aDXE*junDcLr=Q3IV diff --git a/public/js/status.js b/public/js/status.js index c09608834657c6a371c3e39c043e881a58330855..175fb756714ae10b69d26e28e874cd5f40bc1a58 100644 GIT binary patch delta 1081 zcmbu8T}V@57{_^VxivE@ab>o-9dc~!?1L%h*J(*o)F8)bnr g6x$RewW(VjaT_mpadLBF%D4lJrw)o{bn?6h#F*Tq+i83rMf}--1`-gEg+6 zLe)f3UbERtCKSKG(8=&ncU%<6K5R~S#xrdRX(jj({L-yZ0(zvwA>DGtK%6^7<_v6_ z@$rHvEv{Bqi9@W=?lMs$EEnNN9MYXd!duW7b2VdJQF;ig*-VeJyx_>8g>cew)){9< znOKyGIMZ&YAktz=a1u7r!A5LKeg7Cj+Fj8}(;GUe{q+&)?jv OqV28d#UVX`sfBeg?o$ zX*Lsu&=JCfh7Z6A^w 6j_{)y&>A{E| z_Cg~X41!%#jF@+70)^g`yjpp;A9jvu8eI$>ZH%^{?O`y;J|4ET^7;)>*Q#>K(c}ae z4l37RdYiflE83Faxm-05Wh%LI0WKa@g}a7)5;%0yIkifayzEV)UlL4}6A{~`CL-^? zf4yGbd;w=F%akoj(saz*mzT=01`h4Mb?E{4%*8+hWQcPl89FSoJSPMrv`E|2_F@$o zv(SOVE2Kx0S!l;g@3UaTw3mfYMbSK997pfwp$?7BKn81m&p->Nvj|!-O(1B-w1&Wq zX%E3uOiQ!i!L&OIK}=WY;5Mcob6{8H8=+jh6}dU6#l_DYSkX-mjCkn@2d6P9f-X#_ lcxCR+b$Fx7J-vM6tCX?j#zQE-##oXK7p9?;>o=g%@COuLZ ZT4sPu{$VUKJ5BDb~;1_j{k`dwF=?o%h7EZQ}V5 z;S7%_BobYE3m!rkCYj^~1szz0mA&M6eDV}o*}dUK-3qw$Y2q$3#L&WMo|hKR79^Dw zB)&+c=*5CCHxQ(0nu$pZq8Mc=&Y4%|6w6tXS&mQgBG0L%1gmoMRQ#`dkfs%%PodJb zi)V}6{6$$UQCIM?bgZmJ(-k7~22zy|83p1MHgiOu&B~HT( z?Vj5ydh6ivbu#9ttTbBWSxyxSQYkaXs%&5}&{5HYI`pDzb?C=AtkGpmzw0o9Db#@M z{?6yzKp9Ql2QShapkdFi7WmL?6aMSzdJ`hpu-=3TOy8R@hAFfOHB6s3A% ^yYCN3%<3rs diff --git a/public/js/timeline.js b/public/js/timeline.js index 943473953e8c44079743f3ca0120523725fe9213..af705402b1d593b58a4417fe9302f817f158d23f 100644 GIT binary patch delta 1630 zcmb7EOKclO7}iYN#CfRcBazc40eb|?ZoO=tgkrRW9*~M?Vv{D8APwneJ&qTxcii1s zTUK_JL#4u{s*))hkb0n}B5?rZgAWK*NIh^uZ~#O^Na%rMfeR0b1M5eeCJ>ZfRy*H6 z|2N-!|3ClVKX+{Y(eZI#EP^y!&l&}sMp2b24%MjZC 2Z zT8d6g6YJC ?e?C0DgHP4*DLJ89?AT8|Y<|7taKiHv zWpn)j1k-G8<9zVFwl&VyH?DvSZ0deL%l>#h_yhNQ%SShhpcXz0rmx=Js@&ZI_trwP zCfl;9F0 fIxK6H;#@v>Aq|YLz6T%YFP7k8E>if;A(5#3 zGYZS?o`-aj%VCT1d=Yj-&*Rx5?C%Wlj8-77ZBSf`JW)l0tCJ$Cnomo4EC^AmXu3{Z zi&ooOc6ii-i2vZi$uW5-8kl8bYl&t(FW9$7k>`oQ^WRaeMu((|RDfY{a7~f_*A(34 z?IJ{#4~uZL1quTH)`x$N7#WdDHm0s^MW~Y1oJ7$|+9Z!{S`v{-bj?JPAPiT>Q*AZD zR-mvFr;2NvzAtMkvg<}q5o%-%U90wjgJfIHxK^z(L}{634JSy~(uTDZTurX6_j=F_ zWH8mUyXb;BA!`xCijaxK;+t4sph_BN3=21 x zSaO@|Vo=3K%$MS1PMcS4nFJ^3swJXV&IePF-zkAh{L%?1RBo-nMC&0z!2CNO#wuNJ z!6U~NnJNnf5mg87uCmp8U{7r)_?7FhCq$nfi8wjU (=GiYRr`pijXU4~$(}pqMxCaI*Gw(ui F?jMKQMHB!4 delta 507 zcmccro%_!-?hVq~(@)G`6q%;S$h|pA+k|oR1KnRLo0~!+8K=vLGm1_2uTq=5AzWi} zLYUIzw0d_an{~2Wxbk#~bxeGd9cnCrg3gn##tTp8jZm8Iwv$PHa(tB-P*8RA<_Iyy z$y=h$CkIB@PJS1|wmBfiwOPr>Hcu}(KPM+Oxg;|`Pba{(EI%_v!NAtm*2g}tG$+T( zXY%&@*5dvMaX*N-pH7f%-sFqVL?_EV;ARP`sR`Pw``{fDqyJ=?#~wh^{h8V1*^dhu z119sokeRIcq?a*Z`u%DqiOuJqlrw_mEg1cq8=h@%c*b~S5|eH9bjNdy;?t8WnQ|sy zU=*AFHieOEx= W|CnAvVqK6Ad^E=vsP16r`UG#!RIp5 z{i_(+r!&?td2es1VG?IV)&paMG_ip6PZzFZQsMyXPPEMjxlD0;Y#kFf+w|f-CY9;a zTA0pEZU_*WY#X30S6i!-Xq%^@ Pe5a!R7HnQ4-e4k$1+tyA? @~jp52gnhcQ6V+0-`l{>$8 z-LL!CUtqSHsG!zC%nd-xxZK~K0(yh)!=a~)g$twlJTB)KsvR?D;Es)(gF4RJ@)MAg zL^sjFC{Q4&(EMIcA(9caSSxkSxf+7B`M Vuo Date: Mon, 26 Jul 2021 23:59:38 -0600 Subject: [PATCH 34/92] Update AccountService, add syncPostCount method --- .../Controllers/Api/BaseApiController.php | 8 +++--- app/Http/Controllers/PublicApiController.php | 21 ++++++++++----- app/Services/AccountService.php | 27 +++++++++++++++++++ app/Services/StatusService.php | 2 ++ 4 files changed, 48 insertions(+), 10 deletions(-) diff --git a/app/Http/Controllers/Api/BaseApiController.php b/app/Http/Controllers/Api/BaseApiController.php index 70401eba5..c700f2434 100644 --- a/app/Http/Controllers/Api/BaseApiController.php +++ b/app/Http/Controllers/Api/BaseApiController.php @@ -37,6 +37,7 @@ use App\Jobs\VideoPipeline\{ VideoPostProcess, VideoThumbnail }; +use App\Services\AccountService; use App\Services\NotificationService; use App\Services\MediaPathService; use App\Services\MediaBlocklistService; @@ -311,10 +312,8 @@ class BaseApiController extends Controller $status->scope = 'archived'; $status->visibility = 'draft'; $status->save(); - StatusService::del($status->id); - - // invalidate caches + AccountService::syncPostCount($status->profile_id); return [200]; } @@ -339,8 +338,9 @@ class BaseApiController extends Controller $status->scope = $archive->original_scope; $status->visibility = $archive->original_scope; $status->save(); - $archive->delete(); + StatusService::del($status->id); + AccountService::syncPostCount($status->profile_id); return [200]; } diff --git a/app/Http/Controllers/PublicApiController.php b/app/Http/Controllers/PublicApiController.php index 5059f8161..566d2504a 100644 --- a/app/Http/Controllers/PublicApiController.php +++ b/app/Http/Controllers/PublicApiController.php @@ -675,9 +675,7 @@ class PublicApiController extends Controller $limit = $request->limit ?? 9; $max_id = $request->max_id; $min_id = $request->min_id; - $scope = $request->only_media == true ? - ['photo', 'photo:album', 'video', 'video:album'] : - ['photo', 'photo:album', 'video', 'video:album', 'share', 'reply', 'text']; + $scope = ['photo', 'photo:album', 'video', 'video:album']; if($profile->is_private) { if(!$user) { @@ -713,6 +711,8 @@ class PublicApiController extends Controller 'created_at' ) ->whereProfileId($profile->id) + ->whereNull('in_reply_to_id') + ->whereNull('reblog_of_id') ->whereIn('type', $scope) ->where('id', $dir, $id) ->whereIn('scope', $visibility) @@ -720,12 +720,21 @@ class PublicApiController extends Controller ->orderByDesc('id') ->get() ->map(function($s) use($user) { - $status = StatusService::get($s->id, false); - if($user) { + try { + $status = StatusService::get($s->id, false); + } catch (\Exception $e) { + $status = false; + } + if($user && $status) { $status['favourited'] = (bool) LikeService::liked($user->profile_id, $s->id); } return $status; - }); + }) + ->filter(function($s) { + return $s; + }) + ->values(); + return response()->json($res); } diff --git a/app/Services/AccountService.php b/app/Services/AccountService.php index 8f9f5c3b9..955e168b7 100644 --- a/app/Services/AccountService.php +++ b/app/Services/AccountService.php @@ -4,6 +4,7 @@ namespace App\Services; use Cache; use App\Profile; +use App\Status; use App\Transformer\Api\AccountTransformer; use League\Fractal; use League\Fractal\Serializer\ArraySerializer; @@ -35,4 +36,30 @@ class AccountService return Cache::forget(self::CACHE_KEY . $id); } + public static function syncPostCount($id) + { + $profile = Profile::find($id); + + if(!$profile) { + return false; + } + + $key = self::CACHE_KEY . 'pcs:' . $id; + + if(Cache::has($key)) { + return; + } + + $count = Status::whereProfileId($id) + ->whereNull('in_reply_to_id') + ->whereNull('reblog_of_id') + ->whereIn('scope', ['public', 'unlisted', 'private']) + ->count(); + + $profile->status_count = $count; + $profile->save(); + + Cache::put($key, 1, 900); + return true; + } } diff --git a/app/Services/StatusService.php b/app/Services/StatusService.php index 892005f7e..d503f906f 100644 --- a/app/Services/StatusService.php +++ b/app/Services/StatusService.php @@ -41,6 +41,8 @@ class StatusService { public static function del($id) { + Cache::forget('status:thumb:nsfw0' . $id); + Cache::forget('status:thumb:nsfw1' . $id); Cache::forget('pf:services:sh:id:' . $id); Cache::forget('status:transformer:media:attachments:' . $id); PublicTimelineService::rem($id); From acaf630dee2794ef4d1f9d25c7441ee4400581a4 Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Tue, 27 Jul 2021 00:13:03 -0600 Subject: [PATCH 35/92] Update StatusService, invalidate profile embed cache on deletion --- app/Services/StatusService.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/Services/StatusService.php b/app/Services/StatusService.php index d503f906f..90629b9f9 100644 --- a/app/Services/StatusService.php +++ b/app/Services/StatusService.php @@ -41,6 +41,10 @@ class StatusService { public static function del($id) { + $status = self::get($id); + if($status && isset($status['account']) && isset($status['account']['id'])) { + Cache::forget('profile:embed:' . $status['account']['id']); + } Cache::forget('status:thumb:nsfw0' . $id); Cache::forget('status:thumb:nsfw1' . $id); Cache::forget('pf:services:sh:id:' . $id); From 4fb3d1fa70998a69f219709de2b661a383922b90 Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Tue, 27 Jul 2021 00:27:36 -0600 Subject: [PATCH 36/92] Update status.reply view, fix archived post leakage --- resources/views/status/reply.blade.php | 49 +++++++++++++++++--------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/resources/views/status/reply.blade.php b/resources/views/status/reply.blade.php index dee2e360c..7bd11b7b2 100644 --- a/resources/views/status/reply.blade.php +++ b/resources/views/status/reply.blade.php @@ -5,60 +5,74 @@ +-@endsection \ No newline at end of file +@endsection From 5916f8c76a0703d63bf37d01154accfb38bf4eed Mon Sep 17 00:00:00 2001 From: Daniel Supernault- @if($status->parent()->parent()) + @php($gp = $status->parent()->parent()) + @if($gp)+ @if($status->comments->count())+ @if($gp->scope == 'archived') +@endif + + @php($parent = $status->parent())This status cannot be viewed at this time.
+ @else + @endif+ + @if($parent->scope == 'archived') ++ +This status cannot be viewed at this time.
+ @else + @endif@if($status->is_nsfw)@endif@@ -101,6 +115,7 @@From c5281dcdb31716340e42bd28f9cb47058097be40 Mon Sep 17 00:00:00 2001 From: Daniel SupernaultDate: Tue, 27 Jul 2021 00:49:59 -0600 Subject: [PATCH 37/92] Update PostComponents, re-add time to timestamp --- resources/assets/js/components/PostComponent.vue | 2 +- resources/assets/js/components/RemotePost.vue | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/assets/js/components/PostComponent.vue b/resources/assets/js/components/PostComponent.vue index 0ca16f10e..ea87e8a10 100644 --- a/resources/assets/js/components/PostComponent.vue +++ b/resources/assets/js/components/PostComponent.vue @@ -937,7 +937,7 @@ export default { timestampFormat() { let ts = new Date(this.status.created_at); - return ts.toDateString(); + return ts.toDateString() + ' · ' + ts.toLocaleTimeString(); }, fetchData() { diff --git a/resources/assets/js/components/RemotePost.vue b/resources/assets/js/components/RemotePost.vue index 00b01960b..eab25df32 100644 --- a/resources/assets/js/components/RemotePost.vue +++ b/resources/assets/js/components/RemotePost.vue @@ -643,7 +643,7 @@ export default { timestampFormat() { let ts = new Date(this.status.created_at); - return ts.toDateString(); + return ts.toDateString() + ' · ' + ts.toLocaleTimeString(); }, fetchData() { From 03199e2f68862b41911bd7086117848f358e4106 Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Sun, 1 Aug 2021 15:09:52 -0600 Subject: [PATCH 38/92] Update follow intent, fix follower count leak --- resources/views/site/intents/follow.blade.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/views/site/intents/follow.blade.php b/resources/views/site/intents/follow.blade.php index 6b474e06e..b14cf4423 100644 --- a/resources/views/site/intents/follow.blade.php +++ b/resources/views/site/intents/follow.blade.php @@ -14,7 +14,7 @@ {{$profile->username}}
-{{$profile->followers->count()}} followers
+{{$profile->followerCount()}} followers
@if($following == true)Date: Wed, 4 Aug 2021 00:00:50 -0600 Subject: [PATCH 39/92] Update Profile model, fix getAudienceInbox method --- app/Profile.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Profile.php b/app/Profile.php index 2d9ef3fdc..0b2aa9460 100644 --- a/app/Profile.php +++ b/app/Profile.php @@ -277,7 +277,7 @@ class Profile extends Model public function getAudienceInbox($scope = 'public') { - return FollowerService::audience($this->id, $scope); + return FollowerService::audience($this, $scope); } public function circles() From 770922007461a1f3691ea2398a4766295792e7a0 Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Wed, 4 Aug 2021 20:29:21 -0600 Subject: [PATCH 40/92] Add Polls --- app/Http/Controllers/PollController.php | 73 +++ app/Http/Controllers/PublicApiController.php | 44 +- .../StatusActivityPubDeliver.php | 153 ++--- app/Models/Poll.php | 35 ++ app/Models/PollVote.php | 11 + app/Services/PollService.php | 72 +++ .../ActivityPub/Verb/CreateQuestion.php | 46 ++ app/Transformer/ActivityPub/Verb/Question.php | 89 +++ .../Api/StatusStatelessTransformer.php | 5 +- app/Transformer/Api/StatusTransformer.php | 5 +- app/Util/ActivityPub/Helpers.php | 67 ++- app/Util/ActivityPub/Inbox.php | 80 ++- app/Util/Site/Config.php | 5 +- config/exp.php | 1 + .../2021_07_29_014835_create_polls_table.php | 41 ++ ...1_07_29_014849_create_poll_votes_table.php | 36 ++ .../assets/js/components/ComposeModal.vue | 171 +++++- .../assets/js/components/PostComponent.vue | 523 +++++++++++------- resources/assets/js/components/RemotePost.vue | 14 +- .../js/components/partials/CommentFeed.vue | 286 ++++++++++ .../js/components/partials/PollCard.vue | 327 +++++++++++ .../js/components/partials/StatusCard.vue | 53 +- routes/web.php | 3 + 23 files changed, 1819 insertions(+), 321 deletions(-) create mode 100644 app/Http/Controllers/PollController.php create mode 100644 app/Models/Poll.php create mode 100644 app/Models/PollVote.php create mode 100644 app/Services/PollService.php create mode 100644 app/Transformer/ActivityPub/Verb/CreateQuestion.php create mode 100644 app/Transformer/ActivityPub/Verb/Question.php create mode 100644 database/migrations/2021_07_29_014835_create_polls_table.php create mode 100644 database/migrations/2021_07_29_014849_create_poll_votes_table.php create mode 100644 resources/assets/js/components/partials/CommentFeed.vue create mode 100644 resources/assets/js/components/partials/PollCard.vue diff --git a/app/Http/Controllers/PollController.php b/app/Http/Controllers/PollController.php new file mode 100644 index 000000000..21acd283e --- /dev/null +++ b/app/Http/Controllers/PollController.php @@ -0,0 +1,73 @@ +status_id); + if($status->scope != 'public') { + abort_if(!$request->user(), 403); + if($request->user()->profile_id != $status->profile_id) { + abort_if(!FollowerService::follows($request->user()->profile_id, $status->profile_id), 404); + } + } + $pid = $request->user() ? $request->user()->profile_id : false; + $poll = PollService::getById($id, $pid); + return $poll; + } + + public function vote(Request $request, $id) + { + abort_unless($request->user(), 403); + + $this->validate($request, [ + 'choices' => 'required|array' + ]); + + $pid = $request->user()->profile_id; + $poll_id = $id; + $choices = $request->input('choices'); + + // todo: implement multiple choice + $choice = $choices[0]; + + $poll = Poll::findOrFail($poll_id); + + abort_if(now()->gt($poll->expires_at), 422, 'Poll expired.'); + + abort_if(PollVote::wherePollId($poll_id)->whereProfileId($pid)->exists(), 400, 'Already voted.'); + + $vote = new PollVote; + $vote->status_id = $poll->status_id; + $vote->profile_id = $pid; + $vote->poll_id = $poll->id; + $vote->choice = $choice; + $vote->save(); + + $poll->votes_count = $poll->votes_count + 1; + $poll->cached_tallies = collect($poll->getTallies())->map(function($tally, $key) use($choice) { + return $choice == $key ? $tally + 1 : $tally; + })->toArray(); + $poll->save(); + + PollService::del($poll->status_id); + $res = PollService::get($poll->status_id, $pid); + return $res; + } +} diff --git a/app/Http/Controllers/PublicApiController.php b/app/Http/Controllers/PublicApiController.php index 566d2504a..bd96e774e 100644 --- a/app/Http/Controllers/PublicApiController.php +++ b/app/Http/Controllers/PublicApiController.php @@ -93,20 +93,15 @@ class PublicApiController extends Controller $profile = Profile::whereUsername($username)->whereNull('status')->firstOrFail(); $status = Status::whereProfileId($profile->id)->findOrFail($postid); $this->scopeCheck($profile, $status); - if(!Auth::check()) { - $res = Cache::remember('wapi:v1:status:stateless_byid:' . $status->id, now()->addMinutes(30), function() use($status) { - $item = new Fractal\Resource\Item($status, new StatusStatelessTransformer()); - $res = [ - 'status' => $this->fractal->createData($item)->toArray(), - ]; - return $res; - }); - return response()->json($res); + if(!$request->user()) { + $res = ['status' => StatusService::get($status->id)]; + } else { + $item = new Fractal\Resource\Item($status, new StatusStatelessTransformer()); + $res = [ + 'status' => $this->fractal->createData($item)->toArray(), + ]; } - $item = new Fractal\Resource\Item($status, new StatusStatelessTransformer()); - $res = [ - 'status' => $this->fractal->createData($item)->toArray(), - ]; + return response()->json($res); } @@ -403,11 +398,22 @@ class PublicApiController extends Controller } $filtered = $user ? UserFilterService::filters($user->profile_id) : []; - $textOnlyPosts = (bool) Redis::zscore('pf:tl:top', $pid); - $textOnlyReplies = (bool) Redis::zscore('pf:tl:replies', $pid); - $types = $textOnlyPosts ? - ['text', 'photo', 'photo:album', 'video', 'video:album', 'photo:video:album'] : - ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album']; + $types = ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album']; + + $textOnlyReplies = false; + + if(config('exp.top')) { + $textOnlyPosts = (bool) Redis::zscore('pf:tl:top', $pid); + $textOnlyReplies = (bool) Redis::zscore('pf:tl:replies', $pid); + + if($textOnlyPosts) { + array_push($types, 'text'); + } + } + + if(config('exp.polls') == true) { + array_push($types, 'poll'); + } if($min || $max) { $dir = $min ? '>' : '<'; @@ -433,7 +439,7 @@ class PublicApiController extends Controller 'updated_at' ) ->whereIn('type', $types) - ->when(!$textOnlyReplies, function($q, $textOnlyReplies) { + ->when($textOnlyReplies != true, function($q, $textOnlyReplies) { return $q->whereNull('in_reply_to_id'); }) ->with('profile', 'hashtags', 'mentions') diff --git a/app/Jobs/StatusPipeline/StatusActivityPubDeliver.php b/app/Jobs/StatusPipeline/StatusActivityPubDeliver.php index af65aed75..759f5c72c 100644 --- a/app/Jobs/StatusPipeline/StatusActivityPubDeliver.php +++ b/app/Jobs/StatusPipeline/StatusActivityPubDeliver.php @@ -12,6 +12,7 @@ use Illuminate\Queue\SerializesModels; use League\Fractal; use League\Fractal\Serializer\ArraySerializer; use App\Transformer\ActivityPub\Verb\CreateNote; +use App\Transformer\ActivityPub\Verb\CreateQuestion; use App\Util\ActivityPub\Helpers; use GuzzleHttp\Pool; use GuzzleHttp\Client; @@ -20,85 +21,97 @@ use App\Util\ActivityPub\HttpSignature; class StatusActivityPubDeliver implements ShouldQueue { - use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; - protected $status; - - /** - * Delete the job if its models no longer exist. - * - * @var bool - */ - public $deleteWhenMissingModels = true; - - /** - * Create a new job instance. - * - * @return void - */ - public function __construct(Status $status) - { - $this->status = $status; - } + protected $status; - /** - * Execute the job. - * - * @return void - */ - public function handle() - { - $status = $this->status; - $profile = $status->profile; + /** + * Delete the job if its models no longer exist. + * + * @var bool + */ + public $deleteWhenMissingModels = true; - if($status->local == false || $status->url || $status->uri) { - return; - } + /** + * Create a new job instance. + * + * @return void + */ + public function __construct(Status $status) + { + $this->status = $status; + } - $audience = $status->profile->getAudienceInbox(); + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $status = $this->status; + $profile = $status->profile; - if(empty($audience) || !in_array($status->scope, ['public', 'unlisted', 'private'])) { - // Return on profiles with no remote followers - return; - } + if($status->local == false || $status->url || $status->uri) { + return; + } + + $audience = $status->profile->getAudienceInbox(); + + if(empty($audience) || !in_array($status->scope, ['public', 'unlisted', 'private'])) { + // Return on profiles with no remote followers + return; + } + + switch($status->type) { + case 'poll': + $activitypubObject = new CreateQuestion(); + break; + + default: + $activitypubObject = new CreateNote(); + break; + } - $fractal = new Fractal\Manager(); - $fractal->setSerializer(new ArraySerializer()); - $resource = new Fractal\Resource\Item($status, new CreateNote()); - $activity = $fractal->createData($resource)->toArray(); + $fractal = new Fractal\Manager(); + $fractal->setSerializer(new ArraySerializer()); + $resource = new Fractal\Resource\Item($status, $activitypubObject); + $activity = $fractal->createData($resource)->toArray(); - $payload = json_encode($activity); - - $client = new Client([ - 'timeout' => config('federation.activitypub.delivery.timeout') - ]); + $payload = json_encode($activity); - $requests = function($audience) use ($client, $activity, $profile, $payload) { - foreach($audience as $url) { - $headers = HttpSignature::sign($profile, $url, $activity); - yield function() use ($client, $url, $headers, $payload) { - return $client->postAsync($url, [ - 'curl' => [ - CURLOPT_HTTPHEADER => $headers, - CURLOPT_POSTFIELDS => $payload, - CURLOPT_HEADER => true - ] - ]); - }; - } - }; + $client = new Client([ + 'timeout' => config('federation.activitypub.delivery.timeout') + ]); - $pool = new Pool($client, $requests($audience), [ - 'concurrency' => config('federation.activitypub.delivery.concurrency'), - 'fulfilled' => function ($response, $index) { - }, - 'rejected' => function ($reason, $index) { - } - ]); - - $promise = $pool->promise(); + $requests = function($audience) use ($client, $activity, $profile, $payload) { + foreach($audience as $url) { + $headers = HttpSignature::sign($profile, $url, $activity); + yield function() use ($client, $url, $headers, $payload) { + return $client->postAsync($url, [ + 'curl' => [ + CURLOPT_HTTPHEADER => $headers, + CURLOPT_POSTFIELDS => $payload, + CURLOPT_HEADER => true, + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_SSL_VERIFYHOST => false + ] + ]); + }; + } + }; - $promise->wait(); - } + $pool = new Pool($client, $requests($audience), [ + 'concurrency' => config('federation.activitypub.delivery.concurrency'), + 'fulfilled' => function ($response, $index) { + }, + 'rejected' => function ($reason, $index) { + } + ]); + + $promise = $pool->promise(); + + $promise->wait(); + } } diff --git a/app/Models/Poll.php b/app/Models/Poll.php new file mode 100644 index 000000000..2b65162c0 --- /dev/null +++ b/app/Models/Poll.php @@ -0,0 +1,35 @@ + 'array', + 'cached_tallies' => 'array', + 'expires_at' => 'datetime' + ]; + + public function votes() + { + return $this->hasMany(PollVote::class); + } + + public function getTallies() + { + return $this->cached_tallies; + } +} diff --git a/app/Models/PollVote.php b/app/Models/PollVote.php new file mode 100644 index 000000000..c6aae7fa9 --- /dev/null +++ b/app/Models/PollVote.php @@ -0,0 +1,11 @@ +firstOrFail(); + return [ + 'id' => (string) $poll->id, + 'expires_at' => $poll->expires_at->format('c'), + 'expired' => null, + 'multiple' => $poll->multiple, + 'votes_count' => $poll->votes_count, + 'voters_count' => null, + 'voted' => false, + 'own_votes' => [], + 'options' => collect($poll->poll_options)->map(function($option, $key) use($poll) { + $tally = $poll->cached_tallies && isset($poll->cached_tallies[$key]) ? $poll->cached_tallies[$key] : 0; + return [ + 'title' => $option, + 'votes_count' => $tally + ]; + })->toArray(), + 'emojis' => [] + ]; + }); + + if($profileId) { + $res['voted'] = self::voted($id, $profileId); + $res['own_votes'] = self::ownVotes($id, $profileId); + } + + return $res; + } + + public static function getById($id, $pid) + { + $poll = Poll::findOrFail($id); + return self::get($poll->status_id, $pid); + } + + public static function del($id) + { + Cache::forget(self::CACHE_KEY . $id); + } + + public static function voted($id, $profileId = false) + { + return !$profileId ? false : PollVote::whereStatusId($id) + ->whereProfileId($profileId) + ->exists(); + } + + public static function ownVotes($id, $profileId = false) + { + return !$profileId ? [] : PollVote::whereStatusId($id) + ->whereProfileId($profileId) + ->pluck('choice') ?? []; + } +} diff --git a/app/Transformer/ActivityPub/Verb/CreateQuestion.php b/app/Transformer/ActivityPub/Verb/CreateQuestion.php new file mode 100644 index 000000000..a1aaccdc2 --- /dev/null +++ b/app/Transformer/ActivityPub/Verb/CreateQuestion.php @@ -0,0 +1,46 @@ + [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1', + [ + 'sc' => 'http://schema.org#', + 'Hashtag' => 'as:Hashtag', + 'sensitive' => 'as:sensitive', + 'commentsEnabled' => 'sc:Boolean', + 'capabilities' => [ + 'announce' => ['@type' => '@id'], + 'like' => ['@type' => '@id'], + 'reply' => ['@type' => '@id'] + ] + ] + ], + 'id' => $status->permalink(), + 'type' => 'Create', + 'actor' => $status->profile->permalink(), + 'published' => $status->created_at->toAtomString(), + 'to' => $status->scopeToAudience('to'), + 'cc' => $status->scopeToAudience('cc'), + ]; + } + + public function includeObject(Status $status) + { + return $this->item($status, new Question()); + } +} diff --git a/app/Transformer/ActivityPub/Verb/Question.php b/app/Transformer/ActivityPub/Verb/Question.php new file mode 100644 index 000000000..fd78ce2ff --- /dev/null +++ b/app/Transformer/ActivityPub/Verb/Question.php @@ -0,0 +1,89 @@ +mentions->map(function ($mention) { + $webfinger = $mention->emailUrl(); + $name = Str::startsWith($webfinger, '@') ? + $webfinger : + '@' . $webfinger; + return [ + 'type' => 'Mention', + 'href' => $mention->permalink(), + 'name' => $name + ]; + })->toArray(); + + $hashtags = $status->hashtags->map(function ($hashtag) { + return [ + 'type' => 'Hashtag', + 'href' => $hashtag->url(), + 'name' => "#{$hashtag->name}", + ]; + })->toArray(); + $tags = array_merge($mentions, $hashtags); + + return [ + '@context' => [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1', + [ + 'sc' => 'http://schema.org#', + 'Hashtag' => 'as:Hashtag', + 'sensitive' => 'as:sensitive', + 'commentsEnabled' => 'sc:Boolean', + 'capabilities' => [ + 'announce' => ['@type' => '@id'], + 'like' => ['@type' => '@id'], + 'reply' => ['@type' => '@id'] + ] + ] + ], + 'id' => $status->url(), + 'type' => 'Question', + 'summary' => null, + 'content' => $status->rendered ?? $status->caption, + 'inReplyTo' => $status->in_reply_to_id ? $status->parent()->url() : null, + 'published' => $status->created_at->toAtomString(), + 'url' => $status->url(), + 'attributedTo' => $status->profile->permalink(), + 'to' => $status->scopeToAudience('to'), + 'cc' => $status->scopeToAudience('cc'), + 'sensitive' => (bool) $status->is_nsfw, + 'attachment' => [], + 'tag' => $tags, + 'commentsEnabled' => (bool) !$status->comments_disabled, + 'capabilities' => [ + 'announce' => 'https://www.w3.org/ns/activitystreams#Public', + 'like' => 'https://www.w3.org/ns/activitystreams#Public', + 'reply' => $status->comments_disabled == true ? null : 'https://www.w3.org/ns/activitystreams#Public' + ], + 'location' => $status->place_id ? [ + 'type' => 'Place', + 'name' => $status->place->name, + 'longitude' => $status->place->long, + 'latitude' => $status->place->lat, + 'country' => $status->place->country + ] : null, + 'endTime' => $status->poll->expires_at->toAtomString(), + 'oneOf' => collect($status->poll->poll_options)->map(function($option, $index) use($status) { + return [ + 'type' => 'Note', + 'name' => $option, + 'replies' => [ + 'type' => 'Collection', + 'totalItems' => $status->poll->cached_tallies[$index] + ] + ]; + }) + ]; + } +} diff --git a/app/Transformer/Api/StatusStatelessTransformer.php b/app/Transformer/Api/StatusStatelessTransformer.php index e3352bcaa..67bd5c72f 100644 --- a/app/Transformer/Api/StatusStatelessTransformer.php +++ b/app/Transformer/Api/StatusStatelessTransformer.php @@ -12,12 +12,14 @@ use App\Services\MediaTagService; use App\Services\StatusHashtagService; use App\Services\StatusLabelService; use App\Services\ProfileService; +use App\Services\PollService; class StatusStatelessTransformer extends Fractal\TransformerAbstract { public function transform(Status $status) { $taggedPeople = MediaTagService::get($status->id); + $poll = $status->type === 'poll' ? PollService::get($status->id) : null; return [ '_v' => 1, @@ -61,7 +63,8 @@ class StatusStatelessTransformer extends Fractal\TransformerAbstract 'liked_by' => LikeService::likedBy($status), 'media_attachments' => MediaService::get($status->id), 'account' => ProfileService::get($status->profile_id), - 'tags' => StatusHashtagService::statusTags($status->id) + 'tags' => StatusHashtagService::statusTags($status->id), + 'poll' => $poll ]; } } diff --git a/app/Transformer/Api/StatusTransformer.php b/app/Transformer/Api/StatusTransformer.php index f2fd4a2cb..1aca5398d 100644 --- a/app/Transformer/Api/StatusTransformer.php +++ b/app/Transformer/Api/StatusTransformer.php @@ -14,12 +14,14 @@ use App\Services\StatusHashtagService; use App\Services\StatusLabelService; use App\Services\ProfileService; use Illuminate\Support\Str; +use App\Services\PollService; class StatusTransformer extends Fractal\TransformerAbstract { public function transform(Status $status) { $taggedPeople = MediaTagService::get($status->id); + $poll = $status->type === 'poll' ? PollService::get($status->id, request()->user()->profile_id) : null; return [ '_v' => 1, @@ -63,7 +65,8 @@ class StatusTransformer extends Fractal\TransformerAbstract 'liked_by' => LikeService::likedBy($status), 'media_attachments' => MediaService::get($status->id), 'account' => ProfileService::get($status->profile_id), - 'tags' => StatusHashtagService::statusTags($status->id) + 'tags' => StatusHashtagService::statusTags($status->id), + 'poll' => $poll, ]; } } diff --git a/app/Util/ActivityPub/Helpers.php b/app/Util/ActivityPub/Helpers.php index bc2dd57b2..9859bec6a 100644 --- a/app/Util/ActivityPub/Helpers.php +++ b/app/Util/ActivityPub/Helpers.php @@ -33,6 +33,7 @@ use App\Services\MediaStorageService; use App\Jobs\MediaPipeline\MediaStoragePipeline; use App\Jobs\AvatarPipeline\RemoteAvatarFetch; use App\Util\Media\License; +use App\Models\Poll; class Helpers { @@ -270,7 +271,7 @@ class Helpers { $res = self::fetchFromUrl($url); - if(!$res || empty($res) || isset($res['error']) ) { + if(!$res || empty($res) || isset($res['error']) || !isset($res['@context']) ) { return; } @@ -331,7 +332,6 @@ class Helpers { $idDomain = parse_url($id, PHP_URL_HOST); $urlDomain = parse_url($url, PHP_URL_HOST); - if(!self::validateUrl($id)) { return; } @@ -368,6 +368,7 @@ class Helpers { $cw = true; } + $statusLockKey = 'helpers:status-lock:' . hash('sha256', $res['id']); $status = Cache::lock($statusLockKey) ->get(function () use( @@ -380,6 +381,19 @@ class Helpers { $scope, $id ) { + if($res['type'] === 'Question') { + $status = self::storePoll( + $profile, + $res, + $url, + $ts, + $reply_to, + $cw, + $scope, + $id + ); + return $status; + } return DB::transaction(function() use($profile, $res, $url, $ts, $reply_to, $cw, $scope, $id) { $status = new Status; $status->profile_id = $profile->id; @@ -409,6 +423,55 @@ class Helpers { return $status; } + private static function storePoll($profile, $res, $url, $ts, $reply_to, $cw, $scope, $id) + { + if(!isset($res['endTime']) || !isset($res['oneOf']) || !is_array($res['oneOf']) || count($res['oneOf']) > 4) { + return; + } + + $options = collect($res['oneOf'])->map(function($option) { + return $option['name']; + })->toArray(); + + $cachedTallies = collect($res['oneOf'])->map(function($option) { + return $option['replies']['totalItems'] ?? 0; + })->toArray(); + + $status = new Status; + $status->profile_id = $profile->id; + $status->url = isset($res['url']) ? $res['url'] : $url; + $status->uri = isset($res['url']) ? $res['url'] : $url; + $status->object_url = $id; + $status->caption = strip_tags($res['content']); + $status->rendered = Purify::clean($res['content']); + $status->created_at = Carbon::parse($ts); + $status->in_reply_to_id = null; + $status->local = false; + $status->is_nsfw = $cw; + $status->scope = 'draft'; + $status->visibility = 'draft'; + $status->cw_summary = $cw == true && isset($res['summary']) ? + Purify::clean(strip_tags($res['summary'])) : null; + $status->save(); + + $poll = new Poll; + $poll->status_id = $status->id; + $poll->profile_id = $status->profile_id; + $poll->poll_options = $options; + $poll->cached_tallies = $cachedTallies; + $poll->votes_count = array_sum($cachedTallies); + $poll->expires_at = now()->parse($res['endTime']); + $poll->last_fetched_at = now(); + $poll->save(); + + $status->type = 'poll'; + $status->scope = $scope; + $status->visibility = $scope; + $status->save(); + + return $status; + } + public static function statusFetch($url) { return self::statusFirstOrFetch($url); diff --git a/app/Util/ActivityPub/Inbox.php b/app/Util/ActivityPub/Inbox.php index 18f911bfd..920f6d80a 100644 --- a/app/Util/ActivityPub/Inbox.php +++ b/app/Util/ActivityPub/Inbox.php @@ -30,6 +30,8 @@ use App\Util\ActivityPub\Validator\Follow as FollowValidator; use App\Util\ActivityPub\Validator\Like as LikeValidator; use App\Util\ActivityPub\Validator\UndoFollow as UndoFollowValidator; +use App\Services\PollService; + class Inbox { protected $headers; @@ -147,6 +149,12 @@ class Inbox } $to = $activity['to']; $cc = isset($activity['cc']) ? $activity['cc'] : []; + + if($activity['type'] == 'Question') { + $this->handlePollCreate(); + return; + } + if(count($to) == 1 && count($cc) == 0 && parse_url($to[0], PHP_URL_HOST) == config('pixelfed.domain.app') @@ -154,10 +162,11 @@ class Inbox $this->handleDirectMessage(); return; } + if($activity['type'] == 'Note' && !empty($activity['inReplyTo'])) { $this->handleNoteReply(); - } elseif($activity['type'] == 'Note' && !empty($activity['attachment'])) { + } elseif ($activity['type'] == 'Note' && !empty($activity['attachment'])) { if(!$this->verifyNoteAttachment()) { return; } @@ -180,6 +189,18 @@ class Inbox return; } + public function handlePollCreate() + { + $activity = $this->payload['object']; + $actor = $this->actorFirstOrCreate($this->payload['actor']); + if(!$actor || $actor->domain == null) { + return; + } + $url = isset($activity['url']) ? $activity['url'] : $activity['id']; + Helpers::statusFirstOrFetch($url); + return; + } + public function handleNoteCreate() { $activity = $this->payload['object']; @@ -188,6 +209,16 @@ class Inbox return; } + if( isset($activity['inReplyTo']) && + isset($activity['name']) && + !isset($activity['content']) && + !isset($activity['attachment'] && + Helpers::validateLocalUrl($activity['inReplyTo'])) + ) { + $this->handlePollVote(); + return; + } + if($actor->followers()->count() == 0) { return; } @@ -200,6 +231,51 @@ class Inbox return; } + public function handlePollVote() + { + $activity = $this->payload['object']; + $actor = $this->actorFirstOrCreate($this->payload['actor']); + $status = Helpers::statusFetch($activity['inReplyTo']); + $poll = $status->poll; + + if(!$status || !$poll) { + return; + } + + if(now()->gt($poll->expires_at)) { + return; + } + + $choices = $poll->poll_options; + $choice = array_search($activity['name'], $choices); + + if($choice === false) { + return; + } + + if(PollVote::whereStatusId($status->id)->whereProfileId($actor->id)->exists()) { + return; + } + + $vote = new PollVote; + $vote->status_id = $status->id; + $vote->profile_id = $actor->id; + $vote->poll_id = $poll->id; + $vote->choice = $choice; + $vote->uri = isset($activity['id']) ? $activity['id'] : null; + $vote->save(); + + $tallies = $poll->cached_tallies; + $tallies[$choice] = $tallies[$choice] + 1; + $poll->cached_tallies = $tallies; + $poll->votes_count = array_sum($tallies); + $poll->save(); + + PollService::del($status->id); + + return; + } + public function handleDirectMessage() { $activity = $this->payload['object']; @@ -558,10 +634,8 @@ class Inbox return; } - public function handleRejectActivity() { - } public function handleUndoActivity() diff --git a/app/Util/Site/Config.php b/app/Util/Site/Config.php index eb3dd725a..e7132bc00 100644 --- a/app/Util/Site/Config.php +++ b/app/Util/Site/Config.php @@ -7,7 +7,7 @@ use Illuminate\Support\Str; class Config { - const CACHE_KEY = 'api:site:configuration:_v0.3'; + const CACHE_KEY = 'api:site:configuration:_v0.4'; public static function get() { return Cache::remember(self::CACHE_KEY, now()->addMinutes(5), function() { @@ -37,7 +37,8 @@ class Config { 'lc' => config('exp.lc'), 'rec' => config('exp.rec'), 'loops' => config('exp.loops'), - 'top' => config('exp.top') + 'top' => config('exp.top'), + 'polls' => config('exp.polls') ], 'site' => [ diff --git a/config/exp.php b/config/exp.php index 74e9a5e49..76b4861f4 100644 --- a/config/exp.php +++ b/config/exp.php @@ -6,4 +6,5 @@ return [ 'rec' => false, 'loops' => false, 'top' => env('EXP_TOP', false), + 'polls' => env('EXP_POLLS', false) ]; diff --git a/database/migrations/2021_07_29_014835_create_polls_table.php b/database/migrations/2021_07_29_014835_create_polls_table.php new file mode 100644 index 000000000..d7cd636fc --- /dev/null +++ b/database/migrations/2021_07_29_014835_create_polls_table.php @@ -0,0 +1,41 @@ +bigInteger('id')->unsigned()->primary(); + $table->bigInteger('story_id')->unsigned()->nullable()->index(); + $table->bigInteger('status_id')->unsigned()->nullable()->index(); + $table->bigInteger('profile_id')->unsigned()->index(); + $table->json('poll_options')->nullable(); + $table->json('cached_tallies')->nullable(); + $table->boolean('multiple')->default(false); + $table->boolean('hide_totals')->default(false); + $table->unsignedInteger('votes_count')->default(0); + $table->timestamp('last_fetched_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('polls'); + } +} diff --git a/database/migrations/2021_07_29_014849_create_poll_votes_table.php b/database/migrations/2021_07_29_014849_create_poll_votes_table.php new file mode 100644 index 000000000..4db7e23b1 --- /dev/null +++ b/database/migrations/2021_07_29_014849_create_poll_votes_table.php @@ -0,0 +1,36 @@ +id(); + $table->bigInteger('status_id')->unsigned()->index(); + $table->bigInteger('profile_id')->unsigned()->index(); + $table->bigInteger('poll_id')->unsigned()->index(); + $table->unsignedInteger('choice')->default(0)->index(); + $table->string('uri')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('poll_votes'); + } +} diff --git a/resources/assets/js/components/ComposeModal.vue b/resources/assets/js/components/ComposeModal.vue index 0171d236c..076a0c746 100644 --- a/resources/assets/js/components/ComposeModal.vue +++ b/resources/assets/js/components/ComposeModal.vue @@ -44,6 +44,97 @@ +++++ + + + + New Poll + + +++ Loading... ++ + + + Create Poll + +++++ ++ +++++ +++ + +{{composeTextLength}}/{{config.uploader.max_caption_length}}
++++ Poll Options +
+ ++ ++ ++ {{ index + 1 }}. + + + ++ +
+ +++++ ++ Poll Expiry +
+ ++++ + + + + ++++ Poll Visibility +
+ ++++ + + +@@ -147,7 +238,7 @@-+@@ -163,7 +254,7 @@-+@@ -182,7 +273,7 @@-+@@ -200,8 +291,27 @@+ +++ + -+++ +++++ New Poll + + BETA + +
+Create a poll
++--@@ -906,7 +1016,11 @@ export default { }, licenseId: 1, licenseTitle: null, - maxAltTextLength: 140 + maxAltTextLength: 140, + pollOptionModel: null, + pollOptions: [], + pollExpiry: 1440, + postingPoll: false } }, @@ -1590,6 +1704,53 @@ export default { break; } }, + + newPoll() { + this.page = 'poll'; + }, + + savePollOption() { + if(this.pollOptions.indexOf(this.pollOptionModel) != -1) { + this.pollOptionModel = null; + return; + } + this.pollOptions.push(this.pollOptionModel); + this.pollOptionModel = null; + }, + + deletePollOption(index) { + this.pollOptions.splice(index, 1); + }, + + postNewPoll() { + this.postingPoll = true; + axios.post('/api/compose/v0/poll', { + caption: this.composeText, + cw: false, + visibility: this.visibility, + comments_disabled: false, + expiry: this.pollExpiry, + pollOptions: this.pollOptions + }).then(res => { + if(!res.data.hasOwnProperty('url')) { + swal('Oops!', 'An error occured while attempting to create this poll. Please refresh the page and try again.', 'error'); + this.postingPoll = false; + return; + } + window.location.href = res.data.url; + }).catch(err => { + console.log(err.response.data.error); + if(err.response.data.hasOwnProperty('error')) { + if(err.response.data.error == 'Duplicate detected.') { + this.postingPoll = false; + swal('Oops!', 'The poll you are trying to create is similar to an existing poll you created. Please make the poll question (caption) unique.', 'error'); + return; + } + } + this.postingPoll = false; + swal('Oops!', 'An error occured while attempting to create this poll. Please refresh the page and try again.', 'error'); + }) + } } } diff --git a/resources/assets/js/components/PostComponent.vue b/resources/assets/js/components/PostComponent.vue index ea87e8a10..16bfc8e41 100644 --- a/resources/assets/js/components/PostComponent.vue +++ b/resources/assets/js/components/PostComponent.vue @@ -454,235 +454,317 @@- --- - - -- -- {{user.acct.split('@')[0]}}@{{user.acct.split('@')[1]}} -
-- {{user.display_name}} -
++- -+-+ ++++ Loading... ++++ + + - - - - --- - - --+ ++ ++++++++ + +- -+ ++++++ + + ++-+- {{user.display_name}} - +
+ {{user.acct.split('@')[0]}}@{{user.acct.split('@')[1]}} +
++ {{user.display_name}} +
++ + + ++ ++++++ + + +++++ ++ ++ {{user.display_name}} + +
++ + + ++ ++ +++ + ++ +-++ + + ++-- - - -- -- --- -- +- -Learn more about Tagging People.
+ ++ + +-Embed+Copy Link+{{ showComments ? 'Disable' : 'Enable'}} Comments+ Edit +Moderation Tools+Block+Unblock+ Report +Archive+Unarchive+Delete+Cancel--- - --- - --- - --
- -By using this embed, you agree to our Terms of Use
-- ------ - - ----- - {{taguser.username}} - - -
-Learn more about Tagging People.
-- - -- -{{ showComments ? 'Disable' : 'Enable'}} Comments+ ++ -+-{{ showComments ? 'Disable' : 'Enable'}} Comments-Unlist from Timelines-Remove Content Warning-Add Content Warning-Cancel-- +-+- - +Unlist from Timelines+Remove Content Warning+Add Content Warning+Cancel++ ++ ++ + --- --@@ -766,7 +848,10 @@ diff --git a/resources/assets/js/components/RemotePost.vue b/resources/assets/js/components/RemotePost.vue index eab25df32..4436447b7 100644 --- a/resources/assets/js/components/RemotePost.vue +++ b/resources/assets/js/components/RemotePost.vue @@ -19,6 +19,14 @@ :recommended="false" v-on:comment-focus="commentFocus" /> +- - {{replyText.length > config.uploader.max_caption_length ? config.uploader.max_caption_length - replyText.length : replyText.length}}/{{config.uploader.max_caption_length}} - ++--- + +- - +-++ + {{replyText.length > config.uploader.max_caption_length ? config.uploader.max_caption_length - replyText.length : replyText.length}}/{{config.uploader.max_caption_length}} + +++- -+ + ++ ++ + +@@ -545,6 +553,8 @@ pixelfed.postComponent = {}; import StatusCard from './partials/StatusCard.vue'; import CommentCard from './partials/CommentCard.vue'; +import PollCard from './partials/PollCard.vue'; +import CommentFeed from './partials/CommentFeed.vue'; export default { props: [ @@ -560,7 +570,9 @@ export default { components: { StatusCard, - CommentCard + CommentCard, + CommentFeed, + PollCard }, data() { diff --git a/resources/assets/js/components/partials/CommentFeed.vue b/resources/assets/js/components/partials/CommentFeed.vue new file mode 100644 index 000000000..ee29e7c69 --- /dev/null +++ b/resources/assets/js/components/partials/CommentFeed.vue @@ -0,0 +1,286 @@ + ++ + ++ + + + + diff --git a/resources/assets/js/components/partials/PollCard.vue b/resources/assets/js/components/partials/PollCard.vue new file mode 100644 index 000000000..f6366437c --- /dev/null +++ b/resources/assets/js/components/partials/PollCard.vue @@ -0,0 +1,327 @@ + +++++ ++ ++++ + + +++ ++++ + {{ formatCount(pagination.total) }} +
+Comments
++++ + + ++ + + + ++ ++ + ++++ + + diff --git a/resources/assets/js/components/partials/StatusCard.vue b/resources/assets/js/components/partials/StatusCard.vue index 47aff093a..04cc34226 100644 --- a/resources/assets/js/components/partials/StatusCard.vue +++ b/resources/assets/js/components/partials/StatusCard.vue @@ -1,6 +1,7 @@ -++ ++++ +++++ + {{status.account.acct}} + + + · + + + {{shortTimestamp(status.created_at)}} + + + · + + + Poll BETA + + + · + + + + Closed + + + Closes in {{ shortTimestampAhead(status.poll.expires_at) }} + + + + + +++++ +++ + + + + ++ +++ ++++ +
+ ++ +
++++ +++ {{ calculatePercentage(option) }}% + ({{option.votes_count}} {{option.votes_count == 1 ? 'vote' : 'votes'}}) +++++ +++ {{ calculatePercentage(option) }}% + ({{option.votes_count}} {{option.votes_count == 1 ? 'vote' : 'votes'}}) +++++ {{ status.poll.votes_count }} votes + Refresh Results + +
++ Loading... ++ ++ + + Closed + + + Closes in {{ shortTimestampAhead(status.poll.expires_at) }} + + +++ -++@@ -18,7 +19,7 @@ @@ -27,10 +28,10 @@+- +Content Warning
- +
+++ @@ -179,6 +184,7 @@ + + diff --git a/routes/web.php b/routes/web.php index 21bee6aaf..5c5e423bc 100644 --- a/routes/web.php +++ b/routes/web.php @@ -105,6 +105,8 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact Route::get('search', 'SearchController@searchAPI'); Route::get('nodeinfo/2.0.json', 'FederationController@nodeinfo'); Route::post('status/view', 'StatusController@storeView'); + Route::get('v1/polls/{id}', 'PollController@getPoll'); + Route::post('v1/polls/{id}/votes', 'PollController@vote'); Route::group(['prefix' => 'compose'], function() { Route::group(['prefix' => 'v0'], function() { @@ -120,6 +122,7 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact Route::post('/publish/text', 'ComposeController@storeText'); Route::get('/media/processing', 'ComposeController@mediaProcessingCheck'); Route::get('/settings', 'ComposeController@composeSettings'); + Route::post('/poll', 'ComposeController@createPoll'); }); }); From bc53ac2af8518706b5e5b5efe0da0515f0423fbd Mon Sep 17 00:00:00 2001 From: Daniel SupernaultDate: Tue, 10 Aug 2021 22:25:42 -0600 Subject: [PATCH 41/92] Update polls migration, add group support --- .../2021_07_29_014835_create_polls_table.php | 63 ++++++++++--------- 1 file changed, 32 insertions(+), 31 deletions(-) diff --git a/database/migrations/2021_07_29_014835_create_polls_table.php b/database/migrations/2021_07_29_014835_create_polls_table.php index d7cd636fc..0ae7a58ad 100644 --- a/database/migrations/2021_07_29_014835_create_polls_table.php +++ b/database/migrations/2021_07_29_014835_create_polls_table.php @@ -6,36 +6,37 @@ use Illuminate\Support\Facades\Schema; class CreatePollsTable extends Migration { - /** - * Run the migrations. - * - * @return void - */ - public function up() - { - Schema::create('polls', function (Blueprint $table) { - $table->bigInteger('id')->unsigned()->primary(); - $table->bigInteger('story_id')->unsigned()->nullable()->index(); - $table->bigInteger('status_id')->unsigned()->nullable()->index(); - $table->bigInteger('profile_id')->unsigned()->index(); - $table->json('poll_options')->nullable(); - $table->json('cached_tallies')->nullable(); - $table->boolean('multiple')->default(false); - $table->boolean('hide_totals')->default(false); - $table->unsignedInteger('votes_count')->default(0); - $table->timestamp('last_fetched_at')->nullable(); - $table->timestamp('expires_at')->nullable(); - $table->timestamps(); - }); - } + /** + * Run the migrations. + * + * @return void + */ + public function up() + { + Schema::create('polls', function (Blueprint $table) { + $table->bigInteger('id')->unsigned()->primary(); + $table->bigInteger('story_id')->unsigned()->nullable()->index(); + $table->bigInteger('status_id')->unsigned()->nullable()->index(); + $table->bigInteger('group_id')->unsigned()->nullable()->index(); + $table->bigInteger('profile_id')->unsigned()->index(); + $table->json('poll_options')->nullable(); + $table->json('cached_tallies')->nullable(); + $table->boolean('multiple')->default(false); + $table->boolean('hide_totals')->default(false); + $table->unsignedInteger('votes_count')->default(0); + $table->timestamp('last_fetched_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + }); + } - /** - * Reverse the migrations. - * - * @return void - */ - public function down() - { - Schema::dropIfExists('polls'); - } + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('polls'); + } } From fb652805ddc552450f7e1be5874325441f35e922 Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Fri, 27 Aug 2021 20:33:30 -0600 Subject: [PATCH 42/92] Migrations --- .../migrations/2021_07_29_014849_create_poll_votes_table.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/database/migrations/2021_07_29_014849_create_poll_votes_table.php b/database/migrations/2021_07_29_014849_create_poll_votes_table.php index 4db7e23b1..ac7316f1b 100644 --- a/database/migrations/2021_07_29_014849_create_poll_votes_table.php +++ b/database/migrations/2021_07_29_014849_create_poll_votes_table.php @@ -15,7 +15,8 @@ class CreatePollVotesTable extends Migration { Schema::create('poll_votes', function (Blueprint $table) { $table->id(); - $table->bigInteger('status_id')->unsigned()->index(); + $table->bigInteger('story_id')->unsigned()->nullable()->index(); + $table->bigInteger('status_id')->unsigned()->nullable()->index(); $table->bigInteger('profile_id')->unsigned()->index(); $table->bigInteger('poll_id')->unsigned()->index(); $table->unsignedInteger('choice')->default(0)->index(); From d5e5644dbc65cb45feb501b9c471cfe838bcd02f Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Fri, 27 Aug 2021 20:34:47 -0600 Subject: [PATCH 43/92] Migrations --- ...te_stories_table_fix_expires_at_column.php | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 database/migrations/2021_08_23_062246_update_stories_table_fix_expires_at_column.php diff --git a/database/migrations/2021_08_23_062246_update_stories_table_fix_expires_at_column.php b/database/migrations/2021_08_23_062246_update_stories_table_fix_expires_at_column.php new file mode 100644 index 000000000..be33dc3a3 --- /dev/null +++ b/database/migrations/2021_08_23_062246_update_stories_table_fix_expires_at_column.php @@ -0,0 +1,48 @@ +getDoctrineSchemaManager(); + $doctrineTable = $sm->listTableDetails('stories'); + + if($doctrineTable->hasIndex('stories_expires_at_index')) { + $table->dropIndex('stories_expires_at_index'); + } + $table->timestamp('expires_at')->default(null)->index()->nullable()->change(); + $table->boolean('can_reply')->default(true); + $table->boolean('can_react')->default(true); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('stories', function (Blueprint $table) { + $sm = Schema::getConnection()->getDoctrineSchemaManager(); + $doctrineTable = $sm->listTableDetails('stories'); + + if($doctrineTable->hasIndex('stories_expires_at_index')) { + $table->dropIndex('stories_expires_at_index'); + } + $table->timestamp('expires_at')->default(null)->index()->nullable()->change(); + $table->dropColumn('can_reply'); + $table->dropColumn('can_react'); + }); + } +} From 6832836c55d3bebd34e94ea34adb83ccae04f925 Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Fri, 27 Aug 2021 20:36:01 -0600 Subject: [PATCH 44/92] Update changelog --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e3e4d4da..3fce31c85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Customize media description/alt-text length limit ([072d55d1](https://github.com/pixelfed/pixelfed/commit/072d55d1)) - Federate Media Licenses ([14a1367a](https://github.com/pixelfed/pixelfed/commit/14a1367a)) - Archive Posts ([e9ef0c88](https://github.com/pixelfed/pixelfed/commit/e9ef0c88)) +- Polls ([77092200](https://github.com/pixelfed/pixelfed/commit/77092200)) ### Updated - Updated PrettyNumber, fix deprecated warning. ([20ec870b](https://github.com/pixelfed/pixelfed/commit/20ec870b)) @@ -95,6 +96,10 @@ - Updated StatusService, add non-public option and improve cache invalidation. ([15c4fdd9](https://github.com/pixelfed/pixelfed/commit/15c4fdd9)) - Updated ContactAdmin mail, set New Support Message subject. ([bc3add05](https://github.com/pixelfed/pixelfed/commit/bc3add05)) - Updated StatusTransformer, prioritize scope over deprecated visibility attribute. ([6e45021f](https://github.com/pixelfed/pixelfed/commit/6e45021f)) +- Updated StatusService, invalidate profile embed cache on deletion. ([acaf630d](https://github.com/pixelfed/pixelfed/commit/acaf630d)) +- Updated status.reply view, fix archived post leakage. ([4fb3d1fa](https://github.com/pixelfed/pixelfed/commit/4fb3d1fa)) +- Updated PostComponents, re-add time to timestamp. ([c5281dcd](https://github.com/pixelfed/pixelfed/commit/c5281dcd)) +- Updated follow intent, fix follower count leak. ([03199e2f](https://github.com/pixelfed/pixelfed/commit/03199e2f)) - ([](https://github.com/pixelfed/pixelfed/commit/)) ## [v0.11.0 (2021-06-01)](https://github.com/pixelfed/pixelfed/compare/v0.10.10...v0.11.0) From e1277d4081835103706d0bf22964cb76a5066030 Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Fri, 27 Aug 2021 20:37:00 -0600 Subject: [PATCH 45/92] Update StatusStatelessTransformer, cast snowflake ids as strings --- app/Transformer/Api/StatusStatelessTransformer.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/Transformer/Api/StatusStatelessTransformer.php b/app/Transformer/Api/StatusStatelessTransformer.php index 67bd5c72f..5dbca96b2 100644 --- a/app/Transformer/Api/StatusStatelessTransformer.php +++ b/app/Transformer/Api/StatusStatelessTransformer.php @@ -27,8 +27,8 @@ class StatusStatelessTransformer extends Fractal\TransformerAbstract 'shortcode' => HashidService::encode($status->id), 'uri' => $status->url(), 'url' => $status->url(), - 'in_reply_to_id' => $status->in_reply_to_id, - 'in_reply_to_account_id' => $status->in_reply_to_profile_id, + 'in_reply_to_id' => (string) $status->in_reply_to_id, + 'in_reply_to_account_id' => (string) $status->in_reply_to_profile_id, 'reblog' => null, 'content' => $status->rendered ?? $status->caption, 'content_text' => $status->caption, From 07e8ddf8eb94c69e6a30cda8858ce9c62d3cc757 Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Mon, 30 Aug 2021 00:38:12 -0600 Subject: [PATCH 46/92] Add new horizon queue --- config/horizon.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/config/horizon.php b/config/horizon.php index 786eb6741..0b2cb0cd8 100644 --- a/config/horizon.php +++ b/config/horizon.php @@ -81,6 +81,7 @@ return [ 'waits' => [ 'redis:feed' => 30, 'redis:default' => 30, + 'redis:low' => 30, 'redis:high' => 30, 'redis:delete' => 30 ], @@ -166,7 +167,7 @@ return [ 'production' => [ 'supervisor-1' => [ 'connection' => 'redis', - 'queue' => ['high', 'default', 'feed', 'delete'], + 'queue' => ['high', 'default', 'feed', 'low', 'delete'], 'balance' => 'auto', 'maxProcesses' => 20, 'memory' => 128, @@ -178,7 +179,7 @@ return [ 'local' => [ 'supervisor-1' => [ 'connection' => 'redis', - 'queue' => ['high', 'default', 'feed', 'delete'], + 'queue' => ['high', 'default', 'feed', 'low', 'delete'], 'balance' => 'auto', 'maxProcesses' => 20, 'memory' => 128, From bdc5abbfbc36bc45b847c7b9659f6f76e36459ab Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Tue, 31 Aug 2021 00:18:19 -0600 Subject: [PATCH 47/92] Update horizon config --- config/horizon.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/horizon.php b/config/horizon.php index 0b2cb0cd8..e43ff35b6 100644 --- a/config/horizon.php +++ b/config/horizon.php @@ -167,7 +167,7 @@ return [ 'production' => [ 'supervisor-1' => [ 'connection' => 'redis', - 'queue' => ['high', 'default', 'feed', 'low', 'delete'], + 'queue' => ['high', 'default', 'feed', 'low', 'story', 'delete'], 'balance' => 'auto', 'maxProcesses' => 20, 'memory' => 128, @@ -179,7 +179,7 @@ return [ 'local' => [ 'supervisor-1' => [ 'connection' => 'redis', - 'queue' => ['high', 'default', 'feed', 'low', 'delete'], + 'queue' => ['high', 'default', 'feed', 'low', 'story', 'delete'], 'balance' => 'auto', 'maxProcesses' => 20, 'memory' => 128, From fee2857deb3f6173ccae4ac273df0466b4fbc78e Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Tue, 31 Aug 2021 00:22:08 -0600 Subject: [PATCH 48/92] Update ComposeController --- app/Http/Controllers/ComposeController.php | 52 +++++++++++++++++++++- config/instance.php | 4 ++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/app/Http/Controllers/ComposeController.php b/app/Http/Controllers/ComposeController.php index 50e8bf5fa..700bae2d9 100644 --- a/app/Http/Controllers/ComposeController.php +++ b/app/Http/Controllers/ComposeController.php @@ -18,6 +18,7 @@ use App\{ UserFilter, UserSetting }; +use App\Models\Poll; use App\Transformer\Api\{ MediaTransformer, MediaDraftTransformer, @@ -42,7 +43,7 @@ use App\Services\MediaPathService; use App\Services\MediaBlocklistService; use App\Services\MediaStorageService; use App\Services\MediaTagService; -use App\Services\ServiceService; +use App\Services\StatusService; use Illuminate\Support\Str; use App\Util\Lexer\Autolink; use App\Util\Lexer\Extractor; @@ -682,4 +683,53 @@ class ComposeController extends Controller return json_decode($res->compose_settings, true); })); } + + public function createPoll(Request $request) + { + $this->validate($request, [ + 'caption' => 'nullable|string|max:'.config('pixelfed.max_caption_length', 500), + 'cw' => 'nullable|boolean', + 'visibility' => 'required|string|in:public,private', + 'comments_disabled' => 'nullable', + 'expiry' => 'required|in:60,360,1440,10080', + 'pollOptions' => 'required|array|min:1|max:4' + ]); + + abort_if(config('instance.polls.enabled') == false, 404, 'Polls not enabled'); + + abort_if(Status::whereType('poll') + ->whereProfileId($request->user()->profile_id) + ->whereCaption($request->input('caption')) + ->where('created_at', '>', now()->subDays(2)) + ->exists() + , 422, 'Duplicate detected.'); + + $status = new Status; + $status->profile_id = $request->user()->profile_id; + $status->caption = $request->input('caption'); + $status->rendered = Autolink::create()->autolink($status->caption); + $status->visibility = 'draft'; + $status->scope = 'draft'; + $status->type = 'poll'; + $status->local = true; + $status->save(); + + $poll = new Poll; + $poll->status_id = $status->id; + $poll->profile_id = $status->profile_id; + $poll->poll_options = $request->input('pollOptions'); + $poll->expires_at = now()->addMinutes($request->input('expiry')); + $poll->cached_tallies = collect($poll->poll_options)->map(function($o) { + return 0; + })->toArray(); + $poll->save(); + + $status->visibility = $request->input('visibility'); + $status->scope = $request->input('visibility'); + $status->save(); + + NewStatusPipeline::dispatch($status); + + return ['url' => $status->url()]; + } } diff --git a/config/instance.php b/config/instance.php index a4cc534b9..f0b99e60e 100644 --- a/config/instance.php +++ b/config/instance.php @@ -47,6 +47,10 @@ return [ ] ], + 'polls' => [ + 'enabled' => false + ], + 'stories' => [ 'enabled' => env('STORIES_ENABLED', false), ], From a0da80bc700cb7eafd43c8668ec12d4cdca25c8d Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Tue, 31 Aug 2021 00:24:20 -0600 Subject: [PATCH 49/92] Update media gc command --- app/Console/Commands/FailedJobGC.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Console/Commands/FailedJobGC.php b/app/Console/Commands/FailedJobGC.php index f48d49b84..f50d97afd 100644 --- a/app/Console/Commands/FailedJobGC.php +++ b/app/Console/Commands/FailedJobGC.php @@ -40,7 +40,7 @@ class FailedJobGC extends Command { FailedJob::chunk(50, function($jobs) { foreach($jobs as $job) { - if($job->failed_at->lt(now()->subMonth())) { + if($job->failed_at->lt(now()->subHours(48))) { $job->delete(); } } From 9a81a69fbb7388b1e76ed0a06ab885924a4d3f0c Mon Sep 17 00:00:00 2001 From: Daniel Supernault Date: Tue, 31 Aug 2021 00:25:13 -0600 Subject: [PATCH 50/92] Update compiled assets --- public/fonts/fa-light-300.eot | Bin 0 -> 489274 bytes public/fonts/fa-light-300.svg | Bin 0 -> 2444593 bytes public/fonts/fa-light-300.ttf | Bin 0 -> 488992 bytes public/fonts/fa-light-300.woff | Bin 0 -> 245416 bytes public/fonts/fa-light-300.woff2 | Bin 0 -> 184204 bytes public/fonts/fa-regular-400.eot | Bin 34388 -> 450238 bytes public/fonts/fa-regular-400.svg | Bin 144343 -> 2182455 bytes public/fonts/fa-regular-400.ttf | Bin 34092 -> 449944 bytes public/fonts/fa-regular-400.woff | Bin 16812 -> 224592 bytes public/fonts/fa-regular-400.woff2 | Bin 13592 -> 168824 bytes public/fonts/fa-solid-900.eot | Bin 186512 -> 384110 bytes public/fonts/fa-solid-900.svg | Bin 816038 -> 1784910 bytes public/fonts/fa-solid-900.ttf | Bin 186228 -> 383828 bytes public/fonts/fa-solid-900.woff | Bin 96244 -> 183368 bytes public/fonts/fa-solid-900.woff2 | Bin 74348 -> 137104 bytes 15 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 public/fonts/fa-light-300.eot create mode 100644 public/fonts/fa-light-300.svg create mode 100644 public/fonts/fa-light-300.ttf create mode 100644 public/fonts/fa-light-300.woff create mode 100644 public/fonts/fa-light-300.woff2 diff --git a/public/fonts/fa-light-300.eot b/public/fonts/fa-light-300.eot new file mode 100644 index 0000000000000000000000000000000000000000..fd2cfc79a8eb0b4a2c9e0a71c2ff156c5d96edd4 GIT binary patch literal 489274 zcmeFadw^9{6+gcAdEV!_XXd` +Ndlo3%!ML|QoDjF&& zDk&A(l$cm(sAy!Q*TTd^qq3sHnu?N&Y+k@*c=>(yKIhKdLG5RK|M>m>_$uq{*IH|@ zz4l&vul=|%VU14F8+BqN5&usjSr!rGR4^W!-)g0YzaksSSF4`5YB)C)tEQQB4P8yE zDN0jm4P8dpBYhRZC^Z0D={lsNG@q`Z71RMdBG-u2e5%9$HB<@wTB-+b`G4SfBRF4& zI<644P7t+Jg1Q0p^)wpX{}+is#sFT5lT58MXD&MPk@1&ICjCYQxsh4ZXUwEA`V7)< zIE-}Of`wJJHDea70MSyU+b_N9;?6hMzPcDiyNDc*tho64&QqtrNx$I`${j0K-E?_L z=d)1x^!cRi-`#Q9#mh%6{(&E5yu7gk8OmACcJK=zUEFci>a`EvtN#({-KhTLs%tL2 zI9zw}bfU8PM5E}ci`RDQUuIuLc{A{%S6_V9W%07vt4Ys*ylSZPn(J3*y>s=Mq=zCz z7k!_%jm^8e_^KzWUcJaS=@_}K-hZ$9mZw2a2wy$*_ftEhF7_Uh4h##f4G`f^;#30T zB#!cKsmsn6h5Vzp@EpB`ZKZ6H$r&?&6%%P9MUq$rsmPwsE=S%hJ3I!8_30zf#vck= zFQ2iT;@vcOO2|;j_R}I13dz_6_9Ws_VpdBK|0b8x1|)?b2`l7r0jY9?EJ0BzX5%A0 zM9brZauJ?7#lsY&_<)wA7{@zx)WWl9P|kRMDo^0WQI-Znd35Suz?ag%fw&Cdd_NL2 z;58JA{4`!f`M}K=<*BmtFfMSTyuK9JxV#>_ji*kg;-ZYx*kw}8t}ABO!OK#> 7RpNH zaU6@*8biE()^5>4=99pSS>@?GQZynjhB^izBjdp7KLvO_4&ZX0TrPyrHR?~vlGZ0V zHV=Y+#MtI|qF-Vdk327tlEE4qL;T{WB|p+5+Rl1XyafL6n2Fo@2qE|JG 9_KuU!NK*D0#Qc_C(h?5VON%Xpt1T0V 2jJafr$B>Mi*CzmYbKPNH61wE^C}?B6PQfF=Wdgq*F~<#xeDINCTpo;zgb1y1 zG}I3mTa+eyRP-51u@oIbl#k$rGK<%6ID`{LUL24LyNBteSXvqKS=0hS8wZ{ZLv2aR z8!C$t4UIR%g&fG@<)`aR$ec)P5AdLGv>)~u7aom6&?PKBL-lgJD3=@aN^D4eF>XbD z$P>76s~>p1c3lIUpVc0|mRMs5WBgyK{2_Wib~$bgA$=SK51yYGs^6|-sIH`-w``1; zxdaX8*A1|3BbCQxu 8VC>nTZg8SF=l zHK8Lrjxwt)2zeYJ?AwZ4>&of f zoKg&O$8DgrIP$nmQY;P7W=f>yE6cCJI8!;Kc!+j@W@z5z^l=Wb8J^GM2+ K_^A)m*BBA+w#$H @sXm#{G^zjXW8$N%)_#ATY(PZDLgx*v^3&EKT#_b@ihD)-Nn*qP%he$ zKwiwwPY-E0e*{0^2)cNRm(@QK@<+%>={^G1f$r1G?KFX3Vi=%1%ZF t!5BII;qXfqRZ5OArWVXf(w?mXs z3Q)%JAOq^)zV=WV=QFH3K0l}G;557pG>}b%iFDlIIM-o{$8a1n#Vy^Y%0x)OjoA<% zrW1Jt{1NgD_f2{~iFg|AP+Mc*2Obha_?oG8A?0755Oxz!)oIC>E*sYm`cJG6pih@S zD)d4YEw{I!ws3wt&7ni+Z)h$SdF_xRW|fP!A#TZ!H8qZWz)*Y0#!DcK@v&m1hr&2; zg+1H(F&m%f1Eko 03-Un3uz9#EH)fUojlF$10fBCm}{)@CF+Yocc1~DwMKEgxJdIo1oS)v zeBJF!)h}e@csAH=z?lfAwfKpUhH (h$7wjkP7Bdr;KN~rk88CD=Y0v_ z$4{rR^S~?Be^%chA9=&|VEiO3ToIxymA3fvGSr*uE3$AU;0b_CBgO*R`Ei6iPQ&&h z@MEWyi@C-gFT5-+be%dcv-?o+;WAj~vvHmldBby40%_hyoIX{KP{_u^1VG3l^eFUc zt#_$1evSegNYO&yV(bv-E9$c5Ms6n z>QBqpL|qmQw?WYctF3X=%Yk@1;GZEb+Jm&f >+8}gn%gpm)Y5ZtJI+oJI z2%a40bl^PVJfH6?tnwkfK<452aJ}hyAFGG+ijCAK$4Sv(9EiDeIGrj>n`415_@;+s z=fx};(|L+E@%*@0k4CmJ4c}_J7_&Jd9#5O=xW19*@=2f>>X*1B->^K~9#S$}XQROV z*!d{hC(cb%A!vqh>^Mm=$PD1R7}1_M@?Np>M7 ^y`ieu&C8M2K(b zNZW`U9*dBoVHIavkkDyMx7MB6o!vdEdv^EP-HWQQ@)9)C};C%Y%olh-q) zXL`?BJs0#W>$$q;)}DCJ-97j8+}Cq|&x1W*?Ag-uc+b~*p6z+Q=bfIP_WZ7=yXSDv z2R#El$zFG_(VNj5>CNjc?5*r==$+6zrFUlUS-t1=F6+IlcWv*Ty&HNT?)_r#qrG41 zeXRG{-krTK^nS1R&E9u<5BC11_jkR2={?$eyf@j$`t&|`UtZt1zQ(@seRKNG>btOS zRo|MvoBQtT`$FGCeP8VRa^K^9+xoWmz0~*JzVG#YzwZZq@AUn=?@-?deaZfu{=ELm z{we)u^ 6L(>VLBTh5lFjf6)JW|C{|k>Hlf}@A|v@ z|J?tN{=WW!{*MOOfHq(b6b=**%pW*=V9CJJfp-V~bj*3|!DEjd`}(n`jy-p5*Rk&( zd;Qp3$KE;CbL{xBe;qfDXB?k$eCqM7$DcjE@AzBCe|thcQFWs2#F`T~omhWj!- )f-HAUA)(uV>d~EQ^!8ZqgI@mq<{@^LR5uk4Bc60~2 zbG!4q=XNjbUfO+E5A`TLdXI^o35%X7=!x~r>S;yKwD)wPXYLd|^M#&GJrAR2w)S-O zJkhhWC((1D=a=Z2KlZ%e)6+B9OTFG+vo{Mp6YVYTt?q5=o!lGio!#5odqHn|?+v|o z^xoC`pxrZDd%uOAdA@gdZ=&~Y(KCm7yL W}nC`>Xq7{jKPk_Wm2uGq?BO z-G2{y<|}s3ywd-g=$W_CGw<0w(}SM*u>TZ##t=PIJ8;&(LiEh}1HT;j@L2HJ!^ggQ zY}>J?kG*j0rDLxid*j&K#||9pJ2rUi-^b14{^POZXB>b0_)hf9+sEHKp`NHcu?#)4 z?!?_E?mcn;iHA;n89lS>#P?6UapK(*zdG^96Nk|=XAFLI@TtMK27f;I$HBjzIu(_p zQj|q0|5Sc5|D*g5^N*U>o7b7wnw{n~=GEpZ^Gfpyv%|dHyv)4Ryu`fNY&VygZRQ2$ z`Q~}%Qgey9&^*UH+iW$@FsGSQ%*o~?bAmbEY&09pdb7?PXI7YF%`xU^v)n8-OUz=k z$SgDq%uLg1I!yAN@+EyA`Tp(u!1oW|QQr~YVc#Eo2YtWt{nGaf-_Lw+`u6$W@V)N) zp>MbECEs(toxX4Sp7K5Ed(`)^?;+nEzT17b`EK#u -nQyu8V&8?n zHs503*}hia0^eD_Gkx=Y^L%rCb9}RW<9&6$8egSvoUh!M=gakleJ&pvr;Lw`4~-MX zapUjC-;BQ)zc=194jI2Perf#7c*}U%_^z?T_?Gca<7s2N@s#nPakp`ovEI1TxWl;J zh#R*Vw;Hz?>x|DCHybw@YmGI=jm8beYU6t2TBFmr#<<#8Wn5{jG&+nG#^uIk#-&EP zaiP&>oNt_GEH%zG78whTImQfQx^adv&6sM$4Ds3ov)lju@Be=q&?FZZ3hRwniP8jQ zVlm?xd{O&P|D8_xpK$+g+5blx(5L(l730KI+5vZE6&{4`hM(d99D!>BaBjhMX9tn{ z03P6#0}c@xz%}cMGTVv#Cy7E8a3@ig6#;-g8|fT~k<(3-n}E9$$Gf<7cz?GEPonk` zML}OU8&By#Q;IirWq3JOj{MOG$AEq;a4V3mY$mFLpHz*!npUD( i-<_*8y%t`ZmZA zM|eBxzoVJx&YeVe%_h2gGtoV*ME9;D+KBRvsOt-;=L;K&?gx+ik$wQ_O=|(D^Fh!( z1YQqq0wDeHZlcXdZ$3cuMeul}o9I#S+=BLQLHU=C5PdlgK>RD)iMDnTJqCV{9VB`j zc#j_?`s!+;uC+v8ivWQCb>KhIMzjs-ZQ%K2J)no^8_4^{yF^c+kDdbF_B}*T#{daD z3Y-quNAxY|aK}=jXFLGJpIuM%ZN$I55dax>Lhk3l=lS`7?L;qt_Y08e1(faDLG)q( zfcjrD0i8tO$pV1iceW7iMtt{bqVJ;YyBmmJ2JOqJ`+KP8dp$&ZVt`G6Zldp30}d0t zf_h&C-mB{Xs4r0tK=>N!`awI<4;8>}qP+)+USCf1Bh>N6Y@&Uj*|(GEP4Ij30MU<4 zz&@h4;(!67{p$gPL~nNzy@Rr!cmR8e-X*{hqF+=4kp5){(XXO_T|@_05&ar%|IJcB zH_>ksc$PXJ(1Vwun}~iFBl dPisOaBTCoFU z(!0duoy3&Q#MIryv`xhH7~lvoM<+4NN6dAIm>Xpt(0aELGf-}H6Z5qK62#1EKo 3;0>viDj=NmRmtA(g`>~Y}6`Zc@Y3` z@;it{!Ly*8SRwEW4-+c_&tl*gBVGcU(gd+G;FY%l4iX!U_?St=#&Xz2tOB$ZUBt#^ z5vwEs+ENt*Yy<$mdOxw6rGP zV!%;i(+&|k18tmskl2iFVl%4& z>jB_B3plfSh|S(kY|cJnb0Ndr!+5X^{Q1iPz&R7RXO$CM0J>J-w;myOHu4uL#1?_) zV&E>>is#rH0E5KNL*Dt2tqrnVh;}Xm{Y8_AwRaG^7;s4&u}dSwmV ;Can%Vx7plZYQzp_Yhm%O6-PJ#BMYJ-Ne@H zBDQuq;1IE!wi3H}HL=glC$ wtd? zjqN}AF=!D0Y`{!MBBcwme~Ek!JN!Cf#0TW#2%ak=qC0M@E+@fwfh1lc3?*dL2>iAj(v9C`j z_CztUZ7AOc-cO><-`GIxDTLdX6MH&J?3?q6eQOi39qWlb1D?+uCHCxUV&9$)=pnWf zay++{*z*AZ@Lq@k4iMYb3fM>NMWkQcPwb`b#J&T%-KcB#yTmZJvX|Eq``!*>dw}-} z=o4MUUPJy5QTN_e#9l{TKN=vm4`pvICH7+nv9}Q458iKs&)d6*y#w5zAnzyO^V5yQ zeikS8^S#90Z3c7@`$Yr*8Gi+ugW&gT)bSh0_uK8n4x#K2((fH1_IvRAJ@C5UCH9B; z#QxX`*iY m+!86eX6d1{@}KYCrt&4w6_g;4ogG?<7g?B1zdtlG;g 9?2GP3|jNXkr*6gWUq5PU)ozz&kat$+cN zvbK H!E#5tbq>n+|xFr1Ir}0g^@of6RQqAW37_lT@*Yq;V0FD(gwA0^h2WB-LaA zAXDv9lIp5Ss)sD~Ye{N=EDZ-qY6<{$k<<+QX5dXgdIEGf5$&9~kEBV(0F+OLo~C#J z-6XZd0fQvPfH!qJNz+jG8Q?j6IY~1>KVv6JGxv}*YcEN&50Eq`3xN3C>3{)}<`V#Q zpNaYwAiiKTNv$YrMcLV)J*OUkaN!n`7PXSJ81*hG2B5xkkC1d;HA&|K_kvv{wH+qu zLc}jpNNNuNc9V25@-Nvy(xqETT3!zrB-?>M#MQ`wG;(avMpj9DtJ~UA>&7 zYXF^_NV>KhfbhEYBwfFQq}6RC-7p;hyc_oddPrIW`n4#(X(>rJZzgHoB$93c?_0s| zwq_i576W#Zbo&;P?g0Ir5x_o@)`Ra|%SpPsgQU-IBxwWq-&;Y_eW1G!yf@A#=?ma> zKl1KJ`hhq}n~?V)${#}e9?l|ZbAqHt!0!>%`{-7ZwxGT*q3lcVlJw<`Bz>hE0NSmq zNP28GNsmK@F3^3gnxwC t z9nekEv%vrMa*}o~CF!|#lAec*&mSe}g+nCmIzZBk+emt;jim2H0D~m$-cHhYk^b%g zNiXjp>3g8vQ%} ?P?hgCxBVen-}l^w;?S ~(hvOt?0kYdj&RGrsk6iG} z?It<0iR4im00&6ULtXjPNscZhxnLd1g-90#NG?W*xk)Yo|I+z@!z7m-C3!UR$E+e5 zKASui^;Lk^IN(=S0Dxb$m*nb$B-bEbyOrd+a==ECF$c+w;MFvr IikvzMD pGIpM*cYtk{5#iqNOA+1}xb^^0{q*9+H @v~-%9cW$a`Q9$(weO{2=lk z>LB@H$nfw 5l`+(awNOC{; z44~d)`$#^%o8%Laad0}|Aj$uN>>nxs$afNal7~s5-K4;=Riq=NC>wBBwvQAYX@`dt z=Xz3H$oI@A#k-Rf_%aH9SE*zo-ybI>cz~49UQ)6^myP_~wWPrImApNq R}_3hZ5}SVu}F>a5&KN;Pm`=SnU3*0qsRA0wrq z8L*F(M&LIguQ`j9@yMUBoD}#P%4Cz2DT_#HLA<4hl-MLvrlJ1npqqjCOyJKZ0PyET zNSWJ8$~?$4{}3r>0)GMU7J&cRC|?M=MR8IVA0%Z-J1I-IlX6~wl=C6e1@lR1s{kA( z<-)C`EJK|aO(&&&3n>>zNx1|(FI`K@@=j7NL;Uh`0Prr~OUerHg%6>uI6_LtK2okg z`iga=tSl$xO7OfAa;)km<*M1FT#d48Jb*z`t_8pACIL18PLcxqS8h-MXv2+=a}CPY z?jz-<4pMH0JfDjI_LH&>Jl3ry vX^oQsQXO?Wp694W!(;nUwX2-<2TcZq&U2 zIQJm$-Y!z^L!BE@$3~RhkMMzJQZ@wuyGVHu@UThBX5@WwCn=A#0l<69YEr%&1$2}0 zmF1*tg ROwH>5 Faun_P2kQC&@qYq(K-;H~((eH5B4q%wA4B>?D=CAZf&D5U9wO!B zQc^xbSrYW8wvtL7z#ysYT~g)Mq$-`HsxeYE#Pw~YIyy*o0>=e#7nAB~C)K-=RNrh; zGmy^QM`~a>so0ySq3xt*MM=#eQgcs|nwLdtelw}jEu -IvsV)G)bMcp48dMp97qE {JRZr^Gt)yPFiBzn`>a~#PI^bLn{x?F78@G|VW;>~Ck-q69sh`_G z>N@bgr5FI3Tf0b&?<4j0qom#eUU#e`^-jd^nosK8yGZ>!()WP>z3WN653+4E0g(0n z4pJXjOX?=jJV*fWcnG{7M&4%d{NiC!AFU>J3+ma@L+Y0o0S=J*l|!U%O_2I{gw(GB zx|WmrHPAlMM(Q@ipKK-d8@ox}j`TMVk%}`4^;yXB?QNv)i~$ak`W*N^Po%!kOzN&; zQeUhf^(Dk#0^jd+lln5!-*b?<2YGu2NPPvkuXX?iNlk#~Yij|J=?8~N{UK!jA!znO zh980EM+o0Q*}m WouuNtR{aTNJFtb+pKd1gX9-e&-bL!W z;Qb50FA@J0=nta3Saa3iApP4IsfT8h`W|F`5Bz_(j?~{rN$rMge?r+|@cDB!seb{y zUk>Od^{)zGKdFCvm(-)pr2b bTEU8EixAoVz8 zII)M+!6T&p3-te9O6tiiq<+*vYH~WMry`_LIcW@`6eUgGLz=RaG|d6nL7KjvG{-*D zob{x+rjzDwBh9m#G;cF$Miyy41u#HbMm1?c;0BM9hILlU*-qLh;75^Hu!*!{l$Wd{ zt#mJGquWUv1NyO@q*WXstr9rZL|P5-YGb6;Z6&P%yqf^cz{5R`2K&<{c9Aw|8)=h4 z2Y*>>i2zU*s~~ME=%*egZJJ5i8T(0_f%r_wFbh0qL*_Z)I~V!$CXt4Hxi-Ipv@^>A zhe$gM?OEUepdGECJ9`6Z=e$eW!d;{-LVWQm(v~a&oFwhsy`(MONZNVpNIM_-7l6kF z-K4c`CGA4sEDHb*kaiL3ZEq#*V&q=}9+w^^Z8_4HDS#f*E ai_pKvs<5tqX0GtQfNqY$V zAI<^{lJ-T!A6ZKpY*%~qAZc5`^UDMP{#SszH39&S$0`6vNqfAFG_0N4S5Z&bA=19K zowTpVNqYh^ZUa1tyl*Te?Ws=Eo-QWso8bMe&7|$vPueqGq Gk`) zG4Y8phr^NF-rU+;-rQWyV$+r-FRw2xtuJL=^BR(goYK-9%~dm{#x;hc$Hgbk+Ee6S ze|s#} &66di z^(|6kZN3x=_#~~c3V*+X?sOVX=lHzBIZb|V)aySltHAF)P*4>KphPdIDp)hSFt4Jc zV&dFrNvuQ-E(t;-vGGei%kGK<4*0#*Iet}PI%Byd^5 vQiJtM@T(Lf|pVVW`C zg~>$M>?ZY|_P{8wzcNswOB!Q7%~_e(#GHCOF|VPjWD)PdMI}`Y4OZ(e8BseIgB0}H z@9`_pE-IiBs-hP374H#U!=U3zZEUJyMPm4&kHn}g&`Z!8sv+qGl~P%F3e;a#0QH8# zp}K-v_U+QKwHaB-<5?NCV@tJYZXkl8gw|{toBUPuNHlpW)693Ws~!0n8KXmfDY;(q zhjce{xtWo7Ztc|i%*^_!wM+6d+arMvhohsQD&F4S9yg4!+1X=!#$dQ2;`MmEk%};e z8+=SWqkC0i7;}ZxNEc8iQByb+4ER_$fSxZx-=Zme^fff`=7?@*O;xf_&SyT>!tz-a zBM|7Cjs|d2#4tqan;Q8rLKX%TkLrz0nva#0mZ2D<5XGfc>>o3|YOTkqDaw= ^9$7E&`J$T4JO;~;=*-Y0WnOH;ldC68eL#0;npa_rwR9-* z>`~EG6Ejp<+0;~X@qN{k=V+Ok376#6mD(8+)10iJfH^fL&8Ss$ujFcRI&aNUWnV#o zPgZk0W^snYkx^{2d4^k7R>UNxc(L^An_?4IKRIFQTt(6hrb@X{_T!0H7DODGUeAK2 z>U%G#nS38pHRw|1_RH`;X0QVMWlD4DCL&?9P)C>>EzBy2$*K^;Vj^p(M9-s`QZrRR z6WTE_UWBTduuAAIl+Ua~0>E9CuA#Y7|+5c>6T>%{1(u7g@JnmG5`&~H3Wr^orv zo6cS9G91YR4#Tzf+?(!v`Of6ewsv-I?PM2@GV68O<*Uic&GI`-O_vb~XJ8;^2FFY+ z(?^dAWGJdf$(2+l6**m*x~kPM+4QrrfQz|csAv8Y?42HM>Frv9;c_@!MuB$w(hKjr zGx=w*@7y}kAC{dgkdc#ZxI>1!(wmW$n_Fc#y=JpMu|^+N?vmZJ6&5J>n;9|m4AMmC`9vp2oX zbU32^96xLS#MWH&*;Ii+81;B6Godx!em-|5(7)VXe6)aYrJBi?I9Ll-DQorS^NGO? zq0EYj$p|LV1S2S`#B|b7-!z5sna7HWr3*8R&gY&yrZ<)Xt5B;cEamf2Lw!*x3c0ax zTPxLqJdO#9kD(e^m?*3Z!H%$ga#Jd8 5b_F~hf2K1$R#v92@W@PxW{mbrI#b; =V!(_Wg zQ9e2o PEu 6;~>4WNFta${xQrdBow^vIRI d~Inw2tl`p0iVPxBtItSwlvFtp?t*JjN%Qq9K26IA(dPE%4O%Y#sKox-!gOg<&y? zagX7zqHRx$@1H)CMkbj7zt^doy0c4sz5amdQlf#?fvEDj{-Q$(t`7dhsdwwnn58)J zW&Y2oJ(k6F$o2P-lATfQ^+$PcMXfJfxQuM2)SsR0cSItAP1U>%JIQoC7t;iqLYKqK zSW9tuO8=E!KQ>}MgKq2ex-x7N__Dw?n(EM0C|&xQ)QW+|K1sD|f$|~_tc5~Hr*#&a z`xzB_@Svs6STuUhr)f0PG&?O_R;TqD*Q-p?Q{Ai!zGo1-By76 0K9&=h z37J)4b1}>zW%%nV8|_(yFHhX5;XwhH-u5}IDMNTmdIaH4FP}H$f#g4#ynTD}BP3Y;rDHp7D${)#CXv(}@|sw~8fY3u z_6FLBImFto6 7Hnw0?a=!#)aum}W%5>f_qKzd@m=yRr$Ng?Bb>aiWI^Z{P zBFn)R&bnUKq?hSsVLcxkV?LjZ(8}6^%7TK*DkESR0rpx-L#MDof`&Ag+6@KWg^rJI zdJvl*M!GXuHBHYSqo{7(!7{3RzN!r7^C(JfSTEGWwRXNICp$DPKYv=NrRDEl#}bEE ze4?>`@!AS1d2L3!rP}AXYM--IyH` B&z^)^Y`hxi*IU#`I%x~RiZpl8*f z4j-yE3{ W6!=kIBa_td6dQ?|l#L#crk!i#j$i zLu1O)w+3wqMO&U@YBycq$j=l?Lhw0?3X9-(hu{%_x{B5FjS#v6y;0vH*O!TY0WbV% zLjR!m;HiLspE+>vMaI5Q5ODv3Bh@y-X>WixQZxoT7*Pa2gO@Zm=^ARnA8fy;U_XWG zn&5RHqw^O&P5oYKAoFfoXLwhkljf(i?OhmS&xgHwj!%|cimAYV(lR58$y`p|SLLhl zaRnO8@JD>6$CFXw%=3B2P8l1`_c+fi%+4;%zS50FT2d=vHKtbSe#ql-g(a7>B3E@d zRTp*urkvyPs9u*xb!(nnr>SMOI3%CqlO#-DrfQB2s$SU<)?yjDBfXEWa=0(DdbfLc zaNuafnG^M8WCZ30i&ZHrb1q} MNbs~=Y;N#3k*Zlugq48 R6KNMf?2!jE!Cay^-zT(9bpXXJ!px_hoE z`J8jZ=GZoUwA-1@_i3qfmIOtxOPfq*)5Z8}aqlBM=WDXgPvG-m_sbnc?h4k5ZJVxH zM<&SN8@KT3M @RJN#4WSeHW|`Y!P#84~klS1#pH^|0F~WAa#p!%4Dbs@a7*;7u zhUHr3SSO6QtWpO#CIg(3XhSv}woDq^aBkG$Mobu}7Uw9OYh$}pKdApS-H_X4=$GB_ zAX_}7C~nGz0h_Jz=}s3jl}M%r8DP~eNiHx9Y|}mYQCP3nm**_W@OVsLgk{3GGp-Y+ zT+@=NnT}kq=2kr}n1CloHWd$y*y&JnE1WJV?DBYYE$ooJYH%z}7YCi_W|;ERnp&kw z8ElkWbysR;tL}`ZoATMYA*Qeuux7{bSb`~k12&tHQD+9T+>Fi5%#zgN;M_n)G@28P zlx2m=xcP?tuw=6=k{izQN>bT4c8+Drug5|;Gh|FG!eAqAro(`-;VFs~N(^SLWjo!Y z^|rBQc&^jOm^sh23|3#SYU5bmD4)9y%M|{DJ*WBbVVXnd(S<|}n2w8ZSQ0!fEJ?G! zvDj9kLnkw_5|oxT*iMIa#Fbi*#nLfs$z|{{);t}3WMyk-blJ4>=4fe-^tsf6`i`po zT6ba%^<`L|$HXV?h4rqqKJI>c&B{k&ovqWB#hS}=N(YB5!^Cl>SP*Z^^v$xC%DM-K z*2?=vXr8y-+CM8;^O`9}v-ueb7Ji@D>|=PNIoS5{=7lkVStoP$p(J*<6UE+HxD|Zn zkujig9?7swVe_Vk^roV+Qse~#`HX$waXKbtjLFLmPEvJ;%X{&-Yjeioz|>_X6Q)aJ zj&V6>jbrKxHC#0 }Yabu%n~a2m~TVEsKX7lY-fKV=^YGF2^n2 zi@%ber6LBm!L6uS`CBDPS2~oeX;tCT$#|w=WF81-cwJ47$c|bg5(pTz_L&LeXXK;B zUt5kngxFf~qcy9E+ W*)Z( d<0_@mvV=&e}8^SpX?{2u)6XB-V{tV26k+|OwpLjej zZa+>DKR{ {F$3ZLqt4%0Usj|A!eVP@Z_7ixMMd`NV&}1hxRA2!qsy`f ze!k`PU+vPSOwnA2t)RQM_E2qYnP~4BIsVvrBE}LQ?=hPPyGn1_)uM|}vb*7t2K+hT z)Umwt&l+gc^gcY4M$_?+OQh~F_ V0cY60VJ)^# zZ%aQV^nXIH>KvDE-}!t54Oo(6c4^0i_nRfW2G48BON~J#rVfF zt{pz94r`b&v4D!pRBRgf6$};}+)Q9<6`MuIkDUv(Oun*~@?&hDEKZ>9T{?V7+wo}N zi?yIaIoUc)orL4!27j6qw Ok7H8B=xnE!ks+}M zv~kys4~OThm^M1wU_3|0&H0N3?HQqx@+CL>THFQgW~h`$FIcr=PVJ&l<