Compare commits

..

73 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
adb f5388abeb0 fix admin and icons 4 years ago
adb 6a92858ac1 fix update user and input styling 4 years ago
adb 7292700cc9 add class AdminAPI and admin.vue 4 years ago
adb 8ea5c46920 debug login error 4 years ago
adb 9cc93109fc fix smallSideBar and homeServer maxlength 4 years ago
adb 7c1d994dc2 fix 4 years ago
adb 433fbac3fa Merge remote-tracking branch 'origin/master'
# Conflicts:
#	src/views/rooms.vue
4 years ago
adb 63d3f6fb47 refactor/split to roomListElement/userListElement 4 years ago
adb 5651c88298 fix chrome newMessage height 4 years ago
adb 008acbb056 fix scrollBottom 4 years ago
adb 5f536adc8f fix z-index 4 years ago
adb 5d0e96a675 parse reply events 4 years ago
adb b2691f0c85 few fixes 4 years ago
adb f7ee0d9695 update login and throbber 4 years ago
adb 31beb2b436 add throbber, room search, room preview 4 years ago
adb afbb98eb4d fix newMessage 4 years ago
adb c7c7d2c120 update jdenticon 4 years ago
adb 035f30936e debug sendMessage 4 years ago
adb 4f5f03f65f rm matrix.js 4 years ago
adb de80233b0d add event time and cleanup 4 years ago
adb e52ea62fe3 Merge branch 'restructure'
# Conflicts:
#	src/components/chatInformation.vue
4 years ago
adb 52a880da9e fix unnecessary condition 4 years ago
adb 1c0818dc43 old api - not needed anymore 4 years ago
adb dd5afc5856 Add 'LICENSE' 4 years ago

3
.gitmodules vendored

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

@ -0,0 +1,373 @@
Mozilla Public License Version 2.0
==================================
1. Definitions
--------------
1.1. "Contributor"
means each individual or legal entity that creates, contributes to
the creation of, or owns Covered Software.
1.2. "Contributor Version"
means the combination of the Contributions of others (if any) used
by a Contributor and that particular Contributor's Contribution.
1.3. "Contribution"
means Covered Software of a particular Contributor.
1.4. "Covered Software"
means Source Code Form to which the initial Contributor has attached
the notice in Exhibit A, the Executable Form of such Source Code
Form, and Modifications of such Source Code Form, in each case
including portions thereof.
1.5. "Incompatible With Secondary Licenses"
means
(a) that the initial Contributor has attached the notice described
in Exhibit B to the Covered Software; or
(b) that the Covered Software was made available under the terms of
version 1.1 or earlier of the License, but not also under the
terms of a Secondary License.
1.6. "Executable Form"
means any form of the work other than Source Code Form.
1.7. "Larger Work"
means a work that combines Covered Software with other material, in
a separate file or files, that is not Covered Software.
1.8. "License"
means this document.
1.9. "Licensable"
means having the right to grant, to the maximum extent possible,
whether at the time of the initial grant or subsequently, any and
all of the rights conveyed by this License.
1.10. "Modifications"
means any of the following:
(a) any file in Source Code Form that results from an addition to,
deletion from, or modification of the contents of Covered
Software; or
(b) any new file in Source Code Form that contains any Covered
Software.
1.11. "Patent Claims" of a Contributor
means any patent claim(s), including without limitation, method,
process, and apparatus claims, in any patent Licensable by such
Contributor that would be infringed, but for the grant of the
License, by the making, using, selling, offering for sale, having
made, import, or transfer of either its Contributions or its
Contributor Version.
1.12. "Secondary License"
means either the GNU General Public License, Version 2.0, the GNU
Lesser General Public License, Version 2.1, the GNU Affero General
Public License, Version 3.0, or any later versions of those
licenses.
1.13. "Source Code Form"
means the form of the work preferred for making modifications.
1.14. "You" (or "Your")
means an individual or a legal entity exercising rights under this
License. For legal entities, "You" includes any entity that
controls, is controlled by, or is under common control with You. For
purposes of this definition, "control" means (a) the power, direct
or indirect, to cause the direction or management of such entity,
whether by contract or otherwise, or (b) ownership of more than
fifty percent (50%) of the outstanding shares or beneficial
ownership of such entity.
2. License Grants and Conditions
--------------------------------
2.1. Grants
Each Contributor hereby grants You a world-wide, royalty-free,
non-exclusive license:
(a) under intellectual property rights (other than patent or trademark)
Licensable by such Contributor to use, reproduce, make available,
modify, display, perform, distribute, and otherwise exploit its
Contributions, either on an unmodified basis, with Modifications, or
as part of a Larger Work; and
(b) under Patent Claims of such Contributor to make, use, sell, offer
for sale, have made, import, and otherwise transfer either its
Contributions or its Contributor Version.
2.2. Effective Date
The licenses granted in Section 2.1 with respect to any Contribution
become effective for each Contribution on the date the Contributor first
distributes such Contribution.
2.3. Limitations on Grant Scope
The licenses granted in this Section 2 are the only rights granted under
this License. No additional rights or licenses will be implied from the
distribution or licensing of Covered Software under this License.
Notwithstanding Section 2.1(b) above, no patent license is granted by a
Contributor:
(a) for any code that a Contributor has removed from Covered Software;
or
(b) for infringements caused by: (i) Your and any other third party's
modifications of Covered Software, or (ii) the combination of its
Contributions with other software (except as part of its Contributor
Version); or
(c) under Patent Claims infringed by Covered Software in the absence of
its Contributions.
This License does not grant any rights in the trademarks, service marks,
or logos of any Contributor (except as may be necessary to comply with
the notice requirements in Section 3.4).
2.4. Subsequent Licenses
No Contributor makes additional grants as a result of Your choice to
distribute the Covered Software under a subsequent version of this
License (see Section 10.2) or under the terms of a Secondary License (if
permitted under the terms of Section 3.3).
2.5. Representation
Each Contributor represents that the Contributor believes its
Contributions are its original creation(s) or it has sufficient rights
to grant the rights to its Contributions conveyed by this License.
2.6. Fair Use
This License is not intended to limit any rights You have under
applicable copyright doctrines of fair use, fair dealing, or other
equivalents.
2.7. Conditions
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
in Section 2.1.
3. Responsibilities
-------------------
3.1. Distribution of Source Form
All distribution of Covered Software in Source Code Form, including any
Modifications that You create or to which You contribute, must be under
the terms of this License. You must inform recipients that the Source
Code Form of the Covered Software is governed by the terms of this
License, and how they can obtain a copy of this License. You may not
attempt to alter or restrict the recipients' rights in the Source Code
Form.
3.2. Distribution of Executable Form
If You distribute Covered Software in Executable Form then:
(a) such Covered Software must also be made available in Source Code
Form, as described in Section 3.1, and You must inform recipients of
the Executable Form how they can obtain a copy of such Source Code
Form by reasonable means in a timely manner, at a charge no more
than the cost of distribution to the recipient; and
(b) You may distribute such Executable Form under the terms of this
License, or sublicense it under different terms, provided that the
license for the Executable Form does not attempt to limit or alter
the recipients' rights in the Source Code Form under this License.
3.3. Distribution of a Larger Work
You may create and distribute a Larger Work under terms of Your choice,
provided that You also comply with the requirements of this License for
the Covered Software. If the Larger Work is a combination of Covered
Software with a work governed by one or more Secondary Licenses, and the
Covered Software is not Incompatible With Secondary Licenses, this
License permits You to additionally distribute such Covered Software
under the terms of such Secondary License(s), so that the recipient of
the Larger Work may, at their option, further distribute the Covered
Software under the terms of either this License or such Secondary
License(s).
3.4. Notices
You may not remove or alter the substance of any license notices
(including copyright notices, patent notices, disclaimers of warranty,
or limitations of liability) contained within the Source Code Form of
the Covered Software, except that You may alter any license notices to
the extent required to remedy known factual inaccuracies.
3.5. Application of Additional Terms
You may choose to offer, and to charge a fee for, warranty, support,
indemnity or liability obligations to one or more recipients of Covered
Software. However, You may do so only on Your own behalf, and not on
behalf of any Contributor. You must make it absolutely clear that any
such warranty, support, indemnity, or liability obligation is offered by
You alone, and You hereby agree to indemnify every Contributor for any
liability incurred by such Contributor as a result of warranty, support,
indemnity or liability terms You offer. You may include additional
disclaimers of warranty and limitations of liability specific to any
jurisdiction.
4. Inability to Comply Due to Statute or Regulation
---------------------------------------------------
If it is impossible for You to comply with any of the terms of this
License with respect to some or all of the Covered Software due to
statute, judicial order, or regulation then You must: (a) comply with
the terms of this License to the maximum extent possible; and (b)
describe the limitations and the code they affect. Such description must
be placed in a text file included with all distributions of the Covered
Software under this License. Except to the extent prohibited by statute
or regulation, such description must be sufficiently detailed for a
recipient of ordinary skill to be able to understand it.
5. Termination
--------------
5.1. The rights granted under this License will terminate automatically
if You fail to comply with any of its terms. However, if You become
compliant, then the rights granted under this License from a particular
Contributor are reinstated (a) provisionally, unless and until such
Contributor explicitly and finally terminates Your grants, and (b) on an
ongoing basis, if such Contributor fails to notify You of the
non-compliance by some reasonable means prior to 60 days after You have
come back into compliance. Moreover, Your grants from a particular
Contributor are reinstated on an ongoing basis if such Contributor
notifies You of the non-compliance by some reasonable means, this is the
first time You have received notice of non-compliance with this License
from such Contributor, and You become compliant prior to 30 days after
Your receipt of the notice.
5.2. If You initiate litigation against any entity by asserting a patent
infringement claim (excluding declaratory judgment actions,
counter-claims, and cross-claims) alleging that a Contributor Version
directly or indirectly infringes any patent, then the rights granted to
You by any and all Contributors for the Covered Software under Section
2.1 of this License shall terminate.
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
end user license agreements (excluding distributors and resellers) which
have been validly granted by You or Your distributors under this License
prior to termination shall survive termination.
************************************************************************
* *
* 6. Disclaimer of Warranty *
* ------------------------- *
* *
* Covered Software is provided under this License on an "as is" *
* basis, without warranty of any kind, either expressed, implied, or *
* statutory, including, without limitation, warranties that the *
* Covered Software is free of defects, merchantable, fit for a *
* particular purpose or non-infringing. The entire risk as to the *
* quality and performance of the Covered Software is with You. *
* Should any Covered Software prove defective in any respect, You *
* (not any Contributor) assume the cost of any necessary servicing, *
* repair, or correction. This disclaimer of warranty constitutes an *
* essential part of this License. No use of any Covered Software is *
* authorized under this License except under this disclaimer. *
* *
************************************************************************
************************************************************************
* *
* 7. Limitation of Liability *
* -------------------------- *
* *
* Under no circumstances and under no legal theory, whether tort *
* (including negligence), contract, or otherwise, shall any *
* Contributor, or anyone who distributes Covered Software as *
* permitted above, be liable to You for any direct, indirect, *
* special, incidental, or consequential damages of any character *
* including, without limitation, damages for lost profits, loss of *
* goodwill, work stoppage, computer failure or malfunction, or any *
* and all other commercial damages or losses, even if such party *
* shall have been informed of the possibility of such damages. This *
* limitation of liability shall not apply to liability for death or *
* personal injury resulting from such party's negligence to the *
* extent applicable law prohibits such limitation. Some *
* jurisdictions do not allow the exclusion or limitation of *
* incidental or consequential damages, so this exclusion and *
* limitation may not apply to You. *
* *
************************************************************************
8. Litigation
-------------
Any litigation relating to this License may be brought only in the
courts of a jurisdiction where the defendant maintains its principal
place of business and such litigation shall be governed by laws of that
jurisdiction, without reference to its conflict-of-law provisions.
Nothing in this Section shall prevent a party's ability to bring
cross-claims or counter-claims.
9. Miscellaneous
----------------
This License represents the complete agreement concerning the subject
matter hereof. If any provision of this License is held to be
unenforceable, such provision shall be reformed only to the extent
necessary to make it enforceable. Any law or regulation which provides
that the language of a contract shall be construed against the drafter
shall not be used to construe this License against a Contributor.
10. Versions of the License
---------------------------
10.1. New Versions
Mozilla Foundation is the license steward. Except as provided in Section
10.3, no one other than the license steward has the right to modify or
publish new versions of this License. Each version will be given a
distinguishing version number.
10.2. Effect of New Versions
You may distribute the Covered Software under the terms of the version
of the License under which You originally received the Covered Software,
or under the terms of any subsequent version published by the license
steward.
10.3. Modified Versions
If you create software not governed by this License, and you want to
create a new license for such software, you may create and use a
modified version of this License if you rename the license and remove
any references to the name of the license steward (except to note that
such modified license differs from this License).
10.4. Distributing Source Code Form that is Incompatible With Secondary
Licenses
If You choose to distribute Source Code Form that is Incompatible With
Secondary Licenses under the terms of this version of the License, the
notice described in Exhibit B of this License must be attached.
Exhibit A - Source Code Form License Notice
-------------------------------------------
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
If it is not possible or desirable to put the notice in a particular
file, then You may include the notice in a location (such as a LICENSE
file in a relevant directory) where a recipient would be likely to look
for such a notice.
You may add additional accurate notices of copyright ownership.
Exhibit B - "Incompatible With Secondary Licenses" Notice
---------------------------------------------------------
This Source Code Form is "Incompatible With Secondary Licenses", as
defined by the Mozilla Public License, v. 2.0.

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

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

