}2u>B8?Dm$M^!jrkO^ zY-t}j5snPz^@v_`{27+pp${as7=uQema(9!K-L=5O>l%nEb9;XLV|1*Ts^C(cc-eX z-{s5o1}B(pczIYurS!f_$n_5|T{X(f2M>KQu5@FD)JXy#u{Qu|@@XBzt6BNQKoVfV zcIYZw2r+E`tawR${?`Ko5U3XbW@Ej^rOUykP1UEajmB}+BHMQOca^){duQ3Z!k~Y< z%Hv|m=dXEvP@4es0BCc**U;gu5hufD2WtwO-!o6X5YRz-&l!+WXR+fpZtjv`bUO#r zBJF9av0Uio8{L?K(8`bim(IF}!}2=$Ks6*n6e|PBZLCFSm?$xQ zeHT@SDldNDu2LA#iOQ=imAC8K+E?+Vq >a5a@tk@RrY zVQJQYwG-K0)g@rbup?Q6< Vglo0$G7}Q->V;9;%AB0jTS`R~)_i3D%BaZYQ7i5xXLWnk zZ5%3Je|)dRaVu$UZzCqz95-Dj=6A;NERNKXCAg_VLUiTj*H43@`e!9GoHkQELhAZx zc`QS@6OB<$;~XMeS=YVCGr86I*L}v7sKDcIY#9bFh7Rrn!HshCQ&KS@2GsdY8=S)? zR>VGKx@&~cj{G?PyQ1|=(hC!8<+ws!p|=F`@MGkrVjh_a@x;VSNknV;HL-+T!Sip` z&oMrnzxgpxXi=T=ddtZ)tjL^XuZhU4jw%E<-`bGML%Wx`?=TVsKBX->&(AR``=)uo zY{u*z@qa+_^YDsO4{mHI^uXMxH07Vs*iDU=>TV&f;Y^DxvU5H~ckuj-|E}F85_rA! z706OfC&+4)rsh`b&6<$ZeQvvSDFfMRmmcMIdt2Wf4AC=+0-5Pc>;Q;q5sQw&Nj0L! z9lnPu(*yJMg(^xu7??S83QY0c!$f1nY$i$5;so-({_^{PY7bjwWm|Ht3 z5JZl*mirxXovTf|)Sm588eE$s8TwFe#8${x=CFs!iJA+_8^1c#_(sYdU%M*4I3vkS zImO%bwy(_bwYHDXQ7vl|<=Q<&cRHUqEMa-F#20%iu1us{&P_7$*OkrFg|fwGm6{7W zO4Jfe%119&saH`;82i6(mS4!4^WC^jLIwcUdPZ #%~PT6BtEGv&vR?NePC;TB4b -&N%2yu5rz$DB?JV@f@2SSUhjQ1{J502 zw2sJ?q$FKcO<%%#kK1C|`}XYL0;Bz*DCI5ZyBd9bszdgNydI cqtkEB$$>pF;O=&Gqd8s2fw fSY`(uIOop1lccdC?*BK>V9!S!rphpBw<6&z);k`u#QWrrM$I z_xVYQX=F<8L*2U46%=H!8n!9*^E&M*XcQ>~Ev)UTRe%-M4|S4aZb4ROlUMpl+Wb?K zQ$3YYo U7ZrqyUu(>OD3^TE2~bgs}duH&$#Z`k?-(~k)hWYq#Ur_VTbBH zp+D3e#pQT%1D=>xX9mVrjk}c)$drH|9{Cm5j>t1SwR0L$lJ+)*y$QpQMK(tcy76wX z59)RyC9tLJnlL1>w=SCLU2iIzw(53}3)3!zZ`|0&61oz$!Yo|>X?M^W$DIK@ HRl??)@Gbcm-tZkD Iy&m%tt~w*WOBw- zSF|B5jkKYy$txt40JM~Waz}AVNkv5o(Gt=va973(hHZj5{+42#1lBK9Kb3IVI!zR0 zGu@-2qTI{<5icIi?1G1KU4vfWcjxnDo9hMX2HV08tv$}Ha^T6)IMRQX_v%~WD?>w7 zZBM^1OD`?)pkjOs`7>OPOQ;RF+F4t76a6s_B+|}$^LRF4LvgJ?FSIWVxx+YsfpP cF7K}qZ6X>r|9PsR|zDxjkXTD z9vBZZDG;f2Hn%!); <_MRT6GY?C!Fc`Q%*qs#MJiz6yAL*IH7HGcC* z2*}oGFzK~(lzAbEtv$HFr-1k<6kzOZ39fb}3Eu&RZi_) gw8KUO_loO22zZ@=i*cyzv>f^8+C@sxQtoG0#!Z>-f7*Z+wYzL$Zl1BMc6aliWl{ zdYefAkT7YN{XrMdG(4t+aXY&2bhozjnOD*ey2dU&bK55gV=);)h8SK5lr@yJbe^W~ zG-z9?F!Do5AeN1lA0(yf=wA$GVI|ryKvVtIm%@#Wg$jPMxyQR_TPhXrU-Hw8ySsj> z)_J40sHUhW9yIo~SC#q+9rx8S362@NW>DedB&6bOX#|KwSs<@kq+K|v_iTTo=KWjm z7T!9ae0t-7*6~&S8{2o^N>PSxNiedPt3FE=aZZd`Q)ZHrmSKcz@Zn(O0b?#Q3g{cI z+oRa|VsUZZL-x(qk!LTaKHs0(7F|-kw|JkkoGVov50HgGc5_&T+GpdyO@0KccZ#K@ z@nZF{%JVTJMnp2!Qg89f0oE?*T^`&RL%qe81FE6VZYA()UM}9)KwPd)zya>Yjl6uV zY>i)Q-K!h*nj$X<2P;$slE^7|Yf?Y>PFpvE>zcuy?si^ujjko=OkkF0mwuizbA2CH z(Cr^13c&E0l&T=zV5|P1{bdpHk)oCY7T(Ih2w0BBbj1RS`Z_bA9%&ihim;!*cZUe^ z_3fI{U7nS6vDmAcZNoRIcQ+x}vqux3*D!Q8KrAK|!^l%FY7nZ3vv|Dvxa#BuB;AEc z7f`|BbobNMlqa( JeBTcc2*O?l!sb3`C| z^z~)(o$eC#6)E{ugNk|9XB@QmA^jL&DLyr2woLR(OZxla55AcCYu7Y$-iLc4 yu`1Rd#qL^_^&k$QZr^5H7IeU*KfUy7-_&j@5b4bkH= zHMso~AUEvA1P?JChoyH@?`nqV;b=>4@mBul*nCgj<_g2s%neDK_J<{oeczrs>}+EY z?wYY?R(4x<;@#G84@c%B{nM?dPQTIfR=pdRY$rgrwar8oRI$+AFym`Syt7rVd5he2 zA~G=Gh!A%&oLUUyH7bRGk;=4-Epwy7lUn#Mo 8l_;~nbc5dPK==ij6qQbt%@X=^> zee3z5*3lFEwk&8~OjeYHJx1F^ulvs9H9iL05!QUFZ*AOu!a%)dtR4$h;+K=rO7ttw zCib $Ux{1q<7Za8MxZ)Z|Cxc>rsEdjf zL7=remV})*c(H$3rsS#Ni`!Hkw)I8te_op_pR=GB#PE}+USic+;u -JBjZ(f=$JOdSCbpd8tsUUWWF)kIw5^FtO0u`t7n75~ z;T^IvSx(eck8!sgVo)!KDI{&*1+ciSg1@iMZ0TS5m>Y6y-G{5kfAMa%ajwu)*6>`* ztW4CWO1w^2YFT`K_4yGl$5p4|6lIx`MU%XCsmyqNtX!qDWhqHjNJF3J7DcwTaTM m<{x0+P#Fb;n@+hBqewptX zx`s`b6wI7 7PP6>p#Hk=85^1Q5#71ESo`0~*f+lQ+{j%SywClX z$toULtP7q-!AOwUWK-|03_`!YJ7JTKG0UYsrG_ucz&+lD%nO4P+Y<(Z9>bBSV7xH; zwGgcoxk3g*KMnBSY1ANbWdxoSs;dva(J!k>ww9z(baizhN_NWSDb4xKNy %LhQR0-DdCp`*QA;62iHVU5Li;cl!h++u3bYR5NGsyMp`@}Hb%`bri_>V!^ zUGWmw6d<`;0A7kgpN3aFE)OUxt9mCOWN(JCDT(FM{Z2Xnk18HtEN2>%dV@;xskKmu z%_aIBZ-%8|9KbPGKt~5e8);v7F3F99ZhwJt3E*1tM6cHTyn19ZI_leanW2f&z`nuw z%xk`NH+i*QFN}?!JoR++h}*cQG}DakzBz;6O#x5$7C%s2=cPa|EZ<1O |bM9TypK(Z5XH^97+5Vf*22fuce ztWj3gkJ-Vh@8uPhg2zE`iJ-T(Zz=kb5#Vbxfve;wf!Al!03G~2Q%G7uZ0Sq^5&|4X z+@DLod(eCB7Wcu;hHICaFE-UTRF{`lRTgqyt}Yl;YhF0E{zYMFe*t~hS=!I#_xse| zjxE+L%!CG*#5`@Pc(=?+lVxfIa*xrtiJIrP3ew@J$|9}AsS42XcuQM3sU#wcfK$f7 z!}=xW2Js~?hdqjHowW!ZWG)6&CNz1P!ZRXuwm_^k!UYH*s_O8?ro@n9fI9V5xjjaN z%#Mu}z{WAnYh3VAw)`n@9O#epk@}%$mJ0u%)(=kc2ewozwQ|Z1UJW6k=2B5E dT-ii53Yze(rM z59KdDd7yLV!OdowpFGs{?F (_udeK0$Kg!y?1qCrfe-=mO4#TX<8U@gr7 zfWZLI_2SK+KCl1WX$5iUE!iP~T7>k)D`8R?zEnW{dDR7Ai9X7fXMl$@e@U$9xP+uM zL>KU34=eN9pu-?i7-TOF5Jdr3PTJ93ffWNtAQyV2NR&FPR`zf}TR}z!sR_`10ZBpt zxl+Xs6jw)nl-|+ua_gg?pIxhz_!q~kJXWVpt^71_sAVwn#+<}7@8WpV4}Pj;%0=93 zt{zv=s$76R*uM%8*TDmF5QA^SQOIO_{E|pQDxnY;BXZd-EYxs#fC(AO_U)*yHa{%x z_M|!Mz$UnC1~b!E%%0>g3^caOZyb2-lG=X92eM;wX11Vo8af0}24AHxKudtbA&KzY zip%j4a4?MIj4?1=rz$iCfWW#vZn44{CY189fP`4UC2w7=IU%jrGqmFO<5!%O-J5Jw z4z@Nf-cDVJiaIv4O=jBaV3gY=CS_*F#^72kXCdSDvPmp(;|B&xq?#-!5w=)W5{ye( zi0FT|2bAoz70l*KxuXMTvWos%3&lXd-?rg>ZV(+fG3dX|cL-5p+;K92Zs|aI9yUtH zNda+>D)C-^9~=m$kH<=xV B4Yjr4CSziQg4FT}6w9L%I z@KJI}RAmx{Y7McG8I|BtWH0gv21o<^WdKZ906OkPGO8_KhoPE+G2n|LE%lW98l3~7 zQXxCJ@`5Dr($N0BIIZYG>#Gfo=G%6*%owe%ol?^~Y #A6MYy Y!b1%Hn6i$W^yDn1* zFvcxwi7pqyR3SQD(aEa3GWN06;# +=ts7RlI2v25J(e4MGOG3HPhxjL zk=aG|sgWr%&w%10Fjf~3KMw9~LZsDm)IqR10T>*U>sALuu~<1e7m)23cuPF8 lmJ|_EqLkiB%z r{WI@8S79i iq2}DVtIlGRD)g< zZJrbSKnNeWIzx sOuWbHPdR&v@4f%HqT<6P z2jhJQL$@U QEK>^wfEPG#-5MRu#{pD&46{9QX~lF#L5d6}N&t&TE&w2h0mL`5 z5Ko3dI$Q??I!;#+M$HhUsE8r 8JEtL;}_ zuUl=?g)XEIUxNYbMd{qOYbg-VTQc8lr0jt?u;dbnV&V`% q9)yd+5O+Ns3!i&xH1hJDtb9wNT4If{RluxpO2Z~e+_ z^4Ws@dL`#`PZPhc<{05?fK&7;v2*nI(Q*i`ZCQzzusf7l1OX%pgOSbxc=NwV*A$Ks z&ND6uJ|#fvyz3bNcND^A(_y7-wkY)i(btDg;KjO*>hq!Psblpu*6tm#ke|UzWe`;$ z0DNNc&}pKlu7^cL^1wfs3nV 9h39_L zBt$oy|MBK!kXaGmoX5Pd4$~QfJb6Gk7`rtDOXn(D3cC{k8H2Y)h>RsK_#Z;|7WjU= z7n3jfFcjX0*?xW#3=yzYVDSJtT_;aSRT4$QGN3c?xOz8|PP(Wp($7JaJ&D1O?1j4I z7H|WIlfnwvizR0V+d@a_v9%>sNARNwEci+$08F8w{ZyjU^Vx~kzAHUf_srSV9MwE0 zeD^om|HYM)zh-v(eS8)z7Oa$f69{CyA#r2G**7%-{;`FnI~)UphX<34-2cHGE_&@x zUF9#`c%Fs<^dG}YrwB-~SnnF9FqAWrNqkB!>f#^|%>gMLS1Kwc$zyFLua1^^* O*VqUD#M<9{ELXSp0-xrGE zFQ_v(j+N*rh{ay?1$@3YCw0NM4jZZ#81!QxzM&u1bH(moIr+S2WA<-NYr@$VZ4JIR zud$9yO}JM+VXk>`vyJN8aQesc^zuiyFkI=F)I^Rjm^7D=1Vrd5ViD*;lZj-&K&=4; z`kQk`u~RCgM~5w!!5h} sA@s4smZ7bJ T&{tdeM5T{U&lUI-nKWB#2Te70&rG}rF zNhx7GPl-PK?XPZ>1;QA0Pgh}xSP}`~p=(J0m#RR@V=_|JR;r)i@14=vj7@$C-p-+R z9-V!;ys=7wiPdd*p1lOYLZht!5m6>V+1d$9l8E_G8KA(R$sC280qSo tLg-sy>69#goh5YlqNKbGZ=Ul- zAtsi~VU7+ZsNDprU#KP|wkFm 0Afir75~Qq&$I)>hR|je- z5?deTMhGl1VwgIbuvCSAeBh;lca8-&b3*XbE3aqk=dD$&8aBKg3fr~}6E_1t%r2jZ zn$Kot1{bw$107uqaGbCb5qE|k`Y;6O&;!B~%*C+(@kKdSoHOnOvb6-N`7>?e7@p>$ z>9}R@9|0~21Ed(RlyqAeog6|H0U&&m(9J;3L24o 4%_9tI&tc@S3?A-IBF8e~;m&@YFQJNX1u7;Q; z_M=$U(ei`|VBttmyFyC-+gkuqk-Pm)Wa~Ji{FU|wl!Ns&bXv^2?qOvtU5|@yCA?(` zz7NK_p?7)63kr_W01fGjM-L8wj*yRK0lLQ0WuQ0#OT_#(DA1P1Y))OtSq!cl{ovp+ zVPRzGy;~ZyT&C#N)XBk==$EVkGu8(B=3Pl+1kDOytOb1J9;T!A6@IY{!(A@)#bVL1 zQ4kO5y5qnqM0f1tuqkj79&KDFifGFK5*aeXl29~pu~m}rOB!pq?PFM8$c?OiI@%XP z+z=UcB9|f$lD-fS`sb*kcd2^mr~<8@nOadJ!{L;do~bxpP?}#@wdlPVwRmiiwV1FN z^U|PT_AKIiSUGaf1$NZBA?MqnN{QQA0}e~-_-| rBML``fj ~S?2t!Y^Bny@}j&m ztfyM@a*of=BElWZ>yMn+==kEti9AaG?l&tqhAxW4exjg` 7 zVM$pipbOKshB|-* FQx*Bdu%yQ7z<{LDdU`TY-DxJm1Le~x=HxAkwj zXkXE_Ph1IOwK>H$7&ul9;Q&hlL2& W;e7vOr`*R(Q)4(k}fc{3`NosgF}V?!SUYtUq6l>4Jj}9 z9KCgcb;nHVX2RTF(R$XeAJ>n;(z*rjrP3m#)>uyj=M$WSF)FUj!XW7Y+J1uXpJ?t6 zg&=Ll1STJr% 06!)t 1UIfI56G zc(M-;kRe7(Q2%PyzedHp6jFt51hO|NCdOX$Ob`P;!W?!Y@s9$}3HZ~R+pNzgN`&5u zT+R}5j>RIVMuH;4N!b1zP1Y@kdn5!m>7~K78BcEa_n!kwCk> O(b3l&(SevX>fwuc #UI z0mv#q8P(<-Qu@U{XrZ^mGyq6{7YIHBjg39bynTdjIb*;Mt%07qj+~k;8u@|S4CsBb zut}nYaA&Q!q$3N1FTz8%J${g0PTwbZDIq6Fa&mb5%6nSljMiL|E2$-3+UM7I)dqN9 zP+X50MKugm(a;C>fy{>v2;oEWV@m>xgAu@cm|)36?@pm%55gxr@mJ$`n+Ss@Yt%lM z@S%+7Rf$E4&!OS~L8sq{(GD9TgIJ) GX>`_x=;Vyhr^WGRj~{Ne!B%@imX|MDW$Uh-4^aDTo2vSLmu z)tQ%m3(95Dk}NdEWiHVUUO;GD7U3z7QYnI{@c@+;*6>MUA4)Mnbqer+bxH~FvTUot zd|JlLxuNstJU6fT`Cj7j?zaP9)vV^XoZqzTW!=${3*s;cQllG8azXEqQzqhor4v(N z;Gk+Q7OO)>58v@9!=Rw!K~o;+t^A|gIPMaduV2HU%XS;E-s@l?OZ #~AOo^RH}Hv-Zxqul |`J2z}UzOXNlb#!yZmFVu@+mSf` z11V}@Zw-758n=!n$XJ+7+4-$jve0LR9@%K%!j(O-F!(Lcs(NdUANGX6y;J@+*Q}+j zy%%d}HbP$!%z>0Nyz1j!ZyjD=#s<7i)zI&Fv2|*AF0L{9TH2Y(f@#McSG?uTdvA6s z%&ojW6L)Catwdwj>F0%(k={qI-qgA>`>XEvqv{t23-h$5*t@)MZSaaPC_?TH6K B>0JMdtzir-*S;n*?wGVbFp*v?Rak3Z?JMHs7%l@~(bV`7f>aJEZ73 zRbS-AIN;=nxyETto*zI7vcR=|yZRr$uZ255HNF{LL =yyj_E7cQ!_uick&Ga(DwiE4`MAHbnS4k@xVYQwED`k_yF#NT zjZAai=r%2nc)#a+(XKNKW;WY~bFe4!hYJEfZHuxs2=G;1ecZAq)&cKa8?Ad*P(l34 zh6U+;X(`W?cI? 5+TO<^;5x zb`N^`OnqG_9Da~`=ioIj$)Uzo+6P}A%AMP3yvO_9utKe`f5wM} |x3?}#IraSvx-;B<`k+OPYs2FQf+y5X* z+VeWlzr`9$>TI{mEKPC}0mw35&7Zf!^g{2%S7mGUywf|*p3;8y_`S^ZprBK_jFx|F z s_%d_?N?bJJ@}TC1qU197 zJDjU*;e#+RIE1bt#xxK!{5I8=nRv*!@>)wfaogq{W=Zc~*gj}@R(NDh>a@n#o%Gio z1c`ky_lmUQPd@XgKk;OA%g}+(YL`RTTKzaz=Rd4(7#H2XdD-Ms+z9UhsN>qZcNWWo z5M@fo1-^H~WaOMeqte5LDMi bDjGJN@ k%Y9z2X=X1#~H8qbl zHzm(s=?=~M!qRjO%>t?Vtcf76BEIE_({$2-Ee!Ras_r%6BRm3OF}!-OurKwWAMU(4 zJNu?;V$08(zN(Eb(D<%cI2Nsx;8i$K8PYt^-X&P&Nj{4O^y~M)GYY`Hk>z~XV=%id zU0nlqEGZIXqTqM1{%$b})Kj(+JEUKo)>D09LeA^)xA?q` %Oy4dE1sf^Wn3HnVpr# zB}2bE&V4p0&UjcpI{yI-oSWNpC#s^-=F7OpeY;~b5f=-mcF#^xigwIRXx)q>t$lUY zLX9_h)olL2 *Dr25nKc=L*owC$A|N7B4p|H2}-xTG# zo mDTmg3wx5ykjEH{B96XQDOM^aI}& zXSFf5X9Muzi?4R#r^}Yx m$!D02WbC@{94o(`8@N~wQKvIu1&n5KQMRr(sW|bY}wu;*0<08 zzL%hx6>hg#yWRTe$j~d1!}Cu^ZW=s4*ZsMtOz4ZTv8rF1Z~DjN(b !*_PDrJDxt9OLh7%wQlj)*w1&>FZT~gol(8#Breh< zN=IrwcPiSJxI{&!{FrimG2Zf+=R-Pj$mY p@oKi$x?#$9q#`$rlNX1xBi);EPB2CjvA12cmo zTi29KuO{{hM^W>X%~mQroLf$kEo@Mu*M(vA?`xJ;(L6y<=o$|1XlbGllfChDzU-3! zpz|Vecp|$kJt5L;c3OJ*_omYqW6aI+a$7lyQok>sWu8chTX`jpg553BY#;o4W@@5I z;qB4I(!wyUx$~V>SEuH058>K}Yfd&C>0~QO6ZI-mO}i(rzioM>IG4S7=IWKqmkV_w zdsOxt@PywvOIds{X=XjYC+hH_M@84(9dvxAXlUf;{r+Ii8ngGCiO)Yb*tdPTSDv?` z?$GVVom O@#FY^LNzw^Pc9(w%nJdb!VTWJN?t+x@Ojc zOkB-8e^brjFE{V!A6nngQnu{DuEw7Gb-OP$o08I}PkIpF5{$Zr_-C(w&KZk}Xn%h_ z=H3Q;7^Np$IW8qX`^Wy=ONP0v{JcJ;*guC6I53Fn@IU_-19jFmN?nfUSv+g0XfSWE zzwO-&;15=HZF?0G*D=AnyGl} 6L`9gAY%?6IIJe{63)Joi183WgXAz!Txl3 zhSNWCqd9F;(PVcKHl|@y$B)BzoNxPU8z$6-nzqbgA_{J9yI$St|Mbzsf{`VSwLbH+ zY{H|C&yq#Tk7i~Q`mb*+NDB<4xTj!$j12j5f8Y2##5=g=^0k~}X;YcaD;9k}Z$9>< z{n0`9`T27id!JsvAMab27dg23@)@P6`BZ*G3q(F@_IlCG$lRtD?G}z`Sk2i9%gL()K-<#C-U;`8c~AAs?WT6m?K8wvXXC~ zu#c>ndUq`C&W+M=`(=BE-XD(hyDa;;w^!?Y8R5ro5?Svu30rjyo|%p{fR`%|xKpP| z3C(ujZjEb_-eqY>@gmE9>(56-?lwYJ_e&Hd982pE=KmU>{}%h5VvFwUU!an7t53zX e$S&^NwZHO=liS&s#k|E5_u7ijeA~!f4*oyav#2Ei literal 0 HcmV?d00001 diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 1274e44f35..7f5b31cc0d 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -5989,6 +5989,19 @@ button.module-image__border-overlay:focus { $color-white ); } + + &--presenting { + $icon: '../images/icons/v2/share-screen-solid-28.svg'; + &--on { + @include calling-button-icon-on($icon); + } + &--off { + @include calling-button-icon-off($icon); + } + &--disabled { + @include calling-button-icon-disabled($icon); + } + } } @keyframes module-ongoing-call__controls--fade-in { @@ -6286,6 +6299,10 @@ button.module-image__border-overlay:focus { height: 100%; transform: rotateY(180deg); width: 100%; + + &--presenting { + transform: inherit; + } } &--audio-muted::before { @@ -6323,6 +6340,7 @@ button.module-image__border-overlay:focus { } &__toast { + @include button-reset(); @include font-body-1-bold; background-color: $color-gray-75; border-radius: 8px; @@ -6649,6 +6667,17 @@ button.module-image__border-overlay:focus { width: 16px; } } + + &__presenting { + @include color-svg( + '../images/icons/v2/share-screen-solid-28.svg', + $color-white + ); + display: inline-block; + margin-left: 18px; + height: 16px; + width: 16px; + } } .module-call-need-permission-screen { diff --git a/stylesheets/components/CallingScreenSharingController.scss b/stylesheets/components/CallingScreenSharingController.scss new file mode 100644 index 0000000000..e062a9b6eb --- /dev/null +++ b/stylesheets/components/CallingScreenSharingController.scss @@ -0,0 +1,35 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.module-CallingScreenSharingController { + align-items: center; + display: flex; + justify-content: space-between; + padding: 4px 16px; + -webkit-app-region: drag; + + &__text { + @include font-body-2; + color: $color-gray-05; + overflow: hidden; + text-overflow: ellipsis; + user-select: none; + white-space: nowrap; + width: 212px; + } + + &__buttons { + align-items: center; + display: flex; + -webkit-app-region: no-drag; + } + + &__close { + @include button-reset; + @include color-svg('../images/icons/v2/x-24.svg', $color-gray-25); + cursor: pointer; + margin-left: 12px; + height: 20px; + width: 20px; + } +} diff --git a/stylesheets/components/CallingSelectPresentingSourcesModal.scss b/stylesheets/components/CallingSelectPresentingSourcesModal.scss new file mode 100644 index 0000000000..4ee39ce980 --- /dev/null +++ b/stylesheets/components/CallingSelectPresentingSourcesModal.scss @@ -0,0 +1,84 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.module-CallingSelectPresentingSourcesModal { + // specificity + &.module-Modal { + max-width: 665px; + position: relative; + padding-bottom: 48px; + } + + &__footer { + background-color: $color-gray-95; + bottom: 0; + margin-left: -16px; + margin-top: 0; + padding: 16px; + position: absolute; + width: 100%; + } + + &__sources { + margin-bottom: 20px; + margin-left: -6px; + margin-right: -6px; + + &:last-child { + margin-bottom: 0; + } + } + + &__title { + margin-bottom: 12px; + } + + &__source { + @include button-reset(); + + border-radius: 4px; + border: 1px solid $color-gray-60; + margin-bottom: 14px; + margin-left: 6px; + margin-right: 6px; + overflow: hidden; + padding: 8px; + text-align: center; + width: 200px; + + &--selected { + background-color: $ultramarine-ui-dark; + border: 1px solid $ultramarine-ui-dark; + } + + img { + display: inline-block; + } + } + + &__screenshot { + max-height: 102px; + max-width: 184px; + } + + &__name { + &--container { + align-items: center; + display: flex; + margin-top: 8px; + } + + &--text { + display: inline-block; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: middle; + white-space: nowrap; + width: 100%; + } + + &--icon { + margin-right: 8px; + } + } +} diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index b89dff99b5..3ac0d53b41 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -31,6 +31,8 @@ @import './components/Avatar.scss'; @import './components/AvatarInput.scss'; @import './components/Button.scss'; +@import './components/CallingScreenSharingController.scss'; +@import './components/CallingSelectPresentingSourcesModal.scss'; @import './components/ContactPill.scss'; @import './components/ContactPills.scss'; @import './components/ContactSpoofingReviewDialog.scss'; diff --git a/ts/RemoteConfig.ts b/ts/RemoteConfig.ts index 89453bf40e..7ccd85529d 100644 --- a/ts/RemoteConfig.ts +++ b/ts/RemoteConfig.ts @@ -11,8 +11,10 @@ export type ConfigKeyType = | 'desktop.gv2' | 'desktop.mandatoryProfileSharing' | 'desktop.messageRequests' + | 'desktop.screensharing' | 'desktop.storage' | 'desktop.storageWrite3' + | 'desktop.worksAtSignal' | 'global.groupsv2.maxGroupSize' | 'global.groupsv2.groupSizeHardLimit'; type ConfigValueType = { diff --git a/ts/components/CallManager.stories.tsx b/ts/components/CallManager.stories.tsx index 899062e8cf..e525612c6e 100644 --- a/ts/components/CallManager.stories.tsx +++ b/ts/components/CallManager.stories.tsx @@ -68,6 +68,7 @@ const createProps = (storyProps: Partial = {}): PropsType => ({ declineCall: action('decline-call'), getGroupCallVideoFrameSource: (_: string, demuxId: number) => fakeGetGroupCallVideoFrameSource(demuxId), + getPresentingSources: action('get-presenting-sources'), hangUp: action('hang-up'), i18n, keyChangeOk: action('key-change-ok'), @@ -78,16 +79,21 @@ const createProps = (storyProps: Partial = {}): PropsType => ({ }), uuid: 'cb0dd0c8-7393-41e9-a0aa-d631c4109541', }, + openSystemPreferencesAction: action('open-system-preferences-action'), renderDeviceSelection: () => , renderSafetyNumberViewer: (_: SafetyNumberViewerProps) => , setGroupCallVideoRequest: action('set-group-call-video-request'), setLocalAudio: action('set-local-audio'), setLocalPreview: action('set-local-preview'), setLocalVideo: action('set-local-video'), + setPresenting: action('toggle-presenting'), setRendererCanvas: action('set-renderer-canvas'), startCall: action('start-call'), toggleParticipants: action('toggle-participants'), togglePip: action('toggle-pip'), + toggleScreenRecordingPermissionsDialog: action( + 'toggle-screen-recording-permissions-dialog' + ), toggleSettings: action('toggle-settings'), toggleSpeakerView: action('toggle-speaker-view'), }); @@ -104,7 +110,9 @@ story.add('Ongoing Direct Call', () => ( callMode: CallMode.Direct, callState: CallState.Accepted, peekedParticipants: [], - remoteParticipants: [{ hasRemoteVideo: true }], + remoteParticipants: [ + { hasRemoteVideo: true, presenting: false, title: 'Remy' }, + ], }, })} /> @@ -148,7 +156,9 @@ story.add('Call Request Needed', () => ( callMode: CallMode.Direct, callState: CallState.Accepted, peekedParticipants: [], - remoteParticipants: [{ hasRemoteVideo: true }], + remoteParticipants: [ + { hasRemoteVideo: true, presenting: false, title: 'Mike' }, + ], }, })} /> diff --git a/ts/components/CallManager.tsx b/ts/components/CallManager.tsx index 06e5d6f507..014dc66037 100644 --- a/ts/components/CallManager.tsx +++ b/ts/components/CallManager.tsx @@ -6,6 +6,7 @@ import { CallNeedPermissionScreen } from './CallNeedPermissionScreen'; import { CallScreen } from './CallScreen'; import { CallingLobby } from './CallingLobby'; import { CallingParticipantsList } from './CallingParticipantsList'; +import { CallingSelectPresentingSourcesModal } from './CallingSelectPresentingSourcesModal'; import { CallingPip } from './CallingPip'; import { IncomingCallBar } from './IncomingCallBar'; import { @@ -19,6 +20,7 @@ import { CallState, GroupCallJoinState, GroupCallVideoRequest, + PresentedSource, VideoFrameSource, } from '../types/Calling'; import { ConversationType } from '../state/ducks/conversations'; @@ -52,6 +54,7 @@ export type PropsType = { conversationId: string, demuxId: number ) => VideoFrameSource; + getPresentingSources: () => void; incomingCall?: { call: DirectCallStateType; conversation: ConversationType; @@ -65,13 +68,16 @@ export type PropsType = { declineCall: (_: DeclineCallType) => void; i18n: LocalizerType; me: MeType; + openSystemPreferencesAction: () => unknown; setGroupCallVideoRequest: (_: SetGroupCallVideoRequestType) => void; setLocalAudio: (_: SetLocalAudioType) => void; setLocalVideo: (_: SetLocalVideoType) => void; setLocalPreview: (_: SetLocalPreviewType) => void; + setPresenting: (_?: PresentedSource) => void; setRendererCanvas: (_: SetRendererCanvasType) => void; hangUp: (_: HangUpType) => void; togglePip: () => void; + toggleScreenRecordingPermissionsDialog: () => unknown; toggleSettings: () => void; toggleSpeakerView: () => void; }; @@ -89,17 +95,21 @@ const ActiveCallManager: React.FC = ({ i18n, keyChangeOk, getGroupCallVideoFrameSource, + getPresentingSources, me, + openSystemPreferencesAction, renderDeviceSelection, renderSafetyNumberViewer, setGroupCallVideoRequest, setLocalAudio, setLocalPreview, setLocalVideo, + setPresenting, setRendererCanvas, startCall, toggleParticipants, togglePip, + toggleScreenRecordingPermissionsDialog, toggleSettings, toggleSpeakerView, }) => { @@ -110,6 +120,7 @@ const ActiveCallManager: React.FC = ({ joinedAt, peekedParticipants, pip, + presentingSourcesAvailable, settingsDialogOpen, showParticipantsList, } = activeCall; @@ -238,13 +249,15 @@ const ActiveCallManager: React.FC = ({ ? [ ...activeCall.remoteParticipants.map(participant => ({ ...participant, - hasAudio: participant.hasRemoteAudio, - hasVideo: participant.hasRemoteVideo, + hasRemoteAudio: participant.hasRemoteAudio, + hasRemoteVideo: participant.hasRemoteVideo, + presenting: participant.presenting, })), { ...me, - hasAudio: hasLocalAudio, - hasVideo: hasLocalVideo, + hasRemoteAudio: hasLocalAudio, + hasRemoteVideo: hasLocalVideo, + presenting: Boolean(activeCall.presentingSource), }, ] : []; @@ -253,22 +266,35 @@ const ActiveCallManager: React.FC = ({ <> + {presentingSourcesAvailable && presentingSourcesAvailable.length ? ( + + ) : null} {settingsDialogOpen && renderDeviceSelection()} {showParticipantsList && activeCall.callMode === CallMode.Group ? ( ({ activeCall: createActiveCallProp(overrideProps), getGroupCallVideoFrameSource: fakeGetGroupCallVideoFrameSource, + getPresentingSources: action('get-presenting-sources'), hangUp: action('hang-up'), i18n, me: { @@ -145,14 +150,19 @@ const createProps = ( profileName: 'Morty Smith', title: 'Morty Smith', }, + openSystemPreferencesAction: action('open-system-preferences-action'), setGroupCallVideoRequest: action('set-group-call-video-request'), setLocalAudio: action('set-local-audio'), setLocalPreview: action('set-local-preview'), setLocalVideo: action('set-local-video'), + setPresenting: action('toggle-presenting'), setRendererCanvas: action('set-renderer-canvas'), stickyControls: boolean('stickyControls', false), toggleParticipants: action('toggle-participants'), togglePip: action('toggle-pip'), + toggleScreenRecordingPermissionsDialog: action( + 'toggle-screen-recording-permissions-dialog' + ), toggleSettings: action('toggle-settings'), toggleSpeakerView: action('toggle-speaker-view'), }); @@ -249,6 +259,8 @@ story.add('Group call - 1', () => ( demuxId: 0, hasRemoteAudio: true, hasRemoteVideo: true, + presenting: false, + sharingScreen: false, videoAspectRatio: 1.3, ...getDefaultConversation({ isBlocked: false, @@ -266,6 +278,8 @@ const allRemoteParticipants = times(MAX_PARTICIPANTS).map(index => ({ demuxId: index, hasRemoteAudio: index % 3 !== 0, hasRemoteVideo: index % 4 !== 0, + presenting: false, + sharingScreen: false, videoAspectRatio: 1.3, ...getDefaultConversation({ isBlocked: index === 10 || index === MAX_PARTICIPANTS - 1, @@ -303,6 +317,8 @@ story.add('Group call - reconnecting', () => ( demuxId: 0, hasRemoteAudio: true, hasRemoteVideo: true, + presenting: false, + sharingScreen: false, videoAspectRatio: 1.3, ...getDefaultConversation({ isBlocked: false, diff --git a/ts/components/CallScreen.tsx b/ts/components/CallScreen.tsx index b1c1325fad..f363d788cb 100644 --- a/ts/components/CallScreen.tsx +++ b/ts/components/CallScreen.tsx @@ -21,18 +21,23 @@ import { CallState, GroupCallConnectionState, GroupCallVideoRequest, + PresentedSource, VideoFrameSource, } from '../types/Calling'; +import { CallingToastManager } from './CallingToastManager'; import { ColorType } from '../types/Colors'; -import { LocalizerType } from '../types/Util'; -import { missingCaseError } from '../util/missingCaseError'; import { DirectCallRemoteParticipant } from './DirectCallRemoteParticipant'; import { GroupCallRemoteParticipants } from './GroupCallRemoteParticipants'; -import { GroupCallToastManager } from './GroupCallToastManager'; +import { LocalizerType } from '../types/Util'; +import { isScreenSharingEnabled } from '../util/isScreenSharingEnabled'; +import { missingCaseError } from '../util/missingCaseError'; +import { useActivateSpeakerViewOnPresenting } from '../hooks/useActivateSpeakerViewOnPresenting'; +import { NeedsScreenRecordingPermissionsModal } from './NeedsScreenRecordingPermissionsModal'; export type PropsType = { activeCall: ActiveCallType; getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource; + getPresentingSources: () => void; hangUp: (_: HangUpType) => void; i18n: LocalizerType; joinedAt?: number; @@ -44,14 +49,17 @@ export type PropsType = { profileName?: string; title: string; }; + openSystemPreferencesAction: () => unknown; setGroupCallVideoRequest: (_: Array ) => void; setLocalAudio: (_: SetLocalAudioType) => void; setLocalVideo: (_: SetLocalVideoType) => void; setLocalPreview: (_: SetLocalPreviewType) => void; + setPresenting: (_?: PresentedSource) => void; setRendererCanvas: (_: SetRendererCanvasType) => void; stickyControls: boolean; toggleParticipants: () => void; togglePip: () => void; + toggleScreenRecordingPermissionsDialog: () => unknown; toggleSettings: () => void; toggleSpeakerView: () => void; }; @@ -59,18 +67,22 @@ export type PropsType = { export const CallScreen: React.FC = ({ activeCall, getGroupCallVideoFrameSource, + getPresentingSources, hangUp, i18n, joinedAt, me, + openSystemPreferencesAction, setGroupCallVideoRequest, setLocalAudio, setLocalVideo, setLocalPreview, + setPresenting, setRendererCanvas, stickyControls, toggleParticipants, togglePip, + toggleScreenRecordingPermissionsDialog, toggleSettings, toggleSpeakerView, }) => { @@ -78,9 +90,19 @@ export const CallScreen: React.FC = ({ conversation, hasLocalAudio, hasLocalVideo, + isInSpeakerView, + presentingSource, + remoteParticipants, + showNeedsScreenRecordingPermissionsWarning, showParticipantsList, } = activeCall; + useActivateSpeakerViewOnPresenting( + remoteParticipants, + isInSpeakerView, + toggleSpeakerView + ); + const toggleAudio = useCallback(() => { setLocalAudio({ enabled: !hasLocalAudio, @@ -93,6 +115,14 @@ export const CallScreen: React.FC = ({ }); }, [setLocalVideo, hasLocalVideo]); + const togglePresenting = useCallback(() => { + if (presentingSource) { + setPresenting(); + } else { + getPresentingSources(); + } + }, [getPresentingSources, presentingSource, setPresenting]); + const [acceptedDuration, setAcceptedDuration] = useState (null); const [showControls, setShowControls] = useState(true); @@ -151,7 +181,11 @@ export const CallScreen: React.FC = ({ }; }, [toggleAudio, toggleVideo]); - const hasRemoteVideo = activeCall.remoteParticipants.some( + const currentPresenter = remoteParticipants.find( + participant => participant.presenting + ); + + const hasRemoteVideo = remoteParticipants.some( remoteParticipant => remoteParticipant.hasRemoteVideo ); @@ -183,16 +217,22 @@ export const CallScreen: React.FC = ({ case CallMode.Group: participantCount = activeCall.remoteParticipants.length + 1; headerMessage = undefined; - headerTitle = activeCall.remoteParticipants.length - ? undefined - : i18n('calling__in-this-call--zero'); + + if (currentPresenter) { + headerTitle = i18n('calling__presenting--person-ongoing', [ + currentPresenter.title, + ]); + } else if (!activeCall.remoteParticipants.length) { + headerTitle = i18n('calling__in-this-call--zero'); + } + isConnected = activeCall.connectionState === GroupCallConnectionState.Connected; remoteParticipantsElement = ( @@ -206,9 +246,15 @@ export const CallScreen: React.FC = ({ activeCall.callMode === CallMode.Group && !activeCall.remoteParticipants.length; - const videoButtonType = hasLocalVideo - ? CallingButtonType.VIDEO_ON - : CallingButtonType.VIDEO_OFF; + let videoButtonType: CallingButtonType; + if (presentingSource) { + videoButtonType = CallingButtonType.VIDEO_DISABLED; + } else if (hasLocalVideo) { + videoButtonType = CallingButtonType.VIDEO_ON; + } else { + videoButtonType = CallingButtonType.VIDEO_OFF; + } + const audioButtonType = hasLocalAudio ? CallingButtonType.AUDIO_ON : CallingButtonType.AUDIO_OFF; @@ -222,6 +268,23 @@ export const CallScreen: React.FC = ({ !showControls && !isAudioOnly && isConnected, }); + const isGroupCall = activeCall.callMode === CallMode.Group; + const localPreviewVideoClass = classNames({ + 'module-ongoing-call__footer__local-preview__video': true, + 'module-ongoing-call__footer__local-preview__video--presenting': Boolean( + presentingSource + ), + }); + + let presentingButtonType: CallingButtonType; + if (presentingSource) { + presentingButtonType = CallingButtonType.PRESENTING_ON; + } else if (currentPresenter) { + presentingButtonType = CallingButtonType.PRESENTING_DISABLED; + } else { + presentingButtonType = CallingButtonType.PRESENTING_OFF; + } + return ( = ({ }} role="group" > - {activeCall.callMode === CallMode.Group ? ( -) : null} + = ({ {hasLocalVideo && isLonelyInGroup ? ( @@ -308,6 +375,13 @@ export const CallScreen: React.FC= ({ controlsFadeClass )} > + {isScreenSharingEnabled() ? ( + + ) : null} = ({ > {hasLocalVideo && !isLonelyInGroup ? ( diff --git a/ts/components/CallingButton.stories.tsx b/ts/components/CallingButton.stories.tsx index 76c1c09a7f..9e8dc99952 100644 --- a/ts/components/CallingButton.stories.tsx +++ b/ts/components/CallingButton.stories.tsx @@ -1,4 +1,4 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import * as React from 'react'; @@ -14,11 +14,9 @@ import enMessages from '../../_locales/en/messages.json'; const i18n = setupI18n('en', enMessages); const createProps = (overrideProps: Partial = {}): PropsType => ({ - buttonType: select( - 'buttonType', - CallingButtonType, - overrideProps.buttonType || CallingButtonType.HANG_UP - ), + buttonType: + overrideProps.buttonType || + select('buttonType', CallingButtonType, CallingButtonType.HANG_UP), i18n, onClick: action('on-click'), tooltipDirection: select( @@ -30,9 +28,16 @@ const createProps = (overrideProps: Partial = {}): PropsType => ({ const story = storiesOf('Components/CallingButton', module); -story.add('Default', () => { - const props = createProps(); - return ; +story.add('Kitchen Sink', () => { + return ( + <> + {Object.keys(CallingButtonType).map(buttonType => ( + + ))} + > + ); }); story.add('Audio On', () => { @@ -83,3 +88,17 @@ story.add('Tooltip right', () => { }); return ; }); + +story.add('Presenting On', () => { + const props = createProps({ + buttonType: CallingButtonType.PRESENTING_ON, + }); + return ; +}); + +story.add('Presenting Off', () => { + const props = createProps({ + buttonType: CallingButtonType.PRESENTING_OFF, + }); + return ; +}); diff --git a/ts/components/CallingButton.tsx b/ts/components/CallingButton.tsx index 80972b4b9f..9ca6be2974 100644 --- a/ts/components/CallingButton.tsx +++ b/ts/components/CallingButton.tsx @@ -1,4 +1,4 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import React from 'react'; @@ -12,6 +12,9 @@ export enum CallingButtonType { AUDIO_OFF = 'AUDIO_OFF', AUDIO_ON = 'AUDIO_ON', HANG_UP = 'HANG_UP', + PRESENTING_DISABLED = 'PRESENTING_DISABLED', + PRESENTING_OFF = 'PRESENTING_OFF', + PRESENTING_ON = 'PRESENTING_ON', VIDEO_DISABLED = 'VIDEO_DISABLED', VIDEO_OFF = 'VIDEO_OFF', VIDEO_ON = 'VIDEO_ON', @@ -32,9 +35,11 @@ export const CallingButton = ({ }: PropsType): JSX.Element => { let classNameSuffix = ''; let tooltipContent = ''; + let disabled = false; if (buttonType === CallingButtonType.AUDIO_DISABLED) { classNameSuffix = 'audio--disabled'; tooltipContent = i18n('calling__button--audio-disabled'); + disabled = true; } else if (buttonType === CallingButtonType.AUDIO_OFF) { classNameSuffix = 'audio--off'; tooltipContent = i18n('calling__button--audio-on'); @@ -44,6 +49,7 @@ export const CallingButton = ({ } else if (buttonType === CallingButtonType.VIDEO_DISABLED) { classNameSuffix = 'video--disabled'; tooltipContent = i18n('calling__button--video-disabled'); + disabled = true; } else if (buttonType === CallingButtonType.VIDEO_OFF) { classNameSuffix = 'video--off'; tooltipContent = i18n('calling__button--video-on'); @@ -53,6 +59,16 @@ export const CallingButton = ({ } else if (buttonType === CallingButtonType.HANG_UP) { classNameSuffix = 'hangup'; tooltipContent = i18n('calling__hangup'); + } else if (buttonType === CallingButtonType.PRESENTING_DISABLED) { + classNameSuffix = 'presenting--disabled'; + tooltipContent = i18n('calling__button--presenting-disabled'); + disabled = true; + } else if (buttonType === CallingButtonType.PRESENTING_ON) { + classNameSuffix = 'presenting--on'; + tooltipContent = i18n('calling__button--presenting-off'); + } else if (buttonType === CallingButtonType.PRESENTING_OFF) { + classNameSuffix = 'presenting--off'; + tooltipContent = i18n('calling__button--presenting-on'); } const className = classNames( @@ -68,9 +84,10 @@ export const CallingButton = ({ > diff --git a/ts/components/CallingParticipantsList.stories.tsx b/ts/components/CallingParticipantsList.stories.tsx index 67955a6908..377034ea24 100644 --- a/ts/components/CallingParticipantsList.stories.tsx +++ b/ts/components/CallingParticipantsList.stories.tsx @@ -1,4 +1,4 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import * as React from 'react'; @@ -23,6 +23,8 @@ function createParticipant( demuxId: 2, hasRemoteAudio: Boolean(participantProps.hasRemoteAudio), hasRemoteVideo: Boolean(participantProps.hasRemoteVideo), + presenting: Boolean(participantProps.presenting), + sharingScreen: Boolean(participantProps.sharingScreen), videoAspectRatio: 1.3, ...getDefaultConversation({ avatarPath: participantProps.avatarPath, @@ -69,7 +71,7 @@ story.add('Many Participants', () => { }), createParticipant({ hasRemoteAudio: true, - hasRemoteVideo: true, + presenting: true, name: 'Rage Trunks', title: 'Rage Trunks', }), diff --git a/ts/components/CallingParticipantsList.tsx b/ts/components/CallingParticipantsList.tsx index 0b729ec877..f6f679fde8 100644 --- a/ts/components/CallingParticipantsList.tsx +++ b/ts/components/CallingParticipantsList.tsx @@ -13,8 +13,9 @@ import { sortByTitle } from '../util/sortByTitle'; import { ConversationType } from '../state/ducks/conversations'; type ParticipantType = ConversationType & { - hasAudio?: boolean; - hasVideo?: boolean; + hasRemoteAudio?: boolean; + hasRemoteVideo?: boolean; + presenting?: boolean; }; export type PropsType = { @@ -130,12 +131,15 @@ export const CallingParticipantsList = React.memo( )} - {participant.hasAudio === false ? ( + {participant.hasRemoteAudio === false ? ( ) : null} - {participant.hasVideo === false ? ( + {participant.hasRemoteVideo === false ? ( ) : null} + {participant.presenting ? ( + + ) : null}) diff --git a/ts/components/CallingPip.stories.tsx b/ts/components/CallingPip.stories.tsx index 47760d5216..54d31412f7 100644 --- a/ts/components/CallingPip.stories.tsx +++ b/ts/components/CallingPip.stories.tsx @@ -49,7 +49,9 @@ const defaultCall: ActiveCallType = { callMode: CallMode.Direct as CallMode.Direct, callState: CallState.Accepted, peekedParticipants: [], - remoteParticipants: [{ hasRemoteVideo: true }], + remoteParticipants: [ + { hasRemoteVideo: true, presenting: false, title: 'Arsene' }, + ], }; const createProps = (overrideProps: Partial= {}): PropsType => ({ @@ -79,7 +81,9 @@ story.add('Contact (with avatar and no video)', () => { ...conversation, avatarPath: 'https://www.fillmurray.com/64/64', }, - remoteParticipants: [{ hasRemoteVideo: false }], + remoteParticipants: [ + { hasRemoteVideo: false, presenting: false, title: 'Julian' }, + ], }, }); return ; diff --git a/ts/components/CallingPipRemoteVideo.tsx b/ts/components/CallingPipRemoteVideo.tsx index b9e5db5bfa..5b77ba6e2d 100644 --- a/ts/components/CallingPipRemoteVideo.tsx +++ b/ts/components/CallingPipRemoteVideo.tsx @@ -96,9 +96,8 @@ export const CallingPipRemoteVideo = ({ return undefined; } - return maxBy( - activeCall.remoteParticipants, - participant => participant.speakerTime || -Infinity + return maxBy(activeCall.remoteParticipants, participant => + participant.presenting ? Infinity : participant.speakerTime || -Infinity ); }, [activeCall.callMode, activeCall.remoteParticipants]); diff --git a/ts/components/CallingScreenSharingController.stories.tsx b/ts/components/CallingScreenSharingController.stories.tsx new file mode 100644 index 0000000000..208ee32b41 --- /dev/null +++ b/ts/components/CallingScreenSharingController.stories.tsx @@ -0,0 +1,29 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; + +import { + CallingScreenSharingController, + PropsType, +} from './CallingScreenSharingController'; + +import { setup as setupI18n } from '../../js/modules/i18n'; +import enMessages from '../../_locales/en/messages.json'; + +const i18n = setupI18n('en', enMessages); + +const createProps = (): PropsType => ({ + i18n, + onCloseController: action('on-close-controller'), + onStopSharing: action('on-stop-sharing'), + presentedSourceName: 'Application', +}); + +const story = storiesOf('Components/CallingScreenSharingController', module); + +story.add('Controller', () => { + return ; +}); diff --git a/ts/components/CallingScreenSharingController.tsx b/ts/components/CallingScreenSharingController.tsx new file mode 100644 index 0000000000..054b245a27 --- /dev/null +++ b/ts/components/CallingScreenSharingController.tsx @@ -0,0 +1,39 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; +import { Button, ButtonVariant } from './Button'; +import { LocalizerType } from '../types/Util'; + +export type PropsType = { + i18n: LocalizerType; + onCloseController: () => unknown; + onStopSharing: () => unknown; + presentedSourceName: string; +}; + +export const CallingScreenSharingController = ({ + i18n, + onCloseController, + onStopSharing, + presentedSourceName, +}: PropsType): JSX.Element => { + return ( + ++ ); +}; diff --git a/ts/components/CallingSelectPresentingSourcesModal.stories.tsx b/ts/components/CallingSelectPresentingSourcesModal.stories.tsx new file mode 100644 index 0000000000..b0c0829879 --- /dev/null +++ b/ts/components/CallingSelectPresentingSourcesModal.stories.tsx @@ -0,0 +1,61 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; + +import { + CallingSelectPresentingSourcesModal, + PropsType, +} from './CallingSelectPresentingSourcesModal'; + +import { setup as setupI18n } from '../../js/modules/i18n'; +import enMessages from '../../_locales/en/messages.json'; + +const i18n = setupI18n('en', enMessages); + +const createProps = (): PropsType => ({ + i18n, + presentingSourcesAvailable: [ + { + id: 'screen', + name: 'Entire Screen', + thumbnail: + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+P/1PwAF8AL1sEVIPAAAAABJRU5ErkJggg==', + }, + { + id: 'window:123', + name: 'Bozirro Airhorse', + thumbnail: + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z1D4HwAF5wJxzsNOIAAAAABJRU5ErkJggg==', + }, + { + id: 'window:456', + name: 'Discoverer', + thumbnail: + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8HwHwAFHQIIj4yLtgAAAABJRU5ErkJggg==', + }, + { + id: 'window:789', + name: 'Signal Beta', + thumbnail: '', + }, + { + id: 'window:xyz', + name: 'Window that has a really long name and overflows', + thumbnail: + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+O/wHwAEhgJAyqFnAgAAAABJRU5ErkJggg==', + }, + ], + setPresenting: action('set-presenting'), +}); + +const story = storiesOf( + 'Components/CallingSelectPresentingSourcesModal', + module +); + +story.add('Modal', () => { + return+ {i18n('calling__presenting--info', [presentedSourceName])} +++ + ++; +}); diff --git a/ts/components/CallingSelectPresentingSourcesModal.tsx b/ts/components/CallingSelectPresentingSourcesModal.tsx new file mode 100644 index 0000000000..660b3dcf85 --- /dev/null +++ b/ts/components/CallingSelectPresentingSourcesModal.tsx @@ -0,0 +1,137 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { useState } from 'react'; +import classNames from 'classnames'; +import { groupBy } from 'lodash'; +import { Button, ButtonVariant } from './Button'; +import { LocalizerType } from '../types/Util'; +import { Modal } from './Modal'; +import { PresentedSource, PresentableSource } from '../types/Calling'; +import { Theme } from '../util/theme'; + +export type PropsType = { + i18n: LocalizerType; + presentingSourcesAvailable: Array ; + setPresenting: (_?: PresentedSource) => void; +}; + +const Source = ({ + onSourceClick, + source, + sourceToPresent, +}: { + onSourceClick: (source: PresentedSource) => void; + source: PresentableSource; + sourceToPresent?: PresentedSource; +}): JSX.Element => { + return ( + + ); +}; + +export const CallingSelectPresentingSourcesModal = ({ + i18n, + presentingSourcesAvailable, + setPresenting, +}: PropsType): JSX.Element | null => { + const [sourceToPresent, setSourceToPresent] = useState< + PresentedSource | undefined + >(undefined); + + if (!presentingSourcesAvailable.length) { + throw new Error('No sources available for presenting'); + } + + const sources = groupBy(presentingSourcesAvailable, source => + source.id.startsWith('screen') + ); + + return ( + { + setPresenting(sourceToPresent); + }} + theme={Theme.Dark} + title={i18n('calling__SelectPresentingSourcesModal--title')} + > + + ); +}; diff --git a/ts/components/CallingToastManager.tsx b/ts/components/CallingToastManager.tsx new file mode 100644 index 0000000000..63af47cb47 --- /dev/null +++ b/ts/components/CallingToastManager.tsx @@ -0,0 +1,163 @@ +// Copyright 2020-2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import classNames from 'classnames'; +import { + ActiveCallType, + CallMode, + GroupCallConnectionState, +} from '../types/Calling'; +import { ConversationType } from '../state/ducks/conversations'; +import { LocalizerType } from '../types/Util'; + +type PropsType = { + activeCall: ActiveCallType; + i18n: LocalizerType; +}; + +type ToastType = + | { + message: string; + type: 'dismissable' | 'static'; + } + | undefined; + +function getReconnectingToast({ activeCall, i18n }: PropsType): ToastType { + if ( + activeCall.callMode === CallMode.Group && + activeCall.connectionState === GroupCallConnectionState.Reconnecting + ) { + return { + message: i18n('callReconnecting'), + type: 'static', + }; + } + return undefined; +} + +const ME = Symbol('me'); + +function getCurrentPresenter( + activeCall: Readonly+ {i18n('calling__SelectPresentingSourcesModal--entireScreen')} +++ {sources.true.map(source => ( ++setSourceToPresent(selectedSource)} + source={source} + sourceToPresent={sourceToPresent} + /> + ))} + + {i18n('calling__SelectPresentingSourcesModal--window')} +++ {sources.false.map(source => ( ++setSourceToPresent(selectedSource)} + source={source} + sourceToPresent={sourceToPresent} + /> + ))} + + + + ++): ConversationType | typeof ME | undefined { + if (activeCall.presentingSource) { + return ME; + } + if (activeCall.callMode === CallMode.Direct) { + const isOtherPersonPresenting = activeCall.remoteParticipants.some( + participant => participant.presenting + ); + return isOtherPersonPresenting ? activeCall.conversation : undefined; + } + if (activeCall.callMode === CallMode.Group) { + return activeCall.remoteParticipants.find( + participant => participant.presenting + ); + } + return undefined; +} + +function useScreenSharingToast({ activeCall, i18n }: PropsType): ToastType { + const [result, setResult] = useState (undefined); + + const [previousPresenter, setPreviousPresenter] = useState< + undefined | { id: string | typeof ME; title?: string } + >(undefined); + + const previousPresenterId = previousPresenter?.id; + const previousPresenterTitle = previousPresenter?.title; + + useEffect(() => { + const currentPresenter = getCurrentPresenter(activeCall); + if (!currentPresenter && previousPresenterId) { + if (previousPresenterId === ME) { + setResult({ + type: 'dismissable', + message: i18n('calling__presenting--you-stopped'), + }); + } else if (previousPresenterTitle) { + setResult({ + type: 'dismissable', + message: i18n('calling__presenting--person-stopped', [ + previousPresenterTitle, + ]), + }); + } + } + }, [activeCall, i18n, previousPresenterId, previousPresenterTitle]); + + useEffect(() => { + const currentPresenter = getCurrentPresenter(activeCall); + if (currentPresenter === ME) { + setPreviousPresenter({ + id: ME, + }); + } else if (!currentPresenter) { + setPreviousPresenter(undefined); + } else { + const { id, title } = currentPresenter; + setPreviousPresenter({ id, title }); + } + }, [activeCall]); + + return result; +} + +const DEFAULT_DELAY = 5000; + +// In the future, this component should show toasts when users join or leave. See +// DESKTOP-902. +export const CallingToastManager: React.FC = props => { + const reconnectingToast = getReconnectingToast(props); + const screenSharingToast = useScreenSharingToast(props); + + let toast: ToastType; + if (reconnectingToast) { + toast = reconnectingToast; + } else if (screenSharingToast) { + toast = screenSharingToast; + } + + const [toastMessage, setToastMessage] = useState(''); + const timeoutRef = useRef (null); + + const dismissToast = useCallback(() => { + if (timeoutRef) { + setToastMessage(''); + } + }, [setToastMessage, timeoutRef]); + + useEffect(() => { + if (toast) { + if (toast.type === 'dismissable') { + if (timeoutRef && timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + timeoutRef.current = setTimeout(dismissToast, DEFAULT_DELAY); + } + + setToastMessage(toast.message); + } + + return () => { + if (timeoutRef && timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, [dismissToast, setToastMessage, timeoutRef, toast]); + + const isVisible = Boolean(toastMessage); + + return ( + + ); +}; diff --git a/ts/components/GroupCallOverflowArea.stories.tsx b/ts/components/GroupCallOverflowArea.stories.tsx index 507a52f23f..99415d7c65 100644 --- a/ts/components/GroupCallOverflowArea.stories.tsx +++ b/ts/components/GroupCallOverflowArea.stories.tsx @@ -22,6 +22,8 @@ const allRemoteParticipants = times(MAX_PARTICIPANTS).map(index => ({ demuxId: index, hasRemoteAudio: index % 3 !== 0, hasRemoteVideo: index % 4 !== 0, + presenting: false, + sharingScreen: false, videoAspectRatio: 1.3, ...getDefaultConversation({ isBlocked: index === 10 || index === MAX_PARTICIPANTS - 1, diff --git a/ts/components/GroupCallRemoteParticipant.stories.tsx b/ts/components/GroupCallRemoteParticipant.stories.tsx index 267505feed..77027fe661 100644 --- a/ts/components/GroupCallRemoteParticipant.stories.tsx +++ b/ts/components/GroupCallRemoteParticipant.stories.tsx @@ -42,6 +42,8 @@ const createProps = ( demuxId: 123, hasRemoteAudio: false, hasRemoteVideo: true, + presenting: false, + sharingScreen: false, videoAspectRatio: 1.3, ...getDefaultConversation({ isBlocked: Boolean(isBlocked), diff --git a/ts/components/GroupCallRemoteParticipants.tsx b/ts/components/GroupCallRemoteParticipants.tsx index d56bba2ebe..678de4f232 100644 --- a/ts/components/GroupCallRemoteParticipants.tsx +++ b/ts/components/GroupCallRemoteParticipants.tsx @@ -105,7 +105,8 @@ export const GroupCallRemoteParticipants: React.FC = ({ // 2. Split participants into two groups: ones in the main grid and ones in the overflow // sidebar. // - // We start by sorting by `speakerTime` so that the most recent speakers are first in + // We start by sorting by `presenting` first since presenters should be on the main grid + // then we sort by `speakerTime` so that the most recent speakers are next in // line for the main grid. Then we split the list in two: one for the grid and one for // the overflow area. // @@ -119,7 +120,9 @@ export const GroupCallRemoteParticipants: React.FC = ({ remoteParticipants .concat() .sort( - (a, b) => (b.speakerTime || -Infinity) - (a.speakerTime || -Infinity) + (a, b) => + Number(b.presenting || 0) - Number(a.presenting || 0) || + (b.speakerTime || -Infinity) - (a.speakerTime || -Infinity) ), [remoteParticipants] ); @@ -275,18 +278,23 @@ export const GroupCallRemoteParticipants: React.FC = ({ if (isPageVisible) { setGroupCallVideoRequest([ ...gridParticipants.map(participant => { - if (participant.hasRemoteVideo) { - return { - demuxId: participant.demuxId, - width: Math.floor( - gridParticipantHeight * - participant.videoAspectRatio * - VIDEO_REQUEST_SCALAR - ), - height: Math.floor(gridParticipantHeight * VIDEO_REQUEST_SCALAR), - }; + let scalar: number; + if (participant.sharingScreen) { + // We want best-resolution video if someone is sharing their screen. This code + // is extra-defensive against strange devicePixelRatios. + scalar = Math.max(window.devicePixelRatio || 1, 1); + } else if (participant.hasRemoteVideo) { + scalar = VIDEO_REQUEST_SCALAR; + } else { + scalar = 0; } - return nonRenderedRemoteParticipant(participant); + return { + demuxId: participant.demuxId, + width: Math.floor( + gridParticipantHeight * participant.videoAspectRatio * scalar + ), + height: Math.floor(gridParticipantHeight * scalar), + }; }), ...overflowedParticipants.map(participant => { if (participant.hasRemoteVideo) { diff --git a/ts/components/GroupCallToastManager.tsx b/ts/components/GroupCallToastManager.tsx deleted file mode 100644 index bc944af5d3..0000000000 --- a/ts/components/GroupCallToastManager.tsx +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright 2020-2021 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import React, { useState, useEffect } from 'react'; -import classNames from 'classnames'; -import { GroupCallConnectionState } from '../types/Calling'; -import { LocalizerType } from '../types/Util'; - -type PropsType = { - connectionState: GroupCallConnectionState; - i18n: LocalizerType; -}; - -// In the future, this component should show toasts when users join or leave. See -// DESKTOP-902. -export const GroupCallToastManager: React.FC = ({ - connectionState, - i18n, -}) => { - const [isVisible, setIsVisible] = useState(false); - - useEffect(() => { - setIsVisible(connectionState === GroupCallConnectionState.Reconnecting); - }, [connectionState, setIsVisible]); - - const message = i18n('callReconnecting'); - - return ( - - {message} -- ); -}; diff --git a/ts/components/NeedsScreenRecordingPermissionsModal.tsx b/ts/components/NeedsScreenRecordingPermissionsModal.tsx new file mode 100644 index 0000000000..f1f57f7d99 --- /dev/null +++ b/ts/components/NeedsScreenRecordingPermissionsModal.tsx @@ -0,0 +1,60 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; +import { LocalizerType } from '../types/Util'; +import { Theme } from '../util/theme'; +import { Modal } from './Modal'; +import { Button, ButtonVariant } from './Button'; + +type PropsType = { + i18n: LocalizerType; + openSystemPreferencesAction: () => unknown; + toggleScreenRecordingPermissionsDialog: () => unknown; +}; + +function focusRef(el: HTMLElement | null) { + if (el) { + el.focus(); + } +} + +export const NeedsScreenRecordingPermissionsModal = ({ + i18n, + openSystemPreferencesAction, + toggleScreenRecordingPermissionsDialog, +}: PropsType): JSX.Element => { + return ( ++ + ); +}; diff --git a/ts/hooks/useActivateSpeakerViewOnPresenting.ts b/ts/hooks/useActivateSpeakerViewOnPresenting.ts new file mode 100644 index 0000000000..d6536f62eb --- /dev/null +++ b/ts/hooks/useActivateSpeakerViewOnPresenting.ts @@ -0,0 +1,29 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { useEffect } from 'react'; +import { usePrevious } from '../util/hooks'; + +type RemoteParticipant = { + hasRemoteVideo: boolean; + presenting: boolean; + title: string; + uuid?: string; +}; + +export function useActivateSpeakerViewOnPresenting( + remoteParticipants: ReadonlyArray{i18n('calling__presenting--macos-permission-description')}
++
+- {i18n('calling__presenting--permission-instruction-step1')}
+- {i18n('calling__presenting--permission-instruction-step2')}
+- {i18n('calling__presenting--permission-instruction-step3')}
+- {i18n('calling__presenting--permission-instruction-step4')}
++ + + +, + isInSpeakerView: boolean, + toggleSpeakerView: () => void +): void { + const presenterUuid = remoteParticipants.find( + participant => participant.presenting + )?.uuid; + const prevPresenterUuid = usePrevious(presenterUuid, presenterUuid); + + useEffect(() => { + if (prevPresenterUuid !== presenterUuid && !isInSpeakerView) { + toggleSpeakerView(); + } + }, [isInSpeakerView, presenterUuid, prevPresenterUuid, toggleSpeakerView]); +} diff --git a/ts/services/calling.ts b/ts/services/calling.ts index da45e35a51..2aacd5bec0 100644 --- a/ts/services/calling.ts +++ b/ts/services/calling.ts @@ -1,8 +1,9 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only /* eslint-disable class-methods-use-this */ +import { desktopCapturer, ipcRenderer } from 'electron'; import { Call, CallEndedReason, @@ -44,6 +45,8 @@ import { MediaDeviceSettings, GroupCallConnectionState, GroupCallJoinState, + PresentableSource, + PresentedSource, } from '../types/Calling'; import { ConversationModel } from '../models/conversations'; import { @@ -64,6 +67,7 @@ import { REQUESTED_VIDEO_HEIGHT, REQUESTED_VIDEO_FRAMERATE, } from '../calling/constants'; +import { notify } from './notify'; const RINGRTC_HTTP_METHOD_TO_OUR_HTTP_METHOD: Map< HttpMethod, @@ -100,12 +104,14 @@ export class CallingClass { private callsByConversation: { [conversationId: string]: Call | GroupCall }; + private hadLocalVideoBeforePresenting?: boolean; + constructor() { - this.videoCapturer = new GumVideoCapturer( - REQUESTED_VIDEO_WIDTH, - REQUESTED_VIDEO_HEIGHT, - REQUESTED_VIDEO_FRAMERATE - ); + this.videoCapturer = new GumVideoCapturer({ + maxWidth: REQUESTED_VIDEO_WIDTH, + maxHeight: REQUESTED_VIDEO_HEIGHT, + maxFramerate: REQUESTED_VIDEO_FRAMERATE, + }); this.videoRenderer = new CanvasVideoRenderer(); this.callsByConversation = {}; @@ -127,6 +133,10 @@ export class CallingClass { RingRTC.handleLogMessage = this.handleLogMessage.bind(this); RingRTC.handleSendHttpRequest = this.handleSendHttpRequest.bind(this); RingRTC.handleSendCallMessage = this.handleSendCallMessage.bind(this); + + ipcRenderer.on('stop-screen-share', () => { + uxActions.setPresenting(); + }); } async startCallingLobby( @@ -247,7 +257,7 @@ export class CallingClass { } stopCallingLobby(conversationId?: string): void { - this.disableLocalCamera(); + this.disableLocalVideo(); this.stopDeviceReselectionTimer(); this.lastMediaDeviceSettings = undefined; @@ -441,7 +451,7 @@ export class CallingClass { // NOTE: This assumes that only one call is active at a time. For example, if // there are two calls using the camera, this will disable both of them. // That's fine for now, but this will break if that assumption changes. - this.disableLocalCamera(); + this.disableLocalVideo(); delete this.callsByConversation[conversationId]; @@ -457,7 +467,7 @@ export class CallingClass { // NOTE: This assumes only one active call at a time. See comment above. if (localDeviceState.videoMuted) { - this.disableLocalCamera(); + this.disableLocalVideo(); } else { this.videoCapturer.enableCaptureAndSend(groupCall); } @@ -689,6 +699,8 @@ export class CallingClass { demuxId: remoteDeviceState.demuxId, hasRemoteAudio: !remoteDeviceState.audioMuted, hasRemoteVideo: !remoteDeviceState.videoMuted, + presenting: Boolean(remoteDeviceState.presenting), + sharingScreen: Boolean(remoteDeviceState.sharingScreen), speakerTime: normalizeGroupCallTimestamp( remoteDeviceState.speakerTime ), @@ -807,6 +819,8 @@ export class CallingClass { return; } + ipcRenderer.send('close-screen-share-controller'); + if (call instanceof Call) { RingRTC.hangup(call.callId); } else if (call instanceof GroupCall) { @@ -851,6 +865,101 @@ export class CallingClass { } } + private setOutgoingVideoIsScreenShare( + call: Call | GroupCall, + enabled: boolean + ): void { + if (call instanceof Call) { + RingRTC.setOutgoingVideoIsScreenShare(call.callId, enabled); + // Note: there is no "presenting" API for direct calls. + } else if (call instanceof GroupCall) { + call.setOutgoingVideoIsScreenShare(enabled); + call.setPresenting(enabled); + } else { + throw missingCaseError(call); + } + } + + async getPresentingSources(): Promise > { + const sources = await desktopCapturer.getSources({ + fetchWindowIcons: true, + thumbnailSize: { height: 102, width: 184 }, + types: ['window', 'screen'], + }); + + const presentableSources: Array = []; + + sources.forEach(source => { + // If electron can't retrieve a thumbnail then it won't be able to + // present this source so we filter these out. + if (source.thumbnail.isEmpty()) { + return; + } + presentableSources.push({ + appIcon: + source.appIcon && !source.appIcon.isEmpty() + ? source.appIcon.toDataURL() + : undefined, + id: source.id, + name: source.name, + thumbnail: source.thumbnail.toDataURL(), + }); + }); + + return presentableSources; + } + + setPresenting( + conversationId: string, + hasLocalVideo: boolean, + source?: PresentedSource + ): void { + const call = getOwn(this.callsByConversation, conversationId); + if (!call) { + window.log.warn('Trying to set presenting for a non-existent call'); + return; + } + + this.videoCapturer.disable(); + if (source) { + this.hadLocalVideoBeforePresenting = hasLocalVideo; + this.videoCapturer.enableCaptureAndSend(call, { + // 15fps is much nicer but takes up a lot more CPU. + maxFramerate: 5, + maxHeight: 1080, + maxWidth: 1920, + screenShareSourceId: source.id, + }); + this.setOutgoingVideo(conversationId, true); + } else { + this.setOutgoingVideo( + conversationId, + Boolean(this.hadLocalVideoBeforePresenting) || hasLocalVideo + ); + this.hadLocalVideoBeforePresenting = undefined; + } + + const isPresenting = Boolean(source); + this.setOutgoingVideoIsScreenShare(call, isPresenting); + + if (source) { + ipcRenderer.send('show-screen-share', source.name); + notify({ + icon: 'images/icons/v2/video-solid-24.svg', + message: window.i18n('calling__presenting--notification-body'), + onNotificationClick: () => { + if (this.uxActions) { + this.uxActions.setPresenting(); + } + }, + silent: true, + title: window.i18n('calling__presenting--notification-title'), + }); + } else { + ipcRenderer.send('close-screen-share-controller'); + } + } + private async startDeviceReselectionTimer(): Promise { // Poll once await this.pollForMediaDevices(); @@ -1066,7 +1175,7 @@ export class CallingClass { this.videoCapturer.enableCapture(); } - disableLocalCamera(): void { + disableLocalVideo(): void { this.videoCapturer.disable(); } @@ -1387,6 +1496,14 @@ export class CallingClass { hasVideo: call.remoteVideoEnabled, }); }; + + // eslint-disable-next-line no-param-reassign + call.handleRemoteSharingScreen = () => { + uxActions.remoteSharingScreenChange({ + conversationId: conversation.id, + isSharingScreen: Boolean(call.remoteSharingScreen), + }); + }; } private async handleLogMessage( diff --git a/ts/state/ducks/calling.ts b/ts/state/ducks/calling.ts index 75e76b0f00..c91127a4fd 100644 --- a/ts/state/ducks/calling.ts +++ b/ts/state/ducks/calling.ts @@ -1,10 +1,16 @@ // Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +import { ipcRenderer } from 'electron'; import { ThunkAction } from 'redux-thunk'; import { CallEndedReason } from 'ringrtc'; +import { + hasScreenCapturePermission, + openSystemPreferences, +} from 'mac-screen-capture-permissions'; import { has, omit } from 'lodash'; import { getOwn } from '../../util/getOwn'; +import { getPlatform } from '../selectors/user'; import { missingCaseError } from '../../util/missingCaseError'; import { notify } from '../../services/notify'; import { calling } from '../../services/calling'; @@ -18,6 +24,8 @@ import { GroupCallJoinState, GroupCallVideoRequest, MediaDeviceSettings, + PresentedSource, + PresentableSource, } from '../../types/Calling'; import { callingTones } from '../../util/callingTones'; import { requestCameraPermissions } from '../../util/callingPermissions'; @@ -43,6 +51,8 @@ export type GroupCallParticipantInfoType = { demuxId: number; hasRemoteAudio: boolean; hasRemoteVideo: boolean; + presenting: boolean; + sharingScreen: boolean; speakerTime?: number; videoAspectRatio: number; }; @@ -53,6 +63,7 @@ export type DirectCallStateType = { callState?: CallState; callEndedReason?: CallEndedReason; isIncoming: boolean; + isSharingScreen?: boolean; isVideoCall: boolean; hasRemoteVideo?: boolean; }; @@ -73,8 +84,11 @@ export type ActiveCallStateType = { isInSpeakerView: boolean; joinedAt?: number; pip: boolean; + presentingSource?: PresentedSource; + presentingSourcesAvailable?: Array ; safetyNumberChangedUuids: Array ; settingsDialogOpen: boolean; + showNeedsScreenRecordingPermissionsWarning?: boolean; showParticipantsList: boolean; }; @@ -160,6 +174,11 @@ export type RemoteVideoChangeType = { hasVideo: boolean; }; +type RemoteSharingScreenChangeType = { + conversationId: string; + isSharingScreen: boolean; +}; + export type SetLocalAudioType = { enabled: boolean; }; @@ -236,10 +255,15 @@ const OUTGOING_CALL = 'calling/OUTGOING_CALL'; const PEEK_NOT_CONNECTED_GROUP_CALL_FULFILLED = 'calling/PEEK_NOT_CONNECTED_GROUP_CALL_FULFILLED'; const REFRESH_IO_DEVICES = 'calling/REFRESH_IO_DEVICES'; +const REMOTE_SHARING_SCREEN_CHANGE = 'calling/REMOTE_SHARING_SCREEN_CHANGE'; const REMOTE_VIDEO_CHANGE = 'calling/REMOTE_VIDEO_CHANGE'; const RETURN_TO_ACTIVE_CALL = 'calling/RETURN_TO_ACTIVE_CALL'; const SET_LOCAL_AUDIO_FULFILLED = 'calling/SET_LOCAL_AUDIO_FULFILLED'; const SET_LOCAL_VIDEO_FULFILLED = 'calling/SET_LOCAL_VIDEO_FULFILLED'; +const SET_PRESENTING = 'calling/SET_PRESENTING'; +const SET_PRESENTING_SOURCES = 'calling/SET_PRESENTING_SOURCES'; +const TOGGLE_NEEDS_SCREEN_RECORDING_PERMISSIONS = + 'calling/TOGGLE_NEEDS_SCREEN_RECORDING_PERMISSIONS'; const START_DIRECT_CALL = 'calling/START_DIRECT_CALL'; const TOGGLE_PARTICIPANTS = 'calling/TOGGLE_PARTICIPANTS'; const TOGGLE_PIP = 'calling/TOGGLE_PIP'; @@ -326,6 +350,11 @@ type RefreshIODevicesActionType = { payload: MediaDeviceSettings; }; +type RemoteSharingScreenChangeActionType = { + type: 'calling/REMOTE_SHARING_SCREEN_CHANGE'; + payload: RemoteSharingScreenChangeType; +}; + type RemoteVideoChangeActionType = { type: 'calling/REMOTE_VIDEO_CHANGE'; payload: RemoteVideoChangeType; @@ -345,6 +374,16 @@ type SetLocalVideoFulfilledActionType = { payload: SetLocalVideoType; }; +type SetPresentingFulfilledActionType = { + type: 'calling/SET_PRESENTING'; + payload?: PresentedSource; +}; + +type SetPresentingSourcesActionType = { + type: 'calling/SET_PRESENTING_SOURCES'; + payload: Array ; +}; + type ShowCallLobbyActionType = { type: 'calling/SHOW_CALL_LOBBY'; payload: ShowCallLobbyType; @@ -355,6 +394,10 @@ type StartDirectCallActionType = { payload: StartDirectCallType; }; +type ToggleNeedsScreenRecordingPermissionsActionType = { + type: 'calling/TOGGLE_NEEDS_SCREEN_RECORDING_PERMISSIONS'; +}; + type ToggleParticipantsActionType = { type: 'calling/TOGGLE_PARTICIPANTS'; }; @@ -387,14 +430,18 @@ export type CallingActionType = | OutgoingCallActionType | PeekNotConnectedGroupCallFulfilledActionType | RefreshIODevicesActionType + | RemoteSharingScreenChangeActionType | RemoteVideoChangeActionType | ReturnToActiveCallActionType | SetLocalAudioActionType | SetLocalVideoFulfilledActionType + | SetPresentingSourcesActionType | ShowCallLobbyActionType | StartDirectCallActionType + | ToggleNeedsScreenRecordingPermissionsActionType | ToggleParticipantsActionType | TogglePipActionType + | SetPresentingFulfilledActionType | ToggleSettingsActionType | ToggleSpeakerViewActionType; @@ -438,6 +485,7 @@ function callStateChange( } if (callState === CallState.Ended) { await callingTones.playEndCall(); + ipcRenderer.send('close-screen-share-controller'); } dispatch({ @@ -519,10 +567,59 @@ function declineCall(payload: DeclineCallType): DeclineCallActionType { }; } +function getPresentingSources(): ThunkAction< + void, + RootStateType, + unknown, + | SetPresentingSourcesActionType + | ToggleNeedsScreenRecordingPermissionsActionType +> { + return async (dispatch, getState) => { + // We check if the user has permissions first before calling desktopCapturer + // Next we call getPresentingSources so that one gets the prompt for permissions, + // if necessary. + // Finally, we have the if statement which shows the modal, if needed. + // It is in this exact order so that during first-time-use one will be + // prompted for permissions and if they so happen to deny we can still + // capture that state correctly. + const platform = getPlatform(getState()); + const needsPermission = + platform === 'darwin' && !hasScreenCapturePermission(); + + const sources = await calling.getPresentingSources(); + + if (needsPermission) { + dispatch({ + type: TOGGLE_NEEDS_SCREEN_RECORDING_PERMISSIONS, + }); + return; + } + + dispatch({ + type: SET_PRESENTING_SOURCES, + payload: sources, + }); + }; +} + function groupCallStateChange( payload: GroupCallStateChangeArgumentType ): ThunkAction { - return (dispatch, getState) => { + return async (dispatch, getState) => { + let didSomeoneStartPresenting: boolean; + const activeCall = getActiveCall(getState().calling); + if (activeCall?.callMode === CallMode.Group) { + const wasSomeonePresenting = activeCall.remoteParticipants.some( + participant => participant.presenting + ); + const isSomeonePresenting = payload.remoteParticipants.some( + participant => participant.presenting + ); + didSomeoneStartPresenting = !wasSomeonePresenting && isSomeonePresenting; + } else { + didSomeoneStartPresenting = false; + } + dispatch({ type: GROUP_CALL_STATE_CHANGE, payload: { @@ -530,6 +627,10 @@ function groupCallStateChange( ourUuid: getState().user.ourUuid, }, }); + + if (didSomeoneStartPresenting) { + callingTones.someonePresenting(); + } }; } @@ -601,6 +702,17 @@ function receiveIncomingCall( }; } +function openSystemPreferencesAction(): ThunkAction< + void, + RootStateType, + unknown, + never +> { + return () => { + openSystemPreferences(); + }; +} + function outgoingCall(payload: StartDirectCallType): OutgoingCallActionType { callingTones.playRingtone(); @@ -694,6 +806,15 @@ function refreshIODevices( }; } +function remoteSharingScreenChange( + payload: RemoteSharingScreenChangeType +): RemoteSharingScreenChangeActionType { + return { + type: REMOTE_SHARING_SCREEN_CHANGE, + payload, + }; +} + function remoteVideoChange( payload: RemoteVideoChangeType ): RemoteVideoChangeActionType { @@ -764,7 +885,7 @@ function setLocalVideo( } else if (payload.enabled) { calling.enableLocalCamera(); } else { - calling.disableLocalCamera(); + calling.disableLocalVideo(); } ({ enabled } = payload); } else { @@ -797,6 +918,35 @@ function setGroupCallVideoRequest( }; } +function setPresenting( + sourceToPresent?: PresentedSource +): ThunkAction { + return async (dispatch, getState) => { + const callingState = getState().calling; + const { activeCallState } = callingState; + const activeCall = getActiveCall(callingState); + if (!activeCall || !activeCallState) { + window.log.warn('Trying to present when no call is active'); + return; + } + + calling.setPresenting( + activeCall.conversationId, + activeCallState.hasLocalVideo, + sourceToPresent + ); + + dispatch({ + type: SET_PRESENTING, + payload: sourceToPresent, + }); + + if (sourceToPresent) { + await callingTones.someonePresenting(); + } + }; +} + function startCallingLobby( payload: StartCallingLobbyType ): ThunkAction { @@ -857,6 +1007,12 @@ function togglePip(): TogglePipActionType { }; } +function toggleScreenRecordingPermissionsDialog(): ToggleNeedsScreenRecordingPermissionsActionType { + return { + type: TOGGLE_NEEDS_SCREEN_RECORDING_PERMISSIONS, + }; +} + function toggleSettings(): ToggleSettingsActionType { return { type: TOGGLE_SETTINGS, @@ -871,31 +1027,36 @@ function toggleSpeakerView(): ToggleSpeakerViewActionType { export const actions = { acceptCall, - cancelCall, callStateChange, + cancelCall, changeIODevice, closeNeedPermissionScreen, declineCall, + getPresentingSources, groupCallStateChange, hangUp, - keyChanged, keyChangeOk, - receiveIncomingCall, + keyChanged, + openSystemPreferencesAction, outgoingCall, peekNotConnectedGroupCall, + receiveIncomingCall, refreshIODevices, + remoteSharingScreenChange, remoteVideoChange, returnToActiveCall, - setLocalPreview, - setRendererCanvas, - setLocalAudio, - setLocalVideo, setGroupCallVideoRequest, - startCallingLobby, + setLocalAudio, + setLocalPreview, + setLocalVideo, + setPresenting, + setRendererCanvas, showCallLobby, startCall, + startCallingLobby, toggleParticipants, togglePip, + toggleScreenRecordingPermissionsDialog, toggleSettings, toggleSpeakerView, }; @@ -1270,6 +1431,26 @@ export function reducer( }; } + if (action.type === REMOTE_SHARING_SCREEN_CHANGE) { + const { conversationId, isSharingScreen } = action.payload; + const call = getOwn(state.callsByConversation, conversationId); + if (call?.callMode !== CallMode.Direct) { + window.log.warn('Cannot update remote video for a non-direct call'); + return state; + } + + return { + ...state, + callsByConversation: { + ...callsByConversation, + [conversationId]: { + ...call, + isSharingScreen, + }, + }, + }; + } + if (action.type === REMOTE_VIDEO_CHANGE) { const { conversationId, hasVideo } = action.payload; const call = getOwn(state.callsByConversation, conversationId); @@ -1427,6 +1608,59 @@ export function reducer( }; } + if (action.type === SET_PRESENTING) { + const { activeCallState } = state; + if (!activeCallState) { + window.log.warn('Cannot toggle presenting when there is no active call'); + return state; + } + + return { + ...state, + activeCallState: { + ...activeCallState, + presentingSource: action.payload, + presentingSourcesAvailable: undefined, + }, + }; + } + + if (action.type === SET_PRESENTING_SOURCES) { + const { activeCallState } = state; + if (!activeCallState) { + window.log.warn( + 'Cannot set presenting sources when there is no active call' + ); + return state; + } + + return { + ...state, + activeCallState: { + ...activeCallState, + presentingSourcesAvailable: action.payload, + }, + }; + } + + if (action.type === TOGGLE_NEEDS_SCREEN_RECORDING_PERMISSIONS) { + const { activeCallState } = state; + if (!activeCallState) { + window.log.warn( + 'Cannot set presenting sources when there is no active call' + ); + return state; + } + + return { + ...state, + activeCallState: { + ...activeCallState, + showNeedsScreenRecordingPermissionsWarning: !activeCallState.showNeedsScreenRecordingPermissionsWarning, + }, + }; + } + if (action.type === TOGGLE_SPEAKER_VIEW) { const { activeCallState } = state; if (!activeCallState) { diff --git a/ts/state/smart/CallManager.tsx b/ts/state/smart/CallManager.tsx index f18e7cb378..6fa0f3f501 100644 --- a/ts/state/smart/CallManager.tsx +++ b/ts/state/smart/CallManager.tsx @@ -78,7 +78,12 @@ const mapStateToActiveCallProp = ( isInSpeakerView: activeCallState.isInSpeakerView, joinedAt: activeCallState.joinedAt, pip: activeCallState.pip, + presentingSource: activeCallState.presentingSource, + presentingSourcesAvailable: activeCallState.presentingSourcesAvailable, settingsDialogOpen: activeCallState.settingsDialogOpen, + showNeedsScreenRecordingPermissionsWarning: Boolean( + activeCallState.showNeedsScreenRecordingPermissionsWarning + ), showParticipantsList: activeCallState.showParticipantsList, }; @@ -93,6 +98,9 @@ const mapStateToActiveCallProp = ( remoteParticipants: [ { hasRemoteVideo: Boolean(call.hasRemoteVideo), + presenting: Boolean(call.isSharingScreen), + title: conversation.title, + uuid: conversation.uuid, }, ], }; @@ -119,6 +127,8 @@ const mapStateToActiveCallProp = ( demuxId: remoteParticipant.demuxId, hasRemoteAudio: remoteParticipant.hasRemoteAudio, hasRemoteVideo: remoteParticipant.hasRemoteVideo, + presenting: remoteParticipant.presenting, + sharingScreen: remoteParticipant.sharingScreen, speakerTime: remoteParticipant.speakerTime, videoAspectRatio: remoteParticipant.videoAspectRatio, }); diff --git a/ts/test-electron/state/ducks/calling_test.ts b/ts/test-electron/state/ducks/calling_test.ts index 9025f7b1c4..67304de06b 100644 --- a/ts/test-electron/state/ducks/calling_test.ts +++ b/ts/test-electron/state/ducks/calling_test.ts @@ -86,6 +86,8 @@ describe('calling duck', () => { demuxId: 123, hasRemoteAudio: true, hasRemoteVideo: true, + presenting: false, + sharingScreen: false, videoAspectRatio: 4 / 3, }, ], @@ -129,6 +131,188 @@ describe('calling duck', () => { }); describe('actions', () => { + describe('getPresentingSources', () => { + beforeEach(function beforeEach() { + this.callingServiceGetPresentingSources = this.sandbox + .stub(callingService, 'getPresentingSources') + .resolves([ + { + id: 'foo.bar', + name: 'Foo Bar', + thumbnail: 'xyz', + }, + ]); + }); + + it('retrieves sources from the calling service', async function test() { + const { getPresentingSources } = actions; + const dispatch = sinon.spy(); + await getPresentingSources()(dispatch, getEmptyRootState, null); + + sinon.assert.calledOnce(this.callingServiceGetPresentingSources); + }); + + it('dispatches SET_PRESENTING_SOURCES', async function test() { + const { getPresentingSources } = actions; + const dispatch = sinon.spy(); + await getPresentingSources()(dispatch, getEmptyRootState, null); + + sinon.assert.calledOnce(dispatch); + sinon.assert.calledWith(dispatch, { + type: 'calling/SET_PRESENTING_SOURCES', + payload: [ + { + id: 'foo.bar', + name: 'Foo Bar', + thumbnail: 'xyz', + }, + ], + }); + }); + }); + + describe('remoteSharingScreenChange', () => { + it("updates whether someone's screen is being shared", () => { + const { remoteSharingScreenChange } = actions; + + const payload = { + conversationId: 'fake-direct-call-conversation-id', + isSharingScreen: true, + }; + + const state = { + ...stateWithActiveDirectCall, + }; + const nextState = reducer(state, remoteSharingScreenChange(payload)); + + const expectedState = { + ...stateWithActiveDirectCall, + callsByConversation: { + 'fake-direct-call-conversation-id': { + ...stateWithActiveDirectCall.callsByConversation[ + 'fake-direct-call-conversation-id' + ], + isSharingScreen: true, + }, + }, + }; + + assert.deepEqual(nextState, expectedState); + }); + }); + + describe('setPresenting', () => { + beforeEach(function beforeEach() { + this.callingServiceSetPresenting = this.sandbox.stub( + callingService, + 'setPresenting' + ); + }); + + it('calls setPresenting on the calling service', function test() { + const { setPresenting } = actions; + const dispatch = sinon.spy(); + const presentedSource = { + id: 'window:786', + name: 'Application', + }; + const getState = () => ({ + ...getEmptyRootState(), + calling: { + ...stateWithActiveGroupCall, + }, + }); + + setPresenting(presentedSource)(dispatch, getState, null); + + sinon.assert.calledOnce(this.callingServiceSetPresenting); + sinon.assert.calledWith( + this.callingServiceSetPresenting, + 'fake-group-call-conversation-id', + false, + presentedSource + ); + }); + + it('dispatches SET_PRESENTING', () => { + const { setPresenting } = actions; + const dispatch = sinon.spy(); + const presentedSource = { + id: 'window:786', + name: 'Application', + }; + const getState = () => ({ + ...getEmptyRootState(), + calling: { + ...stateWithActiveGroupCall, + }, + }); + + setPresenting(presentedSource)(dispatch, getState, null); + + sinon.assert.calledOnce(dispatch); + sinon.assert.calledWith(dispatch, { + type: 'calling/SET_PRESENTING', + payload: presentedSource, + }); + }); + + it('turns off presenting when no value is passed in', () => { + const dispatch = sinon.spy(); + const { setPresenting } = actions; + const presentedSource = { + id: 'window:786', + name: 'Application', + }; + + const getState = () => ({ + ...getEmptyRootState(), + calling: { + ...stateWithActiveGroupCall, + }, + }); + + setPresenting(presentedSource)(dispatch, getState, null); + + const action = dispatch.getCall(0).args[0]; + + const nextState = reducer(getState().calling, action); + + assert.isDefined(nextState.activeCallState); + assert.equal( + nextState.activeCallState?.presentingSource, + presentedSource + ); + assert.isUndefined( + nextState.activeCallState?.presentingSourcesAvailable + ); + }); + + it('sets the presenting value when one is passed in', () => { + const dispatch = sinon.spy(); + const { setPresenting } = actions; + + const getState = () => ({ + ...getEmptyRootState(), + calling: { + ...stateWithActiveGroupCall, + }, + }); + + setPresenting()(dispatch, getState, null); + + const action = dispatch.getCall(0).args[0]; + + const nextState = reducer(getState().calling, action); + + assert.isDefined(nextState.activeCallState); + assert.isUndefined(nextState.activeCallState?.presentingSource); + assert.isUndefined( + nextState.activeCallState?.presentingSourcesAvailable + ); + }); + }); + describe('acceptCall', () => { const { acceptCall } = actions; @@ -403,6 +587,8 @@ describe('calling duck', () => { demuxId: 123, hasRemoteAudio: true, hasRemoteVideo: true, + presenting: false, + sharingScreen: false, videoAspectRatio: 4 / 3, }, ], @@ -429,6 +615,8 @@ describe('calling duck', () => { demuxId: 123, hasRemoteAudio: true, hasRemoteVideo: true, + presenting: false, + sharingScreen: false, videoAspectRatio: 4 / 3, }, ], @@ -491,6 +679,8 @@ describe('calling duck', () => { demuxId: 456, hasRemoteAudio: false, hasRemoteVideo: true, + presenting: false, + sharingScreen: false, videoAspectRatio: 16 / 9, }, ], @@ -515,6 +705,8 @@ describe('calling duck', () => { demuxId: 456, hasRemoteAudio: false, hasRemoteVideo: true, + presenting: false, + sharingScreen: false, videoAspectRatio: 16 / 9, }, ], @@ -542,6 +734,8 @@ describe('calling duck', () => { demuxId: 456, hasRemoteAudio: false, hasRemoteVideo: true, + presenting: false, + sharingScreen: false, videoAspectRatio: 16 / 9, }, ], @@ -571,6 +765,8 @@ describe('calling duck', () => { demuxId: 456, hasRemoteAudio: false, hasRemoteVideo: true, + presenting: false, + sharingScreen: false, videoAspectRatio: 16 / 9, }, ], @@ -609,6 +805,8 @@ describe('calling duck', () => { demuxId: 456, hasRemoteAudio: false, hasRemoteVideo: true, + presenting: false, + sharingScreen: false, videoAspectRatio: 16 / 9, }, ], @@ -850,6 +1048,8 @@ describe('calling duck', () => { demuxId: 123, hasRemoteAudio: true, hasRemoteVideo: true, + presenting: false, + sharingScreen: false, videoAspectRatio: 4 / 3, }, ], @@ -874,6 +1074,8 @@ describe('calling duck', () => { demuxId: 123, hasRemoteAudio: true, hasRemoteVideo: true, + presenting: false, + sharingScreen: false, videoAspectRatio: 4 / 3, }, ], @@ -925,6 +1127,8 @@ describe('calling duck', () => { demuxId: 123, hasRemoteAudio: true, hasRemoteVideo: true, + presenting: false, + sharingScreen: false, videoAspectRatio: 4 / 3, }, ], @@ -965,6 +1169,8 @@ describe('calling duck', () => { demuxId: 123, hasRemoteAudio: true, hasRemoteVideo: true, + presenting: false, + sharingScreen: false, videoAspectRatio: 4 / 3, }, ], diff --git a/ts/types/Calling.ts b/ts/types/Calling.ts index 476be86364..c969f99a62 100644 --- a/ts/types/Calling.ts +++ b/ts/types/Calling.ts @@ -10,16 +10,31 @@ export enum CallMode { Group = 'Group', } +export type PresentableSource = { + appIcon?: string; + id: string; + name: string; + thumbnail: string; +}; + +export type PresentedSource = { + id: string; + name: string; +}; + type ActiveCallBaseType = { conversation: ConversationType; hasLocalAudio: boolean; hasLocalVideo: boolean; isInSpeakerView: boolean; + isSharingScreen?: boolean; joinedAt?: number; pip: boolean; + presentingSource?: PresentedSource; + presentingSourcesAvailable?: Array ; settingsDialogOpen: boolean; + showNeedsScreenRecordingPermissionsWarning?: boolean; showParticipantsList: boolean; - showSafetyNumberDialog?: boolean; }; type ActiveDirectCallType = ActiveCallBaseType & { @@ -30,6 +45,9 @@ type ActiveDirectCallType = ActiveCallBaseType & { remoteParticipants: [ { hasRemoteVideo: boolean; + presenting: boolean; + title: string; + uuid?: string; } ]; }; @@ -100,6 +118,8 @@ export type GroupCallRemoteParticipantType = ConversationType & { demuxId: number; hasRemoteAudio: boolean; hasRemoteVideo: boolean; + presenting: boolean; + sharingScreen: boolean; speakerTime?: number; videoAspectRatio: number; }; diff --git a/ts/util/callingTones.ts b/ts/util/callingTones.ts index c48e2a827d..0d9127ca3e 100644 --- a/ts/util/callingTones.ts +++ b/ts/util/callingTones.ts @@ -54,6 +54,20 @@ class CallingTones { } }); } + + // eslint-disable-next-line class-methods-use-this + async someonePresenting() { + const canPlayTone = await window.getCallRingtoneNotification(); + if (!canPlayTone) { + return; + } + + const tone = new Sound({ + src: 'sounds/navigation_selection-complete-celebration.ogg', + }); + + await tone.play(); + } } export const callingTones = new CallingTones(); diff --git a/ts/util/isScreenSharingEnabled.ts b/ts/util/isScreenSharingEnabled.ts new file mode 100644 index 0000000000..a2c7576ee2 --- /dev/null +++ b/ts/util/isScreenSharingEnabled.ts @@ -0,0 +1,12 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as RemoteConfig from '../RemoteConfig'; + +// We can remove this function once screen sharing has been turned on for everyone +export function isScreenSharingEnabled(): boolean { + return ( + RemoteConfig.isEnabled('desktop.worksAtSignal') || + RemoteConfig.isEnabled('desktop.screensharing') + ); +} diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 0948329945..9aedc1f8d7 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -2726,6 +2726,13 @@ "updated": "2020-08-26T00:10:28.628Z", "reasonDetail": "isn't react" }, + { + "rule": "jQuery-load(", + "path": "node_modules/execa/node_modules/signal-exit/index.js", + "line": " load()", + "reasonCategory": "falseMatch", + "updated": "2021-05-20T20:01:50.505Z" + }, { "rule": "jQuery-wrap(", "path": "node_modules/expand-range/node_modules/fill-range/index.js", @@ -2859,6 +2866,13 @@ "reasonCategory": "falseMatch", "updated": "2018-09-15T00:38:04.183Z" }, + { + "rule": "jQuery-load(", + "path": "node_modules/foreground-child/node_modules/signal-exit/index.js", + "line": " load()", + "reasonCategory": "falseMatch", + "updated": "2021-05-20T20:01:50.505Z" + }, { "rule": "jQuery-append(", "path": "node_modules/form-data/lib/form_data.js", @@ -2880,6 +2894,13 @@ "reasonCategory": "falseMatch", "updated": "2020-09-11T17:24:56.124Z" }, + { + "rule": "jQuery-load(", + "path": "node_modules/gauge/node_modules/signal-exit/index.js", + "line": " load()", + "reasonCategory": "falseMatch", + "updated": "2021-05-20T20:01:50.505Z" + }, { "rule": "jQuery-$(", "path": "node_modules/global-agent/node_modules/core-js/internals/collection.js", @@ -8702,6 +8723,13 @@ "reasonCategory": "falseMatch", "updated": "2019-03-09T00:08:44.242Z" }, + { + "rule": "jQuery-load(", + "path": "node_modules/loud-rejection/node_modules/signal-exit/index.js", + "line": " load()", + "reasonCategory": "falseMatch", + "updated": "2021-05-20T20:01:50.505Z" + }, { "rule": "thenify-multiArgs", "path": "node_modules/make-dir/node_modules/pify/index.js", @@ -9407,6 +9435,13 @@ "updated": "2021-05-07T20:07:48.358Z", "reasonDetail": "isn't jquery" }, + { + "rule": "jQuery-load(", + "path": "node_modules/os-locale/node_modules/signal-exit/index.js", + "line": " load()", + "reasonCategory": "falseMatch", + "updated": "2021-05-20T20:01:50.505Z" + }, { "rule": "jQuery-append(", "path": "node_modules/pac-proxy-agent/node_modules/socks/build/client/socksclient.js", @@ -11100,13 +11135,6 @@ "reasonCategory": "falseMatch", "updated": "2020-09-11T17:24:56.124Z" }, - { - "rule": "jQuery-load(", - "path": "node_modules/proper-lockfile/node_modules/signal-exit/index.js", - "line": " load()", - "reasonCategory": "falseMatch", - "updated": "2021-04-06T04:01:59.934Z" - }, { "rule": "eval", "path": "node_modules/protobufjs/dist/light/protobuf.js", @@ -12619,13 +12647,6 @@ "reasonCategory": "falseMatch", "updated": "2020-04-30T22:45:07.878Z" }, - { - "rule": "jQuery-load(", - "path": "node_modules/restore-cursor/node_modules/signal-exit/index.js", - "line": " load()", - "reasonCategory": "falseMatch", - "updated": "2020-04-25T01:47:02.583Z" - }, { "rule": "jQuery-$(", "path": "node_modules/rx-lite-aggregates/rx.lite.aggregates.min.js", @@ -12866,13 +12887,6 @@ "reasonCategory": "falseMatch", "updated": "2018-09-19T18:13:29.628Z" }, - { - "rule": "jQuery-load(", - "path": "node_modules/spawn-wrap/node_modules/signal-exit/index.js", - "line": " load()", - "reasonCategory": "falseMatch", - "updated": "2020-04-25T01:47:02.583Z" - }, { "rule": "jQuery-before(", "path": "node_modules/sshpk/lib/dhe.js", @@ -12930,6 +12944,13 @@ "reasonCategory": "falseMatch", "updated": "2021-01-20T22:42:00.662Z" }, + { + "rule": "jQuery-load(", + "path": "node_modules/term-size/node_modules/signal-exit/index.js", + "line": " load()", + "reasonCategory": "falseMatch", + "updated": "2021-05-20T20:01:50.505Z" + }, { "rule": "jQuery-after(", "path": "node_modules/test-exclude/node_modules/braces/index.js", @@ -13263,13 +13284,6 @@ "reasonCategory": "falseMatch", "updated": "2018-09-19T18:13:29.628Z" }, - { - "rule": "jQuery-load(", - "path": "node_modules/write-file-atomic/node_modules/signal-exit/index.js", - "line": " load()", - "reasonCategory": "falseMatch", - "updated": "2020-04-30T22:35:27.860Z" - }, { "rule": "jQuery-$(", "path": "node_modules/xregexp/xregexp-all.js", @@ -13517,6 +13531,13 @@ "updated": "2020-10-26T19:12:24.410Z", "reasonDetail": "Used to get the local video element for rendering." }, + { + "rule": "React-useRef", + "path": "ts/components/CallingToastManager.js", + "line": " const timeoutRef = react_1.useRef(null);", + "reasonCategory": "usageTrusted", + "updated": "2021-05-13T19:40:31.751Z" + }, { "rule": "React-useRef", "path": "ts/components/CaptchaDialog.js", diff --git a/ts/window.d.ts b/ts/window.d.ts index 18d043ed2c..58efd93890 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -84,6 +84,10 @@ import { ConversationModel } from './models/conversations'; import { combineNames } from './util'; import { BatcherType } from './util/batcher'; import { AttachmentList } from './components/conversation/AttachmentList'; +import { + CallingScreenSharingController, + PropsType as CallingScreenSharingControllerProps, +} from './components/CallingScreenSharingController'; import { CaptionEditor } from './components/CaptionEditor'; import { ConfirmationDialog } from './components/ConfirmationDialog'; import { ContactDetail } from './components/conversation/ContactDetail'; @@ -147,6 +151,13 @@ declare global { WhatIsThis: WhatIsThis; + registerScreenShareControllerRenderer: ( + f: ( + component: typeof CallingScreenSharingController, + props: CallingScreenSharingControllerProps + ) => void + ) => void; + attachmentDownloadQueue: Array | undefined; startupProcessingQueue: StartupQueue | undefined; baseAttachmentsPath: string; diff --git a/ts/windows/screenShare.ts b/ts/windows/screenShare.ts new file mode 100644 index 0000000000..94a862262b --- /dev/null +++ b/ts/windows/screenShare.ts @@ -0,0 +1,11 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +// This needs to use window.React & window.ReactDOM since it's +// not commonJS compatible. +window.registerScreenShareControllerRenderer((Component, props) => { + window.ReactDOM.render( + window.React.createElement(Component, props), + document.getElementById('app') + ); +}); diff --git a/webpack-preload.config.ts b/webpack-preload.config.ts index 07fbccbb26..488412b315 100644 --- a/webpack-preload.config.ts +++ b/webpack-preload.config.ts @@ -10,6 +10,7 @@ const context = __dirname; const { NODE_ENV: mode = 'development' } = process.env; const EXTERNAL_MODULE = new Set([ + '@signalapp/signal-client', 'backbone', 'better-sqlite3', 'ffi-napi', @@ -17,7 +18,7 @@ const EXTERNAL_MODULE = new Set([ 'fsevents', 'got', 'jquery', - '@signalapp/signal-client', + 'mac-screen-capture-permissions', 'node-fetch', 'node-sass', 'pino', diff --git a/yarn.lock b/yarn.lock index 6c1ffe0825..04af50355d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6006,7 +6006,7 @@ cross-spawn@^5.0.1: shebang-command "^1.2.0" which "^1.2.9" -cross-spawn@^7.0.1, cross-spawn@^7.0.2, cross-spawn@^7.0.3: +cross-spawn@^7.0.0, cross-spawn@^7.0.1, cross-spawn@^7.0.2, cross-spawn@^7.0.3: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== @@ -6910,6 +6910,11 @@ electron-download@^4.1.0: semver "^5.3.0" sumchecker "^2.0.1" +electron-is-dev@^1.1.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/electron-is-dev/-/electron-is-dev-1.2.0.tgz#2e5cea0a1b3ccf1c86f577cee77363ef55deb05e" + integrity sha512-R1oD5gMBPS7PVU8gJwH6CtT0e6VSoD0+SzSnYpNm+dBkcijgA+K7VAMHDfnRq/lkKPZArpzplTW6jfiMYosdzw== + electron-mocha@8.1.1: version "8.1.1" resolved "https://registry.yarnpkg.com/electron-mocha/-/electron-mocha-8.1.1.tgz#e540e7d9ba80a024007a18533ae491c18f9a0ce2" @@ -6955,6 +6960,14 @@ electron-to-chromium@^1.3.649: resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.707.tgz#71386d0ceca6727835c33ba31f507f6824d18c35" integrity sha512-BqddgxNPrcWnbDdJw7SzXVzPmp+oiyjVrc7tkQVaznPGSS9SKZatw6qxoP857M+HbOyyqJQwYQtsuFIMSTNSZA== +electron-util@^0.13.0: + version "0.13.1" + resolved "https://registry.yarnpkg.com/electron-util/-/electron-util-0.13.1.tgz#ba3b9cb7e5fdb6a51970a01e9070877cf7855ef8" + integrity sha512-CvOuAyQPaPtnDp7SspwnT1yTb1yynw6yp4LrZCfEJ7TG/kJFiZW9RqMHlCEFWMn3QNoMkNhGVeCvWJV5NsYyuQ== + dependencies: + electron-is-dev "^1.1.0" + new-github-issue-url "^0.2.1" + electron-window@^0.8.0: version "0.8.1" resolved "https://registry.yarnpkg.com/electron-window/-/electron-window-0.8.1.tgz#16ca187eb4870b0679274fc8299c5960e6ab2c5e" @@ -7748,6 +7761,21 @@ execa@^1.0.0: signal-exit "^3.0.0" strip-eof "^1.0.0" +execa@^2.0.4: + version "2.1.0" + resolved "https://registry.yarnpkg.com/execa/-/execa-2.1.0.tgz#e5d3ecd837d2a60ec50f3da78fd39767747bbe99" + integrity sha512-Y/URAVapfbYy2Xp/gb6A0E7iR8xeqOCXsuuaoMn7A5PzrXUK84E1gyiEfq0wQd/GHA6GsoHWwhNq8anb0mleIw== + dependencies: + cross-spawn "^7.0.0" + get-stream "^5.0.0" + is-stream "^2.0.0" + merge-stream "^2.0.0" + npm-run-path "^3.0.0" + onetime "^5.1.0" + p-finally "^2.0.0" + signal-exit "^3.0.2" + strip-final-newline "^2.0.0" + execa@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/execa/-/execa-5.0.0.tgz#4029b0007998a841fbd1032e5f4de86a3c1e3376" @@ -8696,6 +8724,13 @@ get-stream@^4.0.0, get-stream@^4.1.0: dependencies: pump "^3.0.0" +get-stream@^5.0.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-5.2.0.tgz#4966a1795ee5ace65e706c4b7beb71257d6e22d3" + integrity sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA== + dependencies: + pump "^3.0.0" + get-stream@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-5.1.0.tgz#01203cdc92597f9b909067c3e656cc1f4d3c4dc9" @@ -11450,11 +11485,28 @@ lru-queue@0.1: dependencies: es5-ext "~0.10.2" +mac-screen-capture-permissions@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/mac-screen-capture-permissions/-/mac-screen-capture-permissions-2.0.0.tgz#fdef314118db4d593a88dd2d7d3e66b175c92f80" + integrity sha512-f70KKpx5WhD8mmrAwLeeee31EfSM4p1K7kBBNBVXyfWE7ZQTIbbAF2PxJ0bMsDxyyeX5roBcH+qJYlSTANtCOA== + dependencies: + electron-util "^0.13.0" + execa "^2.0.4" + macos-version "^5.2.1" + prebuild-install "^6.0.0" + macos-release@^2.2.0: version "2.3.0" resolved "https://registry.yarnpkg.com/macos-release/-/macos-release-2.3.0.tgz#eb1930b036c0800adebccd5f17bc4c12de8bb71f" integrity sha512-OHhSbtcviqMPt7yfw5ef5aghS2jzFVKEFyCJndQt2YpSQ9qRVSEv2axSJI1paVThEu+FFGs584h/1YhxjVqajA== +macos-version@^5.2.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/macos-version/-/macos-version-5.2.1.tgz#056c943aac8edb81d7cafef6445b7ca1d7a2e56e" + integrity sha512-OHJU8nTNxHYL1FQhD+nZawWgXKXAqDGr4kluLtaqKO4au3cR41y1mKuVShOU5U4rOYiuPanljq6oFGmV2B9DFA== + dependencies: + semver "^5.6.0" + make-dir@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-1.0.0.tgz#97a011751e91dd87cfadef58832ebb04936de978" @@ -12268,6 +12320,11 @@ netmask@^2.0.1: resolved "https://registry.yarnpkg.com/netmask/-/netmask-2.0.2.tgz#8b01a07644065d536383835823bc52004ebac5e7" integrity sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg== +new-github-issue-url@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/new-github-issue-url/-/new-github-issue-url-0.2.1.tgz#e17be1f665a92de465926603e44b9f8685630c1d" + integrity sha512-md4cGoxuT4T4d/HDOXbrUHkTKrp/vp+m3aOA7XXVYwNsUNMK49g3SQicTSeV5GIz/5QVGAeYRAOlyp9OvlgsYA== + next-tick@1, next-tick@^1.0.0, next-tick@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.0.0.tgz#ca86d1fe8828169b0120208e3dc8424b9db8342c" @@ -12624,6 +12681,13 @@ npm-run-path@^2.0.0: dependencies: path-key "^2.0.0" +npm-run-path@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-3.1.0.tgz#7f91be317f6a466efed3c9f2980ad8a4ee8b0fa5" + integrity sha512-Dbl4A/VfiVGLgQv29URL9xshU8XDY1GeLy+fsaZ1AA8JDSfjvr5P5+pzRbWqRSBxk6/DW7MIh8lTM/PaGnP2kg== + dependencies: + path-key "^3.0.0" + npm-run-path@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea" @@ -13065,6 +13129,11 @@ p-finally@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae" +p-finally@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-2.0.1.tgz#bd6fcaa9c559a096b680806f4d657b3f0f240561" + integrity sha512-vpm09aKwq6H9phqRQzecoDpD8TmVyGw70qmWlyq5onxY7tqyTTFVvxMykxQSQKILBSFlbXpypIw2T1Ml7+DDtw== + p-is-promise@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/p-is-promise/-/p-is-promise-1.1.0.tgz#9c9456989e9f6588017b0434d56097675c3da05e" @@ -13853,6 +13922,26 @@ postcss@^7.0.0, postcss@^7.0.14, postcss@^7.0.16, postcss@^7.0.17, postcss@^7.0. source-map "^0.6.1" supports-color "^6.1.0" +prebuild-install@^6.0.0: + version "6.1.2" + resolved "https://registry.yarnpkg.com/prebuild-install/-/prebuild-install-6.1.2.tgz#6ce5fc5978feba5d3cbffedca0682b136a0b5bff" + integrity sha512-PzYWIKZeP+967WuKYXlTOhYBgGOvTRSfaKI89XnfJ0ansRAH7hDU45X+K+FZeI1Wb/7p/NnuctPH3g0IqKUuSQ== + dependencies: + detect-libc "^1.0.3" + expand-template "^2.0.3" + github-from-package "0.0.0" + minimist "^1.2.3" + mkdirp-classic "^0.5.3" + napi-build-utils "^1.0.1" + node-abi "^2.21.0" + noop-logger "^0.1.1" + npmlog "^4.0.1" + pump "^3.0.0" + rc "^1.2.7" + simple-get "^3.0.3" + tar-fs "^2.0.0" + tunnel-agent "^0.6.0" + prebuild-install@^6.1.1: version "6.1.1" resolved "https://registry.yarnpkg.com/prebuild-install/-/prebuild-install-6.1.1.tgz#6754fa6c0d55eced7f9e14408ff9e4cba6f097b4" @@ -15467,9 +15556,9 @@ rimraf@^3.0.2, rimraf@~3.0.2: dependencies: glob "^7.1.3" -"ringrtc@https://github.com/signalapp/signal-ringrtc-node.git#b43c6b728d62b6d386d95705e128f32f44edb650": - version "2.9.4" - resolved "https://github.com/signalapp/signal-ringrtc-node.git#b43c6b728d62b6d386d95705e128f32f44edb650" +"ringrtc@https://github.com/signalapp/signal-ringrtc-node.git#17b22fc9d47605867608193202c54be06bce6f56": + version "2.10.1" + resolved "https://github.com/signalapp/signal-ringrtc-node.git#17b22fc9d47605867608193202c54be06bce6f56" ripemd160@^2.0.0, ripemd160@^2.0.1: version "2.0.1"