Compare commits

...

49 Commits

Author SHA1 Message Date
adb 9b2b5b8bb9 fix NewRoom popup 4 years ago
adb eb259bbdae debug membershipEvents / soundRecorder 4 years ago
adb 18bb86a7a9 refactor Components, add newRoom.vue/userSearch.vue/popup.vue/overlay.vue/imageViewer.vue, some design changes 4 years ago
adb c0d9de4fcf add main.scss 4 years ago
adb ec4bdbbec4 fix membership and unsupported event info 4 years ago
adb bf286929cb calc font-width for emojiEvents 4 years ago
adb a6838e3703 Merge remote-tracking branch 'origin/dev' 4 years ago
adb 9a1aa73cc8 paste images/files as attachment 4 years ago
adb 3a8a25d2e1 add CapacitorStorageApi and some fixes 4 years ago
adb fdfb9c0361 Merge remote-tracking branch 'origin/master' into matrix-chat-native
# Conflicts:
#	package.json
4 years ago
adb e48c516211 Merge branch 'dev' 4 years ago
adb 9853c5327e debug and add localStorage 4 years ago
adb f220bbc63b add submodule matrix-chat-android 4 years ago
adb 520a5ca6d9 Merge remote-tracking branch 'origin/master' into matrix-chat-native 4 years ago
adb 292b123f5a Merge remote-tracking branch 'origin/dev' 4 years ago
adb 40812fc4a2 add createRooms from search 4 years ago
adb c57c1d348f refactor newMessage, add fileUpload and soundRecorder 4 years ago
adb c35b5ba446 fix sendEvent, update eventContent file 4 years ago
adb 64d6273a52 send attachments and recordings 4 years ago
adb cce7af2178 add fileInput, refactor eventContent 4 years ago
adb fb707fcde3 add emojiPicker and microphone 4 years ago
adb d3031a1e70 add popupQuestion.vue, search for users, join rooms 4 years ago
adb 286bc25446 some fixes 4 years ago
adb 337a8410cb add screenshot and description to README.md 4 years ago
adb 1d50bae135 fix event overflow 4 years ago
adb 7d9535457f fix media width 4 years ago
adb 1dea2f76ad add solveMarkdownLinks to eventUtils 4 years ago
adb 01fe023d06 fix if prop undefined for getMxc and event 4 years ago
adb 64d52b071d show m.audio, m.image, m.video, m.file / add replyEvent.vue 4 years ago
adb dfaa87f5e7 fix getMXC add getMediaUrl / fix link color 4 years ago
adb 8a9b5a478b fix setReply / add manageScrollBottom 4 years ago
adb 30a7e4da81 fix login userid regex 4 years ago
adb 22c6170787 extract event from timeline.vue to message.vue & message.vue -> event.vue 4 years ago
adb 2c22cae07f add showNativeNotification 4 years ago
adb 550d79af28 Merge branch 'master' into matrix-chat-native 4 years ago
adb 289a230207 add NotificationHandler and listen to pushEvents 4 years ago
adb fda133486a simplify loadEvents and load on mounted 4 years ago
adb 72c7886fc9 refactor router.js -> ./router/index.js 4 years ago
adb f237f7bf7a send typing events 4 years ago
adb bbad002cec refactor getAvatarUrl 4 years ago
adb 57d6238ed6 Merge branch 'master' into matrix-chat-native 4 years ago
adb 8dd34b58ee fix getReplyId 4 years ago
adb d66f2c990e add reply to events / update sendEvent / refactor parseMessage to eventUtils / fix quotes 4 years ago
adb 3b70b5b37c load room by url / +getRoom to matrixUtils 4 years ago
adb 35e6bf86df fix icon paths 4 years ago
adb f1967d4189 add matrixUtils 4 years ago
adb 4a28e22b82 add androidStudioPath to config 4 years ago
adb a67fa94447 sort rooms by latest event and fix scrolling at reloading 4 years ago
adb 257f0c833c init capacitor 4 years ago

3
.gitmodules vendored

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

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

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