@ -1,70 +0,0 @@
const ws = require('ws')
//WS server
const wss = new ws.Server({
port: 8090,
perMessageDeflate: {
zlibDeflateOptions: {
chunkSize: 1024,
memLevel: 7,
level: 3
},
zlibInflateOptions: {
chunkSize: 10 * 1024
},
clientNoContextTakeover: true,
serverNoContextTakeover: true,
serverMaxWindowBits: 10,
concurrencyLimit: 10,
threshold: 1024
}
});
//WS handler
let user = [];
wss.on('connection', (ws, req) => {
let thisuser = ""
console.log(`${req.socket.remoteAddress} connected`)
ws.on('message', msgJSON => {
let msg = JSON.parse(msgJSON)
console.log(`${req.socket.remoteAddress} (${thisuser}) => ${msgJSON}`)
if (msg.type === 'message')
if (thisuser === ""){
ws.send(JSON.stringify({type: "error", content: "please login before writing"}))
ws.send('{"type":"route","path":"/login"}')
}else if (msg.content.text === "")
ws.send(JSON.stringify({type: "error", content: "your message was empty"}))
else if (msg.content.text.length >= 1000)
ws.send(JSON.stringify({type: "error", content: "your message is too long"}))
else{
msg.content.user = thisuser
//msg.content.text = msg.content.text.replace(/</g, "&lt").replace(/>/g, "&gt").replace(/\n/g, "<br>")
wss.clients.forEach(client => client.send(JSON.stringify(msg)))
}
else if (msg.type === 'login' && msg.content.user !== ""){
if (thisuser !== ""){
ws.send(JSON.stringify({type: "error", content: "you are already logged in"}))
ws.send('{"type":"route","path":"/chat"}')
}
else if (msg.content.user.length >= 20) ws.send(JSON.stringify({type: "error", content: "username is too long"}))
else if (msg.content.user === "you" || user.indexOf(msg.content.user) !== -1)
ws.send(JSON.stringify({type: "error", content: "username already exist"}))
else{
thisuser = msg.content.user
user.push(msg.content.user)
ws.send('{"type":"route","path":"/chat"}')
ws.send(JSON.stringify({type: "info", username: thisuser}))
wss.clients.forEach(client =>
client.send(JSON.stringify({type: "room", name: "open chat", user: user})))
}
}
})
ws.on('close', () => {
user.splice(user.indexOf(thisuser), 1);
console.log(`${req.socket.remoteAddress} (${thisuser}) closed`)
wss.clients.forEach(client =>
client.send(JSON.stringify({type: "room", name: "open chat", user: user})))
})
ws.send(JSON.stringify({type: "info", time: Date.now(), content: "connected"}))
})

@ -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,13 +8,18 @@
"lint": "vue-cli-service lint" "lint": "vue-cli-service lint"
}, },
"dependencies": { "dependencies": {
"@capacitor/android": "^2.4.7",
"@capacitor/cli": "^2.4.7",
"@capacitor/core": "^2.4.7",
"@modular-matrix/parse-mxc": "^1.0.1", "@modular-matrix/parse-mxc": "^1.0.1",
"@vue-polkadot/vue-identicon": "^0.0.8", "@vue-polkadot/vue-identicon": "^0.0.8",
"core-js": "^3.6.5", "core-js": "^3.6.5",
"jdenticon": "^3.1.0", "jdenticon": "^3.1.0",
"matrix-js-sdk": "^9.1.0", "matrix-js-sdk": "^9.1.0",
"sass": "^1.29.0", "sass": "^1.29.0",
"superagent": "^6.1.0",
"v-emoji-picker": "^2.3.1", "v-emoji-picker": "^2.3.1",
"viewerjs": "*",
"vue": "^2.6.11", "vue": "^2.6.11",
"ws": "^7.3.1" "ws": "^7.3.1"
}, },
@ -33,6 +38,7 @@
"eslint": "^6.7.2", "eslint": "^6.7.2",
"eslint-plugin-vue": "^7.5.0", "eslint-plugin-vue": "^7.5.0",
"node-sass": "^5.0.0", "node-sass": "^5.0.0",
"recorder-js": "*",
"sass-loader": "^10.1.1", "sass-loader": "^10.1.1",
"vue-router": "^3.4.9", "vue-router": "^3.4.9",
"vue-template-compiler": "^2.6.11" "vue-template-compiler": "^2.6.11"

@ -0,0 +1,4 @@
<svg fill="#FFFFFF" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
<path d="M0 0h24v24H0z" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 199 B

