Compare commits

...

27 Commits

Author SHA1 Message Date
adb
9b2b5b8bb9 fix NewRoom popup 2021-06-08 22:18:32 +02:00
adb
eb259bbdae debug membershipEvents / soundRecorder 2021-06-08 22:08:36 +02:00
adb
18bb86a7a9 refactor Components, add newRoom.vue/userSearch.vue/popup.vue/overlay.vue/imageViewer.vue, some design changes 2021-06-08 22:06:00 +02:00
adb
c0d9de4fcf add main.scss 2021-06-08 21:41:29 +02:00
adb
ec4bdbbec4 fix membership and unsupported event info 2021-05-21 02:00:38 +02:00
adb
bf286929cb calc font-width for emojiEvents 2021-05-19 14:00:22 +02:00
adb
a6838e3703 Merge remote-tracking branch 'origin/dev' 2021-05-14 02:16:55 +02:00
adb
9a1aa73cc8 paste images/files as attachment 2021-05-13 01:51:13 +02:00
adb
3a8a25d2e1 add CapacitorStorageApi and some fixes 2021-05-10 00:35:43 +02:00
adb
fdfb9c0361 Merge remote-tracking branch 'origin/master' into matrix-chat-native
# Conflicts:
#	package.json
2021-05-09 16:54:06 +02:00
adb
e48c516211 Merge branch 'dev' 2021-05-09 16:47:44 +02:00
adb
9853c5327e debug and add localStorage 2021-05-09 16:07:40 +02:00
adb
f220bbc63b add submodule matrix-chat-android 2021-05-04 18:23:19 +02:00
adb
520a5ca6d9 Merge remote-tracking branch 'origin/master' into matrix-chat-native 2021-05-04 17:42:27 +02:00
adb
292b123f5a Merge remote-tracking branch 'origin/dev' 2021-05-04 17:30:36 +02:00
adb
40812fc4a2 add createRooms from search 2021-05-03 23:00:47 +02:00
adb
c57c1d348f refactor newMessage, add fileUpload and soundRecorder 2021-05-02 00:28:50 +02:00
adb
c35b5ba446 fix sendEvent, update eventContent file 2021-05-01 23:02:11 +02:00
adb
64d6273a52 send attachments and recordings 2021-05-01 01:05:14 +02:00
adb
cce7af2178 add fileInput, refactor eventContent 2021-04-30 00:33:47 +02:00
adb
fb707fcde3 add emojiPicker and microphone 2021-04-28 18:55:11 +02:00
adb
337a8410cb add screenshot and description to README.md 2021-04-14 10:32:37 +00:00
adb
2c22cae07f add showNativeNotification 2021-04-09 00:48:06 +02:00
adb
550d79af28 Merge branch 'master' into matrix-chat-native 2021-04-08 23:43:02 +02:00
adb
57d6238ed6 Merge branch 'master' into matrix-chat-native 2021-04-06 03:34:04 +02:00
adb
4a28e22b82 add androidStudioPath to config 2021-04-04 03:32:13 +02:00
adb
257f0c833c init capacitor 2021-03-27 02:12:58 +01:00
49 changed files with 1450 additions and 520 deletions

3
.gitmodules vendored Normal file
View File

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

View File

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

1
android Submodule

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

14
capacitor.config.json Normal file
View File

@ -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": {}
}

View File

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

View File

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

After

Width:  |  Height:  |  Size: 409 B

View File

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

After

Width:  |  Height:  |  Size: 510 B

View File

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

After

Width:  |  Height:  |  Size: 348 B

View File

