-X.loClip?0:i+X.loWoBoffset),n<0?a*100:n==0?Math.round(Math.abs(a)*100)+``+o+``:Number.isInteger(n)?(a*100).toFixed(n):0)}function bi(e=[0,0,0]){function t(e){return(e/255)**X.mainTRC}return X.sRco*t(e[0])+X.sGco*t(e[1])+X.sBco*t(e[2])}var xi=(e,t,n,r,i,a,o,s,c)=>{let l=1-c,u=l*l,d=u*l,f=c*c*c;return{x:d*e+u*3*c*n+l*3*c*c*i+f*o,y:d*t+u*3*c*r+l*3*c*c*a+f*s}},Si=(e,t)=>{let n=[],r={x:+e[0],y:+e[1]};for(let i=0,a=e.length;a-2*!t>i;i+=2){let o=[{x:+e[i-2],y:+e[i-1]},{x:+e[i],y:+e[i+1]},{x:+e[i+2],y:+e[i+3]},{x:+e[i+4],y:+e[i+5]}];t?i?a-4===i?o[3]={x:+e[0],y:+e[1]}:a-2===i&&(o[2]={x:+e[0],y:+e[1]},o[3]={x:+e[2],y:+e[3]}):o[0]={x:+e[a-2],y:+e[a-1]}:a-4===i?o[3]=o[2]:i||(o[0]={x:+e[i],y:+e[i+1]}),n.push([r.x,r.y,(-o[0].x+6*o[1].x+o[2].x)/6,(-o[0].y+6*o[1].y+o[2].y)/6,(o[1].x+6*o[2].x-o[3].x)/6,(o[1].y+6*o[2].y-o[3].y)/6,o[2].x,o[2].y]),r=o[2]}return n},Ci=(e,t,n,r,i,a,o,s)=>{let c=e,l=t,u=0;for(let d=1;d<5;d++){let{x:f,y:p}=xi(e,t,n,r,i,a,o,s,d/5);u+=Math.hypot(f-c,p-l),c=f,l=p}return u+=Math.hypot(o-c,s-l),u},wi=(e,t,n,r,i,a,o,s)=>{let c=Math.floor(Ci(e,t,n,r,i,a,o,s)*.75),l=[],u=0;for(let d=0;d<=c;d++){let f=xi(e,t,n,r,i,a,o,s,d/c),p=Math.round(f.x);if(l[p]=f.y,p-u>1){let e=l[u],t=l[p];for(let n=u+1;nl[Math.round(e)]||null},Ti={CAM02:`jab`,CAM02p:`jch`,HEX:`hex`,HSL:`hsl`,HSLuv:`hsluv`,HSV:`hsv`,LAB:`lab`,LCH:`lch`,RGB:`rgb`,OKLAB:`oklab`,OKLCH:`oklch`};function Z(e,t=0){let n=10**t;return Math.round(e*n)/n}function Ei(e,t){let n;return n=e>1?(e-1)*t+1:e<-1?(e+1)*t-1:1,Z(n,2)}function Di(e){return K(String(e)).jch()}function Oi(e){return K(String(e)).hsluv()}function ki(e,t,n){let r=[[],[],[]];if(e.forEach((e,n)=>r.forEach((r,i)=>r.push(t[n],e[i]))),n===`hcl`){let e=r[1];for(let t=1;t{let t=[];for(let n=1;n{e[t]=e[n]}),t.length=0;break}if(t.length){let n=K(`#ccc`).jch()[2];t.forEach(t=>{e[t]=n})}t.length=0;for(let n=e.length-1;n>0;n-=2)if(Number.isNaN(e[n]))t.push(n);else{t.forEach(t=>{e[t]=e[n]});break}for(let t=1;tSi(e).map(e=>wi(...e)));return e=>{let t=i.map(t=>{for(let n=0;nr*t**e+i}function ji({swatches:e,colorKeys:t,colorspace:n,colorSpace:r=n??`LAB`,shift:i=1,fullScale:a=!0,smooth:o=!1,distributeLightness:s=`linear`,sortColor:c=!0,asFun:l=!1}={}){n!==void 0&&console.warn("Leonardo: `colorspace` is deprecated. Use `colorSpace` instead.");let u=Ti[r];if(!u)throw Error(`Colorspace “${r}” not supported`);if(!t)throw Error(`Colorkeys missing: returned “${t}”`);let d;if(a)d=t.map(t=>e-e*(K(t).jch()[0]/100)).sort((e,t)=>e-t).concat(e),d.unshift(0);else{let n=t.map(e=>K(e).jch()[0]/100),r=Math.min(...n),i=Math.max(...n);d=n.map(t=>t===0||isNaN((t-r)/(i-r))?0:e-(t-r)/(i-r)*e).sort((e,t)=>e-t)}let f=Ai(i,[1,e],[1,e]);if(f=d.map(e=>Math.max(0,f(e))),d=f,s===`polynomial`){let t=e=>Math.sqrt(Math.sqrt((e**2.25+e**4)/2));d=f.map(t=>t/e).map(n=>t(n)*e)}let p=t.map((e,t)=>({colorKeys:Di(e),index:t})).sort((e,t)=>t.colorKeys[0]-e.colorKeys[0]).map(e=>t[e.index]),m=[],h;if(a){let e=u===`lch`?K.lch(...K(`#fff`).lch()):`#ffffff`,t=u===`lch`?K.lch(...K(`#000`).lch()):`#000000`;m=[e,...p,t]}else m=c?p:t;let g;if(o){let t=m;if(m=m.map(e=>K(String(e))[u]()),u===`hcl`&&m.forEach(e=>{e[1]=Number.isNaN(e[1])?0:e[1]}),u===`jch`)for(let e=0;eh(t))}else h=K.scale(m.map(e=>typeof e==`object`&&e.constructor===K.Color?e:String(e))).domain(d).mode(u);return l?h:(!o||o===!1?h.colors(e):g).filter(e=>e!=null)}function Mi(e,t){let n=[],r={};return Object.keys(e).forEach(n=>{r[e[n][t]]=e[n]}),Object.keys(r).forEach(e=>n.push(r[e])),n}function Ni(e){return Number.isNaN(e)?0:e}function Pi(e,t,n=!1){if(!e)throw Error(`Cannot convert color value of “${e}”`);if(!Ti[t])throw Error(`Cannot convert to colorspace “${t}”`);let r=Ti[t],i=K(String(e))[r]();if(t===`HSL`&&i.pop(),t===`HEX`){if(n){let t=K(String(e)).rgb();return{r:t[0],g:t[1],b:t[2]}}return i}let a={},o=i.map(Ni);o=o.map((e,t)=>{let i=Z(e),o=t;r===`hsluv`&&(o+=2);let s=r.charAt(o);return r===`jch`&&s===`c`&&(s=`C`),a[s===`j`?`J`:s]=i,r in{lab:1,lch:1,jab:1,jch:1}?n||(s===`l`||s===`j`)&&(i+=`%`):r!==`hsluv`&&(s===`s`||s===`l`||s===`v`)&&(a[s]=Z(e,2),n||(i=Z(e*100),i+=`%`)),i});let s=`${r}(${o.join(`, `)})`;return n?a:s}function Fi(e,t,n){let r=[e,t,n].map(e=>(e/=255,e<=.03928?e/12.92:((e+.055)/1.055)**2.4));return r[0]*.2126+r[1]*.7152+r[2]*.0722}function Ii(e,t,n,r=`wcag2`){if(n===void 0){let e=K.rgb(...t).hsluv()[2];n=Z(e/100,2)}if(r===`wcag2`){let r=Fi(e[0],e[1],e[2]),i=Fi(t[0],t[1],t[2]),a=(r+.05)/(i+.05),o=(i+.05)/(r+.05);return n<.5?a>=1?a:-o:a<1?o:a===1?a:-a}else if(r===`wcag3`)return n<.5?yi(bi(e),bi(t))*-1:yi(bi(e),bi(t));else throw Error(`Contrast calculation method ${r} unsupported; use 'wcag2' or 'wcag3'`)}function Li(e,t){if(!e)throw Error(`Array undefined`);if(!Array.isArray(e))throw Error(`Passed object is not an array`);let n=t===`wcag2`?0:1;return Math.min(...e.filter(e=>e>=n))}function Ri(e,t){if(!e)throw Error(`Ratios undefined`);e=e.sort((e,t)=>e-t);let n=Li(e,t),r=e.indexOf(n),i=[],a=e.slice(0,r),o=e.slice(r,e.length);for(let e=0;ee-t),i}var zi=(e,t,n,r,i)=>{let a=3e3,o=ji({swatches:a,colorKeys:e._modifiedKeys,colorspace:e._colorspace,shift:1,smooth:e._smooth,asFun:!0}),s={},c=e=>{if(s[e])return s[e];let r=Ii(K(o(e)).rgb(),t,n,i);return s[e]=r,r},l=e=>{let t=c(0).01&&o;)o--,n/=2,iu.push(o(l(+e)))),u},Q=class{constructor({name:e,colorKeys:t,colorspace:n,colorSpace:r=n??`RGB`,ratios:i,smooth:a=!1,output:o=`HEX`,saturation:s=100}){if(n!==void 0&&console.warn("Leonardo: `colorspace` is deprecated. Use `colorSpace` instead."),this._name=e,this._colorKeys=t,this._modifiedKeys=t,this._colorspace=r,this._ratios=i,this._smooth=a,this._output=o,this._saturation=s,!this._name)throw Error(`Color missing name`);if(!this._colorKeys)throw Error(`Color Keys are undefined`);if(!Ti[this._colorspace])throw Error(`Colorspace “${r}” not supported`);if(!Ti[this._output])throw Error(`Output “${this._output}” not supported`);for(let e=0;e{let n=K(`${t}`).oklch(),r=n[1]*(this._saturation/100),i=K.oklch(n[0],r,n[2]),a=K.rgb(i).hex();e.push(a)}),this._modifiedKeys=e,this._generateColorScale()}_generateColorScale(){this._colorScale=ji({swatches:3e3,colorKeys:this._modifiedKeys,colorSpace:this._colorspace,shift:1,smooth:this._smooth,asFun:!0})}},Bi=class extends Q{get backgroundColorScale(){return this._backgroundColorScale||this._generateColorScale(),this._backgroundColorScale}_generateColorScale(){Q.prototype._generateColorScale.call(this);let e=ji({swatches:1e3,colorKeys:this._colorKeys,colorspace:this._colorspace,shift:1,smooth:this._smooth});e.push(...this.colorKeys);let t=Mi(e.map((e,t)=>({value:Math.round(Oi(e)[2]),index:t})),`value`).map(t=>e[t.index]);return t.length>=101&&(t.length=100,t.push(`#ffffff`)),this._backgroundColorScale=t.map(e=>Pi(e,this._output)),this._backgroundColorScale}},Vi=class{constructor({colors:e,backgroundColor:t,lightness:n,contrast:r=1,saturation:i=100,output:a=`HEX`,formula:o=`wcag2`}){if(this._output=a,this._colors=e,this._lightness=n,this._saturation=i,this._formula=o,this._setBackgroundColor(t),this._setBackgroundColorValue(),this._contrast=r,!this._colors)throw Error(`No colors are defined`);if(!this._backgroundColor)throw Error(`Background color is undefined`);if(e.forEach(e=>{if(!e.ratios)throw Error(`Color ${e.name}'s ratios are undefined`)}),!Ti[this._output])throw Error(`Output “${a}” not supported`);this._saturation<100&&this._updateColorSaturation(this._saturation),this._findContrastColors(),this._findContrastColorPairs(),this._findContrastColorValues()}set formula(e){this._formula=e,this._findContrastColors()}get formula(){return this._formula}set contrast(e){this._contrast=e,this._findContrastColors()}get contrast(){return this._contrast}set lightness(e){this._lightness=e,this._setBackgroundColor(this._backgroundColor),this._findContrastColors()}get lightness(){return this._lightness}set saturation(e){this._saturation=e,this._updateColorSaturation(e),this._findContrastColors()}get saturation(){return this._saturation}set backgroundColor(e){this._setBackgroundColor(e),this._findContrastColors()}get backgroundColorValue(){return this._backgroundColorValue}get backgroundColor(){return this._backgroundColor}set colors(e){this._colors=e,this._findContrastColors()}get colors(){return this._colors}set addColor(e){this._colors.push(e),this._findContrastColors()}set removeColor(e){this._colors=this._colors.filter(t=>t.name!==e.name),this._findContrastColors()}set updateColor(e){if(Array.isArray(e))for(let t=0;tn.name===e[t].color);n=n[0];let r=this._colors.indexOf(n),i=this._colors.filter(n=>n.name!==e[t].color);e[t].name&&(n.name=e[t].name),e[t].colorKeys&&(n.colorKeys=e[t].colorKeys),e[t].ratios&&(n.ratios=e[t].ratios),(e[t].colorSpace!==void 0||e[t].colorspace!==void 0)&&(e[t].colorspace!==void 0&&e[t].colorSpace===void 0&&console.warn("Leonardo: `colorspace` is deprecated. Use `colorSpace` instead."),n.colorSpace=e[t].colorSpace??e[t].colorspace),e[t].smooth&&(n.smooth=e[t].smooth),n._generateColorScale(),i.splice(r,0,n),this._colors=i}else{let t=this._colors.filter(t=>t.name===e.color);t=t[0];let n=this._colors.indexOf(t),r=this._colors.filter(t=>t.name!==e.color);e.name&&(t.name=e.name),e.colorKeys&&(t.colorKeys=e.colorKeys),e.ratios&&(t.ratios=e.ratios),(e.colorSpace!==void 0||e.colorspace!==void 0)&&(e.colorspace!==void 0&&e.colorSpace===void 0&&console.warn("Leonardo: `colorspace` is deprecated. Use `colorSpace` instead."),t.colorSpace=e.colorSpace??e.colorspace),e.smooth&&(t.smooth=e.smooth),t._generateColorScale(),r.splice(n,0,t),this._colors=r}this._findContrastColors()}set output(e){this._output=e,this._colors.forEach(e=>{e.output=this._output}),this._backgroundColor.output=this._output,this._findContrastColors()}get output(){return this._output}get contrastColors(){return this._contrastColors}get contrastColorPairs(){return this._contrastColorPairs}get contrastColorValues(){return this._contrastColorValues}_setBackgroundColor(e){if(typeof e==`string`){let t=new Bi({name:`background`,colorKeys:[e],output:`RGB`}),n=Z(K(String(e)).hsluv()[2]);this._backgroundColor=t,this._lightness=n,this._backgroundColorValue=t[this._lightness]}else{e.output=`RGB`;let t=e.backgroundColorScale[this._lightness];this._backgroundColor=e,this._backgroundColorValue=t}}_setBackgroundColorValue(){this._backgroundColorValue=this._backgroundColor.backgroundColorScale[this._lightness]}_updateColorSaturation(e){this._colors.map(t=>{t.saturation=e})}_findContrastColors(){let e=K(String(this._backgroundColorValue)).rgb(),t=this._lightness/100,n={background:Pi(this._backgroundColorValue,this._output)},r=[],i=[],a={...n};return r.push(n),this._colors.map(n=>{if(n.ratios!==void 0){let o,s=[],c={name:n.name,values:s},l;Array.isArray(n.ratios)?l=n.ratios:Array.isArray(n.ratios)||(o=Object.keys(n.ratios),l=Object.values(n.ratios)),l=l.map(e=>Ei(+e,this._contrast));let u=zi(n,e,t,l,this._formula).map(e=>Pi(e,this._output));for(let e=0;e{let t=/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(e);return[Number.parseInt(t[1],16),Number.parseInt(t[2],16),Number.parseInt(t[3],16)]},Wi=(e,t,n)=>{let r=e/255,i=t/255,a=n/255,o=Math.min(r,i,a),s=Math.max(r,i,a),c=s-o,l=0,u=0,d=0;return l=c===0?0:s===r?(i-a)/c%6:s===i?(a-r)/c+2:(r-i)/c+4,l=Math.round(l*60),l<0&&(l+=360),d=(s+o)/2,u=c===0?0:c/(1-Math.abs(2*d-1)),u=+(u*100).toFixed(1),d=+(d*100).toFixed(1),[l,u,Math.round(d)]},Gi=(e,t,n,r)=>{let i=n/100,a=t*Math.min(i,1-i)/100,o=t=>{let n=(t+e/30)%12,r=i-a*Math.max(Math.min(n-3,9-n,1),-1);return Math.round(255*r).toString(16).padStart(2,`0`).toUpperCase()},s=o(0),c=o(8),l=o(4),u=((e,t,n)=>Math.min(Math.max(e,t),n))(r,0,1);return`#${s}${c}${l}${Math.round(u*255).toString(16).padStart(2,`0`).toUpperCase()}`},Ki=(e,t,n=1)=>{let r=Ui(e),i=Ui(t===`white`?`#FFFFFF`:t===`black`?`#000000`:t),a=r.map((e,t)=>[(e-i[t])/(255-i[t]),(e-i[t])/(0-i[t])]),o=Hi(Math.max(...a.flat().filter(e=>/^-?\d+\.?\d*$/.test(e)))),s=r.map((e,t)=>Math.round((e-i[t]+i[t]*o)/o));if(s.includes(NaN)){let e=Wi(r[0],r[1],r[2]);return{h:e[0],s:Math.round(e[1]*n),l:e[2],a:1}}let c=Wi(s[0],s[1],s[2]);return{h:c[0],s:Math.round(c[1]*n),l:c[2],a:o}},qi={backgroundColor:`gray`,colorSpace:`OKLCH`,colorSmoothing:!1,formula:`wcag2`,output:`HEX`,colors:{gray:[$(215,20,90),$(215,8,50),$(215,6,25)],red:[$(358,100,58),$(350,100,30)],orange:[$(32,100,48),$(12,100,30)],yellow:[$(50,100,50),$(25,100,20)],lime:[$(100,68,50),$(115,86,25)],green:[$(163,87,42),$(168,100,25)],cyan:[$(185,80,45),$(200,98,35)],blue:[$(212,98,46),$(222,95,25)],purple:[$(258,94,64),$(265,100,35)],fuchsia:[$(295,56,50),$(285,80,25)],pink:[$(334,90,50),$(330,91,25)]},themes:{light:{ratios:[1.03,1.06,1.12,1.25,1.5,1.75,2.25,3.5,5.25,6.5,8,10.5,13.75,16.75],contrast:1,lightness:100,saturation:100},dark:{ratios:[1.03,1.06,1.12,1.25,1.5,1.75,2.25,3.5,5.25,6.5,8,10.5,13.75,16],contrast:1,lightness:6,saturation:97},lightHc:{ratios:[1.06,1.12,1.25,1.37,1.75,2.25,3.25,4.75,8.87,10,11.75,13.25,16,17],contrast:1,lightness:100,saturation:100},darkHc:{ratios:[1.06,1.12,1.25,1.37,1.75,2.25,3.25,4.75,8.87,10,11.75,13.25,16,17],contrast:1,lightness:6,saturation:97}}};function $(e,t,n){return K.hsl(e,t/100,n/100).hex()}function Ji(e,t){let n=e.colorSpace,r=e.colorSmoothing,i=e.themes[t].ratios,a=new Bi({name:`gray`,colorKeys:e.colors.gray,colorspace:n,ratios:i,smooth:r}),o=new Q({name:`blue`,colorKeys:e.colors.blue,colorspace:n,ratios:i,smooth:r}),s=new Q({name:`cyan`,colorKeys:e.colors.cyan,colorspace:n,ratios:i,smooth:r}),c=new Q({name:`fuchsia`,colorKeys:e.colors.fuchsia,colorspace:n,ratios:i,smooth:r}),l=new Q({name:`green`,colorKeys:e.colors.green,colorspace:n,ratios:i,smooth:r}),u=new Q({name:`lime`,colorKeys:e.colors.lime,colorspace:n,ratios:i,smooth:r}),d=new Q({name:`orange`,colorKeys:e.colors.orange,colorspace:n,ratios:i,smooth:r}),f=new Q({name:`pink`,colorKeys:e.colors.pink,colorspace:n,ratios:i,smooth:r}),p=new Q({name:`purple`,colorKeys:e.colors.purple,colorspace:n,ratios:i,smooth:r}),m={gray:a,red:new Q({name:`red`,colorKeys:e.colors.red,colorspace:n,ratios:i,smooth:r}),orange:d,yellow:new Q({name:`yellow`,colorKeys:e.colors.yellow,colorspace:n,ratios:i,smooth:r}),lime:u,green:l,cyan:s,blue:o,purple:p,fuchsia:c,pink:f};return e.colors.custom&&(m.custom=new Q({name:`custom`,colorKeys:e.colors.custom,colorspace:n,ratios:i,smooth:r})),new Vi({colors:Object.values(m),backgroundColor:m[e.backgroundColor],contrast:e.themes[t].contrast,lightness:e.themes[t].lightness,saturation:e.themes[t].saturation,output:e.output,formula:e.formula}).contrastColors}function Yi(e){let t={};for(let n of Object.keys(e.themes))t[n]=Ji(e,n);return t}function Xi(e){qi.colors.custom=[e];let t=Yi(qi);return Object.fromEntries(Object.entries(t).map(([e,t])=>{let n=t.find(e=>e&&e.name===`custom`),r=Object.fromEntries(n.values.map(({name:e,value:t})=>[e,t]));for(let[e,n]of Object.entries(r)){let i=Ki(n,t[0].background);r[`alpha${e.charAt(0).toUpperCase()+e.slice(1)}`]=Gi(i.h,i.s,i.l,i.a)}return[e,r]}))}return e.generateCustomColors=Xi,e.generateThemesJson=Yi,e.hslToHex=$,e.leonardoConfig=qi,e})({});
\ No newline at end of file
diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/previews/SemanticColorsPreview.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/previews/SemanticColorsPreview.kt
index c629e29866..ff7970c0f4 100644
--- a/libraries/compound/src/main/kotlin/io/element/android/compound/previews/SemanticColorsPreview.kt
+++ b/libraries/compound/src/main/kotlin/io/element/android/compound/previews/SemanticColorsPreview.kt
@@ -122,6 +122,7 @@ private fun getSemanticColors(): ImmutableMap {
"bgBadgeAccent" to bgBadgeAccent,
"bgBadgeDefault" to bgBadgeDefault,
"bgBadgeInfo" to bgBadgeInfo,
+ "bgBadgePrimary" to bgBadgePrimary,
"bgCanvasDefault" to bgCanvasDefault,
"bgCanvasDefaultLevel1" to bgCanvasDefaultLevel1,
"bgCanvasDisabled" to bgCanvasDisabled,
diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/CompoundIcons.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/CompoundIcons.kt
index 456ffcf625..5851a758f0 100644
--- a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/CompoundIcons.kt
+++ b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/CompoundIcons.kt
@@ -148,6 +148,9 @@ object CompoundIcons {
@Composable fun Delete(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_delete)
}
+ @Composable fun DevicePasskey(): ImageVector {
+ return ImageVector.vectorResource(R.drawable.ic_compound_device_passkey)
+ }
@Composable fun Devices(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_devices)
}
@@ -738,6 +741,7 @@ object CompoundIcons {
Copy(),
DarkMode(),
Delete(),
+ DevicePasskey(),
Devices(),
DialPad(),
Document(),
@@ -965,6 +969,7 @@ object CompoundIcons {
R.drawable.ic_compound_copy,
R.drawable.ic_compound_dark_mode,
R.drawable.ic_compound_delete,
+ R.drawable.ic_compound_device_passkey,
R.drawable.ic_compound_devices,
R.drawable.ic_compound_dial_pad,
R.drawable.ic_compound_document,
diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColors.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColors.kt
index 4c9b20ef9f..f5a54bb7be 100644
--- a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColors.kt
+++ b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColors.kt
@@ -49,12 +49,12 @@ data class SemanticColors(
val bgActionTertiaryRest: Color,
/** Background colour for tertiary actions. State: Selected */
val bgActionTertiarySelected: Color,
- /** Badge accent background colour */
val bgBadgeAccent: Color,
- /** Badge default background colour */
+ val bgBadgeCritical: Color,
val bgBadgeDefault: Color,
- /** Badge info background colour */
val bgBadgeInfo: Color,
+ val bgBadgePrimary: Color,
+ val bgBadgeSecondary: Color,
/** Default global background for the user interface. Elevation: Default (Level 0) */
val bgCanvasDefault: Color,
/** Default global background for the user interface. Elevation: Level 1. */
@@ -91,6 +91,8 @@ data class SemanticColors(
val bgSubtleSecondaryLevel0: Color,
/** Subtle background colour for success state elements. State: Rest. */
val bgSuccessSubtle: Color,
+ /** Accent borders for containers */
+ val borderAccentPrimary: Color,
/** accent border intended for keylines on message highlights */
val borderAccentSubtle: Color,
/** High-contrast border for critical state. State: Hover. */
@@ -171,6 +173,8 @@ data class SemanticColors(
val iconTertiary: Color,
/** Translucent version of tertiary icon. Refer to it for intended use. */
val iconTertiaryAlpha: Color,
+ /** Used to separate core sections of the UI as well as containers */
+ val separatorPrimary: Color,
/** Accent text colour for plain actions. */
val textActionAccent: Color,
/** Default text colour for plain actions. */
diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsDark.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsDark.kt
index 9ad028f507..35a1234854 100644
--- a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsDark.kt
+++ b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsDark.kt
@@ -37,9 +37,12 @@ val compoundColorsDark = SemanticColors(
bgActionTertiaryHovered = DarkColorTokens.colorGray300,
bgActionTertiaryRest = DarkColorTokens.colorThemeBg,
bgActionTertiarySelected = DarkColorTokens.colorGray400,
- bgBadgeAccent = DarkColorTokens.colorAlphaGreen500,
- bgBadgeDefault = DarkColorTokens.colorAlphaGray500,
- bgBadgeInfo = DarkColorTokens.colorAlphaBlue500,
+ bgBadgeAccent = DarkColorTokens.colorGreen400,
+ bgBadgeCritical = DarkColorTokens.colorRed300,
+ bgBadgeDefault = DarkColorTokens.colorThemeBg,
+ bgBadgeInfo = DarkColorTokens.colorBlue400,
+ bgBadgePrimary = DarkColorTokens.colorGray1400,
+ bgBadgeSecondary = DarkColorTokens.colorGray400,
bgCanvasDefault = DarkColorTokens.colorThemeBg,
bgCanvasDefaultLevel1 = DarkColorTokens.colorGray300,
bgCanvasDisabled = DarkColorTokens.colorGray200,
@@ -58,6 +61,7 @@ val compoundColorsDark = SemanticColors(
bgSubtleSecondary = DarkColorTokens.colorGray300,
bgSubtleSecondaryLevel0 = DarkColorTokens.colorThemeBg,
bgSuccessSubtle = DarkColorTokens.colorGreen200,
+ borderAccentPrimary = DarkColorTokens.colorGreen900,
borderAccentSubtle = DarkColorTokens.colorGreen700,
borderCriticalHovered = DarkColorTokens.colorRed1000,
borderCriticalPrimary = DarkColorTokens.colorRed900,
@@ -98,6 +102,7 @@ val compoundColorsDark = SemanticColors(
iconSuccessPrimary = DarkColorTokens.colorGreen900,
iconTertiary = DarkColorTokens.colorGray800,
iconTertiaryAlpha = DarkColorTokens.colorAlphaGray800,
+ separatorPrimary = DarkColorTokens.colorGray400,
textActionAccent = DarkColorTokens.colorGreen900,
textActionPrimary = DarkColorTokens.colorGray1400,
textBadgeAccent = DarkColorTokens.colorGreen1100,
diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsDarkHc.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsDarkHc.kt
index 9af9edd913..802e3d56b0 100644
--- a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsDarkHc.kt
+++ b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsDarkHc.kt
@@ -37,9 +37,12 @@ val compoundColorsHcDark = SemanticColors(
bgActionTertiaryHovered = DarkHcColorTokens.colorGray300,
bgActionTertiaryRest = DarkHcColorTokens.colorThemeBg,
bgActionTertiarySelected = DarkHcColorTokens.colorGray400,
- bgBadgeAccent = DarkHcColorTokens.colorAlphaGreen500,
- bgBadgeDefault = DarkHcColorTokens.colorAlphaGray500,
- bgBadgeInfo = DarkHcColorTokens.colorAlphaBlue500,
+ bgBadgeAccent = DarkHcColorTokens.colorGreen400,
+ bgBadgeCritical = DarkHcColorTokens.colorRed300,
+ bgBadgeDefault = DarkHcColorTokens.colorThemeBg,
+ bgBadgeInfo = DarkHcColorTokens.colorBlue400,
+ bgBadgePrimary = DarkHcColorTokens.colorGray1400,
+ bgBadgeSecondary = DarkHcColorTokens.colorGray400,
bgCanvasDefault = DarkHcColorTokens.colorThemeBg,
bgCanvasDefaultLevel1 = DarkHcColorTokens.colorGray300,
bgCanvasDisabled = DarkHcColorTokens.colorGray200,
@@ -58,6 +61,7 @@ val compoundColorsHcDark = SemanticColors(
bgSubtleSecondary = DarkHcColorTokens.colorGray300,
bgSubtleSecondaryLevel0 = DarkHcColorTokens.colorThemeBg,
bgSuccessSubtle = DarkHcColorTokens.colorGreen200,
+ borderAccentPrimary = DarkHcColorTokens.colorGreen900,
borderAccentSubtle = DarkHcColorTokens.colorGreen700,
borderCriticalHovered = DarkHcColorTokens.colorRed1000,
borderCriticalPrimary = DarkHcColorTokens.colorRed900,
@@ -98,6 +102,7 @@ val compoundColorsHcDark = SemanticColors(
iconSuccessPrimary = DarkHcColorTokens.colorGreen900,
iconTertiary = DarkHcColorTokens.colorGray800,
iconTertiaryAlpha = DarkHcColorTokens.colorAlphaGray800,
+ separatorPrimary = DarkHcColorTokens.colorGray400,
textActionAccent = DarkHcColorTokens.colorGreen900,
textActionPrimary = DarkHcColorTokens.colorGray1400,
textBadgeAccent = DarkHcColorTokens.colorGreen1100,
diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsLight.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsLight.kt
index 6569f5a676..166f9ddafc 100644
--- a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsLight.kt
+++ b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsLight.kt
@@ -37,9 +37,12 @@ val compoundColorsLight = SemanticColors(
bgActionTertiaryHovered = LightColorTokens.colorGray300,
bgActionTertiaryRest = LightColorTokens.colorThemeBg,
bgActionTertiarySelected = LightColorTokens.colorGray400,
- bgBadgeAccent = LightColorTokens.colorAlphaGreen400,
- bgBadgeDefault = LightColorTokens.colorAlphaGray400,
- bgBadgeInfo = LightColorTokens.colorAlphaBlue400,
+ bgBadgeAccent = LightColorTokens.colorGreen400,
+ bgBadgeCritical = LightColorTokens.colorRed300,
+ bgBadgeDefault = LightColorTokens.colorThemeBg,
+ bgBadgeInfo = LightColorTokens.colorBlue400,
+ bgBadgePrimary = LightColorTokens.colorGray1400,
+ bgBadgeSecondary = LightColorTokens.colorGray400,
bgCanvasDefault = LightColorTokens.colorThemeBg,
bgCanvasDefaultLevel1 = LightColorTokens.colorThemeBg,
bgCanvasDisabled = LightColorTokens.colorGray200,
@@ -58,6 +61,7 @@ val compoundColorsLight = SemanticColors(
bgSubtleSecondary = LightColorTokens.colorGray300,
bgSubtleSecondaryLevel0 = LightColorTokens.colorGray300,
bgSuccessSubtle = LightColorTokens.colorGreen200,
+ borderAccentPrimary = LightColorTokens.colorGreen900,
borderAccentSubtle = LightColorTokens.colorGreen700,
borderCriticalHovered = LightColorTokens.colorRed1000,
borderCriticalPrimary = LightColorTokens.colorRed900,
@@ -98,6 +102,7 @@ val compoundColorsLight = SemanticColors(
iconSuccessPrimary = LightColorTokens.colorGreen900,
iconTertiary = LightColorTokens.colorGray800,
iconTertiaryAlpha = LightColorTokens.colorAlphaGray800,
+ separatorPrimary = LightColorTokens.colorGray400,
textActionAccent = LightColorTokens.colorGreen900,
textActionPrimary = LightColorTokens.colorGray1400,
textBadgeAccent = LightColorTokens.colorGreen1100,
diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsLightHc.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsLightHc.kt
index 8a8fa44e61..8914b41bfc 100644
--- a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsLightHc.kt
+++ b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsLightHc.kt
@@ -37,9 +37,12 @@ val compoundColorsHcLight = SemanticColors(
bgActionTertiaryHovered = LightHcColorTokens.colorGray300,
bgActionTertiaryRest = LightHcColorTokens.colorThemeBg,
bgActionTertiarySelected = LightHcColorTokens.colorGray400,
- bgBadgeAccent = LightHcColorTokens.colorAlphaGreen400,
- bgBadgeDefault = LightHcColorTokens.colorAlphaGray400,
- bgBadgeInfo = LightHcColorTokens.colorAlphaBlue400,
+ bgBadgeAccent = LightHcColorTokens.colorGreen400,
+ bgBadgeCritical = LightHcColorTokens.colorRed300,
+ bgBadgeDefault = LightHcColorTokens.colorThemeBg,
+ bgBadgeInfo = LightHcColorTokens.colorBlue400,
+ bgBadgePrimary = LightHcColorTokens.colorGray1400,
+ bgBadgeSecondary = LightHcColorTokens.colorGray400,
bgCanvasDefault = LightHcColorTokens.colorThemeBg,
bgCanvasDefaultLevel1 = LightHcColorTokens.colorThemeBg,
bgCanvasDisabled = LightHcColorTokens.colorGray200,
@@ -58,6 +61,7 @@ val compoundColorsHcLight = SemanticColors(
bgSubtleSecondary = LightHcColorTokens.colorGray300,
bgSubtleSecondaryLevel0 = LightHcColorTokens.colorGray300,
bgSuccessSubtle = LightHcColorTokens.colorGreen200,
+ borderAccentPrimary = LightHcColorTokens.colorGreen900,
borderAccentSubtle = LightHcColorTokens.colorGreen700,
borderCriticalHovered = LightHcColorTokens.colorRed1000,
borderCriticalPrimary = LightHcColorTokens.colorRed900,
@@ -98,6 +102,7 @@ val compoundColorsHcLight = SemanticColors(
iconSuccessPrimary = LightHcColorTokens.colorGreen900,
iconTertiary = LightHcColorTokens.colorGray800,
iconTertiaryAlpha = LightHcColorTokens.colorAlphaGray800,
+ separatorPrimary = LightHcColorTokens.colorGray400,
textActionAccent = LightHcColorTokens.colorGreen900,
textActionPrimary = LightHcColorTokens.colorGray1400,
textBadgeAccent = LightHcColorTokens.colorGreen1100,
diff --git a/libraries/compound/src/main/res/drawable/ic_compound_ai.xml b/libraries/compound/src/main/res/drawable/ic_compound_ai.xml
new file mode 100644
index 0000000000..2832283061
--- /dev/null
+++ b/libraries/compound/src/main/res/drawable/ic_compound_ai.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/libraries/compound/src/main/res/drawable/ic_compound_device_passkey.xml b/libraries/compound/src/main/res/drawable/ic_compound_device_passkey.xml
new file mode 100644
index 0000000000..a4784792a3
--- /dev/null
+++ b/libraries/compound/src/main/res/drawable/ic_compound_device_passkey.xml
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/libraries/compound/src/main/res/drawable/ic_compound_folder.xml b/libraries/compound/src/main/res/drawable/ic_compound_folder.xml
new file mode 100644
index 0000000000..9250d4780b
--- /dev/null
+++ b/libraries/compound/src/main/res/drawable/ic_compound_folder.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/MatrixBadgeAtom.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/MatrixBadgeAtom.kt
index 112de47bb2..f354faa69c 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/MatrixBadgeAtom.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/MatrixBadgeAtom.kt
@@ -8,14 +8,19 @@
package io.element.android.libraries.designsystem.atomic.atoms
+import androidx.compose.foundation.BorderStroke
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.components.Badge
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+/**
+ * https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=1960-491
+ */
object MatrixBadgeAtom {
data class MatrixBadgeData(
val text: String,
@@ -40,6 +45,12 @@ object MatrixBadgeAtom {
Type.Negative -> ElementTheme.colors.bgCriticalSubtle
Type.Info -> ElementTheme.colors.bgBadgeInfo
}
+ val borderStroke = when (data.type) {
+ Type.Positive -> null
+ Type.Neutral -> BorderStroke(1.dp, ElementTheme.colors.borderInteractiveSecondary)
+ Type.Negative -> null
+ Type.Info -> null
+ }
val textColor = when (data.type) {
Type.Positive -> ElementTheme.colors.textBadgeAccent
Type.Neutral -> ElementTheme.colors.textPrimary
@@ -58,6 +69,7 @@ object MatrixBadgeAtom {
backgroundColor = backgroundColor,
iconColor = iconColor,
textColor = textColor,
+ borderStroke = borderStroke,
)
}
}
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ExpandableBottomSheetLayout.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ExpandableBottomSheetLayout.kt
index 85307823f6..f70ed3b344 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ExpandableBottomSheetLayout.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ExpandableBottomSheetLayout.kt
@@ -16,7 +16,10 @@ import android.widget.EditText
import androidx.appcompat.app.ActionBar.LayoutParams
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.background
-import androidx.compose.foundation.gestures.detectVerticalDragGestures
+import androidx.compose.foundation.gestures.awaitEachGesture
+import androidx.compose.foundation.gestures.awaitFirstDown
+import androidx.compose.foundation.gestures.awaitVerticalPointerSlopOrCancellation
+import androidx.compose.foundation.gestures.verticalDrag
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
@@ -41,10 +44,14 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.input.pointer.PointerInputChange
+import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
@@ -94,7 +101,7 @@ fun ExpandableBottomSheetLayout(
.run {
if (isSwipeGestureEnabled) {
pointerInput(maxBottomSheetContentHeight) {
- detectVerticalDragGestures(
+ customDetectVerticalDragGestures(
onVerticalDrag = { _, dragAmount ->
val calculatedHeight = max(minBottomContentHeightPx, currentBottomContentHeightPx - dragAmount.roundToInt())
val newHeight = min(calculatedMaxBottomContentHeightPx, calculatedHeight)
@@ -120,7 +127,11 @@ fun ExpandableBottomSheetLayout(
animatable.animateTo(destination)
}
- }
+ },
+ canScroll = {
+ // We only consider we can scroll in the contents if the min size matches the max size so it's maximized
+ minBottomContentHeightPx == calculatedMaxBottomContentHeightPx
+ },
)
}
} else {
@@ -189,6 +200,45 @@ fun ExpandableBottomSheetLayout(
)
}
+// The original detectVerticalDragGestures doesn't allow us to conditionally consume the initial slop event that triggers the drag,
+// which is necessary in our case to allow inner scrollables to work when the sheet is not fully expanded, so we need to re-implement it here
+private suspend fun PointerInputScope.customDetectVerticalDragGestures(
+ onDragStart: (Offset) -> Unit = {},
+ onDragEnd: () -> Unit = {},
+ onDragCancel: () -> Unit = {},
+ canScroll: () -> Boolean = { false },
+ onVerticalDrag: (change: PointerInputChange, dragAmount: Float) -> Unit,
+) {
+ awaitEachGesture {
+ val down = awaitFirstDown(requireUnconsumed = false)
+ var overSlop = 0f
+ val drag =
+ awaitVerticalPointerSlopOrCancellation(down.id, down.type) { change, over ->
+ // Consuming this event is what triggers the dragging instead of the inner content scrolling
+ // We should only consume it if we can't scroll in the inner content so we drag the bottom sheet instead, otherwise we let it pass through
+ // This is the only change compared to the original detectVerticalDragGestures implementation
+ if (!canScroll()) {
+ change.consume()
+ }
+ overSlop = over
+ }
+ if (drag != null) {
+ onDragStart.invoke(drag.position)
+ onVerticalDrag.invoke(drag, overSlop)
+ if (
+ verticalDrag(drag.id) {
+ onVerticalDrag(it, it.positionChange().y)
+ it.consume()
+ }
+ ) {
+ onDragEnd()
+ } else {
+ onDragCancel()
+ }
+ }
+ }
+}
+
@Preview(showBackground = true)
@Composable
@Suppress("UnusedPrivateMember")
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LocationPin.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LocationPin.kt
index af8e29d518..9e783d605c 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LocationPin.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LocationPin.kt
@@ -32,9 +32,13 @@ import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalInspectionMode
+import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
+import androidx.core.content.res.ResourcesCompat
import androidx.core.graphics.createBitmap
+import androidx.core.graphics.drawable.toBitmap
import androidx.core.graphics.withSave
import coil3.Image
import coil3.ImageLoader
@@ -50,6 +54,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.utils.CommonDrawables
private val PIN_WIDTH = 42.dp
private val PIN_HEIGHT = PIN_WIDTH * 1.2f
@@ -99,21 +104,33 @@ fun LocationPin(
fun rememberLocationPinBitmap(variant: PinVariant): ImageBitmap? {
val context = LocalContext.current
val density = LocalDensity.current
- val imageLoader = SingletonImageLoader.get(context)
val colors = pinColors(variant)
val cacheKey = rememberCacheKey(variant)
- return produceState(initialValue = null, cacheKey) {
- val memoryCacheKey = MemoryCache.Key(cacheKey)
- val cached = imageLoader.memoryCache?.get(memoryCacheKey)
- if (cached != null) {
- value = cached.image.toBitmap().asImageBitmap()
- } else {
- val dimensions = PinDimensions(density)
- val bitmap = LocationPinRenderer.renderPin(variant, colors, dimensions, context, imageLoader)
- imageLoader.memoryCache?.set(memoryCacheKey, MemoryCache.Value(bitmap.asImage()))
- value = bitmap.asImageBitmap()
- }
- }.value
+ val resources = LocalResources.current
+
+ return if (LocalInspectionMode.current) {
+ // In preview mode, skip async loading and return a simple placeholder image instead to avoid using ImageLoader
+ val dimensions = PinDimensions(density)
+ val avatarImage = ResourcesCompat.getDrawable(resources, CommonDrawables.sample_avatar, context.theme)?.toBitmap()?.asImage()
+ LocationPinRenderer.renderPin(variant, colors, dimensions, avatarImage).asImageBitmap()
+ } else {
+ produceState(initialValue = null, cacheKey) {
+ val imageLoader = SingletonImageLoader.get(context)
+ val memoryCacheKey = MemoryCache.Key(cacheKey)
+ val cached = imageLoader.memoryCache?.get(memoryCacheKey)
+ if (cached != null) {
+ value = cached.image.toBitmap().asImageBitmap()
+ } else {
+ val dimensions = PinDimensions(density)
+ val bitmap = with(LocationPinRenderer) {
+ val avatarImage = loadAvatarImage(variant, context, imageLoader)
+ renderPin(variant, colors, dimensions, avatarImage)
+ }
+ imageLoader.memoryCache?.set(memoryCacheKey, MemoryCache.Value(bitmap.asImage()))
+ value = bitmap.asImageBitmap()
+ }
+ }.value
+ }
}
@Composable
@@ -208,19 +225,17 @@ private object LocationPinRenderer {
/**
* Renders a pin variant to bitmap. Suspending for async avatar loading.
*/
- suspend fun renderPin(
+ fun renderPin(
variant: PinVariant,
colors: PinColors,
dimensions: PinDimensions,
- context: Context,
- imageLoader: ImageLoader,
+ avatarImage: Image?,
): Bitmap {
val bitmap = createBitmap(dimensions.pinWidth.toInt(), dimensions.pinHeight.toInt())
val canvas = Canvas(bitmap)
canvas.drawPinShape(colors.fill, colors.stroke, dimensions)
when (variant) {
is PinVariant.UserLocation -> {
- val avatarImage = loadAvatarImage(variant.avatarData, context, imageLoader)
canvas.drawAvatar(
avatarImage = avatarImage,
avatarData = variant.avatarData,
@@ -284,11 +299,15 @@ private object LocationPinRenderer {
return path
}
- private suspend fun loadAvatarImage(
- avatarData: AvatarData,
+ suspend fun loadAvatarImage(
+ variant: PinVariant,
context: Context,
imageLoader: ImageLoader,
): Image? {
+ val avatarData = when (variant) {
+ is PinVariant.UserLocation -> variant.avatarData
+ else -> return null
+ }
val request = ImageRequest.Builder(context)
.data(avatarData)
// Disable hardware rendering for Canvas
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt
index cd29773a5b..53d5a7c281 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt
@@ -46,7 +46,8 @@ enum class AvatarSize(val dp: Dp) {
RoomInviteItem(52.dp),
InviteSender(16.dp),
- EditRoomDetails(68.dp),
+ EditRoomDetails(64.dp),
+ EditSpaceDetails(96.dp),
RoomListManageUser(96.dp),
NotificationsOptIn(32.dp),
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorAliases.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorAliases.kt
index 06827fb218..8973e312ce 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorAliases.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/ColorAliases.kt
@@ -75,6 +75,9 @@ val SemanticColors.pinnedMessageBannerIndicator
val SemanticColors.pinnedMessageBannerBorder
get() = if (isLight) LightColorTokens.colorAlphaGray400 else DarkColorTokens.colorAlphaGray400
+val SemanticColors.floatingDateBadgeBackground
+ get() = if (isLight) bgCanvasDefault else bgSubtlePrimary
+
@PreviewsDayNight
@Composable
internal fun ColorAliasesPreview() = ElementPreview {
diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/StateContentFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/StateContentFormatter.kt
index f9d38fd8a7..5abfa89ef0 100644
--- a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/StateContentFormatter.kt
+++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/StateContentFormatter.kt
@@ -118,15 +118,6 @@ class StateContentFormatter(
"PolicyRuleUser"
}
}
- OtherState.RoomAliases -> when (renderingMode) {
- RenderingMode.RoomList -> {
- Timber.v("Filtering timeline item for room state change: $content")
- null
- }
- RenderingMode.Timeline -> {
- "RoomAliases"
- }
- }
OtherState.RoomCanonicalAlias -> when (renderingMode) {
RenderingMode.RoomList -> {
Timber.v("Filtering timeline item for room state change: $content")
diff --git a/libraries/eventformatter/impl/src/main/res/values-lt/translations.xml b/libraries/eventformatter/impl/src/main/res/values-lt/translations.xml
index 9fb1486d59..9a632b8f5a 100644
--- a/libraries/eventformatter/impl/src/main/res/values-lt/translations.xml
+++ b/libraries/eventformatter/impl/src/main/res/values-lt/translations.xml
@@ -1,13 +1,13 @@
"(taip pat buvo pakeistas ir avataras)"
- "%1$s pakeitė savo avatarą"
- "Jūs pakeitėte savo avatarą"
+ "%1$s pakeitė savo pseudoportretą"
+ "Jūs pakeitėte savo pseudoportretą"
"%1$s pakeitė savo slapyvardį iš %2$s į %3$s"
"Jūs pakeitėte savo slapyvardį iš %1$s į %2$s"
"%1$s pašalino savo slapyvardį (jis buvo %2$s)"
"Jūs pašalinote savo slapyvardį (jis buvo %1$s)"
- "%1$s pakeitė savo slapyvardį į %2$s"
+ "%1$s nustatė savo rodomą vardą į %2$s"
"Jūs nustatėte savo slapyvardį į %1$s"
"%1$s pakeitė kambario avatarą"
"Jūs pakeitėte kambario avatarą"
@@ -21,7 +21,7 @@
"%1$s priėmė kvietimą"
"Priėmėte kvietimą"
"Jūs pakvietėte %1$s"
- "%1$s pakvietė Jus"
+ "%1$s pakvietė jus"
"%1$s prisijungė prie kambario"
"Jūs prisijungėte prie kambario"
"%1$s prašo prisijungti"
diff --git a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt
index e91bed409e..38334afad5 100644
--- a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt
+++ b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt
@@ -601,7 +601,6 @@ class DefaultPinnedMessagesBannerFormatterTest {
OtherState.PolicyRuleRoom,
OtherState.PolicyRuleServer,
OtherState.PolicyRuleUser,
- OtherState.RoomAliases,
OtherState.RoomCanonicalAlias,
OtherState.RoomGuestAccess,
OtherState.RoomHistoryVisibility,
diff --git a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatterTest.kt b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatterTest.kt
index 2345af8a33..e1e8717c4c 100644
--- a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatterTest.kt
+++ b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatterTest.kt
@@ -746,7 +746,6 @@ class DefaultRoomLatestEventFormatterTest {
OtherState.PolicyRuleRoom,
OtherState.PolicyRuleServer,
OtherState.PolicyRuleUser,
- OtherState.RoomAliases,
OtherState.RoomCanonicalAlias,
OtherState.RoomGuestAccess,
OtherState.RoomHistoryVisibility,
diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt
index 6cd9dec60c..5b65a32f61 100644
--- a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt
+++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt
@@ -70,27 +70,6 @@ enum class FeatureFlags(
defaultValue = { false },
isFinished = false,
),
- CreateSpaces(
- key = "feature.createSpaces",
- title = "Create spaces",
- description = "Allow creating spaces.",
- defaultValue = { true },
- isFinished = false,
- ),
- SpaceSettings(
- key = "feature.spaceSettings",
- title = "Space settings",
- description = "Allow managing space settings such as details, permissions and privacy.",
- defaultValue = { true },
- isFinished = false,
- ),
- RoomListSpaceFilters(
- key = "feature.roomListSpaceFilters",
- title = "Room list space filters",
- description = "Allow filtering the room list by space.",
- defaultValue = { true },
- isFinished = false,
- ),
PrintLogsToLogcat(
key = "feature.print_logs_to_logcat",
title = "Print logs to logcat",
@@ -156,10 +135,24 @@ enum class FeatureFlags(
),
ValidateNetworkWhenSchedulingNotificationFetching(
key = "feature.validate_network_when_scheduling_notification_fetching",
- title = "validate internet connectivity when scheduling notification fetching",
+ title = "Validate internet connectivity when scheduling notification fetching",
description = "Only fetch events for push notifications when the device has internet connectivity. " +
"Enabling this can be problematic in air-gapped environments.",
defaultValue = { true },
isFinished = false,
),
+ FloatingDateBadge(
+ key = "feature.floating_date_badge",
+ title = "Display sticky date headers in the timeline",
+ description = "When scrolling, a sticky date badge will be displayed so you can easily know on which date the messages you're seeing were sent.",
+ defaultValue = { false },
+ isFinished = false,
+ ),
+ SlashCommand(
+ key = "feature.slash_command",
+ title = "Parse slash commands in the message composer",
+ description = "Allow parsing slash commands in the message composer and perform action.",
+ defaultValue = { false },
+ isFinished = false,
+ ),
}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/RoomIdOrAlias.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/RoomIdOrAlias.kt
index 4ac480e064..462ec0535c 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/RoomIdOrAlias.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/core/RoomIdOrAlias.kt
@@ -27,6 +27,16 @@ sealed interface RoomIdOrAlias : Parcelable {
is Id -> roomId.value
is Alias -> roomAlias.value
}
+
+ companion object {
+ fun from(id: String): RoomIdOrAlias? {
+ return when {
+ MatrixPatterns.isRoomId(id) -> Id(RoomId(id))
+ MatrixPatterns.isRoomAlias(id) -> Alias(RoomAlias(id))
+ else -> null
+ }
+ }
+ }
}
fun RoomId.toRoomIdOrAlias() = RoomIdOrAlias.Id(this)
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/mxc/MxcTools.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/mxc/MxcTools.kt
index 306ab8354b..09ceaa4712 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/mxc/MxcTools.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/mxc/MxcTools.kt
@@ -17,3 +17,18 @@ interface MxcTools {
*/
fun mxcUri2FilePath(mxcUri: String): String?
}
+
+/**
+ * "mxc" scheme, including "://". So "mxc://".
+ */
+const val MATRIX_CONTENT_URI_SCHEME = "mxc://"
+
+/**
+ * Return true if the String starts with "mxc://".
+ */
+fun String.isMxcUrl() = startsWith(MATRIX_CONTENT_URI_SCHEME)
+
+/**
+ * Remove the "mxc://" prefix. No op if the String is not a Mxc URL.
+ */
+fun String.removeMxcPrefix() = removePrefix(MATRIX_CONTENT_URI_SCHEME)
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt
index 5dce175237..c58a458865 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt
@@ -95,7 +95,6 @@ sealed interface NotificationContent {
data object PolicyRuleRoom : StateEvent
data object PolicyRuleServer : StateEvent
data object PolicyRuleUser : StateEvent
- data object RoomAliases : StateEvent
data object RoomAvatar : StateEvent
data object RoomCanonicalAlias : StateEvent
data object RoomCreate : StateEvent
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/StateEventType.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/StateEventType.kt
index 41d64afff1..705dd25122 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/StateEventType.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/StateEventType.kt
@@ -13,7 +13,6 @@ sealed interface StateEventType {
data object PolicyRuleServer : StateEventType
data object PolicyRuleUser : StateEventType
data object CallMember : StateEventType
- data object RoomAliases : StateEventType
data object RoomAvatar : StateEventType
data object RoomCanonicalAlias : StateEventType
data object RoomCreate : StateEventType
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/MsgType.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/MsgType.kt
new file mode 100644
index 0000000000..b8d3933663
--- /dev/null
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/MsgType.kt
@@ -0,0 +1,19 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.matrix.api.timeline
+
+enum class MsgType {
+ MSG_TYPE_TEXT,
+ MSG_TYPE_EMOTE,
+
+ // For future support
+ MSG_TYPE_SNOW,
+
+ // For future support
+ MSG_TYPE_CONFETTI,
+}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt
index 500d9f3191..fe73230dce 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt
@@ -69,6 +69,8 @@ interface Timeline : AutoCloseable {
body: String,
htmlBody: String?,
intentionalMentions: List,
+ msgType: MsgType = MsgType.MSG_TYPE_TEXT,
+ asPlainText: Boolean = false,
): Result
suspend fun editMessage(
@@ -90,6 +92,7 @@ interface Timeline : AutoCloseable {
htmlBody: String?,
intentionalMentions: List,
fromNotification: Boolean = false,
+ msgType: MsgType = MsgType.MSG_TYPE_TEXT,
): Result
suspend fun sendImage(
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/OtherState.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/OtherState.kt
index ed3f53169f..8b4a7eaa13 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/OtherState.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/OtherState.kt
@@ -16,7 +16,6 @@ sealed interface OtherState {
data object PolicyRuleRoom : OtherState
data object PolicyRuleServer : OtherState
data object PolicyRuleUser : OtherState
- data object RoomAliases : OtherState
data class RoomAvatar(val url: String?) : OtherState
data object RoomCanonicalAlias : OtherState
data object RoomCreate : OtherState
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventToNotificationContentMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventToNotificationContentMapper.kt
index 96c4bdf3c4..7e65a1cc5c 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventToNotificationContentMapper.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventToNotificationContentMapper.kt
@@ -49,7 +49,6 @@ private fun StateEventContent.toContent(): NotificationContent.StateEvent {
StateEventContent.PolicyRuleRoom -> NotificationContent.StateEvent.PolicyRuleRoom
StateEventContent.PolicyRuleServer -> NotificationContent.StateEvent.PolicyRuleServer
StateEventContent.PolicyRuleUser -> NotificationContent.StateEvent.PolicyRuleUser
- StateEventContent.RoomAliases -> NotificationContent.StateEvent.RoomAliases
StateEventContent.RoomAvatar -> NotificationContent.StateEvent.RoomAvatar
StateEventContent.RoomCanonicalAlias -> NotificationContent.StateEvent.RoomCanonicalAlias
StateEventContent.RoomCreate -> NotificationContent.StateEvent.RoomCreate
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/StateEventType.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/StateEventType.kt
index 76fea0beef..897d9a34cb 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/StateEventType.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/StateEventType.kt
@@ -16,7 +16,6 @@ fun StateEventType.map(): RustStateEventType = when (this) {
StateEventType.PolicyRuleServer -> RustStateEventType.PolicyRuleServer
StateEventType.PolicyRuleUser -> RustStateEventType.PolicyRuleUser
StateEventType.CallMember -> RustStateEventType.CallMember
- StateEventType.RoomAliases -> RustStateEventType.RoomAliases
StateEventType.RoomAvatar -> RustStateEventType.RoomAvatar
StateEventType.RoomCanonicalAlias -> RustStateEventType.RoomCanonicalAlias
StateEventType.RoomCreate -> RustStateEventType.RoomCreate
@@ -46,7 +45,6 @@ fun RustStateEventType.map(): StateEventType = when (this) {
RustStateEventType.PolicyRuleServer -> StateEventType.PolicyRuleServer
RustStateEventType.PolicyRuleUser -> StateEventType.PolicyRuleUser
RustStateEventType.CallMember -> StateEventType.CallMember
- RustStateEventType.RoomAliases -> StateEventType.RoomAliases
RustStateEventType.RoomAvatar -> StateEventType.RoomAvatar
RustStateEventType.RoomCanonicalAlias -> StateEventType.RoomCanonicalAlias
RustStateEventType.RoomCreate -> StateEventType.RoomCreate
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt
index 3996155871..8a184311de 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt
@@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
+import io.element.android.libraries.matrix.api.timeline.MsgType
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.TimelineException
@@ -271,8 +272,16 @@ class RustTimeline(
body: String,
htmlBody: String?,
intentionalMentions: List,
+ msgType: MsgType,
+ asPlainText: Boolean,
): Result = withContext(dispatcher) {
- MessageEventContent.from(body, htmlBody, intentionalMentions).use { content ->
+ MessageEventContent.from(
+ body = body,
+ htmlBody = htmlBody,
+ intentionalMentions = intentionalMentions,
+ msgType = msgType,
+ asPlainText = asPlainText,
+ ).use { content ->
runCatchingExceptions {
inner.send(content)
}
@@ -337,9 +346,15 @@ class RustTimeline(
htmlBody: String?,
intentionalMentions: List,
fromNotification: Boolean,
+ msgType: MsgType,
): Result = withContext(dispatcher) {
runCatchingExceptions {
- val msg = MessageEventContent.from(body, htmlBody, intentionalMentions)
+ val msg = MessageEventContent.from(
+ body = body,
+ htmlBody = htmlBody,
+ intentionalMentions = intentionalMentions,
+ msgType = msgType,
+ )
inner.sendReply(
msg = msg,
eventId = repliedToEventId.value,
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt
index 11d107cd8b..0d940a0a11 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt
@@ -233,7 +233,6 @@ private fun RustOtherState.map(): OtherState {
RustOtherState.PolicyRuleRoom -> OtherState.PolicyRuleRoom
RustOtherState.PolicyRuleServer -> OtherState.PolicyRuleServer
RustOtherState.PolicyRuleUser -> OtherState.PolicyRuleUser
- RustOtherState.RoomAliases -> OtherState.RoomAliases
is RustOtherState.RoomAvatar -> OtherState.RoomAvatar(url)
RustOtherState.RoomCanonicalAlias -> OtherState.RoomCanonicalAlias
RustOtherState.RoomCreate -> OtherState.RoomCreate
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/MessageEventContent.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/MessageEventContent.kt
index 3e320116c6..f1c0019f17 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/MessageEventContent.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/MessageEventContent.kt
@@ -9,20 +9,54 @@
package io.element.android.libraries.matrix.impl.util
import io.element.android.libraries.matrix.api.room.IntentionalMention
+import io.element.android.libraries.matrix.api.timeline.MsgType
import io.element.android.libraries.matrix.impl.room.map
+import org.matrix.rustcomponents.sdk.MessageContent
+import org.matrix.rustcomponents.sdk.MessageType
import org.matrix.rustcomponents.sdk.RoomMessageEventContentWithoutRelation
+import org.matrix.rustcomponents.sdk.TextMessageContent
+import org.matrix.rustcomponents.sdk.contentWithoutRelationFromMessage
import org.matrix.rustcomponents.sdk.messageEventContentFromHtml
+import org.matrix.rustcomponents.sdk.messageEventContentFromHtmlAsEmote
import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown
+import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdownAsEmote
/**
* Creates a [RoomMessageEventContentWithoutRelation] from a body, an html body and a list of mentions.
*/
object MessageEventContent {
- fun from(body: String, htmlBody: String?, intentionalMentions: List): RoomMessageEventContentWithoutRelation {
- return if (htmlBody != null) {
- messageEventContentFromHtml(body, htmlBody)
- } else {
- messageEventContentFromMarkdown(body)
- }.withMentions(intentionalMentions.map())
+ fun from(
+ body: String,
+ htmlBody: String?,
+ intentionalMentions: List,
+ msgType: MsgType = MsgType.MSG_TYPE_TEXT,
+ asPlainText: Boolean = false,
+ ): RoomMessageEventContentWithoutRelation {
+ return when {
+ asPlainText -> contentWithoutRelationFromMessage(
+ MessageContent(
+ msgType = MessageType.Text(
+ TextMessageContent(
+ body = body,
+ formatted = null,
+ )
+ ),
+ body = body,
+ isEdited = false,
+ mentions = null,
+ )
+ )
+ htmlBody != null -> if (msgType == MsgType.MSG_TYPE_EMOTE) {
+ messageEventContentFromHtmlAsEmote(body, htmlBody)
+ } else {
+ messageEventContentFromHtml(body, htmlBody)
+ }
+ else -> if (msgType == MsgType.MSG_TYPE_EMOTE) {
+ messageEventContentFromMarkdownAsEmote(body)
+ } else {
+ messageEventContentFromMarkdown(body)
+ }
+ }
+ .withMentions(intentionalMentions.map())
}
}
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/EventTimelineItem.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/EventTimelineItem.kt
index 41823a0fbb..8689de3c9b 100644
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/EventTimelineItem.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/EventTimelineItem.kt
@@ -14,10 +14,9 @@ import io.element.android.libraries.matrix.test.A_USER_ID
import org.matrix.rustcomponents.sdk.EventOrTransactionId
import org.matrix.rustcomponents.sdk.EventSendState
import org.matrix.rustcomponents.sdk.EventTimelineItem
-import org.matrix.rustcomponents.sdk.EventTimelineItemDebugInfo
+import org.matrix.rustcomponents.sdk.LazyTimelineItemProvider
import org.matrix.rustcomponents.sdk.ProfileDetails
import org.matrix.rustcomponents.sdk.Receipt
-import org.matrix.rustcomponents.sdk.ShieldState
import org.matrix.rustcomponents.sdk.TimelineItemContent
import uniffi.matrix_sdk_ui.EventItemOrigin
@@ -26,37 +25,35 @@ internal fun aRustEventTimelineItem(
eventOrTransactionId: EventOrTransactionId = EventOrTransactionId.EventId(AN_EVENT_ID.value),
sender: String = A_USER_ID.value,
senderProfile: ProfileDetails = ProfileDetails.Unavailable,
+ forwarder: String? = null,
+ forwarderProfile: ProfileDetails? = null,
isOwn: Boolean = true,
isEditable: Boolean = true,
content: TimelineItemContent = aRustTimelineItemContentMsgLike(),
+ eventTypeRaw: String? = null,
timestamp: ULong = 0uL,
- debugInfo: EventTimelineItemDebugInfo = anEventTimelineItemDebugInfo(),
localSendState: EventSendState? = null,
+ localCreatedAt: ULong? = null,
readReceipts: Map = emptyMap(),
origin: EventItemOrigin? = EventItemOrigin.SYNC,
canBeRepliedTo: Boolean = true,
- shieldsState: ShieldState = ShieldState.None,
- localCreatedAt: ULong? = null,
- forwarder: String? = null,
- forwarderProfile: ProfileDetails? = null,
+ lazyProvider: LazyTimelineItemProvider = FakeFfiLazyTimelineItemProvider(),
) = EventTimelineItem(
isRemote = isRemote,
eventOrTransactionId = eventOrTransactionId,
sender = sender,
senderProfile = senderProfile,
- timestamp = timestamp,
- isOwn = isOwn,
- isEditable = isEditable,
- canBeRepliedTo = canBeRepliedTo,
- content = content,
- localSendState = localSendState,
- readReceipts = readReceipts,
- origin = origin,
- localCreatedAt = localCreatedAt,
- lazyProvider = FakeFfiLazyTimelineItemProvider(
- debugInfo = debugInfo,
- shieldsState = shieldsState,
- ),
forwarder = forwarder,
forwarderProfile = forwarderProfile,
+ isOwn = isOwn,
+ isEditable = isEditable,
+ content = content,
+ eventTypeRaw = eventTypeRaw,
+ timestamp = timestamp,
+ localSendState = localSendState,
+ localCreatedAt = localCreatedAt,
+ readReceipts = readReceipts,
+ origin = origin,
+ canBeRepliedTo = canBeRepliedTo,
+ lazyProvider = lazyProvider,
)
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/StateEventTypeTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/StateEventTypeTest.kt
index 428bb7db7a..93a8f0908f 100644
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/StateEventTypeTest.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/StateEventTypeTest.kt
@@ -20,7 +20,6 @@ class StateEventTypeTest {
assertThat(RustStateEventType.PolicyRuleRoom.map()).isEqualTo(StateEventType.PolicyRuleRoom)
assertThat(RustStateEventType.PolicyRuleServer.map()).isEqualTo(StateEventType.PolicyRuleServer)
assertThat(RustStateEventType.PolicyRuleUser.map()).isEqualTo(StateEventType.PolicyRuleUser)
- assertThat(RustStateEventType.RoomAliases.map()).isEqualTo(StateEventType.RoomAliases)
assertThat(RustStateEventType.RoomAvatar.map()).isEqualTo(StateEventType.RoomAvatar)
assertThat(RustStateEventType.RoomCanonicalAlias.map()).isEqualTo(StateEventType.RoomCanonicalAlias)
assertThat(RustStateEventType.RoomCreate.map()).isEqualTo(StateEventType.RoomCreate)
@@ -47,7 +46,6 @@ class StateEventTypeTest {
assertThat(StateEventType.PolicyRuleRoom.map()).isEqualTo(RustStateEventType.PolicyRuleRoom)
assertThat(StateEventType.PolicyRuleServer.map()).isEqualTo(RustStateEventType.PolicyRuleServer)
assertThat(StateEventType.PolicyRuleUser.map()).isEqualTo(RustStateEventType.PolicyRuleUser)
- assertThat(StateEventType.RoomAliases.map()).isEqualTo(RustStateEventType.RoomAliases)
assertThat(StateEventType.RoomAvatar.map()).isEqualTo(RustStateEventType.RoomAvatar)
assertThat(StateEventType.RoomCanonicalAlias.map()).isEqualTo(RustStateEventType.RoomCanonicalAlias)
assertThat(StateEventType.RoomCreate.map()).isEqualTo(RustStateEventType.RoomCreate)
diff --git a/libraries/matrix/test/build.gradle.kts b/libraries/matrix/test/build.gradle.kts
index ccb1a37a25..63836d857a 100644
--- a/libraries/matrix/test/build.gradle.kts
+++ b/libraries/matrix/test/build.gradle.kts
@@ -19,7 +19,6 @@ dependencies {
api(projects.libraries.matrix.api)
api(libs.coroutines.core)
implementation(libs.coroutines.test)
- implementation(projects.libraries.matrix.impl)
implementation(projects.services.analytics.api)
implementation(projects.tests.testutils)
implementation(libs.kotlinx.collections.immutable)
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/mxc/FakeMxcTools.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/mxc/FakeMxcTools.kt
index c348cd351c..d49a1cc22d 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/mxc/FakeMxcTools.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/mxc/FakeMxcTools.kt
@@ -8,8 +8,12 @@
package io.element.android.libraries.matrix.test.mxc
import io.element.android.libraries.matrix.api.mxc.MxcTools
-import io.element.android.libraries.matrix.impl.mxc.DefaultMxcTools
+import io.element.android.tests.testutils.lambda.lambdaError
class FakeMxcTools(
- private val delegate: MxcTools = DefaultMxcTools()
-) : MxcTools by delegate
+ private val mxcUri2FilePathResult: (String) -> String? = { lambdaError() }
+) : MxcTools {
+ override fun mxcUri2FilePath(mxcUri: String): String? {
+ return mxcUri2FilePathResult(mxcUri)
+ }
+}
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt
index 4451de6276..fcc7057dbe 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/timeline/FakeTimeline.kt
@@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.room.IntentionalMention
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
+import io.element.android.libraries.matrix.api.timeline.MsgType
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
@@ -64,7 +65,9 @@ class FakeTimeline(
body: String,
htmlBody: String?,
intentionalMentions: List,
- ) -> Result = { _, _, _ ->
+ msgType: MsgType,
+ asPlainText: Boolean,
+ ) -> Result = { _, _, _, _, _ ->
lambdaError()
}
@@ -76,8 +79,10 @@ class FakeTimeline(
body: String,
htmlBody: String?,
intentionalMentions: List,
+ msgType: MsgType,
+ asPlainText: Boolean,
): Result = simulateLongTask {
- sendMessageLambda(body, htmlBody, intentionalMentions)
+ sendMessageLambda(body, htmlBody, intentionalMentions, msgType, asPlainText)
}
var redactEventLambda: (eventOrTransactionId: EventOrTransactionId, reason: String?) -> Result = { _, _ ->
@@ -134,7 +139,8 @@ class FakeTimeline(
htmlBody: String?,
intentionalMentions: List,
fromNotification: Boolean,
- ) -> Result = { _, _, _, _, _ ->
+ msgType: MsgType,
+ ) -> Result = { _, _, _, _, _, _ ->
lambdaError()
}
@@ -144,12 +150,14 @@ class FakeTimeline(
htmlBody: String?,
intentionalMentions: List,
fromNotification: Boolean,
+ msgType: MsgType,
): Result = replyMessageLambda(
repliedToEventId,
body,
htmlBody,
intentionalMentions,
fromNotification,
+ msgType,
)
var sendImageLambda: (
diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AvatarPickerView.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AvatarPickerView.kt
index 68a346ab84..e743a2447c 100644
--- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AvatarPickerView.kt
+++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AvatarPickerView.kt
@@ -61,8 +61,16 @@ import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings
+private val editIconContainerSize = 30.dp
+private val editIconContainerRadius = editIconContainerSize / 2
+private val editIconContainerPadding = 4.dp
+private val editIconSize = 20.dp
+private val editIconOffset = 8.dp
+
/**
- * Avatar picker view, based on https://www.figma.com/design/kcnHxunG1LDWXsJhaNuiHz/ER-145--Spaces-on-Element-X?node-id=5918-97417&t=JYDQysgjS33AZb74-4
+ * Avatar picker view.
+ *
+ * https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=1949-1384
*
* It takes a [state], which can be [AvatarPickerState.Pick] for displaying the 'pick avatar' button, or [AvatarPickerState.Selected] when an avatar has
* already been selected.
@@ -96,7 +104,6 @@ fun AvatarPickerView(
fun eraseBackgroundModifier(
parentWidth: Dp,
- editIconRadius: Dp,
) = Modifier
.graphicsLayer {
compositingStrategy = CompositingStrategy.Offscreen
@@ -107,13 +114,13 @@ fun AvatarPickerView(
color = Color.Black,
center = Offset(
x = if (layoutDirection == LayoutDirection.Ltr) {
- parentWidth.toPx() - editIconRadius.toPx() * 0.48f
+ (parentWidth - editIconContainerRadius + editIconOffset).toPx()
} else {
- editIconRadius.toPx() * 0.48f
+ (editIconContainerRadius - editIconOffset).toPx()
},
- y = size.height - editIconRadius.toPx(),
+ y = size.height - editIconContainerRadius.toPx(),
),
- radius = editIconRadius.toPx() * 1.2f,
+ radius = (editIconContainerRadius + editIconContainerPadding).toPx(),
blendMode = BlendMode.Clear,
)
}
@@ -132,7 +139,7 @@ fun AvatarPickerView(
is AvatarPickerState.Selected -> {
Box(modifier = modifier) {
val backgroundModifier = if (enabled) {
- eraseBackgroundModifier(state.avatarData.size.dp, state.avatarData.size.dp * 0.225f)
+ eraseBackgroundModifier(state.avatarData.size.dp)
} else {
Modifier
}
@@ -143,7 +150,6 @@ fun AvatarPickerView(
)
if (enabled) {
OverlayEditButton(
- editButtonSize = state.avatarData.size.dp * 0.44f,
onClick = onClick,
interactionSource = interactionSource
)
@@ -179,15 +185,14 @@ private fun PickButton(
@Composable
private fun BoxScope.OverlayEditButton(
- editButtonSize: Dp,
onClick: () -> Unit,
interactionSource: MutableInteractionSource
) {
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
- .size(editButtonSize)
- .offset(x = editButtonSize * 0.266f)
+ .size(editIconContainerSize)
+ .offset(x = editIconOffset)
.clip(CircleShape)
.clickable(interactionSource = interactionSource, onClick = onClick, indication = null)
.background(ElementTheme.colors.bgCanvasDefault)
@@ -195,7 +200,7 @@ private fun BoxScope.OverlayEditButton(
contentAlignment = Alignment.Center,
) {
Icon(
- modifier = Modifier.size(editButtonSize * 0.66f),
+ modifier = Modifier.size(editIconSize),
imageVector = CompoundIcons.Edit(),
contentDescription = null,
)
@@ -234,97 +239,45 @@ internal fun AvatarPickerViewRtlPreview() = CompositionLocalProvider(
@PreviewsDayNight
@Composable
internal fun AvatarPickerSizesPreview() = ElementPreview {
- Column {
- Row {
- AvatarPickerView(AvatarPickerState.Pick(buttonSize = 24.dp, externalPadding = PaddingValues(6.dp)), onClick = {})
- AvatarPickerView(AvatarPickerState.Pick(buttonSize = 32.dp, externalPadding = PaddingValues(6.dp)), onClick = {})
- AvatarPickerView(AvatarPickerState.Pick(buttonSize = 48.dp, externalPadding = PaddingValues(6.dp)), onClick = {})
- AvatarPickerView(AvatarPickerState.Pick(buttonSize = 64.dp, externalPadding = PaddingValues(6.dp)), onClick = {})
- AvatarPickerView(AvatarPickerState.Pick(buttonSize = 96.dp, externalPadding = PaddingValues(6.dp)), onClick = {})
+ // Size used across the codebase
+ val sizes = listOf(
+ AvatarSize.EditRoomDetails,
+ AvatarSize.EditProfileDetails,
+ )
+ Column(
+ modifier = Modifier.padding(12.dp)
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ sizes.forEach {
+ AvatarPickerView(
+ state = AvatarPickerState.Pick(buttonSize = it.dp, externalPadding = PaddingValues(6.dp)),
+ onClick = {},
+ )
+ }
}
- Row {
- AvatarPickerView(
- AvatarPickerState.Selected(
- avatarData = AvatarData("@user:example.com", "User", "content://test", size = AvatarSize.TimelineThreadLatestEventSender),
- type = AvatarType.User
- ),
- onClick = {},
- modifier = Modifier.padding(6.dp)
- )
- AvatarPickerView(
- AvatarPickerState.Selected(
- avatarData = AvatarData("@user:example.com", "User", "content://test", size = AvatarSize.ReadReceiptList),
- type = AvatarType.User
- ),
- onClick = {},
- modifier = Modifier.padding(6.dp)
- )
- AvatarPickerView(
- AvatarPickerState.Selected(
- avatarData = AvatarData("@user:example.com", "User", "content://test", size = AvatarSize.SelectedUser),
- type = AvatarType.User
- ),
- onClick = {},
- modifier = Modifier.padding(6.dp)
- )
- AvatarPickerView(
- AvatarPickerState.Selected(
- avatarData = AvatarData("@user:example.com", "User", "content://test", size = AvatarSize.EditRoomDetails),
- type = AvatarType.User
- ),
- onClick = {},
- modifier = Modifier.padding(6.dp)
- )
- AvatarPickerView(
- AvatarPickerState.Selected(
- avatarData = AvatarData("@user:example.com", "User", "content://test", size = AvatarSize.RoomListManageUser),
- type = AvatarType.User
- ),
- onClick = {},
- modifier = Modifier.padding(6.dp)
- )
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ sizes.forEach {
+ AvatarPickerView(
+ AvatarPickerState.Selected(
+ avatarData = AvatarData("@user:example.com", "User", "content://test", size = it),
+ type = AvatarType.User,
+ ),
+ onClick = {},
+ modifier = Modifier.padding(6.dp)
+ )
+ }
}
- Row {
- AvatarPickerView(
- AvatarPickerState.Selected(
- avatarData = AvatarData("@user:example.com", "User", "content://test", size = AvatarSize.TimelineThreadLatestEventSender),
- type = AvatarType.Space()
- ),
- onClick = {},
- modifier = Modifier.padding(6.dp)
- )
- AvatarPickerView(
- AvatarPickerState.Selected(
- avatarData = AvatarData("@user:example.com", "User", "content://test", size = AvatarSize.ReadReceiptList),
- type = AvatarType.Space()
- ),
- onClick = {},
- modifier = Modifier.padding(6.dp)
- )
- AvatarPickerView(
- AvatarPickerState.Selected(
- avatarData = AvatarData("@user:example.com", "User", "content://test", size = AvatarSize.SelectedUser),
- type = AvatarType.Space()
- ),
- onClick = {},
- modifier = Modifier.padding(6.dp)
- )
- AvatarPickerView(
- AvatarPickerState.Selected(
- avatarData = AvatarData("@user:example.com", "User", "content://test", size = AvatarSize.EditRoomDetails),
- type = AvatarType.Space()
- ),
- onClick = {},
- modifier = Modifier.padding(6.dp)
- )
- AvatarPickerView(
- AvatarPickerState.Selected(
- avatarData = AvatarData("@user:example.com", "User", "content://test", size = AvatarSize.RoomListManageUser),
- type = AvatarType.Space()
- ),
- onClick = {},
- modifier = Modifier.padding(6.dp)
- )
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ sizes.forEach {
+ AvatarPickerView(
+ AvatarPickerState.Selected(
+ avatarData = AvatarData("@user:example.com", "User", "content://test", size = it),
+ type = AvatarType.Space(),
+ ),
+ onClick = {},
+ modifier = Modifier.padding(6.dp)
+ )
+ }
}
}
}
@@ -335,8 +288,9 @@ private fun PreviewContent() {
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
+ val size = AvatarSize.EditRoomDetails
Text("Pick image")
- AvatarPickerView(AvatarPickerState.Pick(buttonSize = 48.dp, externalPadding = PaddingValues(6.dp)), onClick = {})
+ AvatarPickerView(AvatarPickerState.Pick(buttonSize = size.dp, externalPadding = PaddingValues(6.dp)), onClick = {})
HorizontalDivider()
Text("User avatar")
@@ -345,7 +299,7 @@ private fun PreviewContent() {
Text("No url")
AvatarPickerView(
AvatarPickerState.Selected(
- avatarData = AvatarData("@user:example.com", "User", null, size = AvatarSize.EditRoomDetails),
+ avatarData = AvatarData("@user:example.com", "User", null, size = size),
type = AvatarType.User
),
onClick = {},
@@ -356,7 +310,7 @@ private fun PreviewContent() {
Text("Local")
AvatarPickerView(
AvatarPickerState.Selected(
- avatarData = AvatarData("@user:example.com", "User", "content://test", size = AvatarSize.EditRoomDetails),
+ avatarData = AvatarData("@user:example.com", "User", "content://test", size = size),
type = AvatarType.User
),
onClick = {},
@@ -367,7 +321,7 @@ private fun PreviewContent() {
Text("MXC")
AvatarPickerView(
AvatarPickerState.Selected(
- avatarData = AvatarData("@user:example.com", "User", "mxc://test", size = AvatarSize.EditRoomDetails),
+ avatarData = AvatarData("@user:example.com", "User", "mxc://test", size = size),
type = AvatarType.User
),
onClick = {},
@@ -383,7 +337,7 @@ private fun PreviewContent() {
Text("No url")
AvatarPickerView(
AvatarPickerState.Selected(
- avatarData = AvatarData("!room:example.com", "Room", null, size = AvatarSize.EditRoomDetails),
+ avatarData = AvatarData("!room:example.com", "Room", null, size = size),
type = AvatarType.Room()
),
onClick = {},
@@ -394,7 +348,7 @@ private fun PreviewContent() {
Text("Local")
AvatarPickerView(
AvatarPickerState.Selected(
- avatarData = AvatarData("!room:example.com", "Room", "content://test", size = AvatarSize.EditRoomDetails),
+ avatarData = AvatarData("!room:example.com", "Room", "content://test", size = size),
type = AvatarType.Room()
),
onClick = {},
@@ -405,7 +359,7 @@ private fun PreviewContent() {
Text("MXC")
AvatarPickerView(
AvatarPickerState.Selected(
- avatarData = AvatarData("!room:example.com", "Room", "mxc://test", size = AvatarSize.EditRoomDetails),
+ avatarData = AvatarData("!room:example.com", "Room", "mxc://test", size = size),
type = AvatarType.Room()
),
onClick = {},
@@ -421,7 +375,7 @@ private fun PreviewContent() {
Text("No url")
AvatarPickerView(
AvatarPickerState.Selected(
- avatarData = AvatarData("!room:example.com", "Space", null, size = AvatarSize.EditRoomDetails),
+ avatarData = AvatarData("!room:example.com", "Space", null, size = size),
type = AvatarType.Space()
),
onClick = {},
@@ -432,7 +386,7 @@ private fun PreviewContent() {
Text("Local")
AvatarPickerView(
AvatarPickerState.Selected(
- avatarData = AvatarData("!room:example.com", "Space", "content://test", size = AvatarSize.EditRoomDetails),
+ avatarData = AvatarData("!room:example.com", "Space", "content://test", size = size),
type = AvatarType.Space()
),
onClick = {},
@@ -443,7 +397,7 @@ private fun PreviewContent() {
Text("MXC")
AvatarPickerView(
AvatarPickerState.Selected(
- avatarData = AvatarData("!room:example.com", "Space", "mxc://test", size = AvatarSize.EditRoomDetails),
+ avatarData = AvatarData("!room:example.com", "Space", "mxc://test", size = size),
type = AvatarType.Space()
),
onClick = {},
diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceInfoRow.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceInfoRow.kt
index f83b46a3d7..997fbbee59 100644
--- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceInfoRow.kt
+++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SpaceInfoRow.kt
@@ -48,7 +48,7 @@ fun SpaceInfoRow(
) {
if (iconVector != null) {
Icon(
- modifier = Modifier.size(20.dp),
+ modifier = Modifier.size(16.dp),
imageVector = iconVector,
contentDescription = null,
tint = ElementTheme.colors.iconTertiary,
@@ -61,7 +61,7 @@ fun SpaceInfoRow(
}
Text(
text = text,
- style = ElementTheme.typography.fontBodyLgRegular,
+ style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
textAlign = TextAlign.Center,
)
diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToView.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToView.kt
index dcf12f1953..0dc8aac09e 100644
--- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToView.kt
+++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToView.kt
@@ -18,7 +18,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -82,19 +81,19 @@ private fun ReplyToReadyContent(
modifier: Modifier = Modifier,
) {
val paddings = if (metadata is InReplyToMetadata.Thumbnail) {
- PaddingValues(start = 4.dp, end = 12.dp, top = 4.dp, bottom = 4.dp)
+ PaddingValues(end = 8.dp)
} else {
- PaddingValues(horizontal = 12.dp, vertical = 4.dp)
+ PaddingValues(start = 8.dp, end = 8.dp)
}
Row(
modifier
- .background(MaterialTheme.colorScheme.surface)
+ .background(ElementTheme.colors.bgCanvasDefault)
.padding(paddings)
) {
if (metadata is InReplyToMetadata.Thumbnail) {
AttachmentThumbnail(
info = metadata.attachmentThumbnailInfo,
- backgroundColor = MaterialTheme.colorScheme.surfaceVariant,
+ backgroundColor = ElementTheme.colors.bgSubtlePrimary,
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(4.dp))
@@ -128,7 +127,7 @@ private fun ReplyToLoadingContent(
val paddings = PaddingValues(horizontal = 12.dp, vertical = 4.dp)
Row(
modifier
- .background(MaterialTheme.colorScheme.surface)
+ .background(ElementTheme.colors.bgCanvasDefault)
.padding(paddings)
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
@@ -146,7 +145,7 @@ private fun ReplyToErrorContent(
val paddings = PaddingValues(horizontal = 12.dp, vertical = 4.dp)
Row(
modifier
- .background(MaterialTheme.colorScheme.surface)
+ .background(ElementTheme.colors.bgCanvasDefault)
.padding(paddings)
) {
Text(
diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt
index 2b1883619e..2ab1cd0619 100644
--- a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt
+++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessor.kt
@@ -195,18 +195,16 @@ class AndroidMediaPreProcessor(
file = file,
mimeType = mimeType,
)
- val imageInfo = contentResolver.openInputStream(uri).use { input ->
- val bitmap = BitmapFactory.decodeStream(input, null, null)!!
- ImageInfo(
- width = bitmap.width.toLong(),
- height = bitmap.height.toLong(),
- mimetype = mimeType,
- size = file.length(),
- thumbnailInfo = thumbnailResult?.info,
- thumbnailSource = null,
- blurhash = thumbnailResult?.blurhash,
- )
- }
+ val (width, height) = extractOrientedImageDimensions(file)
+ val imageInfo = ImageInfo(
+ width = width,
+ height = height,
+ mimetype = mimeType,
+ size = file.length(),
+ thumbnailInfo = thumbnailResult?.info,
+ thumbnailSource = null,
+ blurhash = thumbnailResult?.blurhash,
+ )
removeSensitiveImageMetadata(file)
return MediaUploadInfo.Image(
file = file,
@@ -354,6 +352,23 @@ class AndroidMediaPreProcessor(
return contentResolver.openInputStream(uri)?.use { createTmpFileWithInput(it) }
?: error("Could not copy the contents of $uri to a temporary file")
}
+
+ private fun extractOrientedImageDimensions(file: File): Pair {
+ val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
+ BitmapFactory.decodeFile(file.path, options)
+
+ val rawWidth = options.outWidth.toLong()
+ val rawHeight = options.outHeight.toLong()
+ val orientation = tryOrNull {
+ ExifInterface(file).getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_UNDEFINED)
+ } ?: ExifInterface.ORIENTATION_UNDEFINED
+
+ return orientedImageDimensions(
+ rawWidth = rawWidth,
+ rawHeight = rawHeight,
+ orientation = orientation,
+ )
+ }
}
private fun ImageCompressionResult.toImageInfo(mimeType: String, thumbnailResult: ThumbnailResult?) = ImageInfo(
@@ -371,3 +386,18 @@ private fun MediaMetadataRetriever.extractDuration(): Duration {
val durationInMs = extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() ?: 0L
return durationInMs.milliseconds
}
+
+internal fun orientedImageDimensions(rawWidth: Long, rawHeight: Long, orientation: Int): Pair {
+ return if (orientation.rotatesRightAngle()) {
+ rawHeight to rawWidth
+ } else {
+ rawWidth to rawHeight
+ }
+}
+
+private fun Int.rotatesRightAngle(): Boolean {
+ return this == ExifInterface.ORIENTATION_ROTATE_90 ||
+ this == ExifInterface.ORIENTATION_ROTATE_270 ||
+ this == ExifInterface.ORIENTATION_TRANSPOSE ||
+ this == ExifInterface.ORIENTATION_TRANSVERSE
+}
diff --git a/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessorTest.kt b/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessorTest.kt
index f4b4e7d4a5..57726ac5a2 100644
--- a/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessorTest.kt
+++ b/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessorTest.kt
@@ -12,6 +12,7 @@ import android.content.Context
import android.net.Uri
import android.os.Build
import androidx.core.net.toUri
+import androidx.exifinterface.media.ExifInterface
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
@@ -42,6 +43,30 @@ import kotlin.time.Duration
@RunWith(RobolectricTestRunner::class)
class AndroidMediaPreProcessorTest {
+ @Test
+ fun `orientedImageDimensions swaps width and height for 90 degree exif orientation`() {
+ val (width, height) = orientedImageDimensions(
+ rawWidth = 4032,
+ rawHeight = 2268,
+ orientation = ExifInterface.ORIENTATION_ROTATE_90,
+ )
+
+ assertThat(width).isEqualTo(2268)
+ assertThat(height).isEqualTo(4032)
+ }
+
+ @Test
+ fun `orientedImageDimensions keeps width and height for upright exif orientation`() {
+ val (width, height) = orientedImageDimensions(
+ rawWidth = 4032,
+ rawHeight = 2268,
+ orientation = ExifInterface.ORIENTATION_NORMAL,
+ )
+
+ assertThat(width).isEqualTo(4032)
+ assertThat(height).isEqualTo(2268)
+ }
+
private suspend fun TestScope.process(
asset: Asset,
mediaOptimizationConfig: MediaOptimizationConfig,
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaAudioView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaAudioView.kt
index 01115b7c91..cb9d6ae9c8 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaAudioView.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaAudioView.kt
@@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@@ -51,6 +52,7 @@ import androidx.media3.common.Timeline
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
+import com.bumble.appyx.core.node.LocalNodeTargetVisibility
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.audio.api.AudioFocus
@@ -130,6 +132,8 @@ private fun ExoPlayerMediaAudioView(
mutableStateOf(null)
}
+ val isTargetVisible = LocalNodeTargetVisibility.current
+
val playableState: PlayableState.Playable by remember {
derivedStateOf {
PlayableState.Playable(
@@ -196,13 +200,21 @@ private fun ExoPlayerMediaAudioView(
exoPlayer.pause()
}
}
+ LaunchedEffect(isTargetVisible) {
+ if (!isTargetVisible) {
+ exoPlayer.pause()
+ }
+ }
if (localMedia?.uri != null) {
LaunchedEffect(localMedia.uri) {
val mediaItem = MediaItem.fromUri(localMedia.uri)
exoPlayer.setMediaItem(mediaItem)
+ exoPlayer.prepare()
}
} else {
- exoPlayer.setMediaItems(emptyList())
+ LaunchedEffect(Unit) {
+ exoPlayer.setMediaItems(emptyList())
+ }
}
val context = LocalContext.current
val waveform = info?.waveform
@@ -247,7 +259,7 @@ private fun ExoPlayerMediaAudioView(
}
},
update = { playerView ->
- playerView.isVisible = metadata.hasArtwork()
+ playerView.isVisible = metadata.hasArtwork() && isTargetVisible
},
onRelease = { playerView ->
playerView.player = null
@@ -317,16 +329,19 @@ private fun ExoPlayerMediaAudioView(
)
}
- OnLifecycleEvent { _, event ->
- when (event) {
- Lifecycle.Event.ON_CREATE -> exoPlayer.addListener(playerListener)
- Lifecycle.Event.ON_RESUME -> exoPlayer.prepare()
- Lifecycle.Event.ON_PAUSE -> exoPlayer.pause()
- Lifecycle.Event.ON_DESTROY -> {
- exoPlayer.release()
+ DisposableEffect(exoPlayer) {
+ exoPlayer.addListener(playerListener)
+ onDispose {
+ if (!exoPlayer.isReleased) {
exoPlayer.removeListener(playerListener)
+ exoPlayer.release()
}
- else -> Unit
+ }
+ }
+
+ OnLifecycleEvent { _, event ->
+ if (event == Lifecycle.Event.ON_PAUSE) {
+ exoPlayer.pause()
}
}
}
diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/push/PushHandlingWakeLock.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/push/PushHandlingWakeLock.kt
index 2b19ed9225..5c76eb1864 100644
--- a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/push/PushHandlingWakeLock.kt
+++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/push/PushHandlingWakeLock.kt
@@ -22,5 +22,5 @@ interface PushHandlingWakeLock {
/**
* Release the wakelock. If no wakelock is associated with the key, this method does nothing.
*/
- fun unlock()
+ suspend fun unlock()
}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt
index cf76b26e64..64583bd1d4 100644
--- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt
@@ -302,7 +302,6 @@ class DefaultNotifiableEventResolver(
NotificationContent.StateEvent.PolicyRuleRoom,
NotificationContent.StateEvent.PolicyRuleServer,
NotificationContent.StateEvent.PolicyRuleUser,
- NotificationContent.StateEvent.RoomAliases,
NotificationContent.StateEvent.RoomAvatar,
NotificationContent.StateEvent.RoomCanonicalAlias,
NotificationContent.StateEvent.RoomCreate,
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlingWakeLock.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlingWakeLock.kt
index 1388aac19b..27a921c219 100644
--- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlingWakeLock.kt
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlingWakeLock.kt
@@ -14,7 +14,6 @@ import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.push.api.push.PushHandlingWakeLock
import timber.log.Timber
-import java.util.concurrent.atomic.AtomicInteger
import kotlin.time.Duration
@ContributesBinding(AppScope::class)
@@ -22,24 +21,13 @@ import kotlin.time.Duration
class DefaultPushHandlingWakeLock(
@ApplicationContext private val context: Context,
) : PushHandlingWakeLock {
- private val count = AtomicInteger(0)
-
override fun lock(time: Duration) {
Timber.d("Acquiring wakelock for push handling, starting service.")
FetchPushForegroundService.startIfNeeded(context)
-
- count.incrementAndGet()
}
- override fun unlock() {
+ override suspend fun unlock() {
Timber.d("Releasing wakelock used for push handling.")
FetchPushForegroundService.stop(context)
- if (count.decrementAndGet() <= 0) {
- Timber.d("No more wakelock needed for push handling, stopping service.")
- count.set(0)
- } else {
- Timber.d("Wakelock still needed for push handling, restarting service | count: ${count.get()}.")
- FetchPushForegroundService.startIfNeeded(context)
- }
}
}
diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/FetchPushForegroundService.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/FetchPushForegroundService.kt
index f0f4ebb2de..d54b7f5497 100644
--- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/FetchPushForegroundService.kt
+++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/FetchPushForegroundService.kt
@@ -7,6 +7,7 @@
package io.element.android.libraries.push.impl.push
+import android.app.ActivityManager
import android.app.Service
import android.content.Context
import android.content.Intent
@@ -16,6 +17,7 @@ import android.os.PowerManager
import androidx.core.app.NotificationCompat
import dev.zacsweers.metro.Inject
import io.element.android.libraries.architecture.bindings
+import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.push.api.push.PushHandlingWakeLock
@@ -25,7 +27,12 @@ import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withTimeoutOrNull
+import timber.log.Timber
import kotlin.time.Duration.Companion.minutes
+import kotlin.time.Duration.Companion.seconds
private const val NOTIFICATION_ID = 1001
@@ -51,11 +58,15 @@ class FetchPushForegroundService : Service() {
}
}
- override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ private var isOnForeground = false
+
+ override fun onCreate() {
+ Timber.d("Creating FetchPushForegroundService")
+
bindings().inject(this)
- wakelock.acquire(wakelockTimeout)
-
+ Timber.d("Starting FetchPushForegroundService with wakelock timeout of $wakelockTimeout ms")
+ // Start the foreground service as soon as possible
val notificationCompat = NotificationCompat.Builder(this, notificationChannels.getSilentChannelId())
.setSmallIcon(CommonDrawables.ic_notification)
.setContentTitle(getString(CommonStrings.common_android_fetching_notifications_title))
@@ -63,13 +74,39 @@ class FetchPushForegroundService : Service() {
.setVibrate(longArrayOf(0))
.setSound(null)
.build()
- startForeground(NOTIFICATION_ID, notificationCompat)
+
+ // Try to start the service in foreground. This can fail, even in cases where it's supposed to work according to the docs.
+ // In those cases we catch the exception and handle the failure so we don't try to start the wakelock or stop the service
+ // from running in foreground later.
+ runCatchingExceptions {
+ startForeground(NOTIFICATION_ID, notificationCompat)
+ }
+ .onSuccess {
+ isOnForeground = true
+ Timber.d("FetchPushForegroundService started in foreground successfully")
+ }
+ .onFailure {
+ isOnForeground = false
+ Timber.e(it, "Failed to start FetchPushForegroundService in foreground")
+ }
+
+ super.onCreate()
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ if (!isOnForeground) {
+ Timber.w("FetchPushForegroundService is not running in foreground, stopping it to avoid crash")
+ stopSelf()
+ return START_NOT_STICKY
+ }
+
+ wakelock.acquire(wakelockTimeout)
// The timeout is not automatic before Android 15, so we need to schedule it ourselves
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) {
coroutineScope.launch {
delay(wakelockTimeout)
- onTimeout(startId)
+ onTimeoutAction(calledByTheSystem = false)
}
}
@@ -77,19 +114,30 @@ class FetchPushForegroundService : Service() {
}
override fun stopService(intent: Intent?): Boolean {
- wakelock.release()
+ if (isOnForeground) {
+ wakelock.release()
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ }
- stopForeground(STOP_FOREGROUND_REMOVE)
return super.stopService(intent)
}
override fun onTimeout(startId: Int) {
super.onTimeout(startId)
+ onTimeoutAction(calledByTheSystem = true)
+ }
- pushHandlingWakeLock.unlock()
+ private fun onTimeoutAction(calledByTheSystem: Boolean) {
+ Timber.d("onTimeoutAction, calledByTheSystem: $calledByTheSystem, isOnForeground: $isOnForeground")
+ if (isOnForeground) {
+ Timber.d("Wakelock timeout reached, stopping FetchPushForegroundService")
+ coroutineScope.launch { pushHandlingWakeLock.unlock() }
+ }
}
companion object {
+ private val stopMutex = Mutex()
+
fun startIfNeeded(context: Context) {
// Don't start the foreground service if the device is already awake
val powerManager = context.getSystemService(POWER_SERVICE) as PowerManager
@@ -101,15 +149,46 @@ class FetchPushForegroundService : Service() {
fun start(context: Context) {
val intent = Intent(context, FetchPushForegroundService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- context.startForegroundService(intent)
+ runCatchingExceptions { context.startForegroundService(intent) }
+ .onFailure { throwable ->
+ Timber.e(
+ throwable,
+ "Failed to start FetchPushForegroundService, notifications may take longer than usual to sync"
+ )
+ }
} else {
context.startService(intent)
}
}
- fun stop(context: Context) {
- val intent = Intent(context, FetchPushForegroundService::class.java)
- context.stopService(intent)
+ suspend fun stop(context: Context) = stopMutex.withLock {
+ val runningServiceInfo = getRunningServiceInfo(context)
+ if (runningServiceInfo != null) {
+ val intent = Intent(context, FetchPushForegroundService::class.java)
+ // If it's still not running in foreground, it means the service is still starting,
+ // so we delay the stop to give it time to start and be set as foreground, otherwise we can crash
+ // with `ForegroundServiceDidNotStartInTimeException`.
+ var isInForeground = runningServiceInfo.foreground
+ withTimeoutOrNull(5.seconds) {
+ while (!isInForeground) {
+ delay(50)
+ val updatedServiceInfo = getRunningServiceInfo(context)
+ if (updatedServiceInfo == null) {
+ Timber.d("FetchPushForegroundService is no longer running, no need to stop it.")
+ return@withTimeoutOrNull
+ }
+ isInForeground = updatedServiceInfo.foreground == true
+ }
+ } ?: Timber.w("FetchPushForegroundService did not start in foreground after 5s, stopping it anyway.")
+ context.stopService(intent)
+ }
+ }
+
+ @Suppress("DEPRECATION")
+ private fun getRunningServiceInfo(context: Context): ActivityManager.RunningServiceInfo? {
+ val activityManager = context.getSystemService(ACTIVITY_SERVICE) as ActivityManager
+ return activityManager.getRunningServices(Int.MAX_VALUE)
+ .firstOrNull { it.service.className == FetchPushForegroundService::class.java.name }
}
}
}
diff --git a/libraries/push/impl/src/main/res/values-ko/translations.xml b/libraries/push/impl/src/main/res/values-ko/translations.xml
index 3aa015392f..f4399cda2b 100644
--- a/libraries/push/impl/src/main/res/values-ko/translations.xml
+++ b/libraries/push/impl/src/main/res/values-ko/translations.xml
@@ -16,6 +16,7 @@
- "%d개의 새 메시지가 있습니다."
+ "📞 수신 전화"
"📹 수신 전화"
"** 전송 실패 - 방을 열여주세요"
"참가하기"
diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt
index 63b903a3f7..33634e1767 100644
--- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt
+++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt
@@ -56,7 +56,6 @@ import io.element.android.libraries.push.impl.notifications.model.FallbackNotifi
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent
-import io.element.android.libraries.push.test.notifications.FakeCallNotificationEventResolver
import io.element.android.services.toolbox.impl.strings.AndroidStringProvider
import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
@@ -835,7 +834,6 @@ class DefaultNotifiableEventResolverTest {
testNoResults(NotificationContent.StateEvent.PolicyRuleRoom)
testNoResults(NotificationContent.StateEvent.PolicyRuleServer)
testNoResults(NotificationContent.StateEvent.PolicyRuleUser)
- testNoResults(NotificationContent.StateEvent.RoomAliases)
testNoResults(NotificationContent.StateEvent.RoomAvatar)
testNoResults(NotificationContent.StateEvent.RoomCanonicalAlias)
testNoResults(NotificationContent.StateEvent.RoomCreate)
diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultOnMissedCallNotificationHandlerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultOnMissedCallNotificationHandlerTest.kt
index 9bba1c32d3..e9f6b76b7a 100644
--- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultOnMissedCallNotificationHandlerTest.kt
+++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultOnMissedCallNotificationHandlerTest.kt
@@ -18,7 +18,6 @@ import io.element.android.libraries.matrix.test.notification.FakeNotificationSer
import io.element.android.libraries.matrix.test.notification.aNotificationData
import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationDataFactory
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
-import io.element.android.libraries.push.test.notifications.FakeCallNotificationEventResolver
import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runCurrent
diff --git a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeCallNotificationEventResolver.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/FakeCallNotificationEventResolver.kt
similarity index 87%
rename from libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeCallNotificationEventResolver.kt
rename to libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/FakeCallNotificationEventResolver.kt
index f923d0c9fe..a1049683f5 100644
--- a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeCallNotificationEventResolver.kt
+++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/FakeCallNotificationEventResolver.kt
@@ -6,11 +6,10 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.libraries.push.test.notifications
+package io.element.android.libraries.push.impl.notifications
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.notification.NotificationData
-import io.element.android.libraries.push.impl.notifications.CallNotificationEventResolver
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import io.element.android.tests.testutils.lambda.lambdaError
diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandlerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandlerTest.kt
index a52eb16b07..2cf666d92a 100644
--- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandlerTest.kt
+++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiverHandlerTest.kt
@@ -16,6 +16,7 @@ import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.room.IntentionalMention
import io.element.android.libraries.matrix.api.room.RoomInfo
+import io.element.android.libraries.matrix.api.timeline.MsgType
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_MESSAGE
@@ -341,9 +342,9 @@ class NotificationBroadcastReceiverHandlerTest {
@Test
fun `Test send reply`() = runTest {
- val sendMessage = lambdaRecorder, Result> { _, _, _ -> Result.success(Unit) }
+ val sendMessage = lambdaRecorder, MsgType, Boolean, Result> { _, _, _, _, _ -> Result.success(Unit) }
val replyMessage =
- lambdaRecorder, Boolean, Result> { _, _, _, _, _ -> Result.success(Unit) }
+ lambdaRecorder, Boolean, MsgType, Result> { _, _, _, _, _, _ -> Result.success(Unit) }
val liveTimeline = FakeTimeline().apply {
sendMessageLambda = sendMessage
replyMessageLambda = replyMessage
@@ -375,7 +376,13 @@ class NotificationBroadcastReceiverHandlerTest {
advanceUntilIdle()
sendMessage.assertions()
.isCalledOnce()
- .with(value(A_MESSAGE), value(null), value(emptyList()))
+ .with(
+ value(A_MESSAGE),
+ value(null),
+ value(emptyList()),
+ value(MsgType.MSG_TYPE_TEXT),
+ value(false),
+ )
onNotifiableEventsReceivedResult.assertions()
.isCalledOnce()
replyMessage.assertions()
@@ -384,7 +391,7 @@ class NotificationBroadcastReceiverHandlerTest {
@Test
fun `Test send reply blank message`() = runTest {
- val sendMessage = lambdaRecorder, Result> { _, _, _ -> Result.success(Unit) }
+ val sendMessage = lambdaRecorder, MsgType, Boolean, Result> { _, _, _, _, _ -> Result.success(Unit) }
val liveTimeline = FakeTimeline().apply {
sendMessageLambda = sendMessage
}
@@ -408,9 +415,9 @@ class NotificationBroadcastReceiverHandlerTest {
@Test
fun `Test send reply to thread`() = runTest {
- val sendMessage = lambdaRecorder, Result> { _, _, _ -> Result.success(Unit) }
+ val sendMessage = lambdaRecorder, MsgType, Boolean, Result> { _, _, _, _, _ -> Result.success(Unit) }
val replyMessage =
- lambdaRecorder, Boolean, Result> { _, _, _, _, _ -> Result.success(Unit) }
+ lambdaRecorder, Boolean, MsgType, Result> { _, _, _, _, _, _ -> Result.success(Unit) }
val liveTimeline = FakeTimeline().apply {
sendMessageLambda = sendMessage
replyMessageLambda = replyMessage
@@ -453,7 +460,8 @@ class NotificationBroadcastReceiverHandlerTest {
value(A_MESSAGE),
value(null),
value(emptyList()),
- value(true)
+ value(true),
+ value(MsgType.MSG_TYPE_TEXT),
)
}
diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt
index cc6e4674f9..a16568d400 100644
--- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt
+++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt
@@ -26,8 +26,8 @@ import io.element.android.libraries.push.impl.history.PushHistoryService
import io.element.android.libraries.push.impl.notifications.FakeNotificationResultProcessor
import io.element.android.libraries.push.impl.test.DefaultTestPush
import io.element.android.libraries.push.impl.troubleshoot.DiagnosticPushHandler
+import io.element.android.libraries.push.impl.workmanager.FakeSyncPendingNotificationsRequestBuilder
import io.element.android.libraries.push.impl.workmanager.SyncPendingNotificationsRequestBuilder
-import io.element.android.libraries.push.test.workmanager.FakeSyncPendingNotificationsRequestBuilder
import io.element.android.libraries.pushproviders.api.PushData
import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret
import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStore
diff --git a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/workmanager/FakeSyncPendingNotificationsRequestBuilder.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/FakeSyncPendingNotificationsRequestBuilder.kt
similarity index 78%
rename from libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/workmanager/FakeSyncPendingNotificationsRequestBuilder.kt
rename to libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/FakeSyncPendingNotificationsRequestBuilder.kt
index ef0e38991e..f2da936b87 100644
--- a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/workmanager/FakeSyncPendingNotificationsRequestBuilder.kt
+++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/FakeSyncPendingNotificationsRequestBuilder.kt
@@ -5,9 +5,8 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.libraries.push.test.workmanager
+package io.element.android.libraries.push.impl.workmanager
-import io.element.android.libraries.push.impl.workmanager.SyncPendingNotificationsRequestBuilder
import io.element.android.libraries.workmanager.api.WorkManagerRequestWrapper
class FakeSyncPendingNotificationsRequestBuilder(
diff --git a/libraries/push/test/build.gradle.kts b/libraries/push/test/build.gradle.kts
index 475d4a4ae5..9dedeb3996 100644
--- a/libraries/push/test/build.gradle.kts
+++ b/libraries/push/test/build.gradle.kts
@@ -18,7 +18,6 @@ dependencies {
api(projects.libraries.push.api)
api(projects.libraries.pushproviders.api)
implementation(projects.libraries.designsystem)
- implementation(projects.libraries.push.impl)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.workmanager.api)
diff --git a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/push/FakePushHandlingWakeLock.kt b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/push/FakePushHandlingWakeLock.kt
index 925581db9b..077c8f661e 100644
--- a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/push/FakePushHandlingWakeLock.kt
+++ b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/push/FakePushHandlingWakeLock.kt
@@ -18,7 +18,7 @@ class FakePushHandlingWakeLock(
lock.invoke(time)
}
- override fun unlock() {
+ override suspend fun unlock() {
unlock.invoke()
}
}
diff --git a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/VectorFirebaseMessagingService.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/VectorFirebaseMessagingService.kt
index 67da09f1ab..3961f1f591 100644
--- a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/VectorFirebaseMessagingService.kt
+++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/VectorFirebaseMessagingService.kt
@@ -10,6 +10,7 @@ package io.element.android.libraries.pushproviders.firebase
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
+import com.google.firebase.messaging.RemoteMessage.PRIORITY_HIGH
import dev.zacsweers.metro.Inject
import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.core.log.logger.LoggerTag
@@ -45,8 +46,11 @@ class VectorFirebaseMessagingService : FirebaseMessagingService() {
override fun onMessageReceived(message: RemoteMessage) {
Timber.tag(loggerTag.value).w("New Firebase message. Priority: ${message.priority}/${message.originalPriority}")
- // Acquire wakelock to ensure the device stays awake while we handle the push and schedule and run the work
- pushHandlingWakeLock.lock()
+ val isHighPriority = message.priority == PRIORITY_HIGH
+ if (isHighPriority) {
+ // Acquire wakelock to ensure the device stays awake while we handle the push and schedule and run the work
+ pushHandlingWakeLock.lock()
+ }
coroutineScope.launch {
val pushData = pushParser.parse(message.data)
@@ -58,7 +62,9 @@ class VectorFirebaseMessagingService : FirebaseMessagingService() {
"$it: ${message.data[it]}"
},
)
- pushHandlingWakeLock.unlock()
+ if (isHighPriority) {
+ pushHandlingWakeLock.unlock()
+ }
} else {
val handled = pushHandler.handle(
pushData = pushData,
@@ -66,7 +72,7 @@ class VectorFirebaseMessagingService : FirebaseMessagingService() {
)
// If we failed to handle the push, we should release the wakelock early to avoid keeping the device awake for too long.
- if (!handled) {
+ if (!handled && isHighPriority) {
pushHandlingWakeLock.unlock()
}
}
diff --git a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/VectorFirebaseMessagingServiceTest.kt b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/VectorFirebaseMessagingServiceTest.kt
index 87ca74730c..798328e626 100644
--- a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/VectorFirebaseMessagingServiceTest.kt
+++ b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/VectorFirebaseMessagingServiceTest.kt
@@ -96,6 +96,7 @@ class VectorFirebaseMessagingServiceTest {
putString("event_id", AN_EVENT_ID.value)
putString("room_id", A_ROOM_ID.value)
putString("cs", A_SECRET)
+ putString("google.priority", "high")
},
)
)
@@ -127,6 +128,7 @@ class VectorFirebaseMessagingServiceTest {
putString("event_id", AN_EVENT_ID.value)
putString("room_id", A_ROOM_ID.value)
putString("cs", A_SECRET)
+ putString("google.priority", "high")
},
)
)
@@ -141,6 +143,33 @@ class VectorFirebaseMessagingServiceTest {
unlockLambda.assertions().isCalledOnce()
}
+ @Test
+ fun `test pushHandler with a remote message with normal priority won't lock the wakelock`() = runTest {
+ val lockLambda = lambdaRecorder { _ -> }
+ val unlockLambda = lambdaRecorder { }
+ val vectorFirebaseMessagingService = createVectorFirebaseMessagingService(
+ pushHandler = FakePushHandler(handleResult = { _, _ -> false }),
+ pushHandlingWakeLock = FakePushHandlingWakeLock(
+ lock = lockLambda,
+ unlock = unlockLambda
+ )
+ )
+ vectorFirebaseMessagingService.onMessageReceived(
+ message = RemoteMessage(
+ Bundle().apply {
+ putString("event_id", AN_EVENT_ID.value)
+ putString("room_id", A_ROOM_ID.value)
+ putString("cs", A_SECRET)
+ putString("google.priority", "normal")
+ },
+ )
+ )
+
+ // The wakelock should not be locked
+ lockLambda.assertions().isNeverCalled()
+ unlockLambda.assertions().isNeverCalled()
+ }
+
@Test
fun `test new token is forwarded to the handler`() = runTest {
val lambda = lambdaRecorder { }
diff --git a/libraries/slashcommands/api/build.gradle.kts b/libraries/slashcommands/api/build.gradle.kts
new file mode 100644
index 0000000000..8cec0e65af
--- /dev/null
+++ b/libraries/slashcommands/api/build.gradle.kts
@@ -0,0 +1,17 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+plugins {
+ id("io.element.android-library")
+}
+
+android {
+ namespace = "io.element.android.libraries.slashcommands.api"
+}
+
+dependencies {
+ implementation(projects.libraries.matrix.api)
+}
diff --git a/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/ChatEffect.kt b/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/ChatEffect.kt
new file mode 100644
index 0000000000..7b31ffb3b7
--- /dev/null
+++ b/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/ChatEffect.kt
@@ -0,0 +1,13 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.slashcommands.api
+
+enum class ChatEffect {
+ CONFETTI,
+ SNOWFALL
+}
diff --git a/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/MessagePrefix.kt b/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/MessagePrefix.kt
new file mode 100644
index 0000000000..713458c720
--- /dev/null
+++ b/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/MessagePrefix.kt
@@ -0,0 +1,15 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.slashcommands.api
+
+enum class MessagePrefix {
+ Shrug,
+ TableFlip,
+ Unflip,
+ Lenny,
+}
diff --git a/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/SlashCommand.kt b/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/SlashCommand.kt
new file mode 100644
index 0000000000..770543e548
--- /dev/null
+++ b/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/SlashCommand.kt
@@ -0,0 +1,71 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.slashcommands.api
+
+import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
+import io.element.android.libraries.matrix.api.core.UserId
+
+/**
+ * Represent a slash command.
+ */
+sealed interface SlashCommand {
+ // This is not a Slash command
+ data object NotACommand : SlashCommand
+
+ // Slash command types:
+ sealed interface Error : SlashCommand
+ sealed interface SlashCommandSendMessage : SlashCommand
+ sealed interface SlashCommandAdmin : SlashCommand
+ sealed interface SlashCommandNavigation : SlashCommand
+
+ // Errors
+ data class ErrorEmptySlashCommand(val message: String) : Error
+ data class ErrorCommandNotSupportedInThreads(val message: String) : Error
+
+ // Unknown/Unsupported slash command
+ data class ErrorUnknownSlashCommand(val message: String) : Error
+
+ // A slash command is detected, but there is an error
+ data class ErrorSyntax(val message: String) : Error
+
+ // Valid commands:
+ data class SendPlainText(val message: CharSequence) : SlashCommandSendMessage
+ data class SendEmote(val message: CharSequence) : SlashCommandSendMessage
+ data class SendRainbow(val message: CharSequence) : SlashCommandSendMessage
+ data class SendRainbowEmote(val message: CharSequence) : SlashCommandSendMessage
+ data class BanUser(val userId: UserId, val reason: String?) : SlashCommandAdmin
+ data class UnbanUser(val userId: UserId, val reason: String?) : SlashCommandAdmin
+ data class IgnoreUser(val userId: UserId) : SlashCommandAdmin
+ data class UnignoreUser(val userId: UserId) : SlashCommandAdmin
+ data class SetUserPowerLevel(val userId: UserId, val powerLevel: Int?) : SlashCommandAdmin
+ data class ChangeRoomName(val name: String) : SlashCommandAdmin
+ data class Invite(val userId: UserId, val reason: String?) : SlashCommandAdmin
+ data class JoinRoom(val roomIdOrAlias: RoomIdOrAlias, val reason: String?) : SlashCommandAdmin
+ data class ChangeTopic(val topic: String) : SlashCommandAdmin
+ data class RemoveUser(val userId: UserId, val reason: String?) : SlashCommandAdmin
+ data class ChangeDisplayName(val displayName: String) : SlashCommandAdmin
+ data class ChangeDisplayNameForRoom(val displayName: String) : SlashCommandAdmin
+ data class ChangeRoomAvatar(val url: String) : SlashCommandAdmin
+ data class ChangeAvatarForRoom(val url: String) : SlashCommandAdmin
+ data class SendSpoiler(val message: String) : SlashCommandSendMessage
+ data class SendWithPrefix(val prefix: MessagePrefix, val message: CharSequence) : SlashCommandSendMessage
+ data object DiscardSession : SlashCommandAdmin
+ data class SendChatEffect(val chatEffect: ChatEffect, val message: String) : SlashCommandSendMessage
+ data object LeaveRoom : SlashCommandAdmin
+ data class UpgradeRoom(val newVersion: String) : SlashCommandAdmin
+
+ data object DevTools : SlashCommandNavigation
+ data class ShowUser(val userId: UserId) : SlashCommandNavigation
+}
+
+fun SlashCommand.Error.message() = when (this) {
+ is SlashCommand.ErrorEmptySlashCommand -> message
+ is SlashCommand.ErrorCommandNotSupportedInThreads -> message
+ is SlashCommand.ErrorUnknownSlashCommand -> message
+ is SlashCommand.ErrorSyntax -> message
+}
diff --git a/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/SlashCommandService.kt b/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/SlashCommandService.kt
new file mode 100644
index 0000000000..9dfca26078
--- /dev/null
+++ b/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/SlashCommandService.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.slashcommands.api
+
+import io.element.android.libraries.matrix.api.timeline.Timeline
+
+interface SlashCommandService {
+ suspend fun getSuggestions(
+ text: String,
+ isInThread: Boolean,
+ ): List
+
+ /**
+ * Parse the message and return a SlashCommand.
+ */
+ suspend fun parse(
+ textMessage: CharSequence,
+ formattedMessage: String?,
+ isInThreadTimeline: Boolean,
+ ): SlashCommand
+
+ /**
+ * Proceed a SlashCommandSendMessage.
+ */
+ suspend fun proceedSendMessage(
+ slashCommand: SlashCommand.SlashCommandSendMessage,
+ timeline: Timeline,
+ ): Result
+
+ /**
+ * Proceed a SlashCommandAdmin.
+ */
+ suspend fun proceedAdmin(
+ slashCommand: SlashCommand.SlashCommandAdmin,
+ ): Result
+}
diff --git a/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/SlashCommandSuggestion.kt b/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/SlashCommandSuggestion.kt
new file mode 100644
index 0000000000..5a826d5fbd
--- /dev/null
+++ b/libraries/slashcommands/api/src/main/kotlin/io/element/android/libraries/slashcommands/api/SlashCommandSuggestion.kt
@@ -0,0 +1,14 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.slashcommands.api
+
+data class SlashCommandSuggestion(
+ val command: String,
+ val parameters: String?,
+ val description: String,
+)
diff --git a/libraries/slashcommands/impl/build.gradle.kts b/libraries/slashcommands/impl/build.gradle.kts
new file mode 100644
index 0000000000..34dc2e42b2
--- /dev/null
+++ b/libraries/slashcommands/impl/build.gradle.kts
@@ -0,0 +1,35 @@
+import extension.setupDependencyInjection
+import extension.testCommonDependencies
+
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+plugins {
+ id("io.element.android-library")
+}
+
+android {
+ namespace = "io.element.android.libraries.slashcommands.impl"
+}
+
+setupDependencyInjection()
+
+dependencies {
+ implementation(projects.libraries.androidutils)
+ implementation(projects.libraries.preferences.api)
+ implementation(projects.libraries.core)
+ implementation(projects.libraries.matrix.api)
+ api(projects.libraries.slashcommands.api)
+ implementation(projects.libraries.di)
+ implementation(projects.libraries.featureflag.api)
+ implementation(projects.services.toolbox.api)
+
+ testCommonDependencies(libs)
+ testImplementation(projects.libraries.featureflag.test)
+ testImplementation(projects.libraries.preferences.test)
+ testImplementation(projects.libraries.matrix.test)
+ testImplementation(projects.services.toolbox.test)
+}
diff --git a/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/Command.kt b/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/Command.kt
new file mode 100644
index 0000000000..0b7b58a15f
--- /dev/null
+++ b/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/Command.kt
@@ -0,0 +1,233 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.slashcommands.impl
+
+import androidx.annotation.StringRes
+
+/**
+ * Defines the command line operations.
+ * The user can write these messages to perform some actions.
+ * The list will be displayed in this order.
+ */
+enum class Command(
+ val command: String,
+ val aliases: List? = null,
+ val parameters: String? = null,
+ @StringRes val description: Int,
+ val isAllowedInThread: Boolean = true,
+ val isSupported: Boolean = true,
+ val isDevCommand: Boolean = false,
+) {
+ CRASH_APP(
+ command = "/crash",
+ description = R.string.slash_command_description_crash_application,
+ isDevCommand = true,
+ ),
+ EMOTE(
+ command = "/me",
+ parameters = "",
+ description = R.string.slash_command_description_emote,
+ ),
+ BAN_USER(
+ command = "/ban",
+ parameters = " [reason]",
+ description = R.string.slash_command_description_ban_user,
+ ),
+ UNBAN_USER(
+ command = "/unban",
+ parameters = " [reason]",
+ description = R.string.slash_command_description_unban_user,
+ ),
+ IGNORE_USER(
+ command = "/ignore",
+ parameters = " [reason]",
+ description = R.string.slash_command_description_ignore_user,
+ ),
+ UNIGNORE_USER(
+ command = "/unignore",
+ parameters = "",
+ description = R.string.slash_command_description_unignore_user,
+ ),
+ SET_USER_POWER_LEVEL(
+ command = "/op",
+ parameters = " []",
+ description = R.string.slash_command_description_op_user,
+ isAllowedInThread = false,
+ isSupported = false,
+ ),
+ RESET_USER_POWER_LEVEL(
+ command = "/deop",
+ parameters = "",
+ description = R.string.slash_command_description_deop_user,
+ isAllowedInThread = false,
+ isSupported = false,
+ ),
+ ROOM_NAME(
+ command = "/roomname",
+ parameters = "",
+ description = R.string.slash_command_description_room_name,
+ isAllowedInThread = false,
+ ),
+ INVITE(
+ command = "/invite",
+ parameters = " [reason]",
+ description = R.string.slash_command_description_invite_user,
+ ),
+ JOIN_ROOM(
+ command = "/join",
+ aliases = listOf("/j", "/goto"),
+ parameters = " [reason]",
+ description = R.string.slash_command_description_join_room,
+ isAllowedInThread = false,
+ isSupported = false,
+ ),
+ TOPIC(
+ command = "/topic",
+ parameters = "",
+ description = R.string.slash_command_description_topic,
+ isAllowedInThread = false,
+ ),
+ REMOVE_USER(
+ command = "/remove",
+ aliases = listOf("/kick"),
+ parameters = " [reason]",
+ description = R.string.slash_command_description_remove_user,
+ ),
+ CHANGE_DISPLAY_NAME(
+ command = "/nick",
+ parameters = "",
+ description = R.string.slash_command_description_nick,
+ ),
+ CHANGE_DISPLAY_NAME_FOR_ROOM(
+ command = "/myroomnick",
+ aliases = listOf("/roomnick"),
+ parameters = "",
+ description = R.string.slash_command_description_nick_for_room,
+ isAllowedInThread = false,
+ isSupported = false,
+ ),
+ ROOM_AVATAR(
+ command = "/roomavatar",
+ parameters = "",
+ description = R.string.slash_command_description_room_avatar,
+ isAllowedInThread = false,
+ // Dev command since user has to know the mxc url
+ isDevCommand = true,
+ isSupported = false,
+ ),
+ CHANGE_AVATAR_FOR_ROOM(
+ command = "/myroomavatar",
+ parameters = "",
+ description = R.string.slash_command_description_avatar_for_room,
+ isAllowedInThread = false,
+ // Dev command since user has to know the mxc url
+ isDevCommand = true,
+ isSupported = false,
+ ),
+ RAINBOW(
+ command = "/rainbow",
+ parameters = "",
+ description = R.string.slash_command_description_rainbow,
+ ),
+ RAINBOW_EMOTE(
+ command = "/rainbowme",
+ parameters = "",
+ description = R.string.slash_command_description_rainbow_emote,
+ ),
+ DEVTOOLS(
+ command = "/devtools",
+ description = R.string.slash_command_description_devtools,
+ isDevCommand = true,
+ ),
+ SPOILER(
+ command = "/spoiler",
+ parameters = "",
+ description = R.string.slash_command_description_spoiler,
+ ),
+ SHRUG(
+ command = "/shrug",
+ parameters = "",
+ description = R.string.slash_command_description_shrug,
+ ),
+ LENNY(
+ command = "/lenny",
+ parameters = "",
+ description = R.string.slash_command_description_lenny,
+ ),
+ PLAIN(
+ command = "/plain",
+ parameters = "",
+ description = R.string.slash_command_description_plain,
+ ),
+ WHOIS(
+ command = "/whois",
+ parameters = "",
+ description = R.string.slash_command_description_whois,
+ ),
+ DISCARD_SESSION(
+ command = "/discardsession",
+ description = R.string.slash_command_description_discard_session,
+ isAllowedInThread = false,
+ isSupported = false,
+ ),
+ CONFETTI(
+ command = "/confetti",
+ parameters = "",
+ description = R.string.slash_command_confetti,
+ isAllowedInThread = false,
+ isSupported = false,
+ ),
+ SNOWFALL(
+ command = "/snowfall",
+ parameters = "",
+ description = R.string.slash_command_snow,
+ isAllowedInThread = false,
+ isSupported = false,
+ ),
+ LEAVE_ROOM(
+ command = "/leave",
+ aliases = listOf("/part"),
+ description = R.string.slash_command_description_leave_room,
+ isAllowedInThread = false,
+ isDevCommand = true,
+ ),
+ UPGRADE_ROOM(
+ command = "/upgraderoom",
+ parameters = "newVersion",
+ description = R.string.slash_command_description_upgrade_room,
+ isAllowedInThread = false,
+ isDevCommand = true,
+ isSupported = false,
+ ),
+ TABLE_FLIP(
+ command = "/tableflip",
+ parameters = "",
+ description = R.string.slash_command_description_table_flip,
+ ),
+ UNFLIP(
+ command = "/unflip",
+ parameters = "",
+ description = R.string.slash_command_description_unflip,
+ );
+
+ val allAliases = listOf(command) + aliases.orEmpty()
+
+ /**
+ * Checks if the input command matches any of the command aliases, ignoring case.
+ * Do not exclude not supported commands so that user can discover that the command is not supported.
+ * Used for whole command parsing.
+ */
+ fun matches(inputCommand: CharSequence) = allAliases.any { it.contentEquals(inputCommand, true) }
+
+ /**
+ * Checks if the input is a prefix of any of the command aliases, ignoring the first character (the slash), and excluding not supported command.
+ * Used for suggestions.
+ */
+ fun startsWith(input: CharSequence) = isSupported &&
+ allAliases.any { it.startsWith(input, 1, true) }
+}
diff --git a/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/CommandExecutor.kt b/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/CommandExecutor.kt
new file mode 100644
index 0000000000..0acd3af6f8
--- /dev/null
+++ b/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/CommandExecutor.kt
@@ -0,0 +1,214 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.slashcommands.impl
+
+import dev.zacsweers.metro.Inject
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.room.JoinedRoom
+import io.element.android.libraries.matrix.api.timeline.MsgType
+import io.element.android.libraries.matrix.api.timeline.Timeline
+import io.element.android.libraries.slashcommands.api.MessagePrefix
+import io.element.android.libraries.slashcommands.api.SlashCommand
+import io.element.android.libraries.slashcommands.impl.rainbow.RainbowGenerator
+import io.element.android.services.toolbox.api.strings.StringProvider
+
+@Inject
+class CommandExecutor(
+ private val matrixClient: MatrixClient,
+ private val joinedRoom: JoinedRoom,
+ private val rainbowGenerator: RainbowGenerator,
+ private val stringProvider: StringProvider,
+) {
+ suspend fun proceedSendMessage(
+ slashCommand: SlashCommand.SlashCommandSendMessage,
+ timeline: Timeline,
+ ): Result {
+ return when (slashCommand) {
+ is SlashCommand.SendChatEffect -> sendChatEffect()
+ is SlashCommand.SendEmote -> sendEmote(slashCommand, timeline)
+ is SlashCommand.SendWithPrefix -> sendPrefixedMessage(slashCommand.prefix, slashCommand.message, timeline)
+ is SlashCommand.SendPlainText -> sendPlainText(slashCommand, timeline)
+ is SlashCommand.SendRainbow -> sendRainbow(slashCommand, timeline)
+ is SlashCommand.SendRainbowEmote -> sendRainbowEmote(slashCommand, timeline)
+ is SlashCommand.SendSpoiler -> sendSpoiler(slashCommand, timeline)
+ }
+ }
+
+ suspend fun proceedAdmin(
+ slashCommand: SlashCommand.SlashCommandAdmin,
+ ): Result {
+ return when (slashCommand) {
+ is SlashCommand.BanUser -> banUser(slashCommand)
+ is SlashCommand.ChangeAvatarForRoom -> changeAvatarForRoom()
+ is SlashCommand.ChangeDisplayName -> changeDisplayName(slashCommand)
+ is SlashCommand.ChangeDisplayNameForRoom -> changeDisplayNameForRoom()
+ is SlashCommand.ChangeRoomAvatar -> changeRoomAvatar()
+ is SlashCommand.ChangeRoomName -> changeRoomName(slashCommand)
+ is SlashCommand.ChangeTopic -> changeTopic(slashCommand)
+ is SlashCommand.DiscardSession -> discardSession()
+ is SlashCommand.IgnoreUser -> ignoreUser(slashCommand)
+ is SlashCommand.Invite -> invite(slashCommand)
+ is SlashCommand.JoinRoom -> joinRoom(slashCommand)
+ is SlashCommand.LeaveRoom -> leaveRoom(joinedRoom)
+ is SlashCommand.RemoveUser -> removeUser(slashCommand)
+ is SlashCommand.SetUserPowerLevel -> setUserPowerLevel()
+ is SlashCommand.UnbanUser -> unbanUser(slashCommand)
+ is SlashCommand.UnignoreUser -> unignoreUser(slashCommand)
+ is SlashCommand.UpgradeRoom -> upgradeRoom()
+ }
+ }
+
+ private fun upgradeRoom(): Result {
+ return Result.failure(Exception("Not yet implemented"))
+ }
+
+ private suspend fun unignoreUser(slashCommand: SlashCommand.UnignoreUser): Result {
+ return matrixClient.unignoreUser(slashCommand.userId)
+ }
+
+ private suspend fun unbanUser(slashCommand: SlashCommand.UnbanUser): Result {
+ return joinedRoom.unbanUser(slashCommand.userId, slashCommand.reason)
+ }
+
+ private fun setUserPowerLevel(): Result {
+ return Result.failure(Exception("Not yet implemented"))
+ }
+
+ private suspend fun sendSpoiler(slashCommand: SlashCommand.SendSpoiler, timeline: Timeline): Result {
+ val text = "[${stringProvider.getString(R.string.common_spoiler)}](${slashCommand.message})"
+ val formattedText = "${slashCommand.message}"
+ return timeline.sendMessage(
+ body = text,
+ htmlBody = formattedText,
+ intentionalMentions = emptyList(),
+ )
+ }
+
+ private suspend fun sendRainbowEmote(slashCommand: SlashCommand.SendRainbowEmote, timeline: Timeline): Result {
+ val message = slashCommand.message.toString()
+ return timeline.sendMessage(
+ body = message,
+ htmlBody = rainbowGenerator.generate(message),
+ msgType = MsgType.MSG_TYPE_EMOTE,
+ intentionalMentions = emptyList(),
+ )
+ }
+
+ private suspend fun sendRainbow(slashCommand: SlashCommand.SendRainbow, timeline: Timeline): Result {
+ val message = slashCommand.message.toString()
+ return timeline.sendMessage(
+ body = message,
+ htmlBody = rainbowGenerator.generate(message),
+ intentionalMentions = emptyList(),
+ )
+ }
+
+ private suspend fun sendPlainText(slashCommand: SlashCommand.SendPlainText, timeline: Timeline): Result {
+ return timeline.sendMessage(
+ body = slashCommand.message.toString(),
+ htmlBody = null,
+ intentionalMentions = emptyList(),
+ asPlainText = true,
+ )
+ }
+
+ private suspend fun sendEmote(slashCommand: SlashCommand.SendEmote, timeline: Timeline): Result {
+ val message = slashCommand.message.toString()
+ return timeline.sendMessage(
+ body = message,
+ htmlBody = null,
+ msgType = MsgType.MSG_TYPE_EMOTE,
+ intentionalMentions = emptyList(),
+ )
+ }
+
+ private fun sendChatEffect(): Result {
+ return Result.failure(Exception("Not yet implemented"))
+ }
+
+ private suspend fun removeUser(slashCommand: SlashCommand.RemoveUser): Result {
+ return joinedRoom.kickUser(slashCommand.userId, slashCommand.reason)
+ }
+
+ private suspend fun leaveRoom(
+ room: JoinedRoom,
+ ): Result {
+ return room.leave()
+ }
+
+ private suspend fun joinRoom(slashCommand: SlashCommand.JoinRoom): Result {
+ return matrixClient.joinRoomByIdOrAlias(slashCommand.roomIdOrAlias, emptyList())
+ .map {}
+ }
+
+ private suspend fun invite(slashCommand: SlashCommand.Invite): Result {
+ return joinedRoom.inviteUserById(slashCommand.userId)
+ }
+
+ private suspend fun ignoreUser(slashCommand: SlashCommand.IgnoreUser): Result {
+ return matrixClient.ignoreUser(slashCommand.userId)
+ }
+
+ private fun discardSession(): Result {
+ return Result.failure(Exception("Not yet implemented"))
+ }
+
+ private suspend fun changeTopic(slashCommand: SlashCommand.ChangeTopic): Result {
+ return joinedRoom.setTopic(slashCommand.topic)
+ }
+
+ private suspend fun changeRoomName(slashCommand: SlashCommand.ChangeRoomName): Result {
+ return joinedRoom.setName(slashCommand.name)
+ }
+
+ private fun changeRoomAvatar(): Result {
+ return Result.failure(Exception("Not yet implemented"))
+ }
+
+ private fun changeDisplayNameForRoom(): Result {
+ return Result.failure(Exception("Not yet implemented"))
+ }
+
+ private suspend fun changeDisplayName(slashCommand: SlashCommand.ChangeDisplayName): Result {
+ return matrixClient.setDisplayName(slashCommand.displayName)
+ }
+
+ private fun changeAvatarForRoom(): Result {
+ return Result.failure(Exception("Not yet implemented"))
+ }
+
+ private suspend fun banUser(slashCommand: SlashCommand.BanUser): Result {
+ return joinedRoom.banUser(slashCommand.userId, slashCommand.reason)
+ }
+
+ private suspend fun sendPrefixedMessage(
+ prefix: MessagePrefix,
+ message: CharSequence,
+ timeline: Timeline,
+ ): Result {
+ val sequence = buildString {
+ append(prefix.toMarkdown())
+ if (message.isNotEmpty()) {
+ append(" ")
+ append(message)
+ }
+ }
+ return timeline.sendMessage(
+ body = sequence,
+ htmlBody = null,
+ intentionalMentions = emptyList(),
+ )
+ }
+}
+
+private fun MessagePrefix.toMarkdown() = when (this) {
+ MessagePrefix.Shrug -> "¯\\\\_(ツ)\\_/¯"
+ MessagePrefix.TableFlip -> "(╯°□°)╯︵ ┻━┻"
+ MessagePrefix.Unflip -> "┬──┬ ノ( ゜-゜ノ)"
+ MessagePrefix.Lenny -> "( ͡° ͜ʖ ͡°)"
+}
diff --git a/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/CommandParser.kt b/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/CommandParser.kt
new file mode 100644
index 0000000000..85a045f50c
--- /dev/null
+++ b/libraries/slashcommands/impl/src/main/kotlin/io/element/android/libraries/slashcommands/impl/CommandParser.kt
@@ -0,0 +1,430 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.slashcommands.impl
+
+import dev.zacsweers.metro.Inject
+import io.element.android.libraries.featureflag.api.FeatureFlagService
+import io.element.android.libraries.featureflag.api.FeatureFlags
+import io.element.android.libraries.matrix.api.core.MatrixPatterns
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
+import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.mxc.isMxcUrl
+import io.element.android.libraries.preferences.api.store.AppPreferencesStore
+import io.element.android.libraries.slashcommands.api.ChatEffect
+import io.element.android.libraries.slashcommands.api.MessagePrefix
+import io.element.android.libraries.slashcommands.api.SlashCommand
+import io.element.android.services.toolbox.api.strings.StringProvider
+import kotlinx.coroutines.flow.first
+import timber.log.Timber
+
+@Inject
+class CommandParser(
+ private val appPreferencesStore: AppPreferencesStore,
+ private val featureFlagService: FeatureFlagService,
+ private val stringProvider: StringProvider,
+) {
+ /**
+ * Convert the text message into a Slash command.
+ *
+ * @param textMessage the text message in plain text
+ * @param formattedMessage the text messaged in HTML format
+ * @param isInThreadTimeline true if the user is currently typing in a thread
+ * @return a parsed slash command (ok or error)
+ */
+ suspend fun parseSlashCommand(
+ textMessage: CharSequence,
+ formattedMessage: String?,
+ isInThreadTimeline: Boolean,
+ ): SlashCommand {
+ if (!featureFlagService.isFeatureEnabled(FeatureFlags.SlashCommand)) {
+ return SlashCommand.NotACommand
+ }
+ // check if it has the Slash marker
+ val message = formattedMessage ?: textMessage
+ return if (!message.startsWith("/")) {
+ SlashCommand.NotACommand
+ } else {
+ // "/" only
+ if (message.length == 1) {
+ return SlashCommand.ErrorEmptySlashCommand(
+ stringProvider.getString(R.string.slash_command_unrecognized, "/")
+ )
+ }
+ // Exclude "//"
+ if ("/" == message.substring(1, 2)) {
+ return SlashCommand.NotACommand
+ }
+ val (messageParts, message) = extractMessage(message.toString())
+ ?: return SlashCommand.ErrorEmptySlashCommand(
+ stringProvider.getString(R.string.slash_command_unrecognized, "/")
+ )
+ val slashCommand = messageParts.first()
+ getNotSupportedByThreads(isInThreadTimeline, slashCommand)?.let {
+ return SlashCommand.ErrorCommandNotSupportedInThreads(
+ stringProvider.getString(
+ R.string.slash_command_not_supported_in_threads,
+ it.command,
+ )
+ )
+ }
+ when {
+ Command.PLAIN.matches(slashCommand) -> {
+ if (message.isNotEmpty()) {
+ SlashCommand.SendPlainText(message = message)
+ } else {
+ syntaxError(Command.PLAIN)
+ }
+ }
+ Command.CHANGE_DISPLAY_NAME.matches(slashCommand) -> {
+ if (message.isNotEmpty()) {
+ SlashCommand.ChangeDisplayName(displayName = message)
+ } else {
+ syntaxError(Command.CHANGE_DISPLAY_NAME)
+ }
+ }
+ Command.CHANGE_DISPLAY_NAME_FOR_ROOM.matches(slashCommand) -> {
+ if (message.isNotEmpty()) {
+ SlashCommand.ChangeDisplayNameForRoom(displayName = message)
+ } else {
+ syntaxError(Command.CHANGE_DISPLAY_NAME_FOR_ROOM)
+ }
+ }
+ Command.ROOM_AVATAR.matches(slashCommand) -> {
+ if (messageParts.size == 2) {
+ val url = messageParts[1]
+ if (url.isMxcUrl()) {
+ SlashCommand.ChangeRoomAvatar(url)
+ } else {
+ syntaxError(Command.ROOM_AVATAR)
+ }
+ } else {
+ syntaxError(Command.ROOM_AVATAR)
+ }
+ }
+ Command.CHANGE_AVATAR_FOR_ROOM.matches(slashCommand) -> {
+ if (messageParts.size == 2) {
+ val url = messageParts[1]
+
+ if (url.isMxcUrl()) {
+ SlashCommand.ChangeAvatarForRoom(url)
+ } else {
+ syntaxError(Command.CHANGE_AVATAR_FOR_ROOM)
+ }
+ } else {
+ syntaxError(Command.CHANGE_AVATAR_FOR_ROOM)
+ }
+ }
+ Command.TOPIC.matches(slashCommand) -> {
+ if (message.isNotEmpty()) {
+ SlashCommand.ChangeTopic(topic = message)
+ } else {
+ syntaxError(Command.TOPIC)
+ }
+ }
+ Command.EMOTE.matches(slashCommand) -> {
+ if (message.isNotEmpty()) {
+ SlashCommand.SendEmote(message)
+ } else {
+ syntaxError(Command.EMOTE)
+ }
+ }
+ Command.RAINBOW.matches(slashCommand) -> {
+ if (message.isNotEmpty()) {
+ SlashCommand.SendRainbow(message)
+ } else {
+ syntaxError(Command.RAINBOW)
+ }
+ }
+ Command.RAINBOW_EMOTE.matches(slashCommand) -> {
+ if (message.isNotEmpty()) {
+ SlashCommand.SendRainbowEmote(message)
+ } else {
+ syntaxError(Command.RAINBOW_EMOTE)
+ }
+ }
+ Command.JOIN_ROOM.matches(slashCommand) -> {
+ if (messageParts.size >= 2) {
+ val id = messageParts[1]
+ val roomIdOrAlias = RoomIdOrAlias.from(id)
+ if (roomIdOrAlias != null) {
+ SlashCommand.JoinRoom(
+ RoomIdOrAlias.Id(RoomId(id)),
+ trimParts(textMessage, messageParts.take(2))
+ )
+ } else {
+ syntaxError(Command.JOIN_ROOM)
+ }
+ } else {
+ syntaxError(Command.JOIN_ROOM)
+ }
+ }
+ Command.ROOM_NAME.matches(slashCommand) -> {
+ if (message.isNotEmpty()) {
+ SlashCommand.ChangeRoomName(name = message)
+ } else {
+ syntaxError(Command.ROOM_NAME)
+ }
+ }
+ Command.INVITE.matches(slashCommand) -> {
+ if (messageParts.size >= 2) {
+ parseUserId(messageParts)
+ ?.let { userId ->
+ SlashCommand.Invite(
+ userId = userId,
+ reason = trimParts(textMessage, messageParts.take(2))
+ )
+ }
+ ?: syntaxError(Command.INVITE)
+ } else {
+ syntaxError(Command.INVITE)
+ }
+ }
+ Command.REMOVE_USER.matches(slashCommand) -> {
+ parseUserId(messageParts)
+ ?.let { userId ->
+ SlashCommand.RemoveUser(
+ userId = userId,
+ reason = trimParts(textMessage, messageParts.take(2))
+ )
+ }
+ ?: syntaxError(Command.REMOVE_USER)
+ }
+ Command.BAN_USER.matches(slashCommand) -> {
+ parseUserId(messageParts)
+ ?.let { userId ->
+ SlashCommand.BanUser(
+ userId = userId,
+ reason = trimParts(textMessage, messageParts.take(2))
+ )
+ }
+ ?: syntaxError(Command.BAN_USER)
+ }
+ Command.UNBAN_USER.matches(slashCommand) -> {
+ parseUserId(messageParts)
+ ?.let { userId ->
+ SlashCommand.UnbanUser(
+ userId = userId,
+ reason = trimParts(textMessage, messageParts.take(2))
+ )
+ }
+ ?: syntaxError(Command.UNBAN_USER)
+ }
+ Command.IGNORE_USER.matches(slashCommand) -> {
+ parseUserId(messageParts)
+ ?.let { userId ->
+ SlashCommand.IgnoreUser(
+ userId = userId,
+ )
+ }
+ ?: syntaxError(Command.IGNORE_USER)
+ }
+ Command.UNIGNORE_USER.matches(slashCommand) -> {
+ parseUserId(messageParts)
+ ?.let { userId ->
+ SlashCommand.UnignoreUser(
+ userId = userId,
+ )
+ }
+ ?: syntaxError(Command.UNIGNORE_USER)
+ }
+ Command.SET_USER_POWER_LEVEL.matches(slashCommand) -> {
+ if (messageParts.size == 3) {
+ val userId = parseUserId(messageParts)
+ if (userId != null) {
+ val powerLevelsAsString = messageParts[2]
+ try {
+ val powerLevelsAsInt = Integer.parseInt(powerLevelsAsString)
+ SlashCommand.SetUserPowerLevel(
+ userId = userId,
+ powerLevel = powerLevelsAsInt
+ )
+ } catch (_: Exception) {
+ syntaxError(Command.SET_USER_POWER_LEVEL)
+ }
+ } else {
+ syntaxError(Command.SET_USER_POWER_LEVEL)
+ }
+ } else {
+ syntaxError(Command.SET_USER_POWER_LEVEL)
+ }
+ }
+ Command.RESET_USER_POWER_LEVEL.matches(slashCommand) -> {
+ parseUserId(messageParts)
+ ?.let { userId ->
+ SlashCommand.SetUserPowerLevel(
+ userId = userId,
+ powerLevel = null
+ )
+ }
+ ?: syntaxError(Command.SET_USER_POWER_LEVEL)
+ }
+ Command.DEVTOOLS.matches(slashCommand) -> {
+ if (messageParts.size == 1) {
+ SlashCommand.DevTools
+ } else {
+ syntaxError(Command.DEVTOOLS)
+ }
+ }
+ Command.SPOILER.matches(slashCommand) -> {
+ if (message.isNotEmpty()) {
+ SlashCommand.SendSpoiler(message)
+ } else {
+ syntaxError(Command.SPOILER)
+ }
+ }
+ Command.SHRUG.matches(slashCommand) -> {
+ SlashCommand.SendWithPrefix(MessagePrefix.Shrug, message)
+ }
+ Command.LENNY.matches(slashCommand) -> {
+ SlashCommand.SendWithPrefix(MessagePrefix.Lenny, message)
+ }
+ Command.TABLE_FLIP.matches(slashCommand) -> {
+ SlashCommand.SendWithPrefix(MessagePrefix.TableFlip, message)
+ }
+ Command.UNFLIP.matches(slashCommand) -> {
+ SlashCommand.SendWithPrefix(MessagePrefix.Unflip, message)
+ }
+ Command.DISCARD_SESSION.matches(slashCommand) -> {
+ if (messageParts.size == 1) {
+ SlashCommand.DiscardSession
+ } else {
+ syntaxError(Command.DISCARD_SESSION)
+ }
+ }
+ Command.WHOIS.matches(slashCommand) -> {
+ parseUserId(messageParts)
+ ?.let { userId ->
+ SlashCommand.ShowUser(
+ userId = userId,
+ )
+ }
+ ?: syntaxError(Command.WHOIS)
+ }
+ Command.CONFETTI.matches(slashCommand) -> {
+ SlashCommand.SendChatEffect(ChatEffect.CONFETTI, message)
+ }
+ Command.SNOWFALL.matches(slashCommand) -> {
+ SlashCommand.SendChatEffect(ChatEffect.SNOWFALL, message)
+ }
+ Command.LEAVE_ROOM.matches(slashCommand) -> {
+ if (messageParts.size == 1) {
+ SlashCommand.LeaveRoom
+ } else {
+ syntaxError(Command.LEAVE_ROOM)
+ }
+ }
+ Command.UPGRADE_ROOM.matches(slashCommand) -> {
+ if (message.isNotEmpty()) {
+ SlashCommand.UpgradeRoom(newVersion = message)
+ } else {
+ syntaxError(Command.UPGRADE_ROOM)
+ }
+ }
+ Command.CRASH_APP.matches(slashCommand) && appPreferencesStore.isDeveloperModeEnabledFlow().first() -> {
+ error("Application crashed from user demand")
+ }
+ else -> {
+ // Unknown command
+ SlashCommand.ErrorUnknownSlashCommand(
+ stringProvider.getString(R.string.slash_command_unrecognized, slashCommand)
+ )
+ }
+ }
+ }
+ }
+
+ private fun parseUserId(messageParts: List): UserId? {
+ val str = messageParts.getOrNull(1) ?: return null
+ return when {
+ MatrixPatterns.isUserId(str) -> str
+ str == " {
+ // Rich text editor mode
+ messageParts.getOrNull(2)?.let { html ->
+ // html must match "href="https://matrix.to/#/@user:domain.org">@user:domain.org"
+ val regex = "href=\"https://matrix.to/#/([^\"]+)\">([^<]+)