Compare commits

..

No commits in common. "master" and "add-admin-interface" have entirely different histories.

52 changed files with 706 additions and 1987 deletions

3
.gitmodules vendored
View File

@ -1,3 +0,0 @@
[submodule "android"]
path = android
url = https://git.adb.sh/adb/matrix-chat-android.git

View File

@ -2,13 +2,6 @@
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

@ -1 +0,0 @@
Subproject commit df7bff7aadc07c585760f73fdb5fa287a63c8b95

View File

@ -1,14 +0,0 @@
{
"appId": "sh.adb.matrixChat",
"appName": "matrix-chat",
"bundledWebRuntime": false,
"npmClient": "npm",
"webDir": "./dist",
"linuxAndroidStudioPath": "/home/alban/.local/share/JetBrains/Toolbox/apps/AndroidStudio/ch-0/201.7199119/bin/studio.sh",
"plugins": {
"SplashScreen": {
"launchShowDuration": 0
}
},
"cordova": {}
}

View File

@ -8,9 +8,6 @@
"lint": "vue-cli-service lint" "lint": "vue-cli-service lint"
}, },
"dependencies": { "dependencies": {
"@capacitor/android": "^2.4.7",
"@capacitor/cli": "^2.4.7",
"@capacitor/core": "^2.4.7",
"@modular-matrix/parse-mxc": "^1.0.1", "@modular-matrix/parse-mxc": "^1.0.1",
"@vue-polkadot/vue-identicon": "^0.0.8", "@vue-polkadot/vue-identicon": "^0.0.8",
"core-js": "^3.6.5", "core-js": "^3.6.5",
@ -19,7 +16,6 @@
"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"
}, },
@ -38,7 +34,6 @@
"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

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

Before

Width:  |  Height:  |  Size: 409 B

View File

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

Before

Width:  |  Height:  |  Size: 510 B

View File

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

Before

Width:  |  Height:  |  Size: 348 B

View File

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

View File