@ -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,27 +18,12 @@ 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: 0.1rem solid #fff;
text-align: center;
font-size: 1.1rem;
margin: 0.5rem;
appearance: none;
outline: none;
}
input:focus{
color: #000;
background-color: #fff;
}
a{
color: #00BCD4;
}
@ -65,7 +50,7 @@ a{
min-height: 100%;
width: 100%;
}
.content{
#appContent{
position: absolute;
top: 0;
left: calc(50% - 35rem);
@ -77,7 +62,7 @@ a{
box-shadow: 3px 3px 10px #111;
}
@media (max-width: 75rem){
.content{
#appContent{
width: 100%;
left: 0;
}

View File

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

View File

@ -1,39 +1,34 @@
<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.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 UserListElement from "@/components/userListElement";
import avatar from "@/components/avatar";
import {getMxcFromRoom} from "@/lib/getMxc";
import {getUser} from "@/lib/matrixUtils";
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: {},
@ -44,7 +39,7 @@ export default {
return Object.keys(this.room.currentState.members)
},
getUser,
getMxcFromRoom
getMxcFromChat
},
data(){
return{
@ -84,6 +79,9 @@ export default {
height: 100%;
}
}
.popup{
min-height: calc(100% - 10rem);
}
.closeBtn{
position: absolute;
top: 0;

View File

@ -2,39 +2,12 @@
<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"/>
<div v-if="event.content.msgtype==='m.text'" v-html="parseMessage(event.content.body)"/>
<div v-else-if="event.content.msgtype==='m.notice'" class="notice" v-html="parseMessage(event.content.body)"/>
<div v-else-if="event.content.msgtype==='m.image'" class="image">
<img :src="getMediaUrl(event.content.url)" :alt="event.content.body"/><br>
{{event.content.body}}
</div>
<div v-else-if="event.content.msgtype==='m.file'" class="file">
file: <a :href="getMediaUrl(event.content.url)">
{{event.content.filename || getMediaUrl(event.content.url)}}
</a><br>{{event.content.body}}
</div>
<div v-else-if="event.content.msgtype==='m.audio'" class="audio">
<audio controls>
<source :src="getMediaUrl(event.content.url)" :type="event.content.mimetype">
your browser doesn't support audio
</audio><br>
{{event.content.body}}
</div>
<div v-else-if="event.content.msgtype==='m.video'" class="video">
<video controls>
<source :src="getMediaUrl(event.content.url)" :type="event.content.mimetype">
your browser doesn't support video
</video><br>
{{event.content.body}}
</div>
<div v-else class="italic">unsupported message type {{event.content.msgtype}}</div>
<event-content :content="event.content"/>
<div class="time">{{getTime(event.origin_server_ts)}}</div>
</div>
<div v-else class="info">
<div v-else :class="type==='send'?'info send':'info receive'">
<span v-if="event.type==='m.room.member'">{{membershipEvents[event.content.membership](event)}}</span>
<span v-else>unsupported event</span>
<span v-else>unsupported event: {{event.type}}</span>
<span class="time"> {{getTime(event.origin_server_ts)}}</span>
</div>
</div>
@ -46,11 +19,12 @@ import {calcUserName} from '@/lib/matrixUtils';
import {parseMessage} from '@/lib/eventUtils';
import {getTime} from '@/lib/getTimeStrings';
import {getMediaUrl} from '@/lib/getMxc';
import ReplyEvent from '@/components/replyEvent';
import ReplyEvent from '@/components/chat/replyEvent';
import EventContent from '@/components/chat/eventContent';
export default {
name: 'message',
components: {ReplyEvent},
components: {EventContent, ReplyEvent},
props: {
type: String,
event: Object,
@ -75,12 +49,18 @@ export default {
return{
replyEvent: undefined,
membershipEvents:{
invite(event){ return `invited ${calcUserName(event.target.userId)}` },
invite(event){ return `invited ${event.target?calcUserName(event.target.userId):event.content.displayname||event.state_key}` },
join(event){
if (event.content.displayname !== null) return `changed username to ${event.content.displayname}`
return 'joined the room'
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'
},
leave(){ return 'left the room' },
ban(event){return `banned ${calcUserName(event.target.userId)}` }
}
}
@ -105,6 +85,9 @@ export default {
font-size: 0.7rem;
}
}
.info.send{
text-align: right;
}
.message{
position: relative;
width: max-content;
@ -134,32 +117,6 @@ export default {
.notice{
font-style: italic;
}
.image{
width: 100%;
img{
max-width: 100%;
height: auto;
max-height: 35rem;
border-radius: 0.5rem;
}
}
.video{
width: 100%;
video{
max-width: 100%;
height: auto;
max-height: 35rem;
border-radius: 0.5rem;
}
}
.audio{
audio{
max-width: 100%;
}
}
.italic{
font-style: italic;
}
}
.messageReceive{
background-color: #424141;

View File

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

View File

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

View File

@ -40,8 +40,8 @@
</template>
<script>
import event from '@/components/event';
import avatar from '@/components/avatar';
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';

View File

@ -3,7 +3,7 @@
<div>
<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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,20 +2,20 @@
<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 avatar from '@/components/matrix/avatar';
import {getMxcFromChat} from '@/lib/getMxc';
import {getTime} from '@/lib/getTimeStrings';
import {calcUserName} from '@/lib/matrixUtils';
@ -37,8 +37,16 @@ export default {
return room.timeline[room.timeline.length-1]
&& room.timeline[room.timeline.length-1].event;
},
getMxcFromChat,
calcUserName,
getMxcFromRoom
},
data(){
return {
previewString: 'loading'
}
},
created() {
this.previewString = this.getPreviewString(this.room);
}
}
</script>

View File

@ -1,21 +1,21 @@
<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',
@ -23,7 +23,11 @@ export default {
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>

View File

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

View File

@ -1,147 +0,0 @@
<template>
<div class="newMessageBanner" ref="newMessageBanner">
<reply-event v-if="replyTo" :event="replyTo" @click.native="resetReplyTo()"/>
<form v-on:submit.prevent="sendMessage()">
<textarea
@keyup.enter.exact="sendMessage()"
@input="resizeMessageBanner(); sendTyping(2000);"
v-model="event.content.body"
ref="newMessageInput" class="newMessageInput"
rows="1" placeholder="type a message ..."
/>
<icon
type="submit"
title="press enter to submit"
class="sendMessageBtn"
ic="./sym/ic_send_white.svg"
/>
</form>
</div>
</template>
<script>
import icon from '@/components/icon.vue';
import {matrix} from '@/main.js';
import {parseMessage} from '@/lib/eventUtils';
import {calcUserName} from '@/lib/matrixUtils';
import ReplyEvent from '@/components/replyEvent';
export default {
name: 'newMessage',
components: {
ReplyEvent,
icon
},
props: {
onResize: Function,
roomId: String,
replyTo: Object,
resetReplyTo: Function
},
methods: {
sendMessage(){
let content = this.event.content;
if (!content.body.trim()) return;
matrix.sendEvent(this.event, this.roomId, this.replyTo);
content.body = '';
this.resetReplyTo();
let id = this.$refs.newMessageInput;
id.style.height = '1.25rem';
this.onResize(id.parentElement.clientHeight);
},
sendTyping(timeout){
if (this.waitForSendTyping) return;
matrix.client.sendTyping(this.roomId, true, timeout+100);
setTimeout(()=>this.waitForSendTyping=false, timeout);
},
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;
},
parseMessage,
calcUserName
},
data(){
return {
event: {
type: 'm.room.message',
content: {
body: '',
msgtype: 'm.text',
'm.relates_to': {
'm.in_reply_to': {
event_id: undefined
}
}
}
},
showEmojiPicker: false,
waitForSendTyping: 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: 1rem;
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;
}
.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;
}
</style>

View File

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

View File

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

View File

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

21
src/lib/DataStore.js Normal file
View File

@ -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');
}
}

View File

@ -1,5 +1,5 @@
import matrix from 'matrix-js-sdk';
import {NotificationHandler} from "@/lib/NotificationHandler";
import {NotificationHandler} from '@/lib/NotificationHandler';
export class MatrixHandler {
constructor(clientDisplayName = 'matrix-chat') {
@ -48,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;
@ -77,17 +78,9 @@ export class MatrixHandler {
}
});
}
async sendEvent({content, type}, roomId, replyTo = undefined){
await this.client.sendEvent(roomId, type, {
body: content.body.trim(),
msgtype: content.msgtype,
'm.relates_to': {
'm.in_reply_to': {
event_id: replyTo?replyTo.event_id:undefined
}
}
}).then(() => console.log('message sent successfully'))
.catch((err) => console.log(`error while sending message => ${err}`)
);
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,7 +1,9 @@
import {getMxcFromUserId, getAvatarUrl} from '@/lib/getMxc';
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() {
@ -17,12 +19,29 @@ export class NotificationHandler{
.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?getAvatarUrl(mxc):undefined
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,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 = '';

View File

@ -18,6 +18,12 @@ 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);
}

View File

@ -15,4 +15,16 @@ export function isValidUserId(id){
}
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);
});
}