Before

Width:  |  Height:  |  Size: 210 B

After

Width:  |  Height:  |  Size: 210 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="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

Before

Width:  |  Height:  |  Size: 265 B

After

Width:  |  Height:  |  Size: 265 B

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white" width="24px" height="24px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM5.92 19H5v-.92l9.06-9.06.92.92L5.92 19zM20.71 5.63l-2.34-2.34c-.2-.2-.45-.29-.71-.29s-.51.1-.7.29l-1.83 1.83 3.75 3.75 1.83-1.83c.39-.39.39-1.02 0-1.41z"/></svg>

After

Width:  |  Height:  |  Size: 357 B

@ -0,0 +1,4 @@
<svg fill="#FFFFFF" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/>
<path d="M0 0h24v24H0z" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 241 B

Before

Width:  |  Height:  |  Size: 199 B

After

Width:  |  Height:  |  Size: 199 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

Before

Width:  |  Height:  |  Size: 350 B

After

Width:  |  Height:  |  Size: 350 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

Before

Width:  |  Height:  |  Size: 201 B

After

Width:  |  Height:  |  Size: 201 B

Before

Width:  |  Height:  |  Size: 465 B

After

Width:  |  Height:  |  Size: 465 B

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<svg width='200px' height='200px' xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid" class="uil-ring-alt">
<rect x="0" y="0" width="100" height="100" fill="none" class="bk"></rect>
<circle cx="50" cy="50" r="40" stroke="#d9d9d9" fill="none" stroke-width="10" stroke-linecap="round"></circle>
<circle cx="50" cy="50" r="40" stroke="#009eff" fill="none" stroke-width="10" stroke-linecap="round">
<animate attributeName="stroke-dashoffset" dur="2s" repeatCount="indefinite" from="502" to="0"></animate>
<animate attributeName="stroke-dasharray" dur="2s" repeatCount="indefinite" values="125.5 125.5;1 250;125.5 125.5"></animate>
</circle>
</svg>

After

Width:  |  Height:  |  Size: 718 B