@ -1,30 +1,36 @@
<template> <template>
<img v-if="mxcURL" :src="getPreviewUrl(mxcURL)" class="userThumbnail image"/> <img v-if="mxcURL" :src="thumbnailUrl()" 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 parseMXC from '@modular-matrix/parse-mxc';
import {matrix} from "@/main";
import {toSvg} from 'jdenticon'; import {toSvg} from 'jdenticon';
import {getPreviewUrl} from '@/lib/getMxc';
export default { export default {
name: 'userThumbnail.vue', name: "userThumbnail.vue",
components: { components: {
}, },
props: { props: {
mxcURL: String, mxcURL: String,
username: String, username: String,
fallback: String, fallback: String,
homeserver: String,
size: Number size: Number
}, },
methods: { methods: {
thumbnailUrl(){
let mxc = parseMXC.parse(this.mxcURL);
return `${this.homeserver||matrix.baseUrl}/_matrix/media/v1/thumbnail/${
mxc.homeserver}/${mxc.id}?width=${this.imageSize}&height=${this.imageSize}&method=${this.resizeMethod}`;
},
getFontSize(){ getFontSize(){
return window.getComputedStyle(document.body,null).fontSize.split('px', 1)||16; return window.getComputedStyle(document.body,null).fontSize.split("px", 1)||16;
}, },
getJdenticon(){ getJdenticon(){
return toSvg(this.fallback, this.getFontSize()*this.size); return toSvg(this.fallback, this.getFontSize()*this.size);
}, }
getPreviewUrl
}, },
data(){ data(){
return { return {

View File

@ -1,140 +0,0 @@
<template>
<div>
<div ref="chatContainer" class="chatContainer">
<div @scroll="onScroll()" ref="timelineContainer" class="timelineContainer">
<div v-if="loadingStatus" @click="loadEvents()" class="loadMore">{{loadingStatus}}</div>
<p v-if="room.timeline.length === 0" class="chatInfo">this room is empty</p>
<timeline
:timeline="room.timeline" :group-timeline="isGroup()"
:user="user" :roomId="room.roomId"
:setReplyTo="setReplyTo"
:on-update="()=>$nextTick(resize)"
/>
</div>
<icon v-if="showScrollBtn" @click.native="scroll.scrollToBottom()" id="scrollDown" ic="./sym/ic_expand_more_black.svg" />
</div>
<newMessage
:onResize="resize" :roomId="room.roomId" ref="newMessage"
:replyTo="replyTo" :resetReplyTo="resetReplyTo"
/>
<topBanner :room="room" :close-chat="closeChat" :open-chat-info="openChatInfo"/>
</div>
</template>
<script>
import newMessage from '@/components/chat/newMessage.vue';
import topBanner from '@/components/chat/topBanner.vue';
import Icon from '@/components/layout/icon';
import {matrix} from '@/main.js';
import splitArray from '@/lib/splitArray.js'
import timeline from '@/components/chat/timeline';
import scrollHandler from '@/lib/scrollHandler';
import {getUser} from '@/lib/matrixUtils';
export default {
name: 'chat',
components: {
timeline,
Icon,
newMessage,
topBanner
},
props: {
room: [Object, undefined],
user: String,
closeChat: Function,
openChatInfo: Function
},
methods:{
onScroll(){
if (this.$refs.timelineContainer.scrollTop < 400 && this.loadingStatus !== 'loading') this.loadEvents();
this.showScrollBtn = this.scroll.getScrollBottom() > 500;
},
resize(height = this.$refs.newMessage.$refs.newMessageBanner.clientHeight){
this.$refs.chatContainer.style.height = `calc(100% - ${height}px - 3.5rem)`;
this.manageScrollBottom();
},
isGroup(){
return Object.keys(this.room.currentState.members).length > 2;
},
async loadEvents(){
let scrollBottom = this.scroll.getScrollBottom();
this.loadingStatus = 'loading';
await matrix.client.scrollback(this.room, 30);
this.loadingStatus = 'load more';
this.scroll.setScrollBottom(scrollBottom)
},
setReplyTo(event){
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
},
data(){
return {
showScrollBtn: false,
scrollOnUpdate: true,
loadingStatus: 'load more',
scroll: ()=>{},
replyTo: undefined
}
},
updated(){
this.manageScrollBottom();
},
mounted(){
this.scroll = new scrollHandler(this.$refs.timelineContainer);
this.scroll.scrollToBottom();
this.onScroll();
},
watch: {
'$route'(){
this.scroll.scrollToBottom();
}
}
}
</script>
<style scoped lang="scss">
.chatContainer{
position: absolute;
margin: 0;
left: 0;
top: 3.5rem;
width: 100%;
height: calc(100% - 7rem);
.timelineContainer{
height: 100%;
overflow-y: auto;
}
#scrollDown{
position: absolute;
background-color: #fff;
bottom: 1rem;
right: 1rem;
display: block;
height: 2rem;
width: 2rem;
}
}
.loadMore{
position: relative;
background-color: #2d2d2d;
padding: 0.5rem;
border-radius: 0.5rem;
width: fit-content;
left: 50%;
transform: translate(-50%,0);
margin-top: 0.5rem;
cursor: pointer;
}
</style>

View File

@ -1,139 +0,0 @@
<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="type==='send'?'info send':'info receive'">
<span v-if="event.type==='m.room.member'">{{membershipEvents[event.content.membership](event)}}</span>
<span v-else>unsupported event: {{event.type}}</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/chat/replyEvent';
import EventContent from '@/components/chat/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 ${event.target?calcUserName(event.target.userId):event.content.displayname||event.state_key}` },
join(event){
if (!event.unsigned.prev_content) 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'
},
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;
}
}
.info.send{
text-align: right;
}
.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

@ -1,149 +0,0 @@
<template>
<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)"/>
<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>
{{content.body}}
</image-viewer>
<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-if="content.msgtype" class="italic">unsupported message type: {{content.msgtype}}</div>
<div v-else class="italic">deleted message</div>
</template>
<script>
import {getMediaUrl} from '@/lib/getMxc';
import {parseMessage} from '@/lib/eventUtils';
import Icon from '@/components/layout/icon';
import imageViewer from '@/components/layout/imageViewer';
export default {
name: 'eventContent',
components: {
Icon,
imageViewer
},
props: {
content: Object,
compact: {
type: Boolean,
default: false
},
onUpdate: {
type: Function,
default: ()=>{}
}
},
methods: {
getSource(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
},
updated() {
this.onUpdate();
}
}
</script>
<style scoped lang="scss">
.emoji{
font-size: 2rem;
}
.bigEmoji{
font-size: 3rem;
}
.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

@ -1,308 +0,0 @@
<template>
<div class="newMessageBanner" ref="newMessageBanner">
<reply-event v-if="replyTo" :event="replyTo" @click.native="resetReplyTo()"/>
<div v-if="attachment" class="attachment">
<event-content :content="attachment" class="attachmentContent" :compact="true" :onUpdate="resizeMessageBanner()"/>
<icon
title="remove"
class="remove"
ic="./sym/ic_close_white.svg"
@click.native="resetAttachment()"
/>
</div>
<textarea
@keyup.enter.exact="onSubmit(event)"
@input="resizeMessageBanner(); sendTyping(2000);"
@paste="onPaste"
v-model="event.content.body"
ref="newMessageInput" class="newMessageInput"
rows="1" placeholder="type a message ..."
/>
<icon
v-if="event.content.body && !getRecordingState() || attachment"
type="submit"
title="press enter to submit"
class="sendMessageBtn"
:ic="isSending?'./sym/throbber.svg':'./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-show="showEmojiPicker"
class="emojiPicker"
@select="onSelectEmoji"
:dark="true"
:continuousList="true"
/>
</div>
</template>
<script>
import icon from '@/components/layout/icon.vue';
import {matrix} from '@/main.js';
import {parseMessage} from '@/lib/eventUtils';
import {calcUserName} from '@/lib/matrixUtils';
import ReplyEvent from '@/components/chat/replyEvent';
import {VEmojiPicker} from 'v-emoji-picker';
import EventContent from '@/components/chat/eventContent';
import SoundRecorder from '@/components/layout/soundRecorder';
import FileUpload from '@/components/layout/fileUpload';
import {readFileBlob} from '@/lib/readFileBlob';
export default {
name: 'newMessage',
components: {
FileUpload,
SoundRecorder,
EventContent,
ReplyEvent,
icon,
VEmojiPicker
},
props: {
onResize: Function,
roomId: String,
replyTo: Object,
resetReplyTo: Function
},
methods: {
onSubmit(event){
if (this.isSending) return;
event.content.msgtype==='m.text'?this.sendEvent(event):this.sendMediaEvent(event);
},
async sendEvent(event){
if (!event.content.body.trim()) return;
this.setReplyTo(this.replyTo);
this.isSending = true;
await matrix.sendEvent(new Proxy(this.event, this.eventProxyHandler), this.roomId);
this.isSending = false;
event.content.body = '';
this.resetAttachment();
this.resetReplyTo();
let id = this.$refs.newMessageInput;
id.style.height = '1.25rem';
this.onResize(id.parentElement.clientHeight);
},
sendMediaEvent(event){
this.isSending = true;
matrix.client.uploadContent(this.attachment.blob).then(mxc => {
event.content.url = mxc;
this.sendEvent(event);
});
},
sendTyping(timeout){
if (this.waitForSendTyping) return;
matrix.client.sendTyping(this.roomId, true, timeout+100);
setTimeout(()=>this.waitForSendTyping=false, timeout);
},
setReplyTo(event){
this.event.content['m.relates_to'] = {
'm.in_reply_to': event
}
},
resizeMessageBanner(){
let id = this.$refs.newMessageInput;
id.style.height = '1.25rem';
id.style.height = `${id.scrollHeight}px`;
this.onResize(this.$refs.newMessageBanner.clientHeight);
},
focusInput(){
this.$refs.newMessageInput.focus();
},
toggleEmojiPicker() {
this.showEmojiPicker = !this.showEmojiPicker;
},
onSelectEmoji(emoji) {
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 this.attachment = undefined;
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';
},
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,
calcUserName
},
data(){
return {
event: {
type: 'm.room.message',
content: {
body: '',
msgtype: 'm.text',
'm.relates_to': {
'm.in_reply_to': undefined
}
}
},
eventProxyHandler: {
set: () => true,
get: (target, key) => {
if (typeof target[key] === 'object') return new Proxy(Object.assign({}, target[key]), this.eventProxyHandler);
return target[key];
}
},
showEmojiPicker: false,
waitForSendTyping: false,
attachment: undefined,
isSending: false
}
},
updated() {
this.$nextTick(this.resizeMessageBanner);
}
}
</script>
<style scoped lang="scss">
.newMessageBanner{
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: min-content;
min-height: 3.5rem;
background-color: #1d1d1d;
border-radius: 1rem 1rem 0 0;
}
.newMessageInput{
position: relative;
margin-top: 1.25rem;
margin-bottom: 0.75rem;
padding: 0;
left: 5.5rem;
min-height: 1.5rem;
max-height: 10rem;
width: calc(100% - 10rem);
height: 1.25rem;
background-color: #fff0;
border: 0 solid #fff0;
appearance: none;
outline: none;
color: #fff;
font-size: 1rem;
resize: none;
vertical-align: middle;
font-family: Avenir, Helvetica, Arial, sans-serif;
}
.sendMessageBtn{
position: absolute;
right: 1rem;
bottom: 0.25rem;
background-color: unset;
}
.reply{
position: relative;
width: calc(100% - 7rem);
left: 1rem;
border-left: 2px solid #fff;
padding-left: 0.5rem;
margin-top: 0.75rem;
margin-right: 0.5rem;
word-break: break-word;
white-space: pre-line;
}
.username{
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>

View File

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

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

View File

@ -1,53 +0,0 @@
<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/layout/icon';
import {readFileBlob} from '@/lib/readFileBlob';
export default {
name: 'soundRecorder',
components: {
icon
},
props:{
onChange: Function
},
methods: {
setFile({file}){
readFileBlob(file).then(blob => {
blob.name = file.name;
this.onChange({blob})
});
}
}
}
</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,41 +0,0 @@
<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>

View File

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

View File

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

View File

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

View File

@ -1,119 +0,0 @@
<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/layout/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.stream = 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.stream.getTracks().map(track => track.stop());
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);
}
}),
stream: undefined,
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

@ -1,55 +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: 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>

View File

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

View File

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

102
src/components/message.vue Normal file
View File

@ -0,0 +1,102 @@
<template>
<div :class="type==='send'?'messageSend':'messageReceive'" class="message">
<div v-if="replyEvent" class="reply">
{{replyEvent.sender}}<br>
{{replyEvent.type==='m.room.message'?replyEvent.content.body:'unkown event'}}
</div>
<div v-html="parseMessage(msg)"></div>
<div class="time">{{time}}</div>
</div>
</template>
<script>
import {matrix} from "@/main";
export default {
name: "message",
props: {
msg: String,
time: String,
type: String,
content: Object,
roomId: String
},
methods:{
solveTextLinks(text){
return (text || "").replace(
/([^\S]|^)(((https?:\/\/)|(www\.))(\S+))/gi,
(match, space, url)=>{
let hyperlink = url;
if (!hyperlink.match('^https?://')) {
hyperlink = 'http://' + hyperlink;
}
return `${space}<a href="${hyperlink}" target="_blank">${url}</a>`;
}
)
},
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;
return content['m.relates_to']['m.in_reply_to'].event_id || undefined;
},
parseMessage(msg){
return this.solveTextLinks(
msg.replace(/>.*\n/gm, '').trim()
.replace(/</g, '&lt')
.replace(/>/g, '&gt')
);
}
},
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: #42b983;
border-radius: 1rem 1rem 1rem 0;
}
.messageSend{
margin-left:auto;
margin-right:0;
background-color: #42a7b9;
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;
}
</style>

