Merge remote-tracking branch 'origin/master' into matrix-chat-native

This commit is contained in:
adb 2021-05-04 17:42:27 +02:00
commit 520a5ca6d9
26 changed files with 888 additions and 253 deletions

View File

@ -2,6 +2,13 @@
a simple matrix webapp for mobile and desktop a simple matrix webapp for mobile and desktop
<img src="https://chat.adb.sh/media/screenshot-desktop.png" alt="screenshot-desktop">
`matrix-chat` or in short `[chat]` is a simple matrix webclient designed for mobile and desktop use with a clean UI.
It's written with Vue in JavaScript. The webapp is still under development and more features will be added gradually.
To stay tuned or give some input just join the matrix room. [#matrix-chat-public:adb.sh](https://matrix.to/#/#matrix-chat-public:adb.sh)
### setup ### setup
``` ```
npm install npm install

View File

@ -37,6 +37,7 @@
"eslint": "^6.7.2", "eslint": "^6.7.2",
"eslint-plugin-vue": "^7.5.0", "eslint-plugin-vue": "^7.5.0",
"node-sass": "^5.0.0", "node-sass": "^5.0.0",
"recorder-js": "*",
"sass-loader": "^10.1.1", "sass-loader": "^10.1.1",
"vue-router": "^3.4.9", "vue-router": "^3.4.9",
"vue-template-compiler": "^2.6.11" "vue-template-compiler": "^2.6.11"

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#FFFFFF"><path d="M0 0h24v24H0z" fill="none"/><path d="M16.5 6v11.5c0 2.21-1.79 4-4 4s-4-1.79-4-4V5c0-1.38 1.12-2.5 2.5-2.5s2.5 1.12 2.5 2.5v10.5c0 .55-.45 1-1 1s-1-.45-1-1V6H10v9.5c0 1.38 1.12 2.5 2.5 2.5s2.5-1.12 2.5-2.5V5c0-2.21-1.79-4-4-4S7 2.79 7 5v12.5c0 3.04 2.46 5.5 5.5 5.5s5.5-2.46 5.5-5.5V6h-1.5z"/></svg>

After

Width:  |  Height:  |  Size: 409 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#FFFFFF"><path d="M0 0h24v24H0z" fill="none"/><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5 7.67 11 8.5 11zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z"/></svg>

After

Width:  |  Height:  |  Size: 510 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#FFFFFF"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 14c1.66 0 2.99-1.34 2.99-3L15 5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5.3-3c0 3-2.54 5.1-5.3 5.1S6.7 14 6.7 11H5c0 3.41 2.72 6.23 6 6.72V21h2v-3.28c3.28-.48 6-3.3 6-6.72h-1.7z"/></svg>

After

Width:  |  Height:  |  Size: 348 B

View File

@ -39,6 +39,9 @@ input:focus{
color: #000; color: #000;
background-color: #fff; background-color: #fff;
} }
a{
color: #00BCD4;
}
*{ *{
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: #42b983 #2220; scrollbar-color: #42b983 #2220;

View File

@ -1,11 +1,11 @@
<template> <template>
<img v-if="mxcURL" :src="getAvatarUrl(mxcURL)" class="userThumbnail image"/> <img v-if="mxcURL" :src="getPreviewUrl(mxcURL)" class="userThumbnail image"/>
<div v-else v-html="getJdenticon()" class="userThumbnail identicon"/> <div v-else v-html="getJdenticon()" class="userThumbnail identicon"/>
</template> </template>
<script> <script>
import {toSvg} from 'jdenticon'; import {toSvg} from 'jdenticon';
import {getAvatarUrl} from '@/lib/getMxc'; import {getPreviewUrl} from '@/lib/getMxc';
export default { export default {
name: 'userThumbnail.vue', name: 'userThumbnail.vue',
@ -24,7 +24,7 @@ export default {
getJdenticon(){ getJdenticon(){
return toSvg(this.fallback, this.getFontSize()*this.size); return toSvg(this.fallback, this.getFontSize()*this.size);
}, },
getAvatarUrl getPreviewUrl
}, },
data(){ data(){
return { return {

130
src/components/event.vue Normal file
View File

@ -0,0 +1,130 @@
<template>
<div class="event">
<div v-if="event.type==='m.room.message'" :class="type==='send'?'messageSend':'messageReceive'" class="message">
<reply-event :event="replyEvent" v-if="replyEvent"/>
<event-content :content="event.content"/>
<div class="time">{{getTime(event.origin_server_ts)}}</div>
</div>
<div v-else class="info">
<span v-if="event.type==='m.room.member'">{{membershipEvents[event.content.membership](event)}}</span>
<span v-else>unsupported event</span>
<span class="time"> {{getTime(event.origin_server_ts)}}</span>
</div>
</div>
</template>
<script>
import {matrix} from '@/main';
import {calcUserName} from '@/lib/matrixUtils';
import {parseMessage} from '@/lib/eventUtils';
import {getTime} from '@/lib/getTimeStrings';
import {getMediaUrl} from '@/lib/getMxc';
import ReplyEvent from '@/components/replyEvent';
import EventContent from '@/components/eventContent';
export default {
name: 'message',
components: {EventContent, ReplyEvent},
props: {
type: String,
event: Object,
onUpdate: Function
},
methods:{
async getReplyEvent(event){
let replyId = this.getReplyId(event.content);
return replyId && await matrix.client.fetchRoomEvent(this.event.room_id, replyId);
},
getReplyId(content){
return content['m.relates_to']
&& content['m.relates_to']['m.in_reply_to']
&& content['m.relates_to']['m.in_reply_to'].event_id
},
calcUserName,
parseMessage,
getTime,
getMediaUrl
},
data(){
return{
replyEvent: undefined,
membershipEvents:{
invite(event){ return `invited ${calcUserName(event.target.userId)}` },
join(event){
if (event.content.displayname !== null) return `changed username to ${event.content.displayname}`
return 'joined the room'
},
leave(){ return 'left the room' },
ban(event){return `banned ${calcUserName(event.target.userId)}` }
}
}
},
created(){
this.getReplyEvent(this.event).then((res) => this.replyEvent = res);
},
updated(){
this.onUpdate();
}
}
</script>
<style scoped lang="scss">
.event{
.info {
font-style: italic;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
margin-left: 0.5rem;
.time {
font-size: 0.7rem;
}
}
.message{
position: relative;
width: max-content;
min-width: 2rem;
max-width: calc(100% - 5rem);
padding: 0.8rem 1rem 0.45rem 1rem;
background-color: #42a7b9;
border-radius: 1rem 1rem 0 1rem;
text-align: left;
word-break: break-word;
white-space: pre-line;
margin-top: 0.25rem;
.reply{
border-left: 2px solid #fff;
padding-left: 0.5rem;
margin-bottom: 0.5rem;
.username{
font-weight: bold;
}
}
.time{
position: relative;
bottom: -0.2rem;
font-size: 0.7rem;
text-align: right;
}
.notice{
font-style: italic;
}
}
.messageReceive{
background-color: #424141;
border-radius: 1rem 1rem 1rem 0;
}
.messageSend{
margin-left:auto;
margin-right:0;
background-color: #357882;
border-radius: 1rem 1rem 0 1rem;
}
.notice{
margin-top: 0.5rem;
margin-bottom: 0.5rem;
.time{
font-size: 0.7rem;
}
}
}
</style>

View File

@ -0,0 +1,128 @@
<template>
<div v-if="content.msgtype==='m.text'" 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">
<img :src="getSource(content.url)" :alt="content.body" :class="`${compact?'compact':''}`"/><br>
{{content.body}}
</div>
<div v-else-if="content.msgtype==='m.file'" :class="`file ${compact?'compact':''}`">
<a :href="getSource(content.url)" target="_blank">
<div class="fileContent">
<icon
title="file"
ic="./sym/ic_attach_file_white.svg"
class="download"
/>
<div class="filename">
{{content.filename || getSource(content.url)}}
</div>
</div>
</a>
<div class="text">
{{content.body}}
</div>
</div>
<div v-else-if="content.msgtype==='m.audio'" :class="`audio ${compact?'compact':''}`">
<audio controls :class="`${compact?'compact':''}`">
<source :src="getSource(content.url)" :type="content.mimetype">
your browser doesn't support audio
</audio><br>
{{content.body}}
</div>
<div v-else-if="content.msgtype==='m.video'" :class="`video ${compact?'compact':''}`">
<video controls :class="`${compact?'compact':''}`">
<source :src="getSource(content.url)" :type="content.mimetype">
your browser doesn't support video
</video><br>
{{content.body}}
</div>
<div v-else class="italic">unsupported message type {{content.msgtype}}</div>
</template>
<script>
import {getMediaUrl} from '@/lib/getMxc';
import {parseMessage} from '@/lib/eventUtils';
import Icon from '@/components/icon';
export default {
name: 'eventContent',
components: {Icon},
props: {
content: Object,
compact: {
type: Boolean,
default: false
}
},
methods: {
getSource(url){
return url.includes('mxc')?getMediaUrl(url):url;
},
parseMessage
}
}
</script>
<style scoped lang="scss">
.file{
max-width: 30rem;
.fileContent{
position: relative;
background-color: #1d1d1d;
padding: 0.5rem;
border-radius: 0.5rem;
min-height: 3rem;
.filename{
display: inline-block;
position: relative;
margin-left: 4rem;
top: 0;
height: 100%;
}
.download{
position: absolute;
}
}
.compact{
max-width: 20rem;
}
}
.image{
width: 100%;
img{
max-width: 100%;
height: auto;
max-height: 35rem;
border-radius: 0.5rem;
}
.compact{
max-width: 8rem;
max-height: 8rem;
}
}
.video{
width: 100%;
video{
max-width: 100%;
height: auto;
max-height: 35rem;
border-radius: 0.5rem;
}
.compact{
max-width: 8rem;
max-height: 8rem;
}
}
.audio{
audio{
max-width: 100%;
}
.compact{
max-width: 16rem;
max-height: 8rem;
}
}
.italic{
font-style: italic;
}
</style>

View File

@ -0,0 +1,62 @@
<template>
<div class="fileUpload">
<icon
title="upload media"
class="leftBtn attachFile"
ic="./sym/ic_attach_file_white.svg"
@click.native="$refs.fileInput.click()"
/>
<input
type="file" id="fileInput" ref="fileInput"
@change="setFile({file: $refs.fileInput.files[0]})"
>
</div>
</template>
<script>
import icon from '@/components/icon';
export default {
name: 'soundRecorder',
components: {
icon
},
props:{
onChange: Function
},
methods: {
setFile({file}){
this.readFile(file).then(blob => {
blob.name = file.name;
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>
<style scoped>
.fileUpload{
display: inline-block;
position: relative;
}
.leftBtn{
background-color: unset;
height: 2.5rem;
width: 2.5rem;
box-shadow: none;
}
#fileInput{
display: none;
}
</style>

View File

@ -1,91 +0,0 @@
<template>
<div :class="type==='send'?'messageSend':'messageReceive'" class="message">
<div v-if="replyEvent" class="reply">
<span class="username">{{calcUserName(replyEvent.sender)}}</span><br>
<span v-html="replyEvent.type==='m.room.message'?parseMessage(replyEvent.content.body):'unkown event'"></span>
</div>
<div v-html="parseMessage(msg)"></div>
<div class="time">{{time}}</div>
</div>
</template>
<script>
import {matrix} from '@/main';
import {calcUserName} from '@/lib/matrixUtils';
import {parseMessage} from '@/lib/eventUtils';
export default {
name: 'message',
props: {
msg: String,
time: String,
type: String,
content: Object,
roomId: String
},
methods:{
async getReplyEvent(content) {
let replyId = this.getReplyId(content);
if (replyId === undefined) return undefined;
await matrix.client.fetchRoomEvent(this.roomId, replyId).then((res) => {
this.replyEvent = res;
});
},
getReplyId(content){
if(!content['m.relates_to']) return undefined;
if(!content['m.relates_to']['m.in_reply_to']) return undefined;
return content['m.relates_to']['m.in_reply_to'].event_id;
},
calcUserName,
parseMessage
},
data(){
return{
replyEvent: undefined
}
},
created() {
this.getReplyEvent(this.content);
}
}
</script>
<style scoped>
.message{
position: relative;
width: max-content;
min-width: 2rem;
max-width: calc(100% - 5rem);
padding: 0.7rem 1rem 0.45rem 1rem;
background-color: #42a7b9;
border-radius: 1rem 1rem 0 1rem;
text-align: left;
word-break: break-word;
white-space: pre-line;
margin-top: 0.25rem;
}
.messageReceive{
background-color: #424141;
border-radius: 1rem 1rem 1rem 0;
}
.messageSend{
margin-left:auto;
margin-right:0;
background-color: #357882;
border-radius: 1rem 1rem 0 1rem;
}
.time{
position: relative;
bottom: -0.2rem;
font-size: 0.7rem;
text-align: right;
}
.reply{
border-left: 2px solid #fff;
padding-left: 0.5rem;
margin-bottom: 0.5rem;
}
.username{
font-weight: bold;
}
</style>

View File

@ -1,24 +1,47 @@
<template> <template>
<div class="newMessageBanner" ref="newMessageBanner"> <div class="newMessageBanner" ref="newMessageBanner">
<div class="reply" v-if="replyTo" @click="resetReplyTo"> <reply-event v-if="replyTo" :event="replyTo" @click.native="resetReplyTo()"/>
<span class="username">{{calcUserName(replyTo.sender)}}</span><br> <div v-if="attachment" class="attachment">
{{parseMessage(replyTo.content.body)}} <event-content :content="attachment" class="attachmentContent" :compact="true"/>
<icon
title="remove"
class="remove"
ic="./sym/ic_close_white.svg"
@click.native="resetAttachment()"
/>
</div> </div>
<form v-on:submit.prevent="sendMessage()">
<textarea <textarea
@keyup.enter.exact="sendMessage()" @keyup.enter.exact="onSubmit(event)"
@input="resizeMessageBanner(); sendTyping(2000);" @input="resizeMessageBanner(); sendTyping(2000);"
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 ..."
/> />
<icon <icon
v-if="event.content.body && !getRecordingState() || attachment"
type="submit" type="submit"
title="press enter to submit" title="press enter to submit"
class="sendMessageBtn" class="sendMessageBtn"
ic="./sym/ic_send_white.svg" ic="./sym/ic_send_white.svg"
@click.native="onSubmit(event)"
/>
<sound-recorder v-else class="recorder" :on-stop="setAttachment" ref="recorder"/>
<div class="mediaButtons">
<icon
title="toggle emoji"
class="leftBtn emojiToggle"
ic="./sym/ic_insert_emoticon_white.svg"
@click.native="toggleEmojiPicker()"
/>
<fileUpload class="leftBtn" :on-change="setAttachment"/>
</div>
<v-emoji-picker
v-if="showEmojiPicker"
class="emojiPicker"
@select="onSelectEmoji"
:dark="true"
:continuousList="true"
/> />
</form>
</div> </div>
</template> </template>
@ -27,11 +50,21 @@ import icon from '@/components/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 {VEmojiPicker} from 'v-emoji-picker';
import EventContent from '@/components/eventContent';
import SoundRecorder from '@/components/soundRecorder';
import FileUpload from '@/components/fileUpload';
export default { export default {
name: 'newMessage', name: 'newMessage',
components: { components: {
icon FileUpload,
SoundRecorder,
EventContent,
ReplyEvent,
icon,
VEmojiPicker
}, },
props: { props: {
onResize: Function, onResize: Function,
@ -40,21 +73,37 @@ export default {
resetReplyTo: Function resetReplyTo: Function
}, },
methods: { methods: {
sendMessage(){ onSubmit(event){
let content = this.event.content; console.log(event)
if (!content.body.trim()) return; event.content.msgtype==='m.text'?this.sendEvent(event):this.sendMediaEvent(event);
matrix.sendEvent(this.event, this.roomId, this.replyTo); },
content.body = ''; async sendEvent(event){
if (!event.content.body.trim()) return;
if (this.replyTo) this.setReplyTo(this.replyTo);
matrix.sendEvent(new Proxy(this.event, this.eventProxyHandler), this.roomId);
event.content.body = '';
this.resetAttachment();
this.resetReplyTo(); this.resetReplyTo();
let id = this.$refs.newMessageInput; let id = this.$refs.newMessageInput;
id.style.height = '1.25rem'; id.style.height = '1.25rem';
this.onResize(id.parentElement.clientHeight); this.onResize(id.parentElement.clientHeight);
}, },
sendMediaEvent(event){
matrix.client.uploadContent(this.attachment.blob).then(mxc => {
event.content.url = mxc;
this.sendEvent(event);
});
},
sendTyping(timeout){ sendTyping(timeout){
if (this.waitForSendTyping) return; if (this.waitForSendTyping) return;
matrix.client.sendTyping(this.roomId, true, timeout+100); matrix.client.sendTyping(this.roomId, true, timeout+100);
setTimeout(()=>this.waitForSendTyping=false, timeout); setTimeout(()=>this.waitForSendTyping=false, timeout);
}, },
setReplyTo(event){
this.event.content['m.relates_to'] = {
'm.in_reply_to': event
}
},
resizeMessageBanner(){ resizeMessageBanner(){
let id = this.$refs.newMessageInput; let id = this.$refs.newMessageInput;
id.style.height = '1.25rem'; id.style.height = '1.25rem';
@ -70,6 +119,41 @@ export default {
onSelectEmoji(emoji) { onSelectEmoji(emoji) {
this.event.content.body += emoji.data; this.event.content.body += emoji.data;
}, },
getRecordingState(){
return this.$refs.recorder && this.$refs.recorder.isRecording
},
async setAttachment({blob, file = blob}){
this.attachment = {
msgtype: this.getMsgType(file.type),
mimetype: file.type,
url: window.URL.createObjectURL(file),
filename: file.name,
blob,
file
};
this.event.content = {
body: file.name,
msgtype: this.attachment.msgtype,
mimetype: this.attachment.mimetype,
filename: file.name
};
},
resetAttachment(){
if (!this.attachment) return;
window.URL.revokeObjectURL(this.attachment.file);
this.event.content = {
body: this.attachment?this.event.content.body.replace(this.attachment.filename, ''):'',
msgtype: 'm.text'
};
this.attachment = undefined;
},
getMsgType(fileType){
return {
'image': 'm.image',
'audio': 'm.audio',
'video': 'm.video'
}[fileType.split('/', 1)[0]] || 'm.file';
},
parseMessage, parseMessage,
calcUserName calcUserName
}, },
@ -81,20 +165,29 @@ export default {
body: '', body: '',
msgtype: 'm.text', msgtype: 'm.text',
'm.relates_to': { 'm.relates_to': {
'm.in_reply_to': { 'm.in_reply_to': undefined
event_id: undefined
}
} }
} }
}, },
showEmojiPicker: false, showEmojiPicker: false,
waitForSendTyping: false waitForSendTyping: false,
attachment: undefined,
eventProxyHandler: {
set: () => true,
get: (target, key) => {
if (typeof target[key] === 'object') return new Proxy(Object.assign({}, target[key]), this.eventProxyHandler);
return target[key];
} }
} }
} }
},
updated() {
this.resizeMessageBanner();
}
}
</script> </script>
<style scoped> <style scoped lang="scss">
.newMessageBanner{ .newMessageBanner{
position: absolute; position: absolute;
bottom: 0; bottom: 0;
@ -110,10 +203,10 @@ export default {
margin-top: 1.25rem; margin-top: 1.25rem;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
padding: 0; padding: 0;
left: 1rem; left: 5.5rem;
min-height: 1.5rem; min-height: 1.5rem;
max-height: 10rem; max-height: 10rem;
width: calc(100% - 7rem); width: calc(100% - 10rem);
height: 1.25rem; height: 1.25rem;
background-color: #fff0; background-color: #fff0;
border: 0 solid #fff0; border: 0 solid #fff0;
@ -145,4 +238,59 @@ export default {
.username{ .username{
font-weight: bold; font-weight: bold;
} }
.emojiPicker{
position: absolute;
bottom: calc(100% + 0.25rem);
left: 0.25rem;
z-index: 10;
max-width: calc(100% - 0.5rem - 2px);
}
.mediaButtons{
position: absolute;
left: 0;
bottom: 0;
height: fit-content;
width: fit-content;
}
.leftBtn{
position: relative;
left: 0.25rem;
bottom: 0.5rem;
background-color: unset;
height: 2.5rem;
width: 2.5rem;
box-shadow: none;
}
.recorder{
position: absolute;
height: 100%;
bottom: 0;
right: 0;
border-radius: 0 1rem 0 0;
}
.attachment{
top: 0.5rem;
left: 0.5rem;
position: relative;
width: fit-content;
height: fit-content;
.attachmentContent{
position: relative;
width: fit-content;
}
.remove{
position: absolute;
top: 0;
right: -3rem;
background-color: unset;
height: 2.5rem;
width: 2.5rem;
}
}
img{
max-width: 10rem;
height: auto;
max-height: 4rem;
border-radius: 0.5rem;
}
</style> </style>

View File

@ -0,0 +1,34 @@
<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>

View File

@ -0,0 +1,49 @@
<template>
<div class="reply">
<span class="username">{{calcUserName(event.sender)}}</span><br>
<span v-if="event.type==='m.room.message'">
<span v-if="event.content.msgtype==='m.text'" v-html="parseMessage(event.content.body)"/>
<span v-else-if="event.content.msgtype==='m.notice'" class="italic" v-html="parseMessage(event.content.body)"/>
<span v-else>
<span class="italic">{{event.content.msgtype}}</span><br>
<span>{{event.content.body}}</span>
</span>
</span>
<span v-else>unsupported event</span>
</div>
</template>
<script>
import {calcUserName} from '@/lib/matrixUtils';
import {parseMessage} from '@/lib/eventUtils';
export default {
name: 'replyEvent',
props:{
event: Object
},
methods:{
calcUserName,
parseMessage
}
}
</script>
<style scoped lang="scss">
.reply{
border-left: 2px solid #fff;
padding-left: 0.5rem;
margin-bottom: 0.5rem;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 6;
-webkit-box-orient: vertical;
.username{
font-weight: bold;
}
}
.italic{
font-style: italic;
}
</style>

View File

@ -14,13 +14,13 @@
</template> </template>
<script> <script>
import avatar from "@/components/avatar"; import avatar from '@/components/avatar';
import {getMxcFromRoom} from "@/lib/getMxc"; import {getMxcFromRoom} from '@/lib/getMxc';
import {getTime} from "@/lib/getTimeStrings"; import {getTime} from '@/lib/getTimeStrings';
import {calcUserName} from "@/lib/matrixUtils"; import {calcUserName} from '@/lib/matrixUtils';
export default { export default {
name: "userListElement", name: 'roomListElement',
components:{ components:{
avatar avatar
}, },
@ -34,8 +34,8 @@ export default {
return `${this.calcUserName(event.sender)}: ${event.content.body||'unknown event'} ${getTime(event.origin_server_ts)}`; return `${this.calcUserName(event.sender)}: ${event.content.body||'unknown event'} ${getTime(event.origin_server_ts)}`;
}, },
getLatestEvent(room){ getLatestEvent(room){
if (!room.timeline[room.timeline.length-1]) return undefined; return room.timeline[room.timeline.length-1]
return room.timeline[room.timeline.length-1].event; && room.timeline[room.timeline.length-1].event;
}, },
calcUserName, calcUserName,
getMxcFromRoom getMxcFromRoom

View File

@ -0,0 +1,114 @@
<template>
<div class="recorder">
<icon
v-if="!isRecording"
title="record voice"
class="recordBtn start"
ic="./sym/ic_mic_white.svg"
@click.native="startRecording()"
ref="startRecord"
/>
<div v-else class="voiceMeterContainer">
<div class="voiceMeter" ref="voiceMeter"></div>
<icon
title="record voice"
class="recordBtn stop"
ic="./sym/ic_mic_white.svg"
@click.native="stopRecording()"
ref="stopRecord"
/>
</div>
</div>
</template>
<script>
import icon from '@/components/icon';
import Recorder from 'recorder-js';
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
export default {
name: 'soundRecorder',
components: {
icon
},
props: {
onStart: {
type: Function,
default: ()=>{}
},
onStop: {
type: Function,
default: ()=>{}
}
},
methods: {
startRecording(){
this.onStart();
navigator.mediaDevices.getUserMedia({audio: true})
.then(stream => {
this.recorder.init(stream);
this.recorder.start().then(()=>this.isRecording=true);
})
.catch(err => console.log('unable to get stream', err));
},
stopRecording(){
this.recorder.stop()
.then(({blob}) => {
blob.name = `Recording-${new Date().toISOString()}.${blob.type.split('/')[1]}`;
this.onStop({blob});
this.isRecording=false;
});
},
setVoiceMeter(value){
if (!this.$refs.stopRecord) return;
this.$refs.voiceMeter.style.height = `calc(3rem + ${value/4}px`;
this.$refs.voiceMeter.style.width = `calc(3rem + ${value/4}px`;
},
},
data(){
return{
recorder: new Recorder(audioContext, {
onAnalysed: data => this.setVoiceMeter(data.lineTo)
}),
isRecording: false
}
}
}
</script>
<style scoped lang="scss">
.recordBtn{
position: absolute;
right: 1rem;
bottom: 0.25rem;
background-color: #1d1d1d;
border-radius: 50%;
}
.recordBtn.stop{
right: 0;
bottom: 0;
background-color: #c63e3e;
box-shadow: none;
}
.voiceMeter{
position: absolute;
background-color: #fff;
top: 50%;
left: 50%;
transform: translate(-50%,-50%);
border-radius: 50%;
box-shadow: 3px 3px 10px #111;
}
.voiceMeterContainer{
position: absolute;
height: 3rem;
width: 3rem;
bottom: 0.25rem;
right: 1rem;
}
.recorder{
height: 100%;
width: 8rem;
overflow: hidden;
}
</style>

View File

@ -24,38 +24,23 @@
> >
{{calcUserName(group[0].sender)}} {{calcUserName(group[0].sender)}}
</div> </div>
<div <event
:class="groupTimeline?'indent event':'event'"
v-for="event in group" v-for="event in group"
:key="event.origin_server_ts" :key="event.origin_server_ts"
:class="groupTimeline?'indent event':'event'"
:title="`${group[0].sender} at ${getTime(event.origin_server_ts)}`" :title="`${group[0].sender} at ${getTime(event.origin_server_ts)}`"
@contextmenu.prevent="setReplyTo(event)" @contextmenu.prevent.native="setReplyTo(event)"
>
<message
v-if="event.content.msgtype==='m.text'"
:type="event.sender === user?'send':'receive'" :type="event.sender === user?'send':'receive'"
:msg=event.content.body :time=getTime(event.origin_server_ts) :event="event"
:content="event.content" :on-update="onUpdate"
:roomId="roomId"
/> />
<div v-else-if="event.content.msgtype==='m.notice'" class="notice">
{{event.content.body}}
<span class="time">{{getTime(event.origin_server_ts)}}</span>
</div>
<div v-else-if="event.type==='m.room.member'" class="info">
{{membershipEvents[event.content.membership]}}
<span class="time">{{getTime(event.origin_server_ts)}}</span>
</div>
<div v-else class="info">unknown event
<span class="time">{{getTime(event.origin_server_ts)}}</span></div>
</div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import message from '@/components/message'; import event from '@/components/event';
import avatar from '@/components/avatar'; import avatar from '@/components/avatar';
import splitArray from '@/lib/splitArray'; import splitArray from '@/lib/splitArray';
import {getDate, getTime} from '@/lib/getTimeStrings'; import {getDate, getTime} from '@/lib/getTimeStrings';
@ -64,15 +49,15 @@ import {getUser, calcUserName} from '@/lib/matrixUtils';
export default { export default {
name: 'eventGroup', name: 'eventGroup',
components: { components: {
message, event,
avatar avatar
}, },
props: { props: {
timeline: Array, timeline: Array,
user: String, user: String,
groupTimeline: Boolean, groupTimeline: Boolean,
roomId: String, setReplyTo: Function,
setReplyTo: Function onUpdate: Function
}, },
methods: { methods: {
getUser, getUser,
@ -80,16 +65,6 @@ export default {
splitArray, splitArray,
getDate, getDate,
getTime getTime
},
data(){
return {
membershipEvents:{
invite: 'was invented',
join: 'joined the room',
leave: 'left the room',
ban: 'was banned'
}
}
} }
} }
</script> </script>
@ -134,26 +109,13 @@ export default {
} }
} }
.username{ .username{
position: relative;
margin-left: 1rem; margin-left: 1rem;
font-weight: bold; font-weight: bold;
} max-width: 100%;
.event{ text-overflow: ellipsis;
.info { white-space: nowrap;
font-style: italic; overflow: hidden;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
margin-left: 0.5rem;
.time {
font-size: 0.7rem;
}
}
.notice{
margin-top: 0.5rem;
margin-bottom: 0.5rem;
.time{
font-size: 0.7rem;
}
}
} }
.indent{ .indent{
margin-left: 2.5rem; margin-left: 2.5rem;

View File

@ -15,10 +15,10 @@
</template> </template>
<script> <script>
import avatar from "@/components/avatar"; import avatar from '@/components/avatar';
export default { export default {
name: "userListElement", name: 'userListElement',
components:{ components:{
avatar avatar
}, },

View File

@ -1,4 +1,4 @@
import {getMxcFromUserId, getAvatarUrl} from '@/lib/getMxc'; import {getMxcFromUserId, getPreviewUrl} from '@/lib/getMxc';
import {calcUserName} from '@/lib/matrixUtils'; import {calcUserName} from '@/lib/matrixUtils';
import {getRoom} from '@/lib/matrixUtils'; import {getRoom} from '@/lib/matrixUtils';
import {router} from '@/router'; import {router} from '@/router';
@ -25,7 +25,7 @@ export class NotificationHandler{
let mxc = getMxcFromUserId(event.sender); let mxc = getMxcFromUserId(event.sender);
new Notification(`${calcUserName(event.sender)} in ${getRoom(event.room_id).name}`, { new Notification(`${calcUserName(event.sender)} in ${getRoom(event.room_id).name}`, {
body: event.content.body, body: event.content.body,
icon: mxc?getAvatarUrl(mxc):undefined icon: mxc?getPreviewUrl(mxc):undefined
}).onclick = ()=>router.push(`/rooms/${event.room_id}`); }).onclick = ()=>router.push(`/rooms/${event.room_id}`);
} }
showNativeNotification(event){ showNativeNotification(event){

View File

@ -8,12 +8,21 @@ export function solveTextLinks(text){
} }
return `${space}<a href="${hyperlink}" target="_blank">${url}</a>`; return `${space}<a href="${hyperlink}" target="_blank">${url}</a>`;
} }
)
}
export function parseMessage(msg){
return solveTextLinks(
msg.replace(/>.*\n/gm, '').trim()
.replace(/</g, '&lt')
.replace(/>/g, '&gt')
); );
} }
export function solveMarkdownLinks(text){
return (text || '').replace(
/\[([\w\s\d/\\._+-]+)]\(((?:\/|https?:\/\/)[\w\d/.?=#_+-]+)\)/gi,
(match, text, url)=>{
return `<a href="${url}" target="_blank">${text}</a>`;
}
);
}
export function fixHtml(text){
return text.replace(/> .*\n/gm, '').trim()
.replace(/</g, '&lt')
.replace(/>/g, '&gt');
}
export function parseMessage(text){
return solveMarkdownLinks(solveTextLinks(fixHtml(text)));
}

View File

@ -1,6 +1,5 @@
import sdk from 'matrix-js-sdk' import sdk from 'matrix-js-sdk'
import {matrix} from '@/main'; import {matrix} from '@/main';
import parseMXC from '@modular-matrix/parse-mxc';
export function getMxcFromUser(user){ export function getMxcFromUser(user){
return user.avatarUrl; return user.avatarUrl;
@ -19,8 +18,10 @@ export function getMxcFromRoomId(roomId){
return getMxcFromRoom(matrix.client.getRoom(roomId)); return getMxcFromRoom(matrix.client.getRoom(roomId));
} }
export function getAvatarUrl(mxcUrl, size = 64, resizeMethod = 'crop'){ export function getPreviewUrl(mxcUrl, size = 64, resizeMethod = 'crop'){
let mxc = parseMXC.parse(mxcUrl); return matrix.client.mxcUrlToHttp(mxcUrl, size, size, resizeMethod);
return `${matrix.baseUrl}/_matrix/media/v1/thumbnail/${ }
mxc.homeserver}/${mxc.id}?width=${size}&height=${size}&method=${resizeMethod}`;
export function getMediaUrl(mxcUrl){
return matrix.client.mxcUrlToHttp(mxcUrl);
} }

View File

@ -1,5 +1,5 @@
import matrix from 'matrix-js-sdk'; import matrix from 'matrix-js-sdk';
import {NotificationHandler} from "@/lib/NotificationHandler"; import {NotificationHandler} from '@/lib/NotificationHandler';
export class MatrixHandler { export class MatrixHandler {
constructor(clientDisplayName = 'matrix-chat') { constructor(clientDisplayName = 'matrix-chat') {
@ -67,7 +67,7 @@ export class MatrixHandler {
this.rooms = this.client.getRooms(); this.rooms = this.client.getRooms();
this.loading = false; this.loading = false;
callback(); callback();
this.listenToPushEvents() this.listenToPushEvents();
}); });
} }
listenToPushEvents(){ listenToPushEvents(){
@ -77,17 +77,9 @@ export class MatrixHandler {
} }
}); });
} }
async sendEvent({content, type}, roomId, replyTo = undefined){ async sendEvent({content, type}, roomId){
await this.client.sendEvent(roomId, type, { return await this.client.sendEvent(roomId, type, content)
body: content.body.trim(), .then(() => console.log('message sent successfully'))
msgtype: content.msgtype, .catch((err) => console.log(`error while sending message => ${err}`));
'm.relates_to': {
'm.in_reply_to': {
event_id: replyTo?replyTo.event_id:undefined
}
}
}).then(() => console.log('message sent successfully'))
.catch((err) => console.log(`error while sending message => ${err}`)
);
} }
} }

View File

@ -10,3 +10,9 @@ export function calcUserName(userId){
export function getRoom(roomId){ export function getRoom(roomId){
return matrix.client.getRoom(roomId); return matrix.client.getRoom(roomId);
} }
export function isValidUserId(id){
return id.match(/^@[a-zA-Z0-9_.+-]+:[a-z0-9.-]+\.[a-z]+$/);
}
export function isValidRoomId(id){
return id.match(/^(#|!)[a-zA-Z0-9_.+-]+:[a-z0-9.-]+\.[a-z]+$/);
}

View File

@ -7,16 +7,17 @@
<timeline <timeline
:timeline="room.timeline" :group-timeline="isGroup()" :timeline="room.timeline" :group-timeline="isGroup()"
:user="user" :roomId="room.roomId" :user="user" :roomId="room.roomId"
:setReplyTo="event=>{replyTo=event; resize();}" :setReplyTo="setReplyTo"
:on-update="()=>$nextTick(resize)"
/> />
</div> </div>
<icon v-if="showScrollBtn" @click.native="scroll.scrollToBottom()" id="scrollDown" ic="./sym/ic_expand_more_black.svg" /> <icon v-if="showScrollBtn" @click.native="scroll.scrollToBottom()" id="scrollDown" ic="./sym/ic_expand_more_black.svg" />
</div> </div>
<newMessage <newMessage
:onResize="height=>resize(height)" :roomId="room.roomId" ref="newMessage" :onResize="resize" :roomId="room.roomId" ref="newMessage"
:replyTo="replyTo" :resetReplyTo="()=>replyTo=undefined" :replyTo="replyTo" :resetReplyTo="resetReplyTo"
/> />
<topBanner :room="room" :close-chat="()=>closeChat()" :open-chat-info="()=>openChatInfo()"/> <topBanner :room="room" :close-chat="closeChat" :open-chat-info="openChatInfo"/>
</div> </div>
</template> </template>
@ -28,6 +29,7 @@ import {matrix} from '@/main';
import splitArray from '@/lib/splitArray.js' import splitArray from '@/lib/splitArray.js'
import timeline from '@/components/timeline'; import timeline from '@/components/timeline';
import scrollHandler from '@/lib/scrollHandler'; import scrollHandler from '@/lib/scrollHandler';
import {getUser} from "@/lib/matrixUtils";
export default { export default {
name: 'chat', name: 'chat',
@ -48,8 +50,9 @@ export default {
if (this.$refs.timelineContainer.scrollTop < 400 && this.loadingStatus !== 'loading') this.loadEvents(); if (this.$refs.timelineContainer.scrollTop < 400 && this.loadingStatus !== 'loading') this.loadEvents();
this.showScrollBtn = this.scroll.getScrollBottom() > 500; this.showScrollBtn = this.scroll.getScrollBottom() > 500;
}, },
resize(height = this.$refs.newMessage.clientHeight){ resize(height = this.$refs.newMessage.$refs.newMessageBanner.clientHeight){
this.$refs.chatContainer.style.height = `calc(100% - ${height}px - 3.5rem)`; this.$refs.chatContainer.style.height = `calc(100% - ${height}px - 3.5rem)`;
this.manageScrollBottom();
}, },
isGroup(){ isGroup(){
return Object.keys(this.room.currentState.members).length > 2; return Object.keys(this.room.currentState.members).length > 2;
@ -61,9 +64,19 @@ export default {
this.loadingStatus = 'load more'; this.loadingStatus = 'load more';
this.scroll.setScrollBottom(scrollBottom) this.scroll.setScrollBottom(scrollBottom)
}, },
getUser(userId){ setReplyTo(event){
return matrix.client.getUser(userId); this.replyTo=event;
this.$refs.newMessage.focusInput();
this.$nextTick(this.resize);
}, },
resetReplyTo(){
this.replyTo=undefined;
this.$nextTick(this.resize);
},
manageScrollBottom(){
if(this.scroll.getScrollBottom() < 400 && this.loadingStatus !== 'loading') this.scroll.scrollToBottom();
},
getUser,
splitArray splitArray
}, },
data(){ data(){
@ -76,7 +89,7 @@ export default {
} }
}, },
updated(){ updated(){
if(this.scroll.getScrollBottom() < 400 && this.loadingStatus !== 'loading') this.scroll.scrollToBottom(); this.manageScrollBottom();
}, },
mounted(){ mounted(){
this.scroll = new scrollHandler(this.$refs.timelineContainer); this.scroll = new scrollHandler(this.$refs.timelineContainer);

View File

@ -22,11 +22,12 @@
<script> <script>
import textbtn from '@/components/textbtn'; import textbtn from '@/components/textbtn';
import {matrix} from '@/main.js'; import {matrix} from '@/main.js';
import {cookieHandler} from "@/lib/cookieHandler"; import {cookieHandler} from '@/lib/cookieHandler';
import ThrobberOverlay from "@/components/throbberOverlay"; import ThrobberOverlay from '@/components/throbberOverlay';
import {isValidUserId} from '@/lib/matrixUtils';
export default { export default {
name: "login.vue", name: 'login.vue',
components: { components: {
ThrobberOverlay, ThrobberOverlay,
textbtn textbtn
@ -42,7 +43,7 @@ export default {
} if (this.password === '') { } if (this.password === '') {
this.loginError = 'password is empty'; this.loginError = 'password is empty';
return; return;
} if (!(this.user.match(/^@[a-zA-Z0-9]+:[a-z0-9]+\.[a-z]/))) { } if (!isValidUserId(this.user)) {
this.loginError = 'username is in wrong style'; this.loginError = 'username is in wrong style';
return; return;
} }
@ -83,10 +84,10 @@ export default {
}, },
data(){ data(){
return { return {
user: "", user: '',
password: "", password: '',
homeServer: "https://adb.sh", homeServer: 'https://adb.sh',
loginError: "", loginError: '',
cookie: new cookieHandler(), cookie: new cookieHandler(),
loading: false loading: false
} }

View File

@ -6,16 +6,34 @@
<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">
<div <p class="wideElement">rooms </p>
v-for="room in Object.assign([], matrix.rooms)
.sort(obj => obj.timeline[obj.timeline.length-1].event.origin_server_ts)"
:key="room.roomId" @click="openChat(room)"
>
<room-list-element <room-list-element
v-if="!search || room.name.toLowerCase().includes(search.toLowerCase().trim())" v-for="room in Object.assign([], matrix.client.getRooms())
.sort(obj => obj.timeline[obj.timeline.length-1].event.origin_server_ts)
.filter(prop=>matchResults(prop.name, search)||prop.roomId===search)"
:key="room.roomId" @click.native="openChat(room)"
:room="room" :room="room"
class="roomListElement" class="roomListElement"
/> />
<div v-if="search">
<p class="wideElement">users </p><p class="smallElement"></p>
<user-list-element
v-for="user in matrix.client.getUsers()
.filter(prop=>matchResults(prop.displayName, search)||matchResults(prop.userId, search))
.slice(0,10)"
:user="user" :key="user.userId"
@click.native="setQuestion(`create private chat with '${user.displayName}'?`,()=>createRoom({user}))"
/>
<p class="wideElement">suggestions </p><p class="smallElement"></p>
<div class="wideElement">
<p v-if="isValidUserId(search)">create chat: {{search}} </p>
<p v-if="isValidRoomId(search)"
@click="setQuestion(`join room '${search}'?`, ()=>joinRoom(search))"
>join room: {{search}} </p>
<p v-if="search.match(/^[a-zA-Z0-9_.+-]+$/)"
@click="setQuestion(`create room '${search}'?`,()=>createRoom({name: search}))"
>create room: {{search}} </p>
</div>
</div> </div>
</div> </div>
<chat <chat
@ -28,21 +46,27 @@
/> />
<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"/> <chatInformation v-if="showRoom && showChatInfo" :room="getCurrentRoom()" :close-chat-info="()=>showChatInfo=false"/>
<popup-question v-if="popup.question" :callback="popup.callback" :question="popup.question" class="center"/>
</div> </div>
</template> </template>
<script> <script>
import chat from '@/views/chat.vue'; import chat from '@/views/chat.vue';
import chatInformation from "@/components/chatInformation"; import chatInformation from '@/components/chatInformation';
import {matrix} from "@/main"; import {matrix} from '@/main';
import ThrobberOverlay from "@/components/throbberOverlay"; import ThrobberOverlay from '@/components/throbberOverlay';
import {getMxcFromRoom} from "@/lib/getMxc"; import {getMxcFromRoom} from '@/lib/getMxc';
import roomListElement from "@/components/roomListElement"; import roomListElement from '@/components/roomListElement';
import {getRoom} from "@/lib/matrixUtils"; import {getRoom, getUser} from '@/lib/matrixUtils';
import {isValidUserId, isValidRoomId} from '@/lib/matrixUtils';
import userListElement from '@/components/userListElement';
import PopupQuestion from '@/components/popupQuestion';
export default { export default {
name: "rooms", name: 'rooms',
components:{ components:{
PopupQuestion,
userListElement,
ThrobberOverlay, ThrobberOverlay,
chat, chat,
chatInformation, chatInformation,
@ -56,21 +80,52 @@ export default {
this.$nextTick(() => this.showRoom = true); this.$nextTick(() => this.showRoom = true);
this.search = ''; this.search = '';
}, },
getMxcFromRoom,
getRoom,
getCurrentRoom(){ getCurrentRoom(){
return getRoom(this.$route.path.split('/')[2]); return getRoom(this.$route.path.split('/')[2]);
}, },
closeChat(){ closeChat(){
this.$router.push('/rooms'); this.$router.push('/rooms');
},
matchResults(prop, search){
return prop.toLowerCase().includes(search.toLowerCase().trim());
},
setQuestion(question, callback){
this.popup = {
question,
callback:(res)=>{
this.popup = false;
if (res) callback();
} }
}
},
joinRoom(room){
this.matrix.client.join(room).then(()=>{
this.openChat(getRoom(room.room_id));
});
},
async createRoom({name = '', user = undefined}){
return this.matrix.client.createRoom({name}).then(room => {
if (user) this.matrix.client.invite(room.room_id, user.userId);
this.openChat(getRoom(room.room_id));
return room;
});
},
getMxcFromRoom,
getRoom,
getUser,
isValidUserId,
isValidRoomId
}, },
data(){ data(){
return { return {
matrix, matrix,
showChatInfo: false, showChatInfo: false,
showRoom: true, showRoom: true,
search: '' search: '',
popup:{
question: '',
callback: ()=>{}
}
} }
}, },
mounted() { mounted() {
@ -89,6 +144,8 @@ export default {
background-color: #222; background-color: #222;
text-align: center; text-align: center;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden;
text-overflow: ellipsis;
} }
.chat{ .chat{
position: absolute; position: absolute;
@ -122,6 +179,13 @@ input{
.smallElement{ .smallElement{
display: none; display: none;
} }
.center{
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%,-50%);
z-index: 50;
}
@media (max-width: 48rem) and (min-width: 30rem) { @media (max-width: 48rem) and (min-width: 30rem) {
.wideElement{ .wideElement{
@ -134,7 +198,6 @@ input{
z-index: 30; z-index: 30;
width: 4rem; width: 4rem;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden;
scrollbar-width: none; scrollbar-width: none;
} }
.roomList:hover{ .roomList:hover{