@ -0,0 +1,14 @@
{
"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": {}
}

@ -8,6 +8,9 @@
"lint": "vue-cli-service lint"
},
"dependencies": {
"@capacitor/android": "^2.4.7",
"@capacitor/cli": "^2.4.7",
"@capacitor/core": "^2.4.7",
"@modular-matrix/parse-mxc": "^1.0.1",
"@vue-polkadot/vue-identicon": "^0.0.8",
"core-js": "^3.6.5",
@ -16,6 +19,7 @@
"sass": "^1.29.0",
"superagent": "^6.1.0",
"v-emoji-picker": "^2.3.1",
"viewerjs": "*",
"vue": "^2.6.11",
"ws": "^7.3.1"
},
@ -34,6 +38,7 @@
"eslint": "^6.7.2",
"eslint-plugin-vue": "^7.5.0",
"node-sass": "^5.0.0",
"recorder-js": "*",
"sass-loader": "^10.1.1",
"vue-router": "^3.4.9",
"vue-template-compiler": "^2.6.11"

@ -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

@ -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

@ -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

@ -1,6 +1,6 @@
<template>
<div id="app">
<div class="content">
<div id="appContent">
<router-view />
<error />
</div>
@ -8,7 +8,7 @@
</template>
<script>
import error from "@/components/error.vue";
import error from '@/components/layout/error.vue';
export default {
name: 'App',
@ -18,26 +18,14 @@ export default {
}
</script>
<style>
<style lang="scss">
@import "main.scss";
body{
margin: 0;
}
input{
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;
a{
color: #00BCD4;
}
*{
scrollbar-width: thin;
@ -62,7 +50,7 @@ input:focus{
min-height: 100%;
width: 100%;
}
.content{
#appContent{
position: absolute;
top: 0;
left: calc(50% - 35rem);
@ -74,7 +62,7 @@ input:focus{
box-shadow: 3px 3px 10px #111;
}
@media (max-width: 75rem){
.content{
#appContent{
width: 100%;
left: 0;
}

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

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

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

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

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

@ -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>

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

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

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

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

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

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

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

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

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

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

@ -1,36 +1,30 @@
<template>
<img v-if="mxcURL" :src="thumbnailUrl()" class="userThumbnail image"/>
<img v-if="mxcURL" :src="getPreviewUrl(mxcURL)" class="userThumbnail image"/>
<div v-else v-html="getJdenticon()" class="userThumbnail identicon"/>
</template>
<script>
import parseMXC from '@modular-matrix/parse-mxc';
import {matrix} from "@/main";
import {toSvg} from 'jdenticon';
import {getPreviewUrl} from '@/lib/getMxc';
export default {
name: "userThumbnail.vue",
name: 'userThumbnail.vue',
components: {
},
props: {
mxcURL: String,
username: String,
fallback: String,
homeserver: String,
size: Number
},
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(){
return window.getComputedStyle(document.body,null).fontSize.split("px", 1)||16;
return window.getComputedStyle(document.body,null).fontSize.split('px', 1)||16;
},
getJdenticon(){
return toSvg(this.fallback, this.getFontSize()*this.size);
}
},
getPreviewUrl
},
data(){
return {

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

@ -2,25 +2,25 @@
<div class="roomListElement" :title="room.name">
<div class="imageContainer">
<avatar
class="roomImage"
:mxcURL="getMxcFromRoom(room)"
:fallback="room.roomId"
:size="3"
class="roomImage"
:mxcURL="getMxcFromChat(room)"
:fallback="room.roomId"
:size="3"
/>
</div>
<div class="roomListName">{{room.name}}</div>
<div class="status">{{getPreviewString(room)}}</div>
<div class="status">{{previewString}}</div>
</div>
</template>
<script>
import avatar from "@/components/avatar";
import {getMxcFromRoom} from "@/lib/getMxc";
import {getTime} from "@/lib/getTimeStrings";
import {matrix} from "@/main";
import avatar from '@/components/matrix/avatar';
import {getMxcFromChat} from '@/lib/getMxc';
import {getTime} from '@/lib/getTimeStrings';
import {calcUserName} from '@/lib/matrixUtils';
export default {
name: "userListElement",
name: 'roomListElement',
components:{
avatar
},
@ -34,14 +34,19 @@ export default {
return `${this.calcUserName(event.sender)}: ${event.content.body||'unknown event'} ${getTime(event.origin_server_ts)}`;
},
getLatestEvent(room){
if (!room.timeline[room.timeline.length-1]) return undefined;
return room.timeline[room.timeline.length-1].event;
return room.timeline[room.timeline.length-1]
&& room.timeline[room.timeline.length-1].event;
},
calcUserName(userId) {
if (matrix.user === userId) return 'you';
return matrix.client.getUser(userId).displayName || userId;
},
getMxcFromRoom
getMxcFromChat,
calcUserName,
},
data(){
return {
previewString: 'loading'
}
},
created() {
this.previewString = this.getPreviewString(this.room);
}
}
</script>

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

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

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

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

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

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

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

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

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

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

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

@ -1,5 +1,5 @@
import sdk from 'matrix-js-sdk'
import {matrix} from "@/main";
import {matrix} from '@/main';
export function getMxcFromUser(user){
return user.avatarUrl;
@ -10,10 +10,24 @@ export function getMxcFromUserId(userId){
}
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;
}
export function getMxcFromRoomId(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);
}

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

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

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

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

@ -1,40 +0,0 @@
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
}
]
})

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

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

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