View File

@ -0,0 +1,113 @@
<template>
<form class="newMessageBanner" ref="newMessageBanner" v-on:submit.prevent="sendMessage()">
<label for="newMessageInput"></label>
<textarea
@keyup.enter.exact="sendMessage()"
@input="resizeMessageBanner()"
v-model="msg.content.body"
ref="newMessageInput"
id="newMessageInput"
class="newMessageInput"
autocomplete="off"
rows="1"
placeholder="type a message ..."
/>
<icon
type="submit"
title="press enter to submit"
class="sendMessageBtn"
ic="./sym/ic_send_white.svg"
/>
</form>
</template>
<script>
import icon from '@/components/icon.vue';
import {matrix} from '@/main.js';
export default {
name: "newMessage",
components: {
icon
},
props: {
onResize: Function,
roomId: String
},
methods: {
sendMessage(){
let content = this.msg.content;
if (!content.body.trim()) return;
let msgSend = Object.assign({}, this.msg);
matrix.sendEvent(msgSend, this.roomId);
content.body = "";
let id = this.$refs.newMessageInput;
id.style.height = "1.25rem";
this.onResize(id.parentElement.clientHeight);
},
resizeMessageBanner(){
let id = this.$refs.newMessageInput;
id.style.height = '1.25rem';
id.style.height = `${id.scrollHeight}px`;
this.onResize(id.parentElement.clientHeight);
},
toggleEmojiPicker() {
this.showEmojiPicker = !this.showEmojiPicker;
},
onSelectEmoji(emoji) {
this.msg.content.body += emoji.data;
}
},
data(){
return {
msg: {
type: "m.room.message",
content: {
body: "",
msgtype: "m.text"
}
},
showEmojiPicker: false
}
}
}
</script>
<style scoped>
.newMessageBanner{
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: min-content;
min-height: 3.5rem;
background-color: #1d1d1d;
border-radius: 1rem 1rem 0 0;
}
.newMessageInput{
position: relative;
margin-top: 1.25rem;
margin-bottom: 0.75rem;
padding: 0;
left: 2rem;
min-height: 1.5rem;
max-height: 10rem;
width: calc(100% - 7rem);
height: 1.25rem;
background-color: #fff0;
border: 0 solid #fff0;
appearance: none;
outline: none;
color: #fff;
font-size: 1rem;
resize: none;
vertical-align: middle;
font-family: Avenir, Helvetica, Arial, sans-serif;
}
.sendMessageBtn{
position: absolute;
right: 1rem;
bottom: 0.25rem;
background-color: unset;
}
</style>