@ -1,6 +1,6 @@
<template> <template>
<div id="app"> <div id="app">
<div class="content"> <div id="appContent">
<router-view /> <router-view />
<error /> <error />
</div> </div>
@ -8,7 +8,7 @@
</template> </template>
<script> <script>
import error from "@/components/error.vue"; import error from '@/components/layout/error.vue';
export default { export default {
name: 'App', name: 'App',
@ -18,10 +18,15 @@ export default {
} }
</script> </script>
<style> <style lang="scss">
@import "main.scss";
body{ body{
margin: 0; margin: 0;
} }
a{
color: #00BCD4;
}
*{ *{
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: #42b983 #2220; scrollbar-color: #42b983 #2220;
@ -45,19 +50,19 @@ body{
min-height: 100%; min-height: 100%;
width: 100%; width: 100%;
} }
.content{ #appContent{
position: absolute; position: absolute;
top: 0; top: 0;
left: calc(50% - 35rem); left: calc(50% - 35rem);
padding: 0; padding: 0;
min-height: calc(100%); min-height: 100%;
width: 70rem; width: 70rem;
min-width: 18rem; min-width: 18rem;
background-color: #313131; background-color: #313131;
box-shadow: 3px 3px 10px #111; box-shadow: 3px 3px 10px #111;
} }
@media (max-width: 75rem){ @media (max-width: 75rem){
.content{ #appContent{
width: 100%; width: 100%;
left: 0; left: 0;
} }

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

@ -0,0 +1,132 @@
<template>
<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>
<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 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',
components:{
avatar,
UserListElement,
popup
},
props:{
room: {},
closeChatInfo: Function
},
methods: {
getMembers(){
return Object.keys(this.room.currentState.members)
},
getUser,
getMxcFromChat
},
data(){
return{
members: this.getMembers()
}
}
}
</script>
<style scoped>
.chatInfo{
position: absolute;
left: 50%;
transform: translate(-50%, 0);
top: 5rem;
width: calc(100% - 4rem);
max-width: 30rem;
height: calc(100% - 10rem);
background-color: #1d1d1d;
box-shadow: 6px 6px 20px #111;
border-radius: 1rem;
text-align: center;
z-index: 30;
}
@media (max-width: 30rem) {
.chatInfo{
transform: unset;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
}
@media (max-height: 40rem) {
.chatInfo{
top: 0;
height: 100%;
}
}
.popup{
min-height: calc(100% - 10rem);
}
.closeBtn{
position: absolute;
top: 0;
right: 0;
background-color: #0000;
box-shadow: none;
}
.box{
position: absolute;
top: 0;
width: calc(100% - 2rem);
padding: 0 1rem 0 1rem;
overflow-y: auto;
overflow-x: hidden;
height: 100%;
}
.scrollContainer{
padding: 1rem 0 1rem 0;
position: absolute;
width: 100%;
height: auto;
}
.info{
position: absolute;
top: 1rem;
left: 6rem;
text-align: left;
}
.roomName{
color: white;
font-size: 1.8rem;
}
.users {
font-size: 1.2rem;
color: #9c9c9c;
}
.topContainer{
position: relative;
height: 6rem;
width: 100%;
}
.roomImage{
position: absolute;
width: 5rem;
height: 5rem;
left: 0;
}
</style>

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

@ -0,0 +1,126 @@
<template>
<div class="timeline">
<div
class="timeGroup"
v-for="timeGroup in splitArray(timeline,obj => getDate(obj.event.origin_server_ts),obj => obj.event)"
:key="timeGroup[0].origin_server_ts"
>
<div class="date">{{getDate(timeGroup[0].origin_server_ts)}}</div>
<div class="eventGroup" v-for="group in splitArray(timeGroup, obj => obj.sender)" :key="group[0].origin_server_ts">
<div class="thumbnailContainer">
<div class="filler"></div>
<avatar
v-if="group[0].sender !== user && groupTimeline"
:fallback="group[0].sender"
class="userThumbnail"
:mxcURL="getUser(group[0].sender).avatarUrl"
:size="2"
:title="group[0].sender"
/>
</div>
<div
:class="groupTimeline?'indent username':'username'"
v-if="group[0].sender !== user && groupTimeline"
>
{{calcUserName(group[0].sender)}}
</div>
<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)}`"
@contextmenu.prevent.native="setReplyTo(event)"
:type="event.sender === user?'send':'receive'"
:event="event"
:on-update="onUpdate"
/>
</div>
</div>
</div>
</template>
<script>
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: {
event,
avatar
},
props: {
timeline: Array,
user: String,
groupTimeline: Boolean,
setReplyTo: Function,
onUpdate: Function
},
methods: {
getUser,
calcUserName,
splitArray,
getDate,
getTime
}
}
</script>
<style scoped lang="scss">
.timeline{
margin-top: 0.5rem;
margin-bottom: 0.5rem;
.timeGroup {
.date {
top: 0.25rem;
position: sticky;
z-index: 10;
background-color: #2d2d2d;
padding: 0.5rem;
border-radius: 0.5rem;
width: fit-content;
left: 50%;
transform: translate(-50%, 0);
margin-top: 0.5rem;
}
.eventGroup {
position: relative;
width: calc(100% - 1rem);
height: fit-content;
margin-top: 0.75rem;
margin-left: 0.5rem;
margin-right: 0.5rem;
.thumbnailContainer {
position: absolute;
top: 0;
left: 0;
height: 100%;
.filler {
height: calc(100% - 2rem);
}
.userThumbnail {
position: sticky;
bottom: 0.5rem;
width: 2rem;
height: 2rem;
}
}
.username{
position: relative;
margin-left: 1rem;
font-weight: bold;
max-width: 100%;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.indent{
margin-left: 2.5rem;
}
}
}
}
</style>

@ -0,0 +1,70 @@
<template>
<div class="topBanner">
<div>
<icon @click.native="closeChat()" class="topIcon" ic="./sym/ic_arrow_back_white.svg" />
<div @click="openChatInfo()" class="container">
<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>
</div>
</div>
</template>
<script>
import icon from '@/components/layout/icon.vue';
import avatar from '@/components/matrix/avatar';
import {getMxcFromChat} from '@/lib/getMxc';
export default {
name: 'topBanner',
components:{
icon,
avatar
},
props:{
room: [Object, undefined],
closeChat: Function,
openChatInfo: Function
},
methods: {
getMxcFromChat
}
}
</script>
<style scoped>
.topBanner{
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 3.5rem;
background-color: #1d1d1d;
cursor: pointer;
}
.topIcon{
position: absolute;
width: 3rem;
height: 3rem;
left: 0.5rem;
top: 0.25rem;
background-color: unset;
box-shadow: none;
}
.container{
position: absolute;
height: 100%;
width: calc(100% - 3.5rem);
right: 0;
}
.chatName{
position: absolute;
top: 1rem;
left: 4rem;
}
.info{
position: absolute;
top: 2rem;
left: 4rem;
font-size: 0.8rem;
}
</style>

@ -1,162 +0,0 @@
<template>
<div id="chatInformation">
<div id="box">
<div class="scrollContainer">
<div class="informationBox">
<div class="picBoxBig"><div class="placeholderBig">{{room.name.substr(0,2)}}</div></div>
<div class="roomInformation">
<div class="roomName">{{room.name}}</div>
<div class="users">{{getMembers().length}} members</div>
</div>
</div>
<h2 v-if="getMembers().length !== 0">members:</h2>
<div v-for="member in getMembers().slice(0,20)" :key="member" class="contentBox" :title="member">
<userThumbnail
:mxcURL="getUser(member).avatarUrl"
:fallback="getUser(member).displayName"
class="userThumbnail" :size="3"
/>
<div class="information">
<div class="userName">{{getUser(member).displayName || member}}</div>
<div class="status">{{getStatus(getUser(member))}}</div>
</div>
</div>
<p v-if="getMembers().length>20">and {{getMembers().length-20}} other members</p>
</div>
</div>
<icon class="closeBtn" @click.native="closeChatInfo()" ic="./sym/ic_close_white_24px.svg" />
</div>
</template>
<script>
import icon from './icon.vue';
import userThumbnail from './userThumbnail';
import {matrix} from "@/main";
export default {
name: "chatInformation",
components:{
icon,
userThumbnail
},
props:{
room: {},
closeChatInfo: Function
},
methods: {
getUser(userId){
return matrix.client.getUser(userId);
},
getMembers(){
return Object.keys(this.room.currentState.members)
},
getStatus(){
}
}
}
</script>
<style scoped>
#chatInformation{
position: absolute;
left: 50%;
transform: translate(-50%, 0);
top: 5rem;
width: calc(100% - 4rem);
max-width: 30rem;
height: calc(100% - 10rem);
background-color: #1d1d1d;
box-shadow: 6px 6px 20px #111;
border-radius: 1rem;
text-align: center;
}
@media (max-width: 30rem) {
#chatInformation{
transform: unset;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
}
@media (max-height: 40rem) {
#chatInformation{
top: 0;
height: 100%;
}
}
.closeBtn{
position: absolute;
top: 0;
right: 0;
background-color: #0000;
box-shadow: none;
}
#box{
position: absolute;
top: 0;
width: calc(100% - 2rem);
padding: 0 1rem 0 1rem;
overflow-y: auto;
overflow-x: hidden;
height: 100%;
}
.scrollContainer{
padding: 1rem 0 1rem 0;
position: absolute;
width: 100%;
height: auto;
}
.picBoxBig{
text-align: center;
background-color: #42a7b9;
padding-top: 1.5rem;
width: 5rem;
height: 3.5rem;
border-radius: 5rem;
font-size: 2rem;
}
.roomInformation{
position: relative;
text-align: left;
margin-top: -4rem;
margin-left: 5.7rem;
}
.roomName{
color: white;
font-size: 1.8rem;
}
.users {
font-size: 1.2rem;
color: #9c9c9c;
}
.contentBox{
position: relative;
margin-top: 0.5rem;
height: 3rem;
width: 100%;
}
.userThumbnail{
position: absolute;
left: 0;
top: 0;
width: 3rem;
height: 3rem;
}
.information{
position: absolute;
left: 4rem;
top: 0;
width: calc(100% - 4rem);
}
.userName{
position: absolute;
top: 0.75rem;
color: white;
}
.status{
position: absolute;
font-size: 0.8rem;
top: 1.75rem;
color: #9c9c9c;
}
</style>

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

@ -1,6 +1,6 @@
<template> <template>
<button class="iconContainer" > <button class="iconContainer" >
<img class="icon" v-bind:src=ic alt="" /> <img class="icon" :src=ic alt="" />
</button> </button>
</template> </template>
@ -15,6 +15,7 @@ name: "icon",
<style scoped> <style scoped>
.iconContainer{ .iconContainer{
position: relative;
width: 3rem; width: 3rem;
height: 3rem; height: 3rem;
border-radius: 1.5rem; border-radius: 1.5rem;

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

@ -0,0 +1,42 @@
<template>
<div class="box">
<img class="icon" :src="'./sym/throbber.svg'" alt="[animation]"/>
<div class="text">{{text}}</div>
</div>
</template>
<script>
export default {
name: "throbber",
props:{
text: String
}
}
</script>
<style scoped lang="scss">
.box{
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
height: 8rem;
width: 8rem;
background-color: #1d1d1d;
box-shadow: 6px 6px 10px #111;
border-radius: 2rem;
.icon{
position: absolute;
top: 15%;
left: 25%;
height: 50%;
width: 50%;
}
.text{
position: absolute;
bottom: 1rem;
width: 100%;
text-align: center;
}
}
</style>

@ -0,0 +1,47 @@
<template>
<img v-if="mxcURL" :src="getPreviewUrl(mxcURL)" class="userThumbnail image"/>
<div v-else v-html="getJdenticon()" class="userThumbnail identicon"/>
</template>
<script>
import {toSvg} from 'jdenticon';
import {getPreviewUrl} from '@/lib/getMxc';
export default {
name: 'userThumbnail.vue',
components: {
},
props: {
mxcURL: String,
username: String,
fallback: String,
size: Number
},
methods: {
getFontSize(){
return window.getComputedStyle(document.body,null).fontSize.split('px', 1)||16;
},
getJdenticon(){
return toSvg(this.fallback, this.getFontSize()*this.size);
},
getPreviewUrl
},
data(){
return {
resizeMethod: 'scale',
imageSize: 128
}
}
}
</script>
<style scoped lang="scss">
.userThumbnail{
border-radius: 50%;
background-color: unset;
}
.identicon {
clip-path: circle(49% at 50% 50%);
background-color: #111;
}
</style>

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

@ -0,0 +1,97 @@
<template>
<div class="roomListElement" :title="room.name">
<div class="imageContainer">
<avatar
class="roomImage"
:mxcURL="getMxcFromChat(room)"
:fallback="room.roomId"
:size="3"
/>
</div>
<div class="roomListName">{{room.name}}</div>
<div class="status">{{previewString}}</div>
</div>
</template>
<script>
import avatar from '@/components/matrix/avatar';
import {getMxcFromChat} from '@/lib/getMxc';
import {getTime} from '@/lib/getTimeStrings';
import {calcUserName} from '@/lib/matrixUtils';
export default {
name: 'roomListElement',
components:{
avatar
},
props:{
room: Object
},
methods:{
getPreviewString(room){
let event = this.getLatestEvent(room);
if (!event) return '';
return `${this.calcUserName(event.sender)}: ${event.content.body||'unknown event'} ${getTime(event.origin_server_ts)}`;
},
getLatestEvent(room){
return room.timeline[room.timeline.length-1]
&& room.timeline[room.timeline.length-1].event;
},
getMxcFromChat,
calcUserName,
},
data(){
return {
previewString: 'loading'
}
},
created() {
this.previewString = this.getPreviewString(this.room);
}
}
</script>
<style scoped lang="scss">
.roomListElement{
position: relative;
height: 3rem;
width: 100%;
font-size: 1.2rem;
cursor: pointer;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
.imageContainer{
position: absolute;
height: 3rem;
width: 3rem;
left: 0.5rem;
.roomImage {
height: inherit;
width: inherit;
}
}
.roomListName{
position: absolute;
left: 4rem;
top: 0.25rem;
text-overflow: ellipsis;
white-space: nowrap;
width: calc(100% - 5rem);
text-align: left;
}
.status{
position: absolute;
top: 1.5rem;
left: 4rem;
font-size: 0.8rem;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
width: calc(100% - 4.5rem);
}
}
.roomListElement:hover{
background-color: #4444;
}
</style>

@ -0,0 +1,99 @@
<template>
<div :class="compact?'userListElement compact':'userListElement'" :title="user.userId">
<div :class="compact?'imageContainer compact':'imageContainer'">
<avatar
:class="compact?'userImage compact':'userImage'"
:mxcURL="user.avatarUrl"
:fallback="user.userId"
:size="compact?1.5:3"
/>
<div v-if="user.currentlyActive" class="online"></div>
</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/matrix/avatar';
export default {
name: 'userListElement',
components:{
avatar
},
props:{
user: Object,
compact: {
type: Boolean,
default: false
}
}
}
</script>
<style scoped lang="scss">
.userListElement{
position: relative;
height: 3rem;
width: 100%;
font-size: 1.2rem;
cursor: pointer;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
.imageContainer{
position: absolute;
height: 3rem;
width: 3rem;
left: 0.5rem;
.userImage {
height: inherit;
width: inherit;
}
.online {
position: absolute;
bottom: 0;
right: 0;
height: 0.5rem;
width: 0.5rem;
background-color: #42b983;
border-radius: 50%;
border: 2px solid #222;
}
}
.userListName{
position: absolute;
left: 4rem;
top: 0.25rem;
text-overflow: ellipsis;
white-space: nowrap;
width: calc(100% - 5rem);
text-align: left;
}
.status{
position: absolute;
top: 1.5rem;
left: 4rem;
font-size: 0.8rem;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
width: calc(100% - 4.5rem);
}
}
.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,64 +0,0 @@
<template>
<div :class="type==='send'?'messageSend':'messageReceive'" class="message">
<div v-html="solveTextLinks(msg.replace(/</g, '&lt')
.replace(/>/g, '&gt'))"></div>
<div class="time">{{time}}</div>
</div>
</template>
<script>
export default {
name: "message",
props: {
msg: String,
time: String,
type: 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>`;
}
)
}
}
}
</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;
}
</style>