8
src/lib/readFileBlob.js Normal file
View File

@ -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);
});
}

View File

@ -2,25 +2,29 @@ import Vue from 'vue'
import VueRouter from 'vue-router'
import App from './App.vue'
import {router} from '@/router'
import {MatrixHandler} from './lib/matrixHandler.js'
import {cookieHandler} from './lib/cookieHandler.js';
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');
})()

50
src/main.scss Normal file
View File

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

View File

@ -1,6 +1,5 @@
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';
@ -9,18 +8,13 @@ export const router = new VueRouter({
{
path: '/',
name: 'home',
component: login
component: rooms
},
{
path: '/login',
name: 'login',
component: login
},
{
path: '/chat/*',
name: 'chat',
component: chat
},
{
path: '/rooms/*',
name: 'room',

View File

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

View File

@ -7,59 +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',
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 (!isValidUserId(this.user)) {
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/');
});
@ -67,17 +56,17 @@ 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;
}
@ -88,7 +77,7 @@ export default {
password: '',
homeServer: 'https://adb.sh',
loginError: '',
cookie: new cookieHandler(),
store,
loading: false
}
}
@ -96,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%;
@ -109,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 {

View File

@ -1,39 +1,45 @@
<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">
<p class="wideElement">rooms </p>
<p class="wideElement">- rooms -</p>
<room-list-element
v-for="room in Object.assign([], matrix.client.getRooms())
.sort(obj => obj.timeline[obj.timeline.length-1].event.origin_server_ts)
.filter(prop=>matchResults(prop.name, search))"
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>
<p class="wideElement">- users -</p><p class="smallElement"></p>
<user-list-element
v-for="user in matrix.client.getUsers()
.filter(prop=>matchResults(prop.displayName, search)||matchResults(prop.userId, search))
.slice(0,10)"
:user="user" :key="user.userId"
@click.native="setQuestion(`create private chat with '${search}'?`,()=>search='cool')"
@click.native="setQuestion({
title:'New Chat',
question:`Create private chat with '${user.displayName}'?`,
callback:()=>createRoom({users:[user], access: 'private'}).then(openChat)
})"
/>
<p class="wideElement">suggestions </p><p class="smallElement"></p>
<p class="wideElement">- suggestions -</p><p class="smallElement"></p>
<div class="wideElement">
<p v-if="isValidUserId(search)">create chat: {{search}} </p>
<p v-if="isValidUserId(search)" class="suggestion">create chat: {{search}} </p>
<p v-if="isValidRoomId(search)"
@click="setQuestion(`join room '${search}'?`, ()=>joinRoom(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="setQuestion(`create room '${search}'?`,()=>search='cool')"
@click="setShowCreateRoom({name: search}, openChat)"
class="suggestion"
>create room: {{search}} </p>
<room-list-element v-if="isValidRoomId(search) && getRoom(search)" :room="getRoom(search)"/>
</div>
</div>
</div>
@ -46,32 +52,40 @@
:open-chat-info="()=>showChatInfo=true"
/>
<div class="noRoomSelected" v-else>Please select a room to be displayed.</div>
<chatInformation v-if="showRoom && showChatInfo" :room="getCurrentRoom()" :close-chat-info="()=>showChatInfo=false"/>
<popup-question v-if="popup.question" :callback="popup.callback" :question="popup.question" class="center"/>
<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 chat from '@/components/chat/chat.vue';
import chatInformation from '@/components/chat/chatInformation';
import {matrix} from '@/main';
import ThrobberOverlay from '@/components/throbberOverlay';
import {getMxcFromRoom} from '@/lib/getMxc';
import roomListElement from '@/components/roomListElement';
import roomListElement from '@/components/matrix/roomListElement';
import {getRoom, getUser} from '@/lib/matrixUtils';
import {isValidUserId, isValidRoomId} from '@/lib/matrixUtils';
import userListElement from '@/components/userListElement';
import PopupQuestion from '@/components/popupQuestion';
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',
components:{
Throbber,
Overlay,
PopupQuestion,
userListElement,
ThrobberOverlay,
chat,
chatInformation,
roomListElement
roomListElement,
newRoom
},
methods:{
openChat(room){
@ -90,25 +104,36 @@ export default {
matchResults(prop, search){
return prop.toLowerCase().includes(search.toLowerCase().trim());
},
setQuestion(question, callback){
setQuestion({title, question, callback}){
this.popup = {
question,
title,
callback:(res)=>{
this.popup = false;
this.popup = {};
if (res) callback();
}
}
},
joinRoom(room){
this.matrix.client.join(room).then(()=>{
this.openChat(room);
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
isValidRoomId,
createRoom
},
data(){
return {
@ -116,10 +141,8 @@ export default {
showChatInfo: false,
showRoom: true,
search: '',
popup:{
question: '',
callback: ()=>{}
}
popup:{},
showCreateRoom:{}
}
},
mounted() {
@ -165,7 +188,10 @@ export default {
text-align: center;
}
input{
width: calc(100% - 5.2rem);
position: relative;
margin-left: auto;
margin-right: auto;
width: calc(100% - 4rem);
}
.wideElement{
display: block;
@ -174,11 +200,17 @@ input{
display: none;
}
.center{
position: fixed;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%,-50%);
z-index: 50;
}
.suggestion{
cursor: pointer;
text-decoration: underline;
}
.suggestion:hover{
background-color: #4444;
}
@media (max-width: 48rem) and (min-width: 30rem) {