View File

@ -2,25 +2,25 @@
<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="getMxcFromChat(room)" :mxcURL="getMxcFromRoom(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">{{previewString}}</div> <div class="status">{{getPreviewString(room)}}</div>
</div> </div>
</template> </template>
<script> <script>
import avatar from '@/components/matrix/avatar'; import avatar from "@/components/avatar";
import {getMxcFromChat} from '@/lib/getMxc'; import {getMxcFromRoom} from "@/lib/getMxc";
import {getTime} from '@/lib/getTimeStrings'; import {getTime} from "@/lib/getTimeStrings";
import {calcUserName} from '@/lib/matrixUtils'; import {matrix} from "@/main";
export default { export default {
name: 'roomListElement', name: "userListElement",
components:{ components:{
avatar avatar
}, },
@ -34,19 +34,14 @@ 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){
return room.timeline[room.timeline.length-1] if (!room.timeline[room.timeline.length-1]) return undefined;
&& room.timeline[room.timeline.length-1].event; return room.timeline[room.timeline.length-1].event;
}, },
getMxcFromChat, calcUserName(userId) {
calcUserName, if (matrix.user === userId) return 'you';
}, return matrix.client.getUser(userId).displayName || userId;
data(){ },
return { getMxcFromRoom
previewString: 'loading'
}
},
created() {
this.previewString = this.getPreviewString(this.room);
} }
} }
</script> </script>

View File

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

View File

@ -17,9 +17,6 @@ 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;

View File

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

View File