@ -7,58 +7,48 @@
<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>
<div v-if="loginError" class="info">{{loginError}}</div>
<textbtn type="submit" text="login" />
<textbtn type="submit" text="login" class="rounded"/>
</form>
<div v-else>
<p>you are already logged in</p>
<textbtn @click.native="$router.push('rooms')" text="chat" />
<textbtn @click.native="logout()" text="logout" />
<textbtn @click.native="logout()" text="logout" class="outline"/>
</div>
</div>
<throbber-overlay v-if="loading" :text="loading" class="throbber"/>
<overlay v-if="loading"><throbber :text="loading"/></overlay>
</div>
</template>
<script>
import textbtn from '@/components/textbtn';
import textbtn from '@/components/layout/textbtn';
import {matrix} from '@/main.js';
import {cookieHandler} from "@/lib/cookieHandler";
import ThrobberOverlay from "@/components/throbberOverlay";
import {isValidUserId} from '@/lib/matrixUtils';
import {DataStore} from '@/lib/DataStore';
import Overlay from '@/components/layout/overlay';
import Throbber from '@/components/layout/throbber';
const store = new DataStore();
export default {
name: "login.vue",
name: 'login.vue',
components: {
ThrobberOverlay,
Throbber,
Overlay,
textbtn
},
methods: {
login(){
if (matrix.client !== undefined) {
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;
}
// eslint-disable-next-line no-cond-assign
if (this.loginError = this.getInputErrors()) return false;
this.loading = 'logging in';
matrix.login(this.user, this.password, this.homeServer, (error) => {
this.loginError = `login failed: ${error}`;
this.loading = false;
}, token => {
this.loading = 'store token';
this.cookie.setCookie({
this.store.set('login', {
baseUrl: this.homeServer,
userId: this.user,
accessToken: token
});
this.cookie.setExpire(15);
this.cookie.store();
this.loading = false;
this.$router.push('/rooms/');
});
@ -66,28 +56,28 @@ export default {
async logout(){
this.loading = 'logging out';
await matrix.logout();
this.loading = 'remove token';
this.cookie.setCookie({
baseUrl: undefined,
userId: undefined,
accessToken: undefined
});
this.cookie.setExpire(0);
this.cookie.store();
this.store.set('login', {});
this.loading = false;
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(){
return matrix.client === undefined;
}
},
data(){
return {
user: "",
password: "",
homeServer: "https://adb.sh",
loginError: "",
cookie: new cookieHandler(),
user: '',
password: '',
homeServer: 'https://adb.sh',
loginError: '',
store,
loading: false
}
}
@ -95,6 +85,23 @@ export default {
</script>
<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{
width: 100%;
height: 100%;
@ -108,12 +115,6 @@ export default {
height: min-content;
width: 100%;
}
.throbber{
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
@media (max-width: 35rem) {
input {

@ -1,64 +1,148 @@
<template>
<div v-if="matrix.loading">
<throbber-overlay text="loading"/>
</div>
<overlay v-if="matrix.loading"><throbber text="loading" class="center"/></overlay>
<div v-else>
<div id="roomList" class="roomList">
<h1 class="wideElement">[chat]</h1><h1 class="smallElement">[c]</h1>
<input v-model="search" class="input wideElement" type="text" maxlength="50" placeholder="search">
<div v-for="room in matrix.rooms" :key="room.roomId" @click="openChat(room)" >
<room-list-element
v-if="!search || room.name.toLowerCase().includes(search.toLowerCase().trim())"
:room="room"
class="roomListElement"
<p class="wideElement">- rooms -</p>
<room-list-element
v-for="room in matrix.client.getRooms()
.filter(prop=>matchResults(prop.name, search)||prop.roomId===search)"
:key="room.roomId" @click.native="openChat(room)"
: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>
<chat
class="chat"
v-if="currentRoom"
:room="currentRoom"
v-if="showRoom && getCurrentRoom()"
:room="getCurrentRoom()"
:user="matrix.user"
:close-chat="()=>currentRoom=undefined"
:close-chat="closeChat"
:open-chat-info="()=>showChatInfo=true"
/>
<div class="noRoomSelected" v-else>Please select a room to be displayed.</div>
<chatInformation v-if="currentRoom && showChatInfo" :room="currentRoom" :close-chat-info="()=>showChatInfo=false"/>
<overlay>
<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>
</template>
<script>
import chat from '@/views/chat.vue';
import chatInformation from "@/components/chatInformation";
import {matrix} from "@/main";
import ThrobberOverlay from "@/components/throbberOverlay";
import {getMxcFromRoom} from "@/lib/getMxc";
import roomListElement from "@/components/roomListElement";
import chat from '@/components/chat/chat.vue';
import chatInformation from '@/components/chat/chatInformation';
import {matrix} from '@/main';
import {getMxcFromRoom} from '@/lib/getMxc';
import roomListElement from '@/components/matrix/roomListElement';
import {getRoom, getUser} from '@/lib/matrixUtils';
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 {
name: "rooms",
name: 'rooms',
components:{
ThrobberOverlay,
Throbber,
Overlay,
PopupQuestion,
userListElement,
chat,
chatInformation,
roomListElement
roomListElement,
newRoom
},
methods:{
openChat(room){
this.showChatInfo = false;
this.currentRoom = undefined
this.$nextTick(() => this.currentRoom = room);
this.showRoom = false;
this.$router.push(`/rooms/${room.roomId}`);
this.$nextTick(() => this.showRoom = true);
this.search = '';
},
getMxcFromRoom
getCurrentRoom(){
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(){
return {
matrix,
currentRoom: undefined,
showChatInfo: false,
search: ''
showRoom: true,
search: '',
popup:{},
showCreateRoom:{}
}
},
mounted() {
@ -77,6 +161,8 @@ export default {
background-color: #222;
text-align: center;
overflow-y: auto;
overflow-x: hidden;
text-overflow: ellipsis;
}
.chat{
position: absolute;
@ -102,7 +188,10 @@ export default {
text-align: center;
}
input{
width: calc(100% - 5rem);
position: relative;
margin-left: auto;
margin-right: auto;
width: calc(100% - 4rem);
}
.wideElement{
display: block;
@ -110,6 +199,19 @@ input{
.smallElement{
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) {
.wideElement{
@ -122,7 +224,6 @@ input{
z-index: 30;
width: 4rem;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: none;
}
.roomList:hover{

Loading…
Cancel
Save