@ -1,112 +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_24px.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: {
async sendMessage(){
if (this.msg.content.body !== "") {
let msgSend = Object.assign({}, this.msg);
await matrix.sendEvent(msgSend, this.roomId);
this.msg.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;
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,133 +0,0 @@
<template>
<div class="timeline">
<div class="timeGroup"
v-for="timeGroup in splitArray(timeline,obj => getDate(obj.event.origin_server_ts),obj => obj.event)"
:key="timeGroup[0].origin_server_ts"
>
<div class="time">{{getDate(timeGroup[0].origin_server_ts)}}</div>
<div class="eventGroup" v-for="group in splitArray(timeGroup, obj => obj.sender)" :key="group[0].origin_server_ts">
<div class="thumbnailContainer">
<div class="filler"></div>
<userThumbnail
v-if="group[0].sender !== user && groupTimeline"
:fallback="group[0].sender"
class="userThumbnail"
:mxcURL="getUser(group[0].sender).avatarUrl"
:size="2"
:title="group[0].sender"
/>
</div>
<div :class="groupTimeline?'indent username':'username'"
v-if="group[0].sender !== user && groupTimeline">{{getUser(group[0].sender).displayName || group[0].sender}}
</div>
<div :class="groupTimeline?'indent event':'event'"
v-for="event in group" :key="event.origin_server_ts"
:title="`${group[0].sender} at ${getTime(event.origin_server_ts)}`"
>
<message v-if="event.content.msgtype==='m.text'"
:type="event.sender === user?'send':'receive'"
:msg=event.content.body :time=getTime(event.origin_server_ts)
/>
<div v-else-if="event.type==='m.room.member'" class="info">{{membershipEvents[event.content.membership]}}</div>
<div v-else class="info">unknown event</div>
</div>
</div>
</div>
</div>
</template>
<script>
import message from "@/components/message";
import userThumbnail from "@/components/userThumbnail";
import {matrix} from "@/main";
import splitArray from "@/lib/splitArray";
import {getDate, getTime} from "@/lib/getTimeStrings";
export default {
name: 'eventGroup',
components: {
message,
userThumbnail
},
props: {
timeline: Array,
user: String,
groupTimeline: Boolean
},
methods: {
getUser(userId) {
return matrix.client.getUser(userId);
},
splitArray,
getDate,
getTime
},
data(){
return {
membershipEvents:{
invite: 'was invented',
join: 'joined the room',
leave: 'left the room',
ban: 'was banned'
}
}
}
}
</script>
<style scoped lang="scss">
.timeline{
margin-top: 0.5rem;
margin-bottom: 0.5rem;
.timeGroup {
.time {
top: 0.25rem;
position: sticky;
z-index: 100;
background-color: #2d2d2d;
padding: 0.5rem;
border-radius: 0.5rem;
width: fit-content;
left: 50%;
transform: translate(-50%, 0);
margin-top: 0.5rem;
}
.eventGroup {
position: relative;
width: calc(100% - 1rem);
height: fit-content;
margin-top: 0.75rem;
margin-left: 0.5rem;
margin-right: 0.5rem;
.thumbnailContainer {
position: absolute;
top: 0;
left: 0;
height: 100%;
.filler {
height: calc(100% - 2rem);
}
.userThumbnail {
position: sticky;
bottom: 0.5rem;
width: 2rem;
height: 2rem;
}
}
.username{
margin-left: 1rem;
}
.event{
.info{
font-style: italic;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
}
.indent{
margin-left: 2.5rem;
}
}
}
}
</style>

@ -1,94 +0,0 @@
<template>
<div class="topBanner">
<div>
<icon @click.native="closeChat()" class="smallIcon" id="icon-arrow" ic="./sym/arrow_back-24px.svg" />
<userThumbnail @click.native="openChatInfo()" class="userThumbnail"
:mxcURL="getUrl()" :fallback="room.roomId" :size="3"/>
<div id="container">
<div id="chatName">{{room.name}}</div>
<div id="users">{{Object.keys(room.currentState.members).length}} members</div>
</div>
</div>
</div>
</template>
<script>
import icon from '@/components/icon.vue';
import {matrix} from "@/main";
import userThumbnail from "@/components/userThumbnail";
import sdk from 'matrix-js-sdk'
export default {
name: "topBanner",
components:{
icon,
userThumbnail
},
props:{
room: [Object, undefined],
closeChat: Function,
openChatInfo: Function
},
data(){
return {
}
},
methods: {
getRoom(roomId) {
return matrix.client.getRoom(roomId);
},
getUrl(){
let avatarState = this.room.getLiveTimeline().getState(sdk.EventTimeline.FORWARDS).getStateEvents("m.room.avatar");
return avatarState.length>0?avatarState[avatarState.length-1].getContent().url:undefined;
}
}
}
</script>
<style scoped>
.topBanner{
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 3.5rem;
background-color: #1d1d1d;
}
.smallIcon{
top: 0.25rem;
background-color: #2d2d2d;
padding-top: 0.75rem;
height: 1.75rem;
width: 2.5rem;
box-shadow: none;
border-radius: 1.25rem;
text-align: center;
user-select: none;
cursor: pointer;
}
#icon-arrow{
height: 2.5rem;
position: absolute;
left: 0.5rem;
background-color: unset;
box-shadow: none;
}
.userThumbnail{
position: absolute;
top: 0.25rem;
left: 3.5rem;
width: 3rem;
height: 3rem;
}
#container{
position: absolute;
top: 0.55rem;
left: 7rem;
}
#chatName{
font-size: 1rem;
color: #ededed;
}
#users{
font-size: 0.75rem;
color: #9c9c9c;
}
</style>

@ -1,47 +0,0 @@
<template>
<img v-if="mxcURL" :src="thumbnailUrl()" class="userThumbnail image"/>
<Identicon v-else :value="fallback" :theme="'jdenticon'" :size="this.getFontSize()*this.size" class="userThumbnail identicon"/>
</template>
<script>
import parseMXC from '@modular-matrix/parse-mxc';
import {matrix} from "@/main";
import Identicon from '@vue-polkadot/vue-identicon';
export default {
name: "userThumbnail.vue",
components: {
Identicon
},
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;
}
},
data(){
return {
resizeMethod: 'scale',
imageSize: 128
}
}
}
</script>
<style scoped lang="scss">
.userThumbnail{
border-radius: 50%;
background-color: unset;
}
</style>

@ -0,0 +1,45 @@
import rest from 'superagent';
export class AdminAPI{
constructor(baseUrl, accessToken){
this.baseUrl = baseUrl;
this.accessToken = accessToken;
}
async sendRequest({path, obj={}, method=rest=>rest.get}){
return await method(rest)(`${this.baseUrl}${path}?access_token=${this.accessToken}`)
.send(obj).set('accept', 'json').then(res => {
return JSON.parse(res.text);
}).catch(err => {
console.log(err);
return false;
});
}
async getUsers(){
return await this.sendRequest({path: 'v2/users'});
}
async resetPassword({userId, password}){
return await this.sendRequest({
path: `v1/reset_password/${userId}`,
method: rest=>rest.post,
obj: {
new_password: password,
logout_devices: true
}
});
}
async updateUser({userId, password, displayname, avatar_url=undefined}){
return await this.sendRequest({
path: `v2/users/${userId}`,
method: rest=>rest.put,
obj: {
password,
displayname,
avatar_url,
admin: false,
deactivated: false
}
});
}
}