@ -22,49 +22,68 @@
:class="groupTimeline?'indent username':'username'" :class="groupTimeline?'indent username':'username'"
v-if="group[0].sender !== user && groupTimeline" v-if="group[0].sender !== user && groupTimeline"
> >
{{calcUserName(group[0].sender)}} {{getUser(group[0].sender).displayName || group[0].sender}}
</div> </div>
<event <div
v-for="event in group"
:key="event.origin_server_ts"
:class="groupTimeline?'indent event':'event'" :class="groupTimeline?'indent event':'event'"
v-for="event in group"
:key="event.origin_server_ts"
:title="`${group[0].sender} at ${getTime(event.origin_server_ts)}`" :title="`${group[0].sender} at ${getTime(event.origin_server_ts)}`"
@contextmenu.prevent.native="setReplyTo(event)" >
:type="event.sender === user?'send':'receive'" <message
:event="event" v-if="event.content.msgtype==='m.text'"
:on-update="onUpdate" :type="event.sender === user?'send':'receive'"
/> :msg=event.content.body :time=getTime(event.origin_server_ts)
:content="event.content"
:roomId="roomId"
/>
<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 event from '@/components/chat/event'; import message from "@/components/message";
import avatar from '@/components/matrix/avatar'; import avatar from "@/components/avatar";
import splitArray from '@/lib/splitArray'; import {matrix} from "@/main";
import {getDate, getTime} from '@/lib/getTimeStrings'; import splitArray from "@/lib/splitArray";
import {getUser, calcUserName} from '@/lib/matrixUtils'; import {getDate, getTime} from "@/lib/getTimeStrings";
export default { export default {
name: 'eventGroup', name: 'eventGroup',
components: { components: {
event, message,
avatar avatar
}, },
props: { props: {
timeline: Array, timeline: Array,
user: String, user: String,
groupTimeline: Boolean, groupTimeline: Boolean,
setReplyTo: Function, roomId: String
onUpdate: Function
}, },
methods: { methods: {
getUser, getUser(userId) {
calcUserName, return matrix.client.getUser(userId);
},
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>
@ -109,13 +128,19 @@ export default {
} }
} }
.username{ .username{
position: relative;
margin-left: 1rem; margin-left: 1rem;
font-weight: bold; font-weight: bold;
max-width: 100%; }
text-overflow: ellipsis; .event{
white-space: nowrap; .info{
overflow: hidden; font-style: italic;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
margin-left: 0.5rem;
.time{
font-size: 0.7rem;
}
}
} }
.indent{ .indent{
margin-left: 2.5rem; margin-left: 2.5rem;

View File

@ -1,9 +1,9 @@
<template> <template>
<div class="topBanner"> <div class="topBanner">
<div> <div>
<icon @click.native="closeChat()" class="topIcon" ic="./sym/ic_arrow_back_white.svg" /> <icon @click.native="closeChat()" class="topIcon" ic="./sym/arrow_back-24px.svg" />
<div @click="openChatInfo()" class="container"> <div @click="openChatInfo()" class="container">
<avatar class="topIcon avatar" :mxcURL="getMxcFromChat(room)" :fallback="room.roomId" :size="3"/> <avatar class="topIcon avatar" :mxcURL="getMxcFromRoom(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,12 +11,13 @@
</div> </div>
</template> </template>
<script> <script>
import icon from '@/components/layout/icon.vue'; import icon from '@/components/icon.vue';
import avatar from '@/components/matrix/avatar'; import avatar from "@/components/avatar";
import {getMxcFromChat} from '@/lib/getMxc'; import {getMxcFromRoom} from "@/lib/getMxc";
export default { export default {
name: 'topBanner', name: "topBanner",
components:{ components:{
icon, icon,
avatar avatar
@ -27,7 +28,7 @@ export default {
openChatInfo: Function openChatInfo: Function
}, },
methods: { methods: {
getMxcFromChat getMxcFromRoom
} }
} }
</script> </script>
@ -39,7 +40,6 @@ 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;

View File

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

View File

@ -1,21 +0,0 @@
import {cookieHandler} from '@/lib/cookieHandler';
import {Capacitor, Plugins} from '@capacitor/core';
const {Storage} = Plugins;
export class DataStore{
constructor(){
this.cookie = new cookieHandler();
this.cookie.setExpire(15);
this.store = localStorage;
}
async set(key, value){
if (Capacitor.isNative) return await Storage.set({key, value: JSON.stringify(value)});
this.store.setItem(key, JSON.stringify(value));
this.cookie.set(key, JSON.stringify(value));
this.cookie.store();
}
async get(key){
if (Capacitor.isNative) return JSON.parse((await Storage.get({key})).value||'null');
return JSON.parse(this.store.getItem(key) || this.cookie.get(key) || 'null');
}
}

View File

@ -1,47 +0,0 @@
import {getMxcFromUserId, getPreviewUrl} from '@/lib/getMxc';
import {calcUserName} from '@/lib/matrixUtils';
import {getRoom} from '@/lib/matrixUtils';
import {router} from '@/router';
import {Capacitor, Plugins} from '@capacitor/core';
const {LocalNotifications} = Plugins;
export class NotificationHandler{
constructor() {
this.activateNotification();
}
async activateNotification(){
if (!window.Notification){
console.log('notifications are unsupported')
return false;
}
if (Notification.permission === 'granted') return true;
return await Notification.requestPermission()
.then(permission => {return permission === 'granted'});
}
showNotification(event){
if (Capacitor.isNative) return this.showNativeNotification(event);
if (Notification.permission !== 'granted') return false;
console.log(event);
let mxc = getMxcFromUserId(event.sender);
new Notification(`${calcUserName(event.sender)} in ${getRoom(event.room_id).name}`, {
body: event.content.body,
icon: mxc?getPreviewUrl(mxc):undefined
}).onclick = ()=>router.push(`/rooms/${event.room_id}`);
}
showNativeNotification(event){
LocalNotifications.schedule({
notifications: [
{
title: `${calcUserName(event.sender)} in ${getRoom(event.room_id).name}`,
body: event.content.body,
id: 1,
schedule: { at: new Date(Date.now() + 1000 * 5) },
sound: null,
attachments: null,
actionTypeId: '',
extra: null
}
]
});
}
}

View File

@ -8,17 +8,11 @@ export class cookieHandler {
getCookies(){ getCookies(){
return this.cookies; return this.cookies;
} }
setCookies(cookies){ setCookie(cookies){
Object.keys(cookies).forEach(key => { Object.keys(cookies).forEach(key => {
this.cookies[key] = cookies[key]; this.cookies[key] = cookies[key];
}) })
} }
set(key, value){
this.cookies[key] = value;
}
get(key){
return this.cookies[key];
}
parseCookie(string){ parseCookie(string){
let cookies = {}; let cookies = {};
string.replace(/ /g, '').split(';').forEach(cookie => { string.replace(/ /g, '').split(';').forEach(cookie => {
@ -30,12 +24,14 @@ export class cookieHandler {
reload(){ reload(){
if (document.cookie) this.cookies = this.parseCookie(document.cookie); if (document.cookie) this.cookies = this.parseCookie(document.cookie);
console.log('cookie loaded') console.log('cookie loaded')
console.log(this.cookies);
} }
store(){ store(){
Object.keys(this.cookies).forEach(key => { Object.keys(this.cookies).forEach(key => {
document.cookie = `${key}=${this.cookies[key]}; expires=${this.expires}; SameSite=${this.SameSite}; Secure;`; document.cookie = `${key}=${this.cookies[key]}; expires=${this.expires}; SameSite=${this.SameSite}; Secure;`;
}); });
console.log('cookie stored'); console.log('cookie stored');
console.log(this.cookies);
} }
toString(cookies = this.cookies){ toString(cookies = this.cookies){
let string = ''; let string = '';

View File

@ -1,28 +0,0 @@
export function solveTextLinks(text){
return (text || '').replace(
/([^\S]|^)(((https?:\/\/)|(www\.))(\S+))/gi,
(match, space, url)=>{
let hyperlink = url;
if (!hyperlink.match('^https?://')) {
hyperlink = 'http://' + hyperlink;
}
return `${space}<a href="${hyperlink}" target="_blank">${url}</a>`;
}
);
}
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,5 +1,5 @@
import sdk from 'matrix-js-sdk' import sdk from 'matrix-js-sdk'
import {matrix} from '@/main'; import {matrix} from "@/main";
export function getMxcFromUser(user){ export function getMxcFromUser(user){
return user.avatarUrl; return user.avatarUrl;
@ -10,24 +10,10 @@ export function getMxcFromUserId(userId){
} }
export function getMxcFromRoom(room){ export function getMxcFromRoom(room){
let avatarState = room.getLiveTimeline().getState(sdk.EventTimeline.FORWARDS).getStateEvents('m.room.avatar'); let avatarState = room.getLiveTimeline().getState(sdk.EventTimeline.FORWARDS).getStateEvents("m.room.avatar");
return avatarState.length>0?avatarState[avatarState.length-1].getContent().url:undefined; return avatarState.length>0?avatarState[avatarState.length-1].getContent().url:undefined;
} }
export function getMxcFromRoomId(roomId){ 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'){
return matrix.client.mxcUrlToHttp(mxcUrl, size, size, resizeMethod);
}
export function getMediaUrl(mxcUrl){
return matrix.client.mxcUrlToHttp(mxcUrl);
}

View File

@ -1,5 +1,4 @@
import matrix from 'matrix-js-sdk'; import matrix from 'matrix-js-sdk';
import {NotificationHandler} from '@/lib/NotificationHandler';
export class MatrixHandler { export class MatrixHandler {
constructor(clientDisplayName = 'matrix-chat') { constructor(clientDisplayName = 'matrix-chat') {
@ -10,7 +9,6 @@ export class MatrixHandler {
this.loading = undefined; this.loading = undefined;
this.user = undefined; this.user = undefined;
this.baseUrl = undefined; this.baseUrl = undefined;
this.notify = new NotificationHandler();
} }
login(user, password, baseUrl, onError, callback = ()=>{}){ login(user, password, baseUrl, onError, callback = ()=>{}){
if (this.client){ console.log('there is already an active session'); return; } if (this.client){ console.log('there is already an active session'); return; }
@ -48,8 +46,7 @@ export class MatrixHandler {
baseUrl, baseUrl,
accessToken, accessToken,
userId, userId,
store: new matrix.MemoryStore(window.localStorage), store: new matrix.MemoryStore(window.localStorage)
sessionStore: new matrix.WebStorageSessionStore(window.localStorage)
}); });
this.user = userId; this.user = userId;
this.baseUrl = baseUrl; this.baseUrl = baseUrl;
@ -60,27 +57,29 @@ export class MatrixHandler {
await this.client.stopClient(); await this.client.stopClient();
this.client = undefined; this.client = undefined;
} }
async startSync(callback = ()=>{}){ startSync(callback = ()=>{}){
this.loading = true; this.loading = true;
await this.client.startClient(); this.client.startClient();
this.client.once('sync', (state) => { this.client.once('sync', (state) => {
console.log(state); console.log(state);
this.rooms = this.client.getRooms(); this.rooms = this.client.getRooms();
console.log(this.rooms)
this.loading = false; this.loading = false;
callback(); callback();
this.listenToPushEvents();
}); });
} }
listenToPushEvents(){ async sendEvent(msg, roomId){
this.client.on('event', event => { const msgSend = {
if (this.client.getPushActionsForEvent(event).notify){ type: msg.type,
this.notify.showNotification(event.event); content: {
} body: msg.content.body.trim(),
msgtype: msg.content.msgtype,
},
};
await this.client.sendEvent(roomId, msgSend.type, msgSend.content, '').then(() => {
console.log('message sent successfully');
}).catch((err) => {
console.log(`error while sending message => ${err}`);
}); });
} }
async sendEvent({content, type}, roomId){
return await this.client.sendEvent(roomId, type, content)
.then(() => console.log('message sent successfully'))
.catch((err) => console.log(`error while sending message => ${err}`));
}
} }

View File

@ -1,30 +0,0 @@
import {matrix} from '@/main';
export function getUser(userId) {
return matrix.client.getUser(userId);
}
export function calcUserName(userId){
if (matrix.user === userId) return 'you';
return matrix.client.getUser(userId).displayName || userId;
}
export function 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]+$/);
}
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);
});
}

View File

@ -1,8 +0,0 @@
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);
});
}

View File

@ -1,30 +1,26 @@
import Vue from 'vue' import Vue from 'vue'
import VueRouter from 'vue-router' import VueRouter from 'vue-router'
import App from './App.vue' import App from './App.vue'
import {router} from '@/router' import {router} from './router.js'
import {MatrixHandler} from './lib/MatrixHandler.js' import {MatrixHandler} from './lib/matrixHandler.js'
import {DataStore} from '@/lib/DataStore'; import {cookieHandler} from './lib/cookieHandler.js';
Vue.config.productionTip = false; Vue.config.productionTip = false;
Vue.use(VueRouter); Vue.use(VueRouter);
export let matrix = new MatrixHandler(); export let matrix = new MatrixHandler();
(async () => { let cookie = new cookieHandler().getCookies();
let login = await new DataStore().get('login'); if (cookie && cookie.baseUrl && cookie.accessToken && cookie.userId) {
if (login && login.baseUrl && login.accessToken && login.userId) { matrix.tokenLogin(cookie.baseUrl, cookie.accessToken, cookie.userId);
matrix.tokenLogin(login.baseUrl, login.accessToken, login.userId); }
new Vue({
el: '#app',
router,
template: '<App/>',
components: {App},
data() {
return {}
} }
}).$mount('#app');
new Vue({
el: '#app',
router,
template: '<App/>',
components: {App},
data() {
return {}
}
}).$mount('#app');
})()

View File

@ -1,50 +0,0 @@
: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;
}

40
src/router.js Normal file
View File

@ -0,0 +1,40 @@
import VueRouter from 'vue-router';
import login from '@/views/login';
import chat from '@/views/chat';
import rooms from '@/views/rooms';
import admin from '@/views/admin';
export const router = new VueRouter({
routes: [
{
path: '/',
name: 'home',
component: login
},
{
path: '/login',
name: 'login',
component: login
},
{
path: '/chat/*',
name: 'chat',
component: chat
},
{
path: '/rooms/*',
name: 'room',
component: rooms
},
{
path: '/rooms',
name: 'rooms',
component: rooms
},
{
path: '/admin',
name: 'admin',
component: admin
}
]
})

View File

@ -1,34 +0,0 @@
import VueRouter from 'vue-router';
import login from '@/views/login';
import rooms from '@/views/rooms';
import admin from '@/views/admin';
export const router = new VueRouter({
routes: [
{
path: '/',
name: 'home',
component: rooms
},
{
path: '/login',
name: 'login',
component: login
},
{
path: '/rooms/*',
name: 'room',
component: rooms
},
{
path: '/rooms',
name: 'rooms',
component: rooms
},
{
path: '/admin',
name: 'admin',
component: admin
}
]
})

View File

@ -33,25 +33,23 @@
</div> </div>
<textbtn @click.native="updateUser()" text="update user"/> <textbtn @click.native="updateUser()" text="update user"/>
</div> </div>
<overlay v-if="loading"><throbber :text="loading"/></overlay> <throbber-overlay v-if="loading" :text="loading"/>
</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/layout/icon'; import icon from "@/components/icon";
import textbtn from '@/components/layout/textbtn'; import textbtn from "@/components/textbtn";
import overlay from '@/components/layout/overlay'; import ThrobberOverlay from "@/components/throbberOverlay";
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{

113
src/views/chat.vue Normal file
View File

@ -0,0 +1,113 @@
<template>
<div>
<div ref="chatContainer" class="chatContainer">
<div @scroll="onScroll()" ref="msgContainer" id="messagesContainer" class="messagesContainer">
<div v-if="loadingStatus" @click="loadEvents()" class="loadMore">{{loadingStatus}}</div>
<p v-if="room.timeline.length === 0" class="chatInfo">this room is empty</p>
<timeline :timeline="room.timeline" :group-timeline="isGroup()" :user="user" :roomId="room.roomId" />
</div>
<icon v-if="showScrollBtn" @click.native="scroll.scrollToBottom()" id="scrollDown" ic="./sym/ic_expand_more_black.svg" />
</div>
<newMessage :onResize="height=>resize(height)" :roomId="room.roomId"/>
<topBanner :room="room" :close-chat="()=>closeChat()" :open-chat-info="()=>openChatInfo()"/>
</div>
</template>
<script>
import newMessage from '@/components/newMessage.vue';
import topBanner from '@/components/topBanner.vue';
import Icon from '@/components/icon';
import {matrix} from '@/main';
import splitArray from '@/lib/splitArray.js'
import timeline from '@/components/timeline';
import scrollHandler from "@/lib/scrollHandler";
export default {
name: 'chat',
components: {
timeline,
Icon,
newMessage,
topBanner
},
props: {
room: [Object, undefined],
user: String,
closeChat: Function,
openChatInfo: Function
},
methods:{
onScroll(){
if (this.$refs.msgContainer.scrollTop === 0) this.loadEvents();
this.showScrollBtn = this.scroll.getScrollBottom() > 400;
},
resize(height){
this.$refs.chatContainer.style.height = `calc(100% - ${height}px - 3.5rem)`;
},
isGroup(){
return Object.keys(this.room.currentState.members).length > 2;
},
async loadEvents(){
let scrollBottom = this.scroll.getScrollBottom();
this.loadingStatus = 'loading ...';
await matrix.client.paginateEventTimeline(this.room.getLiveTimeline(), {backwards: true})
.then(state => this.loadingStatus = state?'load more':false);
this.scroll.setScrollBottom(scrollBottom);
},
getUser(userId){
return matrix.client.getUser(userId);
},
splitArray
},
data(){
return {
showScrollBtn: false,
scrollOnUpdate: true,
loadingStatus: 'load more',
scroll: ()=>{}
}
},
updated(){
if(this.scroll.getScrollBottom() < 350) this.scroll.scrollToBottom();
},
mounted(){
this.scroll = new scrollHandler(this.$refs.msgContainer);
this.scroll.scrollToBottom();
}
}
</script>
<style scoped lang="scss">
.chatContainer{
position: absolute;
margin: 0;
left: 0;
top: 3.5rem;
width: 100%;
height: calc(100% - 7rem);
.messagesContainer{
height: 100%;
overflow-y: auto;
}
#scrollDown{
position: absolute;
background-color: #fff;
bottom: 1rem;
right: 1rem;
display: block;
height: 2rem;
width: 2rem;
}
}
.loadMore{
position: relative;
background-color: #2d2d2d;
padding: 0.5rem;
border-radius: 0.5rem;
width: fit-content;
left: 50%;
transform: translate(-50%,0);
margin-top: 0.5rem;
cursor: pointer;
}
</style>

View File

@ -7,48 +7,58 @@
<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" class="rounded"/> <textbtn type="submit" text="login" />
</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" class="outline"/> <textbtn @click.native="logout()" text="logout" />
</div> </div>
</div> </div>
<overlay v-if="loading"><throbber :text="loading"/></overlay> <throbber-overlay v-if="loading" :text="loading" class="throbber"/>
</div> </div>
</template> </template>
<script> <script>
import textbtn from '@/components/layout/textbtn'; import textbtn from '@/components/textbtn';
import {matrix} from '@/main.js'; import {matrix} from '@/main.js';
import {isValidUserId} from '@/lib/matrixUtils'; import {cookieHandler} from "@/lib/cookieHandler";
import {DataStore} from '@/lib/DataStore'; import ThrobberOverlay from "@/components/throbberOverlay";
import Overlay from '@/components/layout/overlay';
import Throbber from '@/components/layout/throbber';
const store = new DataStore();
export default { export default {
name: 'login.vue', name: "login.vue",
components: { components: {
Throbber, ThrobberOverlay,
Overlay,
textbtn textbtn
}, },
methods: { methods: {
login(){ login(){
// eslint-disable-next-line no-cond-assign if (matrix.client !== undefined) {
if (this.loginError = this.getInputErrors()) return false; this.loginError = 'you are already logged in';
return;
} if (this.user === '') {
this.loginError = 'username is empty';
return;
} if (this.password === '') {
this.loginError = 'password is empty';
return;
} if (!(this.user.match(/^@[a-zA-Z0-9]+:[a-z0-9]+\.[a-z]/))) {
this.loginError = 'username is in wrong style';
return;
}
this.loading = 'logging in'; this.loading = 'logging in';
matrix.login(this.user, this.password, this.homeServer, (error) => { matrix.login(this.user, this.password, this.homeServer, (error) => {
this.loginError = `login failed: ${error}`; this.loginError = `login failed: ${error}`;
this.loading = false; this.loading = false;
}, token => { }, token => {
this.store.set('login', { this.loading = 'store token';
this.cookie.setCookie({
baseUrl: this.homeServer, baseUrl: this.homeServer,
userId: this.user, userId: this.user,
accessToken: token accessToken: token
}); });
this.cookie.setExpire(15);
this.cookie.store();
this.loading = false; this.loading = false;
this.$router.push('/rooms/'); this.$router.push('/rooms/');
}); });
@ -56,28 +66,28 @@ export default {
async logout(){ async logout(){
this.loading = 'logging out'; this.loading = 'logging out';
await matrix.logout(); await matrix.logout();
this.store.set('login', {}); this.loading = 'remove token';
this.cookie.setCookie({
baseUrl: undefined,
userId: undefined,
accessToken: undefined
});
this.cookie.setExpire(0);
this.cookie.store();
this.loading = false; this.loading = false;
this.$forceUpdate(); this.$forceUpdate();
}, },
getInputErrors(){
if (matrix.client !== undefined) return 'you are already logged in';
if (this.user === '') return 'username is empty';
if (this.password === '') return 'password is empty';
if (!isValidUserId(this.user)) return 'username is in wrong style';
return false;
},
showLogin(){ showLogin(){
return matrix.client === undefined; return matrix.client === undefined;
} }
}, },
data(){ data(){
return { return {
user: '', user: "",
password: '', password: "",
homeServer: 'https://adb.sh', homeServer: "https://adb.sh",
loginError: '', loginError: "",
store, cookie: new cookieHandler(),
loading: false loading: false
} }
} }
@ -85,23 +95,6 @@ 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%;
@ -115,6 +108,12 @@ input:focus{
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 {

View File

@ -1,148 +1,64 @@
<template> <template>
<overlay v-if="matrix.loading"><throbber text="loading" class="center"/></overlay> <div v-if="matrix.loading">
<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> <div v-for="room in matrix.rooms" :key="room.roomId" @click="openChat(room)" >
<room-list-element <room-list-element
v-for="room in matrix.client.getRooms() v-if="!search || room.name.toLowerCase().includes(search.toLowerCase().trim())"
.filter(prop=>matchResults(prop.name, search)||prop.roomId===search)" :room="room"
:key="room.roomId" @click.native="openChat(room)" class="roomListElement"
:room="room"
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({
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>
<div class="wideElement">
<p v-if="isValidUserId(search)" class="suggestion">create chat: {{search}} </p>
<p v-if="isValidRoomId(search)"
class="suggestion"
@click="setQuestion({
title:'Join room',
question:`Join '${search}'?`,
callback:()=>joinRoom(search)
})"
>join room: {{search}} </p>
<p v-if="search.match(/^[a-zA-Z0-9_.+-]+$/)"
@click="setShowCreateRoom({name: search}, openChat)"
class="suggestion"
>create room: {{search}} </p>
</div>
</div> </div>
</div> </div>
<chat <chat
class="chat" class="chat"
v-if="showRoom && getCurrentRoom()" v-if="currentRoom"
:room="getCurrentRoom()" :room="currentRoom"
:user="matrix.user" :user="matrix.user"
:close-chat="closeChat" :close-chat="()=>currentRoom=undefined"
: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>
<overlay> <chatInformation v-if="currentRoom && showChatInfo" :room="currentRoom" :close-chat-info="()=>showChatInfo=false"/>
<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 '@/components/chat/chat.vue'; import chat from '@/views/chat.vue';
import chatInformation from '@/components/chat/chatInformation'; import chatInformation from "@/components/chatInformation";
import {matrix} from '@/main'; import {matrix} from "@/main";
import {getMxcFromRoom} from '@/lib/getMxc'; import ThrobberOverlay from "@/components/throbberOverlay";
import roomListElement from '@/components/matrix/roomListElement'; import {getMxcFromRoom} from "@/lib/getMxc";
import {getRoom, getUser} from '@/lib/matrixUtils'; import roomListElement from "@/components/roomListElement";
import {isValidUserId, isValidRoomId} from '@/lib/matrixUtils';
import userListElement from '@/components/matrix/userListElement';
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, ThrobberOverlay,
Overlay,
PopupQuestion,
userListElement,
chat, chat,
chatInformation, chatInformation,
roomListElement, roomListElement
newRoom
}, },
methods:{ methods:{
openChat(room){ openChat(room){
this.showChatInfo = false; this.showChatInfo = false;
this.showRoom = false; this.currentRoom = undefined
this.$nextTick(() => this.currentRoom = room);
this.$router.push(`/rooms/${room.roomId}`); this.$router.push(`/rooms/${room.roomId}`);
this.$nextTick(() => this.showRoom = true);
this.search = ''; this.search = '';
}, },
getCurrentRoom(){ getMxcFromRoom
return getRoom(this.$route.path.split('/')[2]);
},
closeChat(){
this.$router.push('/rooms');
},
matchResults(prop, search){
return prop.toLowerCase().includes(search.toLowerCase().trim());
},
setQuestion({title, question, callback}){
this.popup = {
question,
title,
callback:(res)=>{
this.popup = {};
if (res) callback();
}
}
},
joinRoom(room){
this.matrix.client.join(room).then(()=>{
this.openChat(getRoom(room.room_id));
});
},
setShowCreateRoom(props, callback=()=>{}){
this.showCreateRoom = {
props,
callback:(res)=>{
this.showCreateRoom = {};
if (res) callback(res);
}
}
},
getMxcFromRoom,
getRoom,
getUser,
isValidUserId,
isValidRoomId,
createRoom
}, },
data(){ data(){
return { return {
matrix, matrix,
currentRoom: undefined,
showChatInfo: false, showChatInfo: false,
showRoom: true, search: ''
search: '',
popup:{},
showCreateRoom:{}
} }
}, },
mounted() { mounted() {
@ -161,8 +77,6 @@ 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;
@ -188,10 +102,7 @@ export default {
text-align: center; text-align: center;
} }
input{ input{
position: relative; width: calc(100% - 5rem);
margin-left: auto;
margin-right: auto;
width: calc(100% - 4rem);
} }
.wideElement{ .wideElement{
display: block; display: block;
@ -199,19 +110,6 @@ input{
.smallElement{ .smallElement{
display: none; display: none;
} }
.center{
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%,-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) {
.wideElement{ .wideElement{
@ -224,6 +122,7 @@ 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{