Compare commits

..

8 Commits

@ -19,6 +19,7 @@
"sass": "^1.29.0", "sass": "^1.29.0",
"superagent": "^6.1.0", "superagent": "^6.1.0",
"v-emoji-picker": "^2.3.1", "v-emoji-picker": "^2.3.1",
"viewerjs": "*",
"vue": "^2.6.11", "vue": "^2.6.11",
"ws": "^7.3.1" "ws": "^7.3.1"
}, },

@ -1,6 +1,6 @@
<template> <template>
<div id="app"> <div id="app">
<div class="content"> <div id="appContent">
<router-view /> <router-view />
<error /> <error />
</div> </div>
@ -8,7 +8,7 @@
</template> </template>
<script> <script>
import error from "@/components/error.vue"; import error from '@/components/layout/error.vue';
export default { export default {
name: 'App', name: 'App',
@ -18,27 +18,12 @@ export default {
} }
</script> </script>
<style> <style lang="scss">
@import "main.scss";
body{ body{
margin: 0; margin: 0;
} }
input{
padding: 0 2rem 0 2rem;
height: 2.5rem;
color: #fff;
background-color: #1d1d1d;
border-radius: 1.25rem;
border: 0.1rem solid #fff;
text-align: center;
font-size: 1.1rem;
margin: 0.5rem;
appearance: none;
outline: none;
}
input:focus{
color: #000;
background-color: #fff;
}
a{ a{
color: #00BCD4; color: #00BCD4;
} }
@ -65,7 +50,7 @@ a{
min-height: 100%; min-height: 100%;
width: 100%; width: 100%;
} }
.content{ #appContent{
position: absolute; position: absolute;
top: 0; top: 0;
left: calc(50% - 35rem); left: calc(50% - 35rem);
@ -77,7 +62,7 @@ a{
box-shadow: 3px 3px 10px #111; box-shadow: 3px 3px 10px #111;
} }
@media (max-width: 75rem){ @media (max-width: 75rem){
.content{ #appContent{
width: 100%; width: 100%;
left: 0; left: 0;
} }

@ -22,14 +22,14 @@
</template> </template>
<script> <script>
import newMessage from '@/components/newMessage.vue'; import newMessage from '@/components/chat/newMessage.vue';
import topBanner from '@/components/topBanner.vue'; import topBanner from '@/components/chat/topBanner.vue';
import Icon from '@/components/icon'; import Icon from '@/components/layout/icon';
import {matrix} from '@/main'; import {matrix} from '@/main.js';
import splitArray from '@/lib/splitArray.js' import splitArray from '@/lib/splitArray.js'
import timeline from '@/components/timeline'; import timeline from '@/components/chat/timeline';
import scrollHandler from '@/lib/scrollHandler'; import scrollHandler from '@/lib/scrollHandler';
import {getUser} from "@/lib/matrixUtils"; import {getUser} from '@/lib/matrixUtils';
export default { export default {
name: 'chat', name: 'chat',

@ -1,39 +1,34 @@
<template> <template>
<div class="chatInfo"> <popup :on-close="closeChatInfo" class="popup">
<div class="box"> <div class="topContainer">
<div class="scrollContainer"> <avatar
<div class="topContainer"> class="roomImage"
<avatar :mxcURL="getMxcFromChat(room)"
class="roomImage" :fallback="room.roomId"
:mxcURL="getMxcFromRoom(room)" :size="5"
:fallback="room.roomId" />
:size="5" <div class="info">
/> <div class="roomName">{{room.name}}</div>
<div class="info"> <div class="users">{{members.length}} members</div>
<div class="roomName">{{room.name}}</div>
<div class="users">{{members.length}} members</div>
</div>
</div>
<user-list-element v-for="member in members.slice(0,20)" :key="member" :user="getUser(member)"/>
<p v-if="members.length>20">and {{members.length-20}} other members</p>
</div> </div>
</div> </div>
<icon class="closeBtn" @click.native="closeChatInfo()" ic="./sym/ic_close_white.svg" /> <user-list-element v-for="member in members.slice(0,20)" :key="member" :user="getUser(member)"/>
</div> <p v-if="members.length>20">and {{members.length-20}} other members</p>
</popup>
</template> </template>
<script> <script>
import icon from './icon.vue'; import UserListElement from '@/components/matrix/userListElement';
import UserListElement from "@/components/userListElement"; import avatar from '@/components/matrix/avatar';
import avatar from "@/components/avatar"; import {getMxcFromChat} from '@/lib/getMxc';
import {getMxcFromRoom} from "@/lib/getMxc"; import {getUser} from '@/lib/matrixUtils';
import {getUser} from "@/lib/matrixUtils"; import popup from '@/components/layout/popup';
export default { export default {
name: "chatInformation", name: 'chatInformation',
components:{ components:{
avatar, avatar,
UserListElement, UserListElement,
icon, popup
}, },
props:{ props:{
room: {}, room: {},
@ -44,7 +39,7 @@ export default {
return Object.keys(this.room.currentState.members) return Object.keys(this.room.currentState.members)
}, },
getUser, getUser,
getMxcFromRoom getMxcFromChat
}, },
data(){ data(){
return{ return{
@ -84,6 +79,9 @@ export default {
height: 100%; height: 100%;
} }
} }
.popup{
min-height: calc(100% - 10rem);
}
.closeBtn{ .closeBtn{
position: absolute; position: absolute;
top: 0; top: 0;

@ -5,9 +5,9 @@
<event-content :content="event.content"/> <event-content :content="event.content"/>
<div class="time">{{getTime(event.origin_server_ts)}}</div> <div class="time">{{getTime(event.origin_server_ts)}}</div>
</div> </div>
<div v-else class="info"> <div v-else :class="type==='send'?'info send':'info receive'">
<span v-if="event.type==='m.room.member'">{{membershipEvents[event.content.membership](event)}}</span> <span v-if="event.type==='m.room.member'">{{membershipEvents[event.content.membership](event)}}</span>
<span v-else>unsupported event</span> <span v-else>unsupported event: {{event.type}}</span>
<span class="time"> {{getTime(event.origin_server_ts)}}</span> <span class="time"> {{getTime(event.origin_server_ts)}}</span>
</div> </div>
</div> </div>
@ -19,8 +19,8 @@ import {calcUserName} from '@/lib/matrixUtils';
import {parseMessage} from '@/lib/eventUtils'; import {parseMessage} from '@/lib/eventUtils';
import {getTime} from '@/lib/getTimeStrings'; import {getTime} from '@/lib/getTimeStrings';
import {getMediaUrl} from '@/lib/getMxc'; import {getMediaUrl} from '@/lib/getMxc';
import ReplyEvent from '@/components/replyEvent'; import ReplyEvent from '@/components/chat/replyEvent';
import EventContent from '@/components/eventContent'; import EventContent from '@/components/chat/eventContent';
export default { export default {
name: 'message', name: 'message',
@ -49,12 +49,18 @@ export default {
return{ return{
replyEvent: undefined, replyEvent: undefined,
membershipEvents:{ membershipEvents:{
invite(event){ return `invited ${calcUserName(event.target.userId)}` }, invite(event){ return `invited ${event.target?calcUserName(event.target.userId):event.content.displayname||event.state_key}` },
join(event){ join(event){
if (event.content.displayname !== null) return `changed username to ${event.content.displayname}` if (!event.unsigned.prev_content) return 'joined the room';
return 'joined the room' if (event.unsigned.prev_content.membership === 'invite') return 'accepted invite';
if (event.unsigned.prev_content.displayname !== event.content.displayname)
return `changed displayname from ${event.unsigned.prev_content.displayname} to ${event.content.displayname}`;
return 'updated their account';
},
leave(event){
if (event.unsigned.prev_content && event.unsigned.prev_content.membership === 'invite') return 'rejected invite';
return 'left the room'
}, },
leave(){ return 'left the room' },
ban(event){return `banned ${calcUserName(event.target.userId)}` } ban(event){return `banned ${calcUserName(event.target.userId)}` }
} }
} }
@ -79,6 +85,9 @@ export default {
font-size: 0.7rem; font-size: 0.7rem;
} }
} }
.info.send{
text-align: right;
}
.message{ .message{
position: relative; position: relative;
width: max-content; width: max-content;

@ -1,26 +1,18 @@
<template> <template>
<div v-if="content.msgtype==='m.text'" v-html="parseMessage(content.body)"/> <div v-if="content.msgtype==='m.text'" v-html="parseMessage(content.body)" :class="getEmojiClass(content)"/>
<div v-else-if="content.msgtype==='m.notice'" class="notice" v-html="parseMessage(content.body)"/> <div v-else-if="content.msgtype==='m.notice'" class="notice" v-html="parseMessage(content.body)"/>
<div v-else-if="content.msgtype==='m.image'" class="image"> <image-viewer v-else-if="content.msgtype==='m.image'" :alt="content.body" class="image" :class="compact?'compact':''">
<img :src="getSource(content.url)" :alt="content.body" :class="`${compact?'compact':''}`"/><br> <img :src="getSource(content.url)" :alt="content.body" :class="compact?'compact':''"/><br>
{{content.body}} {{content.body}}
</div> </image-viewer>
<div v-else-if="content.msgtype==='m.file'" :class="`file ${compact?'compact':''}`"> <div v-else-if="content.msgtype==='m.file'" :class="`file ${compact?'compact':''}`">
<a :href="getSource(content.url)" target="_blank"> <a :href="getSource(content.url)" target="_blank">
<div class="fileContent"> <div class="fileContent">
<icon <icon title="file" ic="./sym/ic_attach_file_white.svg" class="download"/>
title="file" <div class="filename">{{content.filename || getSource(content.url)}}</div>
ic="./sym/ic_attach_file_white.svg"
class="download"
/>
<div class="filename">
{{content.filename || getSource(content.url)}}
</div> </div>
</div>
</a> </a>
<div class="text"> <div class="text">{{content.body}}</div>
{{content.body}}
</div>
</div> </div>
<div v-else-if="content.msgtype==='m.audio'" :class="`audio ${compact?'compact':''}`"> <div v-else-if="content.msgtype==='m.audio'" :class="`audio ${compact?'compact':''}`">
<audio controls :class="`${compact?'compact':''}`"> <audio controls :class="`${compact?'compact':''}`">
@ -36,34 +28,63 @@
</video><br> </video><br>
{{content.body}} {{content.body}}
</div> </div>
<div v-else class="italic">unsupported message type {{content.msgtype}}</div> <div v-else-if="content.msgtype" class="italic">unsupported message type: {{content.msgtype}}</div>
<div v-else class="italic">deleted message</div>
</template> </template>
<script> <script>
import {getMediaUrl} from '@/lib/getMxc'; import {getMediaUrl} from '@/lib/getMxc';
import {parseMessage} from '@/lib/eventUtils'; import {parseMessage} from '@/lib/eventUtils';
import Icon from '@/components/icon'; import Icon from '@/components/layout/icon';
import imageViewer from '@/components/layout/imageViewer';
export default { export default {
name: 'eventContent', name: 'eventContent',
components: {Icon}, components: {
Icon,
imageViewer
},
props: { props: {
content: Object, content: Object,
compact: { compact: {
type: Boolean, type: Boolean,
default: false default: false
},
onUpdate: {
type: Function,
default: ()=>{}
} }
}, },
methods: { methods: {
getSource(url){ getSource(url){
return url.includes('mxc')?getMediaUrl(url):url; return url.includes('mxc')?getMediaUrl(url):url;
}, },
getEmojiContentLength(content){
return content.body.match(/^(\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])+$/)
&& content.body.match(/\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff]/g).length
|| 0;
},
getEmojiClass(content){
let emojiLength = this.getEmojiContentLength(content);
if (emojiLength > 1) return 'emoji';
if (emojiLength === 1) return 'bigEmoji';
return '';
},
parseMessage parseMessage
},
updated() {
this.onUpdate();
} }
} }
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.emoji{
font-size: 2rem;
}
.bigEmoji{
font-size: 3rem;
}
.file{ .file{
max-width: 30rem; max-width: 30rem;
.fileContent{ .fileContent{

@ -2,7 +2,7 @@
<div class="newMessageBanner" ref="newMessageBanner"> <div class="newMessageBanner" ref="newMessageBanner">
<reply-event v-if="replyTo" :event="replyTo" @click.native="resetReplyTo()"/> <reply-event v-if="replyTo" :event="replyTo" @click.native="resetReplyTo()"/>
<div v-if="attachment" class="attachment"> <div v-if="attachment" class="attachment">
<event-content :content="attachment" class="attachmentContent" :compact="true"/> <event-content :content="attachment" class="attachmentContent" :compact="true" :onUpdate="resizeMessageBanner()"/>
<icon <icon
title="remove" title="remove"
class="remove" class="remove"
@ -13,6 +13,7 @@
<textarea <textarea
@keyup.enter.exact="onSubmit(event)" @keyup.enter.exact="onSubmit(event)"
@input="resizeMessageBanner(); sendTyping(2000);" @input="resizeMessageBanner(); sendTyping(2000);"
@paste="onPaste"
v-model="event.content.body" v-model="event.content.body"
ref="newMessageInput" class="newMessageInput" ref="newMessageInput" class="newMessageInput"
rows="1" placeholder="type a message ..." rows="1" placeholder="type a message ..."
@ -46,15 +47,16 @@
</template> </template>
<script> <script>
import icon from '@/components/icon.vue'; import icon from '@/components/layout/icon.vue';
import {matrix} from '@/main.js'; import {matrix} from '@/main.js';
import {parseMessage} from '@/lib/eventUtils'; import {parseMessage} from '@/lib/eventUtils';
import {calcUserName} from '@/lib/matrixUtils'; import {calcUserName} from '@/lib/matrixUtils';
import ReplyEvent from '@/components/replyEvent'; import ReplyEvent from '@/components/chat/replyEvent';
import {VEmojiPicker} from 'v-emoji-picker'; import {VEmojiPicker} from 'v-emoji-picker';
import EventContent from '@/components/eventContent'; import EventContent from '@/components/chat/eventContent';
import SoundRecorder from '@/components/soundRecorder'; import SoundRecorder from '@/components/layout/soundRecorder';
import FileUpload from '@/components/fileUpload'; import FileUpload from '@/components/layout/fileUpload';
import {readFileBlob} from '@/lib/readFileBlob';
export default { export default {
name: 'newMessage', name: 'newMessage',
@ -157,6 +159,12 @@ export default {
'video': 'm.video' 'video': 'm.video'
}[fileType.split('/', 1)[0]] || 'm.file'; }[fileType.split('/', 1)[0]] || 'm.file';
}, },
onPaste(event){
let item = (event.clipboardData || event.originalEvent.clipboardData).items[0];
if (item.kind !== 'file') return false;
let file = item.getAsFile();
return readFileBlob(file).then(blob => this.setAttachment({blob, file}));
},
parseMessage, parseMessage,
calcUserName calcUserName
}, },

@ -40,8 +40,8 @@
</template> </template>
<script> <script>
import event from '@/components/event'; import event from '@/components/chat/event';
import avatar from '@/components/avatar'; import avatar from '@/components/matrix/avatar';
import splitArray from '@/lib/splitArray'; import splitArray from '@/lib/splitArray';
import {getDate, getTime} from '@/lib/getTimeStrings'; import {getDate, getTime} from '@/lib/getTimeStrings';
import {getUser, calcUserName} from '@/lib/matrixUtils'; import {getUser, calcUserName} from '@/lib/matrixUtils';

@ -3,7 +3,7 @@
<div> <div>
<icon @click.native="closeChat()" class="topIcon" ic="./sym/ic_arrow_back_white.svg" /> <icon @click.native="closeChat()" class="topIcon" ic="./sym/ic_arrow_back_white.svg" />
<div @click="openChatInfo()" class="container"> <div @click="openChatInfo()" class="container">
<avatar class="topIcon avatar" :mxcURL="getMxcFromRoom(room)" :fallback="room.roomId" :size="3"/> <avatar class="topIcon avatar" :mxcURL="getMxcFromChat(room)" :fallback="room.roomId" :size="3"/>
<div class="chatName">{{room.name}}</div> <div class="chatName">{{room.name}}</div>
<div class="info">{{Object.keys(room.currentState.members).length}} members</div> <div class="info">{{Object.keys(room.currentState.members).length}} members</div>
</div> </div>
@ -11,13 +11,12 @@
</div> </div>
</template> </template>
<script> <script>
import icon from '@/components/icon.vue'; import icon from '@/components/layout/icon.vue';
import avatar from "@/components/avatar"; import avatar from '@/components/matrix/avatar';
import {getMxcFromRoom} from "@/lib/getMxc"; import {getMxcFromChat} from '@/lib/getMxc';
export default { export default {
name: "topBanner", name: 'topBanner',
components:{ components:{
icon, icon,
avatar avatar
@ -28,7 +27,7 @@ export default {
openChatInfo: Function openChatInfo: Function
}, },
methods: { methods: {
getMxcFromRoom getMxcFromChat
} }
} }
</script> </script>
@ -40,6 +39,7 @@ export default {
width: 100%; width: 100%;
height: 3.5rem; height: 3.5rem;
background-color: #1d1d1d; background-color: #1d1d1d;
cursor: pointer;
} }
.topIcon{ .topIcon{
position: absolute; position: absolute;

@ -14,7 +14,8 @@
</template> </template>
<script> <script>
import icon from '@/components/icon'; import icon from '@/components/layout/icon';
import {readFileBlob} from '@/lib/readFileBlob';
export default { export default {
name: 'soundRecorder', name: 'soundRecorder',
@ -26,21 +27,11 @@ export default {
}, },
methods: { methods: {
setFile({file}){ setFile({file}){
this.readFile(file).then(blob => { readFileBlob(file).then(blob => {
blob.name = file.name; blob.name = file.name;
this.onChange({blob}) this.onChange({blob})
}); });
}, }
readFile(file){
return new Promise(resolve => {
let reader = new FileReader();
reader.onerror = console.error;
reader.onload = async event => {
resolve(await (await fetch(event.target.result)).blob());
}
reader.readAsDataURL(file);
});
},
} }
} }
</script> </script>

@ -0,0 +1,41 @@
<template>
<div class="viewer" ref="images">
<slot></slot>
</div>
</template>
<script>
import Viewer from 'viewerjs';
import 'viewerjs/dist/viewer.css';
export default {
name: 'imageViewer',
props: {
options: Object
},
mounted() {
new Viewer(this.$refs.images, this.options||{
inline: false,
navbar: false,
button: true,
toolbar: {
reset: {show: 1, size: 'large'},
zoomIn: {show: 1, size: 'large'},
zoomOut: {show: 1, size: 'large'},
rotateLeft: {show: 1, size: 'large'},
rotateRight: {show: 1, size: 'large'}
},
zoomRatio: 0.25,
minZoomRatio: 0.5,
maxZoomRatio: 10,
toggleOnDblclick: true,
});
}
}
</script>
<style scoped lang="scss">
.viewer{
cursor: pointer;
}
</style>

@ -0,0 +1,24 @@
<template>
<div v-if="this.$slots.default" class="overlay">
<slot></slot>
</div>
</template>
<script>
export default {
name: 'overlay'
}
</script>
<style scoped>
.overlay{
position: fixed;
top: 0;
left: 0;
height: 100%;
width: 100%;
background-color: #111d;
user-select: none;
z-index: 50;
}
</style>

@ -0,0 +1,91 @@
<template>
<div class="popup">
<div class="scrollContainer" ref="scrollContainer">
<div class="content">
<slot></slot>
</div>
<icon v-if="onClose" class="closeBtn" @click.native="onClose(false)" ic="./sym/ic_close_white.svg" />
</div>
</div>
</template>
<script>
import icon from '@/components/layout/icon';
export default {
name: 'popup',
components: {
icon
},
props: {
onClose: Function
},
methods: {
calcMaxHeight(){
this.$refs.scrollContainer.style.maxHeight = `calc(${this.$el.parentElement.clientHeight}px - 4rem`;
}
},
mounted() {
this.calcMaxHeight();
}
}
</script>
<style scoped>
.popup{
position: relative;
top: 5rem;
width: calc(100% - 4rem);
max-width: 30rem;
min-height: 10rem;
background-color: #1d1d1d;
box-shadow: 6px 6px 20px #111;
border-radius: 1rem;
z-index: 30;
overflow: hidden;
}
.scrollContainer{
position: relative;
max-height: 30rem;
top: 0;
width: calc(100% - 2rem);
padding: 0 1rem 0 1rem;
overflow-y: auto;
overflow-x: hidden;
}
@media (max-width: 30rem) {
.popup{
transform: unset;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 0;
}
.scrollContainer{
max-height: 100% !important;
}
}
@media (max-height: 40rem) {
.popup{
top: 0;
height: 100%;
}
.scrollContainer{
max-height: 100% !important;
}
}
.closeBtn{
position: fixed;
top: 0;
right: 0;
background-color: #0000;
box-shadow: none;
}
.content{
position: relative;
padding: 1rem 0 1rem 0;
width: 100%;
height: auto;
}
</style>

@ -0,0 +1,33 @@
<template>
<popup :on-close="callback">
<h2>{{title}}</h2>
<p>{{question}}</p>
<textbtn :text="action" @click.native="callback(true)"/>
<textbtn text="Cancel" @click.native="callback(false)" class="outline"/>
</popup>
</template>
<script>
import textbtn from '@/components/layout/textbtn';
import popup from '@/components/layout/popup';
export default {
name: 'popupQuestion',
components: {
textbtn,
popup
},
props:{
title: String,
question: String,
callback: Function,
action: {
type: String,
default: 'Apply'
}
}
}
</script>
<style scoped lang="scss">
</style>

@ -22,7 +22,7 @@
</template> </template>
<script> <script>
import icon from '@/components/icon'; import icon from '@/components/layout/icon';
import Recorder from 'recorder-js'; import Recorder from 'recorder-js';
const audioContext = new (window.AudioContext || window.webkitAudioContext)(); const audioContext = new (window.AudioContext || window.webkitAudioContext)();
@ -46,6 +46,7 @@ export default {
this.onStart(); this.onStart();
navigator.mediaDevices.getUserMedia({audio: true}) navigator.mediaDevices.getUserMedia({audio: true})
.then(stream => { .then(stream => {
this.stream = stream;
this.recorder.init(stream); this.recorder.init(stream);
this.recorder.start().then(()=>this.isRecording=true); this.recorder.start().then(()=>this.isRecording=true);
}) })
@ -56,6 +57,7 @@ export default {
.then(({blob}) => { .then(({blob}) => {
blob.name = `Recording-${new Date().toISOString()}.${blob.type.split('/')[1]}`; blob.name = `Recording-${new Date().toISOString()}.${blob.type.split('/')[1]}`;
this.onStop({blob}); this.onStop({blob});
this.stream.getTracks().map(track => track.stop());
this.isRecording=false; this.isRecording=false;
}); });
}, },
@ -68,8 +70,11 @@ export default {
data(){ data(){
return{ return{
recorder: new Recorder(audioContext, { recorder: new Recorder(audioContext, {
onAnalysed: data => this.setVoiceMeter(data.lineTo) onAnalysed: data => {
this.setVoiceMeter(data.lineTo);
}
}), }),
stream: undefined,
isRecording: false isRecording: false
} }
} }

@ -0,0 +1,55 @@
<template>
<button class="btn">
<div class="btnText">{{text}}</div>
</button>
</template>
<script>
export default {
name: "textbtn",
props:{
text: String,
}
}
</script>
<style scoped>
.btn{
cursor: pointer;
border: none;
height: 2.5rem;
padding-left: 1.5rem;
padding-right: 1.5rem;
background-color: var(--primary);
box-shadow: var(--shadow100);
border-radius: 1rem;
margin: 0.2rem;
}
.btn.primary{
background-color: var(--primary);
}
.btn.underline{
background-color: unset;
text-decoration: underline;
color: #fff;
box-shadow: none;
}
.btn.rounded{
border-radius: 1.25rem;
}
.btn.outline{
background-color: unset;
box-shadow: none;
text-decoration: underline;
border: #fff solid 1px;
}
.btn.squared{
border-radius: 0;
}
.btnText {
position: relative;
font-size: 1rem;
color:#fff;
font-family:Arial, "lucida console", sans-serif;
}
</style>

@ -17,6 +17,9 @@ export default {
<style scoped lang="scss"> <style scoped lang="scss">
.box{ .box{
position: absolute; position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
height: 8rem; height: 8rem;
width: 8rem; width: 8rem;
background-color: #1d1d1d; background-color: #1d1d1d;

@ -0,0 +1,86 @@
<template>
<popup :on-close="callback">
<h2>New room</h2>
<input v-model="name" type="text" placeholder="Room name">
<select v-model="access">
<option>private</option>
<option>public</option>
</select><br>
<textarea v-model="description" placeholder="Room description"></textarea><br>
<h3>Add User</h3>
<user-search :filter="prop=>!users.find(temp=>temp===prop)" :callback="addUser" class="userSearch"/>
<h3 v-if="users.length">User</h3>
<div>
<user-list-element
v-for="user in users"
:user="user" :key="user.userId"
@click.native="removeUser(user)"
/>
</div>
<textbtn :text="action" @click.native="createRoom({name, users, description, access}).then(callback)"/>
<textbtn text="Cancel" @click.native="callback(false)" class="outline"/>
<overlay v-if="loading"><throbber text="loading"/></overlay>
</popup>
</template>
<script>
import textbtn from '@/components/layout/textbtn';
import popup from '@/components/layout/popup';
import userListElement from '@/components/matrix/userListElement';
import UserSearch from '@/components/matrix/userSearch';
import overlay from '@/components/layout/overlay';
import throbber from '@/components/layout/throbber';
import {createRoom} from '@/lib/matrixUtils';
export default {
name: 'popupQuestion',
components: {
UserSearch,
textbtn,
popup,
userListElement,
overlay,
throbber
},
props:{
props: Object,
callback: Function,
action: {
type: String,
default: 'Apply'
}
},
data(){
return{
name: this.props.name,
description: this.props.description,
users: [],
loading: false,
access: 'private'
}
},
methods:{
addUser(user){
if (this.users.find(tmp => tmp === user)) return;
this.users.push(user);
this.userSearch = '';
},
removeUser(user){
this.users = this.users.filter(tmp => tmp !== user);
},
async createRoom(props){
this.loading = true;
return await createRoom(props);
}
}
}
</script>
<style scoped lang="scss">
textarea{
width: 100%;
}
.userSearch{
}
</style>

@ -2,20 +2,20 @@
<div class="roomListElement" :title="room.name"> <div class="roomListElement" :title="room.name">
<div class="imageContainer"> <div class="imageContainer">
<avatar <avatar
class="roomImage" class="roomImage"
:mxcURL="getMxcFromRoom(room)" :mxcURL="getMxcFromChat(room)"
:fallback="room.roomId" :fallback="room.roomId"
:size="3" :size="3"
/> />
</div> </div>
<div class="roomListName">{{room.name}}</div> <div class="roomListName">{{room.name}}</div>
<div class="status">{{getPreviewString(room)}}</div> <div class="status">{{previewString}}</div>
</div> </div>
</template> </template>
<script> <script>
import avatar from '@/components/avatar'; import avatar from '@/components/matrix/avatar';
import {getMxcFromRoom} from '@/lib/getMxc'; import {getMxcFromChat} from '@/lib/getMxc';
import {getTime} from '@/lib/getTimeStrings'; import {getTime} from '@/lib/getTimeStrings';
import {calcUserName} from '@/lib/matrixUtils'; import {calcUserName} from '@/lib/matrixUtils';
@ -37,8 +37,16 @@ export default {
return room.timeline[room.timeline.length-1] return room.timeline[room.timeline.length-1]
&& room.timeline[room.timeline.length-1].event; && room.timeline[room.timeline.length-1].event;
}, },
getMxcFromChat,
calcUserName, calcUserName,
getMxcFromRoom },
data(){
return {
previewString: 'loading'
}
},
created() {
this.previewString = this.getPreviewString(this.room);
} }
} }
</script> </script>

@ -1,21 +1,21 @@
<template> <template>
<div class="userListElement" :title="user.userId"> <div :class="compact?'userListElement compact':'userListElement'" :title="user.userId">
<div class="imageContainer"> <div :class="compact?'imageContainer compact':'imageContainer'">
<avatar <avatar
class="userImage" :class="compact?'userImage compact':'userImage'"
:mxcURL="user.avatarUrl" :mxcURL="user.avatarUrl"
:fallback="user.userId" :fallback="user.userId"
:size="3" :size="compact?1.5:3"
/> />
<div v-if="user.currentlyActive" class="online"></div> <div v-if="user.currentlyActive" class="online"></div>
</div> </div>
<div class="userListName">{{user.displayName || user.userId}}</div> <div :class="compact?'userListName compact':'userListName'">{{user.displayName || user.userId}}</div>
<div class="status">{{user.presence}}</div> <div v-if="!compact" class="status">{{user.presence}}</div>
</div> </div>
</template> </template>
<script> <script>
import avatar from '@/components/avatar'; import avatar from '@/components/matrix/avatar';
export default { export default {
name: 'userListElement', name: 'userListElement',
@ -23,7 +23,11 @@ export default {
avatar avatar
}, },
props:{ props:{
user: Object user: Object,
compact: {
type: Boolean,
default: false
}
} }
} }
</script> </script>
@ -50,11 +54,11 @@ export default {
position: absolute; position: absolute;
bottom: 0; bottom: 0;
right: 0; right: 0;
height: 0.6rem; height: 0.5rem;
width: 0.6rem; width: 0.5rem;
background-color: #42b983; background-color: #42b983;
border-radius: 50%; border-radius: 50%;
border: 0.2rem solid #222; border: 2px solid #222;
} }
} }
.userListName{ .userListName{
@ -81,4 +85,15 @@ export default {
.userListElement:hover{ .userListElement:hover{
background-color: #4444; background-color: #4444;
} }
.userListElement.compact{
height: 1.5rem;
}
.imageContainer.compact{
height: 1.5rem;
width: 1.5rem;
}
.userListName.compact{
left: 2.5rem;
font-size: 1rem;
}
</style> </style>

@ -0,0 +1,78 @@
<template>
<div class="userSearch">
<div v-if="userSearch" class="box">
<div class="results">
<user-list-element
v-for="user in matrix.client.getUsers()
.filter(prop=>matchResults(prop.displayName, userSearch)||matchResults(prop.userId, userSearch))
.filter(filter)
.slice(0,16)"
:user="user" :key="user.userId"
@click.native="callback(user); userSearch='';"
:compact="true"
/>
</div>
<div class="filler"></div>
</div>
<input v-model="userSearch" type="text" placeholder="search" class="input">
</div>
</template>
<script>
import userListElement from '@/components/matrix/userListElement';
import {matrix} from '@/main';
export default {
name: 'userSearch',
components:{
userListElement
},
props:{
callback: Function,
filter: Function
},
methods:{
matchResults(prop, search){
return prop.toLowerCase().includes(search.toLowerCase().trim());
}
},
data(){
return {
matrix,
userSearch: ''
}
}
}
</script>
<style scoped>
.userSearch{
position: relative;
background-color: #1d1d1d;
height: 2.2rem;
width: 14rem;
margin: 0.2rem;
}
.input{
position: absolute;
width: 13rem;
margin: 0;
}
.filler{
height: 2.5rem;
}
.box{
position: absolute;
bottom: -0.4rem;
left: -0.4rem;
background-color: var(--grey500);
box-shadow: var(--shadow200);
width: calc(100% + 0.8rem);
border-radius: 0.6rem;
}
.results{
max-height: 10.5rem;
overflow-y: auto;
overflow-x: hidden;
}
</style>

@ -1,34 +0,0 @@
<template>
<div class="questionBox">
<p class="text">{{question}}</p>
<textbtn text="yes" @click.native="callback(true)"/>
<textbtn text="no" @click.native="callback(false)"/>
</div>
</template>
<script>
import textbtn from '@/components/textbtn';
export default {
name: 'popupQuestion',
components: {textbtn},
props:{
question: String,
callback: Function
}
}
</script>
<style scoped lang="scss">
.questionBox{
position: absolute;
width: auto;
background-color: #1d1d1d;
box-shadow: 6px 6px 10px #111;
border-radius: 1.5rem;
.text{
position: relative;
width: 100%;
text-align: center;
}
}
</style>

@ -1,34 +0,0 @@
<template>
<button class="btn">
<div class="btnText">{{text}}</div>
</button>
</template>
<script>
export default {
name: "textbtn",
props:{
text: String
}
}
</script>
<style scoped>
.btn{
cursor: pointer;
border: none;
height: 2.5rem;
padding-left: 2rem;
padding-right: 2rem;
background-color: #00BCD4;
box-shadow: 3px 3px 10px #222;
border-radius: 1.25rem;
margin: 1rem;
}
.btnText {
position: relative;
font-size: 1.4rem;
color:#fff;
font-family:Arial, "lucida console", sans-serif;
}
</style>

@ -1,36 +0,0 @@
<template>
<div class="overlay">
<throbber :text="text" class="throbber"/>
</div>
</template>
<script>
import throbber from "@/components/throbber";
export default {
name: "throbberOverlay",
components:{
throbber
},
props: {
text: String
}
}
</script>
<style scoped>
.throbber{
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.overlay{
position: fixed;
top: 0;
left: 0;
height: 100%;
width: 100%;
background-color: #111d;
user-select: none;
}
</style>

@ -18,6 +18,12 @@ export function getMxcFromRoomId(roomId){
return getMxcFromRoom(matrix.client.getRoom(roomId)); return getMxcFromRoom(matrix.client.getRoom(roomId));
} }
export function getMxcFromChat(room){
return Object.keys(room.currentState.members).length===2
?getMxcFromUserId(Object.keys(room.currentState.members).filter(tmp=>tmp!==matrix.user)[0])
:getMxcFromRoom(room);
}
export function getPreviewUrl(mxcUrl, size = 64, resizeMethod = 'crop'){ export function getPreviewUrl(mxcUrl, size = 64, resizeMethod = 'crop'){
return matrix.client.mxcUrlToHttp(mxcUrl, size, size, resizeMethod); return matrix.client.mxcUrlToHttp(mxcUrl, size, size, resizeMethod);
} }

@ -15,4 +15,16 @@ export function isValidUserId(id){
} }
export function isValidRoomId(id){ export function isValidRoomId(id){
return id.match(/^(#|!)[a-zA-Z0-9_.+-]+:[a-z0-9.-]+\.[a-z]+$/); return id.match(/^(#|!)[a-zA-Z0-9_.+-]+:[a-z0-9.-]+\.[a-z]+$/);
}
export async function createRoom({name = '', users = [], description = undefined, access = 'private'}){
if (users.length === 0) return;
return matrix.client.createRoom({name}).then(async room => {
await Promise.all(users.map(async user => await matrix.client.invite(room.room_id, user.userId)));
if (description) await matrix.client.setRoomTopic(room.room_id, description);
await matrix.client.setGuestAccess(room.room_id, access === 'public'
?{allowJoin: true, allowRead: true}
:{allowJoin: false, allowRead: false}
);
return matrix.client.getRoom(room.room_id);
});
} }

@ -0,0 +1,8 @@
export function readFileBlob(file){
return new Promise(resolve => {
let reader = new FileReader();
reader.onerror = console.error;
reader.onload = async event => resolve(await (await fetch(event.target.result)).blob());
reader.readAsDataURL(file);
});
}

@ -0,0 +1,50 @@
:root {
--primary: #2196f3;
--secondary: #2196f3;
--red: #e53935;
--green: #4caf50;
--blue: #03a9f4;
--teal: #009688;
--white: #fff;
--black: #000;
--grey100: #fff;
--grey200: #414141;
--grey300: #313131;
--grey400: #2d2d2d;
--grey500: #222222;
--grey600: #1d1d1d;
--grey700: #111111;
--grey800: #050505;
--grey900: #000;
--shadow100: 2px 2px 5px #111;
--shadow200: 3px 3px 10px #111;
--shadow300: 6px 6px 20px #111;
}
input, textarea, select {
background-color: var(--grey400);
border-radius: 0.7rem;
border: none;
padding: 0.5rem;
color: var(--grey100);
outline: none;
margin: 0.2rem;
}
input:focus, textarea:focus {
background-color: var(--grey300);
}
textarea {
width: 20rem;
min-width: 10rem;
min-height: 3rem;
max-width: calc(100% - 1.2rem);
}
.viewer-backdrop{
background-color: #000e !important;
}

@ -1,6 +1,5 @@
import VueRouter from 'vue-router'; import VueRouter from 'vue-router';
import login from '@/views/login'; import login from '@/views/login';
import chat from '@/views/chat';
import rooms from '@/views/rooms'; import rooms from '@/views/rooms';
import admin from '@/views/admin'; import admin from '@/views/admin';
@ -16,11 +15,6 @@ export const router = new VueRouter({
name: 'login', name: 'login',
component: login component: login
}, },
{
path: '/chat/*',
name: 'chat',
component: chat
},
{ {
path: '/rooms/*', path: '/rooms/*',
name: 'room', name: 'room',

@ -33,23 +33,25 @@
</div> </div>
<textbtn @click.native="updateUser()" text="update user"/> <textbtn @click.native="updateUser()" text="update user"/>
</div> </div>
<throbber-overlay v-if="loading" :text="loading"/> <overlay v-if="loading"><throbber :text="loading"/></overlay>
</div> </div>
</template> </template>
<script> <script>
import {matrix} from "@/main"; import {matrix} from '@/main';
import {AdminAPI} from "@/lib/AdminAPI"; import {AdminAPI} from '@/lib/AdminAPI';
import icon from "@/components/icon"; import icon from '@/components/layout/icon';
import textbtn from "@/components/textbtn"; import textbtn from '@/components/layout/textbtn';
import ThrobberOverlay from "@/components/throbberOverlay"; import overlay from '@/components/layout/overlay';
import throbber from '@/components/layout/throbber';
export default { export default {
name: "admin", name: 'admin',
components:{ components:{
ThrobberOverlay,
icon, icon,
textbtn textbtn,
overlay,
throbber
}, },
data(){ data(){
return{ return{

@ -7,30 +7,32 @@
<input v-model="password" class="input" name="password" type="password" maxlength="30" placeholder="password"><br> <input v-model="password" class="input" name="password" type="password" maxlength="30" placeholder="password"><br>
<input v-model="homeServer" class="input" name="homeserver" placeholder="https://matrix.org"><br> <input v-model="homeServer" class="input" name="homeserver" placeholder="https://matrix.org"><br>
<div v-if="loginError" class="info">{{loginError}}</div> <div v-if="loginError" class="info">{{loginError}}</div>
<textbtn type="submit" text="login" /> <textbtn type="submit" text="login" class="rounded"/>
</form> </form>
<div v-else> <div v-else>
<p>you are already logged in</p> <p>you are already logged in</p>
<textbtn @click.native="$router.push('rooms')" text="chat" /> <textbtn @click.native="$router.push('rooms')" text="chat" />
<textbtn @click.native="logout()" text="logout" /> <textbtn @click.native="logout()" text="logout" class="outline"/>
</div> </div>
</div> </div>
<throbber-overlay v-if="loading" :text="loading" class="throbber"/> <overlay v-if="loading"><throbber :text="loading"/></overlay>
</div> </div>
</template> </template>
<script> <script>
import textbtn from '@/components/textbtn'; import textbtn from '@/components/layout/textbtn';
import {matrix} from '@/main.js'; import {matrix} from '@/main.js';
import ThrobberOverlay from '@/components/throbberOverlay';
import {isValidUserId} from '@/lib/matrixUtils'; import {isValidUserId} from '@/lib/matrixUtils';
import {DataStore} from '@/lib/DataStore'; import {DataStore} from '@/lib/DataStore';
import Overlay from '@/components/layout/overlay';
import Throbber from '@/components/layout/throbber';
const store = new DataStore(); const store = new DataStore();
export default { export default {
name: 'login.vue', name: 'login.vue',
components: { components: {
ThrobberOverlay, Throbber,
Overlay,
textbtn textbtn
}, },
methods: { methods: {
@ -83,6 +85,23 @@ export default {
</script> </script>
<style scoped> <style scoped>
input{
padding: 0 2rem 0 2rem;
height: 2.5rem;
color: #fff;
background-color: #1d1d1d;
border-radius: 1.25rem;
border: 0.1rem solid #fff;
text-align: center;
font-size: 1.1rem;
margin: 0.5rem;
appearance: none;
outline: none;
}
input:focus{
color: #000;
background-color: #fff;
}
.login{ .login{
width: 100%; width: 100%;
height: 100%; height: 100%;
@ -96,12 +115,6 @@ export default {
height: min-content; height: min-content;
width: 100%; width: 100%;
} }
.throbber{
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
@media (max-width: 35rem) { @media (max-width: 35rem) {
input { input {

@ -1,37 +1,44 @@
<template> <template>
<div v-if="matrix.loading"> <overlay v-if="matrix.loading"><throbber text="loading" class="center"/></overlay>
<throbber-overlay text="loading"/>
</div>
<div v-else> <div v-else>
<div id="roomList" class="roomList"> <div id="roomList" class="roomList">
<h1 class="wideElement">[chat]</h1><h1 class="smallElement">[c]</h1> <h1 class="wideElement">[chat]</h1><h1 class="smallElement">[c]</h1>
<input v-model="search" class="input wideElement" type="text" maxlength="50" placeholder="search"> <input v-model="search" class="input wideElement" type="text" maxlength="50" placeholder="search">
<p class="wideElement">rooms </p> <p class="wideElement">- rooms -</p>
<room-list-element <room-list-element
v-for="room in Object.assign([], matrix.client.getRooms()) v-for="room in matrix.client.getRooms()
.sort(obj => obj.timeline[obj.timeline.length-1].event.origin_server_ts)
.filter(prop=>matchResults(prop.name, search)||prop.roomId===search)" .filter(prop=>matchResults(prop.name, search)||prop.roomId===search)"
:key="room.roomId" @click.native="openChat(room)" :key="room.roomId" @click.native="openChat(room)"
:room="room" :room="room"
class="roomListElement" class="roomListElement"
/> />
<div v-if="search"> <div v-if="search">
<p class="wideElement">users </p><p class="smallElement"></p> <p class="wideElement">- users -</p><p class="smallElement"></p>
<user-list-element <user-list-element
v-for="user in matrix.client.getUsers() v-for="user in matrix.client.getUsers()
.filter(prop=>matchResults(prop.displayName, search)||matchResults(prop.userId, search)) .filter(prop=>matchResults(prop.displayName, search)||matchResults(prop.userId, search))
.slice(0,10)" .slice(0,10)"
:user="user" :key="user.userId" :user="user" :key="user.userId"
@click.native="setQuestion(`create private chat with '${user.displayName}'?`,()=>createRoom({user}))" @click.native="setQuestion({
title:'New Chat',
question:`Create private chat with '${user.displayName}'?`,
callback:()=>createRoom({users:[user], access: 'private'}).then(openChat)
})"
/> />
<p class="wideElement">suggestions </p><p class="smallElement"></p> <p class="wideElement">- suggestions -</p><p class="smallElement"></p>
<div class="wideElement"> <div class="wideElement">
<p v-if="isValidUserId(search)">create chat: {{search}} </p> <p v-if="isValidUserId(search)" class="suggestion">create chat: {{search}} </p>
<p v-if="isValidRoomId(search)" <p v-if="isValidRoomId(search)"
@click="setQuestion(`join room '${search}'?`, ()=>joinRoom(search))" class="suggestion"
@click="setQuestion({
title:'Join room',
question:`Join '${search}'?`,
callback:()=>joinRoom(search)
})"
>join room: {{search}} </p> >join room: {{search}} </p>
<p v-if="search.match(/^[a-zA-Z0-9_.+-]+$/)" <p v-if="search.match(/^[a-zA-Z0-9_.+-]+$/)"
@click="setQuestion(`create room '${search}'?`,()=>createRoom({name: search}))" @click="setShowCreateRoom({name: search}, openChat)"
class="suggestion"
>create room: {{search}} </p> >create room: {{search}} </p>
</div> </div>
</div> </div>
@ -45,32 +52,40 @@
:open-chat-info="()=>showChatInfo=true" :open-chat-info="()=>showChatInfo=true"
/> />
<div class="noRoomSelected" v-else>Please select a room to be displayed.</div> <div class="noRoomSelected" v-else>Please select a room to be displayed.</div>
<chatInformation v-if="showRoom && showChatInfo" :room="getCurrentRoom()" :close-chat-info="()=>showChatInfo=false"/> <overlay>
<popup-question v-if="popup.question" :callback="popup.callback" :question="popup.question" class="center"/> <chatInformation v-if="showRoom && showChatInfo" :room="getCurrentRoom()" :close-chat-info="()=>showChatInfo=false" class="center"/>
<new-room v-if="showCreateRoom.props" :callback="showCreateRoom.callback" :props="showCreateRoom.props" class="center"/>
<popup-question v-if="popup.question" :callback="popup.callback" :question="popup.question" :title="popup.title" class="center"/>
</overlay>
</div> </div>
</template> </template>
<script> <script>
import chat from '@/views/chat.vue'; import chat from '@/components/chat/chat.vue';
import chatInformation from '@/components/chatInformation'; import chatInformation from '@/components/chat/chatInformation';
import {matrix} from '@/main'; import {matrix} from '@/main';
import ThrobberOverlay from '@/components/throbberOverlay';
import {getMxcFromRoom} from '@/lib/getMxc'; import {getMxcFromRoom} from '@/lib/getMxc';
import roomListElement from '@/components/roomListElement'; import roomListElement from '@/components/matrix/roomListElement';
import {getRoom, getUser} from '@/lib/matrixUtils'; import {getRoom, getUser} from '@/lib/matrixUtils';
import {isValidUserId, isValidRoomId} from '@/lib/matrixUtils'; import {isValidUserId, isValidRoomId} from '@/lib/matrixUtils';
import userListElement from '@/components/userListElement'; import userListElement from '@/components/matrix/userListElement';
import PopupQuestion from '@/components/popupQuestion'; import PopupQuestion from '@/components/layout/popupQuestion';
import newRoom from '@/components/matrix/newRoom';
import Overlay from '@/components/layout/overlay';
import Throbber from '@/components/layout/throbber';
import {createRoom} from '@/lib/matrixUtils';
export default { export default {
name: 'rooms', name: 'rooms',
components:{ components:{
Throbber,
Overlay,
PopupQuestion, PopupQuestion,
userListElement, userListElement,
ThrobberOverlay,
chat, chat,
chatInformation, chatInformation,
roomListElement roomListElement,
newRoom
}, },
methods:{ methods:{
openChat(room){ openChat(room){
@ -89,11 +104,12 @@ export default {
matchResults(prop, search){ matchResults(prop, search){
return prop.toLowerCase().includes(search.toLowerCase().trim()); return prop.toLowerCase().includes(search.toLowerCase().trim());
}, },
setQuestion(question, callback){ setQuestion({title, question, callback}){
this.popup = { this.popup = {
question, question,
title,
callback:(res)=>{ callback:(res)=>{
this.popup = false; this.popup = {};
if (res) callback(); if (res) callback();
} }
} }
@ -103,18 +119,21 @@ export default {
this.openChat(getRoom(room.room_id)); this.openChat(getRoom(room.room_id));
}); });
}, },
async createRoom({name = '', user = undefined}){ setShowCreateRoom(props, callback=()=>{}){
return this.matrix.client.createRoom({name}).then(room => { this.showCreateRoom = {
if (user) this.matrix.client.invite(room.room_id, user.userId); props,
this.openChat(getRoom(room.room_id)); callback:(res)=>{
return room; this.showCreateRoom = {};
}); if (res) callback(res);
}
}
}, },
getMxcFromRoom, getMxcFromRoom,
getRoom, getRoom,
getUser, getUser,
isValidUserId, isValidUserId,
isValidRoomId isValidRoomId,
createRoom
}, },
data(){ data(){
return { return {
@ -122,10 +141,8 @@ export default {
showChatInfo: false, showChatInfo: false,
showRoom: true, showRoom: true,
search: '', search: '',
popup:{ popup:{},
question: '', showCreateRoom:{}
callback: ()=>{}
}
} }
}, },
mounted() { mounted() {
@ -171,7 +188,10 @@ export default {
text-align: center; text-align: center;
} }
input{ input{
width: calc(100% - 5.2rem); position: relative;
margin-left: auto;
margin-right: auto;
width: calc(100% - 4rem);
} }
.wideElement{ .wideElement{
display: block; display: block;
@ -180,11 +200,17 @@ input{
display: none; display: none;
} }
.center{ .center{
position: fixed; position: absolute;
top: 50%; top: 50%;
left: 50%; left: 50%;
transform: translate(-50%,-50%); transform: translate(-50%,-50%);
z-index: 50; }
.suggestion{
cursor: pointer;
text-decoration: underline;
}
.suggestion:hover{
background-color: #4444;
} }
@media (max-width: 48rem) and (min-width: 30rem) { @media (max-width: 48rem) and (min-width: 30rem) {

Loading…
Cancel
Save