@ -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 matrix from 'matrix-js-sdk';
import {NotificationHandler} from '@/lib/NotificationHandler';
export class MatrixHandler { export class MatrixHandler {
constructor(clientDisplayName = 'matrix-chat') { constructor(clientDisplayName = 'matrix-chat') {
@ -9,11 +10,13 @@ export class MatrixHandler {
this.loading = undefined; this.loading = undefined;
this.user = undefined; this.user = undefined;
this.baseUrl = undefined; this.baseUrl = undefined;
this.notify = new NotificationHandler();
} }
login(user, password, baseUrl, onError, callback = ()=>{}){ login(user, password, baseUrl, onError, callback = ()=>{}){
if (this.client){ console.log('there is already an active session'); return; } if (this.client){ console.log('there is already an active session'); return; }
this.client = new matrix.createClient({ this.client = new matrix.createClient({
baseUrl: baseUrl baseUrl: baseUrl,
store: new matrix.MemoryStore(window.localStorage)
}); });
this.client.login('m.login.password', { this.client.login('m.login.password', {
user: user, user: user,
@ -30,6 +33,7 @@ export class MatrixHandler {
callback(response.access_token); callback(response.access_token);
this.user = user; this.user = user;
this.baseUrl = baseUrl; this.baseUrl = baseUrl;
this.accessToken = response.access_token;
this.startSync() this.startSync()
} }
}).catch(error => { }).catch(error => {
@ -40,36 +44,43 @@ export class MatrixHandler {
} }
tokenLogin(baseUrl, accessToken, userId){ tokenLogin(baseUrl, accessToken, userId){
if (this.client){ console.log('there is already an active session'); return; } if (this.client){ console.log('there is already an active session'); return; }
this.client = new matrix.createClient({baseUrl, accessToken, userId}); this.client = new matrix.createClient({
baseUrl,
accessToken,
userId,
store: new matrix.MemoryStore(window.localStorage),
sessionStore: new matrix.WebStorageSessionStore(window.localStorage)
});
this.user = userId; this.user = userId;
this.baseUrl = baseUrl; this.baseUrl = baseUrl;
this.accessToken = accessToken;
this.startSync(); this.startSync();
} }
logout(){ async logout(){
this.client.stopClient(); await this.client.stopClient();
this.client = undefined; this.client = undefined;
} }
startSync(callback = ()=>{}){ async startSync(callback = ()=>{}){
this.loading = true; this.loading = true;
this.client.startClient(); await this.client.startClient();
this.client.once('sync', (state) => { this.client.once('sync', (state) => {
console.log(state); console.log(state);
this.rooms = this.client.getRooms(); this.rooms = this.client.getRooms();
console.log(this.rooms)
this.loading = false; this.loading = false;
callback(); callback();
this.listenToPushEvents();
}); });
this.client.on('event', (event) => {
if (event.getType() === 'm.room.create') {
console.log(event)
} }
}) listenToPushEvents(){
this.client.on('event', event => {
if (this.client.getPushActionsForEvent(event).notify){
this.notify.showNotification(event.event);
} }
async sendEvent(msg, roomId){
await this.client.sendEvent(roomId, msg.type, msg.content, '').then(() => {
console.log('message sent successfully');
}).catch((err) => {
console.log(`error while sending message => ${err}`);
}); });
} }
async sendEvent({content, type}, roomId){
return await this.client.sendEvent(roomId, type, content)
.then(() => console.log('message sent successfully'))
.catch((err) => console.log(`error while sending message => ${err}`));
}
} }

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

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

@ -0,0 +1,33 @@
import sdk from 'matrix-js-sdk'
import {matrix} from '@/main';
export function getMxcFromUser(user){
return user.avatarUrl;
}
export function getMxcFromUserId(userId){
return matrix.client.getUser(userId).avatarUrl;
}
export function getMxcFromRoom(room){
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);
});
}

@ -0,0 +1,14 @@
export default class {
constructor(element) {
this.element = element
}
scrollToBottom(){
this.setScrollBottom(0);
}
getScrollBottom(){
return this.element.scrollHeight - this.element.scrollTop - this.element.offsetHeight;
}
setScrollBottom(height){
this.element.scrollTop = this.element.scrollHeight - this.element.offsetHeight - height;
}
}

@ -1,21 +1,22 @@
import Vue from 'vue' import Vue from 'vue'
import VueRouter from 'vue-router' import VueRouter from 'vue-router'
import App from './App.vue' import App from './App.vue'
import {router} from './router.js' import {router} from '@/router'
import {MatrixHandler} from './lib/matrixHandler.js' import {MatrixHandler} from './lib/MatrixHandler.js'
import {cookieHandler} from './lib/cookieHandler.js'; import {DataStore} from '@/lib/DataStore';
Vue.config.productionTip = false; Vue.config.productionTip = false;
Vue.use(VueRouter); Vue.use(VueRouter);
export let matrix = new MatrixHandler(); export let matrix = new MatrixHandler();
let cookie = new cookieHandler().getCookies(); (async () => {
if (cookie && cookie.baseUrl && cookie.accessToken && cookie.userId) { let login = await new DataStore().get('login');
matrix.tokenLogin(cookie.baseUrl, cookie.accessToken, cookie.userId); if (login && login.baseUrl && login.accessToken && login.userId) {
} matrix.tokenLogin(login.baseUrl, login.accessToken, login.userId);
}
new Vue({ new Vue({
el: '#app', el: '#app',
router, router,
template: '<App/>', template: '<App/>',
@ -23,4 +24,7 @@ new Vue({
data() { data() {
return {} return {}
} }
}).$mount('#app'); }).$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,168 +0,0 @@
/*import matrix from 'matrix-js-sdk';
import main from '@/main.js';
// import Vue from 'vue';
let client = undefined;
let session = {
user: '',
baseUrl: '',
accessToken: '',
rooms: [],
currentRoom: undefined,
login: {
user: '',
password: '',
baseUrl: 'https://adb.sh',
},
};
console.log(`cookie => ${document.cookie}`);
if (getCookie('accessToken') && getCookie('userId') && getCookie('baseUrl')) {
document.cookie = `expires=${new Date(Date.now() + 86400 * 10 * 1000)}`;
session = {
user: getCookie('userId'),
baseUrl: getCookie('baseUrl'),
accessToken: getCookie('accessToken'),
rooms: [],
currentRoom: undefined,
};
// Vue.$router.push("/rooms/")
window.location.href = '/#/rooms/';
client = matrix.createClient({
baseUrl: getCookie('baseUrl'),
accessToken: getCookie('accessToken'),
userId: getCookie('userId'),
});
client.startClient();
client.once('sync', (state) => {
console.log(state);
});
} else window.location.href = '/#/login';
export default {
data() {
return {
session,
client
};
},
methods: {
login() {
client = matrix.createClient({
baseUrl: session.login.baseUrl
});
client.login('m.login.password', {
user: session.login.user,
password: session.login.password,
initial_device_display_name: 'matrix chat',
}).then((response) => {
document.cookie = `accessToken=${response.access_token}`;
document.cookie = `userId=${session.login.user}`;
document.cookie = `baseUrl=${session.login.baseUrl}`;
document.cookie = `SameSite=Strict`;
document.cookie = `expires=${new Date(Date.now() + 86400 * 10 * 1000)}`;
session = {
user: session.login.user,
baseUrl: session.login.baseUrl,
accessToken: response.access_token,
rooms: [],
currentRoom: undefined,
};
console.log(`access token => ${response.access_token}`);
if (response.error) {
main.methods.error(response.error);
console.log(`login error => ${response.error}`);
}
window.location.href = '/#/rooms/';
window.location.reload();*/
/*client.startClient();
client.once('sync', (state) => {
console.log(state);
});*/
/* });
},
logout(){
document.cookie = `accessToken=`;
document.cookie = `userId=`;
document.cookie = `baseUrl=`;
document.cookie = `expires=${new Date(0)}`;
window.location.reload();
},
sendMessage(msg) {
const msgSend = {
type: msg.type,
content: {
body: msg.content.body,
msgtype: msg.content.msgtype,
},
};
client.sendEvent(session.currentRoom.roomId, msgSend.type, msgSend.content, '').then(() => {
console.log('message sent successfully');
}).catch((err) => {
console.log(`error while sending message => ${err}`);
main.methods.error('message could not be sent');
});
},
},
};
function getCookie(key) {
const cookie = document.cookie.replace(/ /g, '').split(';')
.find((cookie) => cookie.split('=')[0] === key);
return cookie ? cookie.split('=')[1] : false;
}
/*client.on('event', (event) => {
//console.log(event.getType());
//console.log(event);
if (event.getType() === 'm.room.name') {
client.getRooms().forEach((newRoom) => {
const room = session.rooms.find((room) => room.roomId === newRoom.roomId);
if (!room) {
session.rooms.push({
name: newRoom.name, roomId: newRoom.roomId, messages: [], members: [],
});
console.log(`new room => ${newRoom.name}`);
} else if (room.name !== newRoom.name) {
//console.log(`roomname changed from ${room.name} to ${newRoom.name}`);
room.name = newRoom.name;
}
});
} else if (event.getType() === 'm.room.member') {
const room = session.rooms.find((room) => room.roomId === event.event.room_id);
if (!room) {
session.rooms.push({
name: 'undefined', roomId: event.event.room_id, messages: [], members: [],
});
//console.log(`new user => ${event.event.sender}`);
} else {
room.members.push({ sender: event.event.sender, content: event.event.content });
}
}
});
client.on('Room.timeline', (event, room) => {
if (event.getType() === 'm.room.message') {
//console.log(`message => ${event.event.content.body}`);
const thisRoom = session.rooms.find((thisRoom) => thisRoom.roomId === room.roomId);
if (!thisRoom) {
session.rooms.push({
name: room.name, roomId: event.event.room_id, messages: [event.event], members: [],
});
} else {
thisRoom.messages.push(event.event);
if (thisRoom.name === 'undefined') thisRoom.name = room.name;
}
const msgContainer = document.getElementById('messagesContainer');
if (session.currentRoom && session.currentRoom.roomId === room.roomId) {
if (event.event.sender === session.user || msgContainer.scrollHeight < msgContainer.scrollTop + 1000) {
setTimeout(() => {
msgContainer.scrollTo(0, msgContainer.scrollHeight);
}, 10);
} else document.getElementById('scrollDown').style.display = 'block';
}
}
});*/

@ -1,34 +0,0 @@
import VueRouter from 'vue-router';
import login from '@/views/login';
import chat from '@/views/chat';
import rooms from '@/views/rooms';
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
}
]
})

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

@ -0,0 +1,155 @@
<template>
<div>
<div v-if="accessDenied">access denied!</div>
<div class="admin" v-else-if="users !== undefined">
<h1>admin</h1>
<h2>users</h2>
<div class="tableScroll">
<table>
<tr>
<th class="left">user</th>
<th class="mid">displayname</th>
<th class="mid">deactivated</th>
<th class="mid">admin</th>
<th class="right edit">edit</th>
</tr>
<tr v-for="user in users.users" :key="user.name">
<td class="left">{{user.name}}</td>
<td class="mid">{{user.displayname}}</td>
<td class="mid">{{user.deactivated}}</td>
<td class="mid">{{user.admin}}</td>
<td class="right edit">
<icon @click.native="newUser.userId = user.name" class="editIcon" type="submit" ic="./sym/ic_create_white.svg"/>
<icon class="editIcon" type="submit" ic="./sym/ic_delete_white.svg"/>
</td>
</tr>
</table>
</div>
<h2>update user</h2>
<div class="update">
<input v-model="newUser.userId" type="text" placeholder="userId"><br>
<input v-model="newUser.password" type="password" placeholder="password"><br>
<input v-model="newUser.displayname" type="text" placeholder="displayname">
</div>
<textbtn @click.native="updateUser()" text="update user"/>
</div>
<overlay v-if="loading"><throbber :text="loading"/></overlay>
</div>
</template>
<script>
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',
components:{
icon,
textbtn,
overlay,
throbber
},
data(){
return{
users: undefined,
newUser:{
userId: '',
password: '',
displayname: '',
},
loading: false,
accessDenied: false
}
},
methods:{
async updateUser(){
await this.api.updateUser(this.newUser);
}
},
async created() {
this.loading = 'loading';
this.api = new AdminAPI(matrix.baseUrl+'/_synapse/admin/', matrix.accessToken);
this.users = await this.api.getUsers('users');
this.accessDenied = !this.users;
this.loading = false;
}
}
</script>
<style scoped>
.admin{
text-align: center;
height: 100%;
overflow-y: auto;
padding: 1rem;
}
.editIcon{
float: left;
margin: 0 0.25rem;
}
.edit{
width: 6rem;
text-align: center;
}
.tableScroll{
position: relative;
width: 100%;
height: auto;
overflow-x: auto;
}
table {
position: relative;
margin: auto;
width: calc(100% - 2rem);
max-width: 80%;
background-color: #1f262b;
box-shadow: 3px 3px 10px #333;
border: 2px solid #fff;
border-collapse: separate;
border-spacing: 2px;
border-radius: 0.75rem;
color: #fff;
font-size: 1rem;
table-layout: auto;
min-width: max-content;
}
tr {
margin: auto;
border: 1px solid #fff;
text-align: left;
}
th {
margin: auto;
background-color: #546E7A;
padding: 1rem;
border-radius: 1rem;
text-align: center;
}
td {
margin: auto;
background-color: #37474F;
padding: 0.5rem;
border-radius: 1rem;
}
td.left, th.left {
border-radius: 0.5rem 0 0 0.5rem;
width: auto;
}
td.right, th.right {
border-radius: 0 0.5rem 0.5rem 0;
}
td.mid, th.mid {
border-radius: 0 0 0 0;
width: auto;
}
</style>

@ -1,112 +0,0 @@
<template>
<div>
<div ref="chatContainer" class="chatContainer">
<div @scroll="scrollHandler()" 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"/>
</div>
<icon v-if="showScrollBtn" @click.native="scrollToBottom()" id="scrollDown" ic="./sym/expand_more-black-24dp.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';
export default {
name: 'chat',
components: {
timeline,
Icon,
newMessage,
topBanner
},
props: {
room: [Object, undefined],
user: String,
closeChat: Function,
openChatInfo: Function
},
methods:{
scrollToBottom(){
this.$refs.msgContainer.scrollTop = this.$refs.msgContainer.scrollHeight;
},
scrollHandler(){
if (this.$refs.msgContainer.scrollTop === 0) this.loadEvents();
let msg = this.$refs.msgContainer;
this.showScrollBtn = msg.scrollHeight - msg.scrollTop > msg.offsetHeight + 200;
},
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(){
this.loadingStatus = 'loading ...';
await matrix.client.paginateEventTimeline(this.room.getLiveTimeline(), {backwards: true})
.then(state => this.loadingStatus = state?'load more':false);
},
getUser(userId){
return matrix.client.getUser(userId);
},
splitArray
},
data(){
return {
showScrollBtn: false,
scrollOnUpdate: true,
loadingStatus: 'load more'
}
},
updated(){
//this.scrollToBottom();
},
mounted(){
//this.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>

@ -1,93 +1,97 @@
<template> <template>
<div id="login"> <div class="login">
<div class="loginBox">
<h1 class="title">[chat]</h1> <h1 class="title">[chat]</h1>
<form v-if="showLogin" @submit.prevent="login()"> <form v-if="showLogin()" @submit.prevent="login()">
<input v-model="user" class="input" name="user" type="text" maxlength="30" placeholder="@user:adb.sh"><br> <input v-model="user" class="input" name="user" type="text" maxlength="30" placeholder="@user:adb.sh"><br>
<input v-model="password" class="input" name="password" type="password" maxlength="30" placeholder="password"><br> <input v-model="password" class="input" name="password" type="password" maxlength="30" placeholder="password"><br>
<input v-model="homeServer" class="input" name="homeserver" maxlength="50" placeholder="https://matrix.org"><br> <input v-model="homeServer" class="input" name="homeserver" placeholder="https://matrix.org"><br>
<div v-if="loginError" class="info">{{loginError}}</div> <div v-if="loginError" class="info">{{loginError}}</div>
<textbtn type="submit" text="login" /> <textbtn type="submit" text="login" class="rounded"/>
</form> </form>
<div v-else> <div v-else>
<p>you are already logged in</p> <p>you are already logged in</p>
<textbtn @click.native="$router.push('rooms')" text="chat" /> <textbtn @click.native="$router.push('rooms')" text="chat" />
<textbtn @click.native="logout()" text="logout" /> <textbtn @click.native="logout()" text="logout" class="outline"/>
</div> </div>
</div> </div>
<overlay v-if="loading"><throbber :text="loading"/></overlay>
</div>
</template> </template>
<script> <script>
import textbtn from '@/components/textbtn'; import textbtn from '@/components/layout/textbtn';
import {matrix} from '@/main.js'; import {matrix} from '@/main.js';
import {cookieHandler} from "@/lib/cookieHandler"; 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 { export default {
name: "login.vue", name: 'login.vue',
components: { components: {
Throbber,
Overlay,
textbtn textbtn
}, },
methods: { methods: {
login(){ login(){
if (matrix.client !== undefined) { // eslint-disable-next-line no-cond-assign
this.loginError = 'you are already logged in'; if (this.loginError = this.getInputErrors()) return false;
return; this.loading = 'logging in';
} 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;
}
matrix.login(this.user, this.password, this.homeServer, (error) => { matrix.login(this.user, this.password, this.homeServer, (error) => {
this.loginError = `login failed: ${error}`; this.loginError = `login failed: ${error}`;
this.loading = false;
}, token => { }, token => {
let cookie = new cookieHandler(); this.store.set('login', {
cookie.setCookie({
baseUrl: this.homeServer, baseUrl: this.homeServer,
userId: this.user, userId: this.user,
accessToken: token accessToken: token
}); });
cookie.setExpire(15); this.loading = false;
cookie.store();
this.$router.push('/rooms/'); this.$router.push('/rooms/');
}); });
}, },
logout(){ async logout(){
matrix.logout(); this.loading = 'logging out';
await matrix.logout();
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(){ data(){
return { return {
user: "", user: '',
password: "", password: '',
homeServer: "https://adb.sh", homeServer: 'https://adb.sh',
loginError: "", loginError: '',
showLogin: (matrix.client === undefined) store,
loading: false
} }
} }
} }
</script> </script>
<style scoped> <style scoped>
#login{
position: absolute;
top: 40%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
height: min-content;
width: 100%;
}
input{ input{
padding: 0 2rem 0 2rem; padding: 0 2rem 0 2rem;
height: 2.5rem; height: 2.5rem;
color: #fff; color: #fff;
background-color: #1d1d1d; background-color: #1d1d1d;
border-radius: 1.25rem; border-radius: 1.25rem;
border: 1px solid #fff; border: 0.1rem solid #fff;
text-align: center; text-align: center;
font-size: 1.1rem; font-size: 1.1rem;
margin: 0.5rem; margin: 0.5rem;
@ -98,7 +102,19 @@ input:focus{
color: #000; color: #000;
background-color: #fff; background-color: #fff;
} }
.login{
width: 100%;
height: 100%;
}
.loginBox{
position: absolute;
top: 40%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
height: min-content;
width: 100%;
}
@media (max-width: 35rem) { @media (max-width: 35rem) {
input { input {

@ -1,76 +1,148 @@
<template> <template>
<div v-if="matrix.loading"> <overlay v-if="matrix.loading"><throbber text="loading" class="center"/></overlay>
loading...
</div>
<div v-else> <div v-else>
<div id="roomList" class="roomList"> <div id="roomList" class="roomList">
<h1>[chat]</h1> <h1 class="wideElement">[chat]</h1><h1 class="smallElement">[c]</h1>
<div v-for="room in matrix.rooms" :key="room.roomId" @click="openChat(room)" class="roomListElement"> <input v-model="search" class="input wideElement" type="text" maxlength="50" placeholder="search">
<userThumbnail <p class="wideElement">- rooms -</p>
class="roomImg" <room-list-element
:mxcURL="getUrl(room)" v-for="room in matrix.client.getRooms()
:fallback="room.roomId" .filter(prop=>matchResults(prop.name, search)||prop.roomId===search)"
:size="3" :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)
})"
/> />
<div class="roomListName">{{room.name}}</div> <p class="wideElement">- suggestions -</p><p class="smallElement"></p>
<div class="wideElement">
<p v-if="isValidUserId(search)" class="suggestion">create chat: {{search}} </p>
<p v-if="isValidRoomId(search)"
class="suggestion"
@click="setQuestion({
title:'Join room',
question:`Join '${search}'?`,
callback:()=>joinRoom(search)
})"
>join room: {{search}} </p>
<p v-if="search.match(/^[a-zA-Z0-9_.+-]+$/)"
@click="setShowCreateRoom({name: search}, openChat)"
class="suggestion"
>create room: {{search}} </p>
</div>
</div> </div>
</div> </div>
<chat <chat
class="chat" class="chat"
v-if="currentRoom" v-if="showRoom && getCurrentRoom()"
:room="currentRoom" :room="getCurrentRoom()"
:user="matrix.user" :user="matrix.user"
:close-chat="()=>currentRoom=undefined" :close-chat="closeChat"
:open-chat-info="()=>showChatInfo=true" :open-chat-info="()=>showChatInfo=true"
/> />
<div class="noRoomSelected" v-else>Please select a room to be displayed.</div> <div class="noRoomSelected" v-else>Please select a room to be displayed.</div>
<div class="roomListSmall"> <overlay>
<h1>[c]</h1> <chatInformation v-if="showRoom && showChatInfo" :room="getCurrentRoom()" :close-chat-info="()=>showChatInfo=false" class="center"/>
<div v-for="(room, index) in matrix.rooms" :key="index" @click="openChat(room)" class="roomListElement" :title="room.name"> <new-room v-if="showCreateRoom.props" :callback="showCreateRoom.callback" :props="showCreateRoom.props" class="center"/>
<userThumbnail <popup-question v-if="popup.question" :callback="popup.callback" :question="popup.question" :title="popup.title" class="center"/>
class="roomImg" </overlay>
:mxcURL="getUrl(room)"
:fallback="room.roomId"
:size="3"
/>
<div class="roomListName">{{room.name}}</div>
</div>
</div>
<chatInformation v-if="currentRoom && showChatInfo" :room="currentRoom" :close-chat-info="()=>showChatInfo=false"/>
</div> </div>
</template> </template>
<script> <script>
import chat from '@/views/chat.vue'; import chat from '@/components/chat/chat.vue';
import chatInformation from "@/components/chatInformation"; import chatInformation from '@/components/chat/chatInformation';
import userThumbnail from "@/components/userThumbnail"; import {matrix} from '@/main';
import {matrix} from "@/main"; import {getMxcFromRoom} from '@/lib/getMxc';
import sdk from "matrix-js-sdk"; 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 { export default {
name: "rooms", name: 'rooms',
components:{ components:{
Throbber,
Overlay,
PopupQuestion,
userListElement,
chat, chat,
chatInformation, chatInformation,
userThumbnail roomListElement,
newRoom
}, },
methods:{ methods:{
openChat(room){ openChat(room){
this.currentRoom = room; this.showChatInfo = false;
this.showRoom = false;
this.$router.push(`/rooms/${room.roomId}`); this.$router.push(`/rooms/${room.roomId}`);
this.$forceUpdate(); this.$nextTick(() => this.showRoom = true);
//chat.methods.scrollToBottom(); this.search = '';
},
getCurrentRoom(){
return getRoom(this.$route.path.split('/')[2]);
},
closeChat(){
this.$router.push('/rooms');
},
matchResults(prop, search){
return prop.toLowerCase().includes(search.toLowerCase().trim());
}, },
getUrl(room){ setQuestion({title, question, callback}){
let avatarState = room.getLiveTimeline().getState(sdk.EventTimeline.FORWARDS).getStateEvents("m.room.avatar"); this.popup = {
return avatarState.length>0?avatarState[avatarState.length-1].getContent().url:undefined; question,
title,
callback:(res)=>{
this.popup = {};
if (res) callback();
}
}
},
joinRoom(room){
this.matrix.client.join(room).then(()=>{
this.openChat(getRoom(room.room_id));
});
},
setShowCreateRoom(props, callback=()=>{}){
this.showCreateRoom = {
props,
callback:(res)=>{
this.showCreateRoom = {};
if (res) callback(res);
}
} }
}, },
getMxcFromRoom,
getRoom,
getUser,
isValidUserId,
isValidRoomId,
createRoom
},
data(){ data(){
return { return {
matrix, matrix,
currentRoom: undefined, showChatInfo: false,
showChatInfo: false showRoom: true,
search: '',
popup:{},
showCreateRoom:{}
} }
}, },
mounted() { mounted() {
@ -79,7 +151,7 @@ export default {
} }
</script> </script>
<style scoped> <style scoped lang="scss">
.roomList{ .roomList{
position: absolute; position: absolute;
left: 0; left: 0;
@ -89,6 +161,8 @@ export default {
background-color: #222; background-color: #222;
text-align: center; text-align: center;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden;
text-overflow: ellipsis;
} }
.chat{ .chat{
position: absolute; position: absolute;
@ -106,38 +180,6 @@ export default {
cursor: pointer; cursor: pointer;
background-color: #222; background-color: #222;
} }
.roomListName{
position: absolute;
left: 4.5rem;
top: 1rem;
color: #fff;
}
.roomListSmall{
position: absolute;
left: 0;
top: 0;
width: 4rem;
height: 100%;
background-color: #222;
text-align: center;
display: none;
overflow-y: auto;
overflow-x: hidden;
animation: ease;
animation-duration: 0.2s;
scrollbar-width: none;
}
.roomListSmall:hover{
width: 18rem;
scrollbar-width: thin;
box-shadow: 0 0 20px #111;
}
.roomListSmall::-webkit-scrollbar {
width: 0;
}
.roomListSmall:hover::-webkit-scrollbar {
width: 0.5rem;
}
.noRoomSelected{ .noRoomSelected{
position: absolute; position: absolute;
width: calc(100% - 20rem); width: calc(100% - 20rem);
@ -145,28 +187,58 @@ export default {
left: 20rem; left: 20rem;
text-align: center; text-align: center;
} }
.roomImg{ input{
position: relative;
margin-left: auto;
margin-right: auto;
width: calc(100% - 4rem);
}
.wideElement{
display: block;
}
.smallElement{
display: none;
}
.center{
position: absolute; position: absolute;
left: 0.5rem; top: 50%;
height: 3rem; left: 50%;
width: 3rem; transform: translate(-50%,-50%);
}
.suggestion{
cursor: pointer;
text-decoration: underline;
} }
.roomImg.small{ .suggestion:hover{
margin-left: calc(50% - 2rem); background-color: #4444;
} }
@media (max-width: 48rem) { @media (max-width: 48rem) and (min-width: 30rem) {
.roomList{ .wideElement{
display: none; display: none;
} }
.chat{ .smallElement{
width: calc(100% - 4rem); display: block;
}
.roomList{
z-index: 30;
width: 4rem;
overflow-y: auto;
scrollbar-width: none;
} }
.roomListSmall{ .roomList:hover{
width: 18rem;
scrollbar-width: thin;
box-shadow: 0 0 20px #111;
.wideElement{
display: block; display: block;
} }
.roomImgPlaceholder{ .smallElement{
left: 0.5rem; display: none;
}
}
.chat{
width: calc(100% - 4rem);
} }
.noRoomSelected{ .noRoomSelected{
left: 4rem; left: 4rem;
@ -175,6 +247,12 @@ export default {
} }
@media (max-width: 30rem) { @media (max-width: 30rem) {
.wideElement{
display: block;
}
.smallElement{
display: none;
}
.roomList{ .roomList{
width: 100%; width: 100%;
} }
@ -184,11 +262,5 @@ export default {
.noRoomSelected{ .noRoomSelected{
display: none; display: none;
} }
.roomListSmall{
display: none;
}
.roomList{
display: block;
}
} }
</style> </style>
Loading…
Cancel
Save