Compare commits

..

133 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 3516754139 clenup 4 years ago
adb a953f9c68e add userThumbnail to rooms 4 years ago
adb 3c07329931 split chat.vue 4 years ago
adb 6e18eab9f8 update user and room icons 4 years ago
adb d192046f85 debug chatInfo and show profile pictures 4 years ago
adb 75569e2aae load history events 4 years ago
adb 5525e61b47 add events and floating icons 4 years ago
adb 7e611a96f5 update/debug newMessage and chat 4 years ago
adb 4963bec650 add sticky time and update scrollToBottom 4 years ago
adb 5a3a49ab43 ad splitArray method 4 years ago
adb 5da4aaf066 update chat.vue 4 years ago
adb 6e429193f5 debug cookieHandler 4 years ago
adb a2479875d1 add cookie login 4 years ago
adb 95b8e38dd4 list rooms 4 years ago
adb 4821e08b80 update login 4 years ago
adb c57d32c450 add matrixHandler and cookieHandler 4 years ago
adb 22c89e6b2e move vue router 4 years ago
adb 08338551ad move vue router 4 years ago
adb 28b5bea1a1 update eslint 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
adb 3e78cb7cdc group messages and user thumbnail 4 years ago
adb 5557163377 Merge remote-tracking branch 'origin/master' 4 years ago
adb b004f9a782 add userThumbnail 4 years ago
adb b8ca871e09 debug 4 years ago
adb 33866bfdc1 Update 'README.md' 4 years ago
adb 4090afc9d8 Merge remote-tracking branch 'origin/master' 4 years ago
adb c964ea93af added icon and login witch other homeservers 4 years ago
adb cb440efb35 Delete 'package-lock.json' 4 years ago
adb d16d7153e3 undefined room fix 4 years ago
adb f39699479e show links 4 years ago
adb 490c4036fe chatInformation render bugfix 4 years ago
adb 68a373cbe5 show time and date of messages 4 years ago
adb 1005837ae5 a bit styling 4 years ago
adb 5825801881 list members 4 years ago
adb 2ba39db752 typo 4 years ago
adb 553caab7f9 send messages 4 years ago
adb 0dbb060cf0 cookie management - stay logged in 4 years ago
adb 8dbe826457 receive and display messages 4 years ago
adb ee3ff30534 dynamic room view 4 years ago
adb 6018fd554d room and chat view 4 years ago
adb 9747505da1 matrix login and list rooms 4 years ago
adb ea0653f08b renaming disabled 4 years ago
adb af35a2e501 submit with shift + enter 4 years ago
adb d7d1340245 autoscroll and error update 4 years ago
adb ce860230e8 linebreak update 4 years ago
adb 032e56e5c4 chatInfo styling 4 years ago
techcrafter 766cc06a59 adding ChatInformation Popup 4 years ago
adb 07117763bf integrate master 4 years ago
adb 94eddf81a6 Merge remote-tracking branch 'origin/master' into chatInformations
# Conflicts:
#	src/views/chat.vue
4 years ago
adb 451b30c3f2 dynamic loaded chat and PWA 4 years ago
adb fd4fff2a78 first working chat version 4 years ago
adb e86f0635c1 login backend 4 years ago
adb 49aa6731df login 4 years ago
techcrafter 49791b5276 Information-Popup 0.0 4 years ago
techcrafter 5fb2449daf Information-Popup 0.0 4 years ago
adb 5ca45742e4 chatInfo styling 4 years ago
adb c31a08e5ea Merge remote-tracking branch 'origin/master' into chatInformations
# Conflicts:
#	src/App.vue
4 years ago
adb a2b82ab615 update min-width 4 years ago
adb 40cf3e9cca Merge branch 'topBanner' into master 4 years ago
adb 6ad113327d login styling 4 years ago
techcrafter 8d1fabf695 adding ChatInformation Popup 4 years ago

@ -0,0 +1,35 @@
module.exports = {
"env": {
"browser": true,
"es6": true
},
"extends": [
"eslint:recommended",
"plugin:vue/essential"
],
"globals": {
"Atomics": "readonly",
"SharedArrayBuffer": "readonly"
},
"parserOptions": {
"ecmaVersion": 2018,
"sourceType": "module"
},
"plugins": [
"vue"
],
"rules": {
"indent": [
"warn",
2
],
"linebreak-style": [
"error",
"unix"
],
"quotes": [
"warn",
"single"
]
}
}

1
.gitignore vendored

@ -1,6 +1,7 @@
.DS_Store .DS_Store
node_modules node_modules
/dist /dist
package-lock.json
# local env files # local env files

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.

@ -1,24 +1,25 @@
# basic_messaging # [chat]
## Project setup a simple matrix webapp for mobile and desktop
<img src="https://chat.adb.sh/media/screenshot-desktop.png" alt="screenshot-desktop">
`matrix-chat` or in short `[chat]` is a simple matrix webclient designed for mobile and desktop use with a clean UI.
It's written with Vue in JavaScript. The webapp is still under development and more features will be added gradually.
To stay tuned or give some input just join the matrix room. [#matrix-chat-public:adb.sh](https://matrix.to/#/#matrix-chat-public:adb.sh)
### setup
``` ```
npm install npm install
``` ```
### Compiles and hot-reloads for development ### run for development
``` ```
npm run serve npm run serve
``` ```
### Compiles and minifies for production ### compile for production
``` ```
npm run build npm run build
``` ```
### Lints and fixes files
```
npm run lint
```
### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/).

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

@ -1,37 +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
wss.on('connection', (ws, req) => {
console.log(`${req.socket.remoteAddress} connected`)
ws.on('message', msgJSON => {
let msg = JSON.parse(msgJSON)
console.log(`${req.socket.remoteAddress} => ${msgJSON}`)
if (msg.type === 'message') wss.clients.forEach(client => client.send(msgJSON))
})
let msg = {
type: "info",
time: Date.now(),
content: "connected"
}
ws.send(JSON.stringify(msg))
})

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

11937
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -1,5 +1,5 @@
{ {
"name": "basic_messaging", "name": "matrix-chat",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
@ -8,37 +8,39 @@
"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",
"@vue-polkadot/vue-identicon": "^0.0.8",
"core-js": "^3.6.5", "core-js": "^3.6.5",
"jdenticon": "^3.1.0",
"matrix-js-sdk": "^9.1.0",
"sass": "^1.29.0",
"superagent": "^6.1.0",
"v-emoji-picker": "^2.3.1",
"viewerjs": "*",
"vue": "^2.6.11", "vue": "^2.6.11",
"ws": "^7.3.1" "ws": "^7.3.1"
}, },
"devDependencies": { "devDependencies": {
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.13.0",
"@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3",
"@vue/cli-plugin-babel": "~4.5.0", "@vue/cli-plugin-babel": "~4.5.0",
"@vue/cli-plugin-eslint": "~4.5.0", "@vue/cli-plugin-eslint": "~4.5.0",
"@vue/cli-plugin-pwa": "~4.5.0",
"@vue/cli-service": "~4.5.0", "@vue/cli-service": "~4.5.0",
"@vue/compiler-sfc": "^3.0.0",
"babel": "^6.23.0",
"babel-cli": "^6.18.0",
"babel-eslint": "^10.1.0", "babel-eslint": "^10.1.0",
"electron": "^11.0.1",
"eslint": "^6.7.2", "eslint": "^6.7.2",
"eslint-plugin-vue": "^6.2.2", "eslint-plugin-vue": "^7.5.0",
"node-sass": "^5.0.0",
"recorder-js": "*",
"sass-loader": "^10.1.1",
"vue-router": "^3.4.9", "vue-router": "^3.4.9",
"vue-template-compiler": "^2.6.11" "vue-template-compiler": "^2.6.11"
}, }
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/essential",
"eslint:recommended"
],
"parserOptions": {
"parser": "babel-eslint"
},
"rules": {}
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead"
]
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 128 KiB

@ -3,6 +3,10 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge"/> <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<!-- PWA support -->
<link rel="manifest" href="./manifest.json"/>
<meta name="theme-color" content="#2F3BA2"/>
<meta name="description" content="open chat"/>
<meta name='viewport' content='width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0'/> <meta name='viewport' content='width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0'/>
<link rel="icon" href="<%= BASE_URL %>favicon.ico"/> <link rel="icon" href="<%= BASE_URL %>favicon.ico"/>
<title><%= htmlWebpackPlugin.options.title %></title> <title><%= htmlWebpackPlugin.options.title %></title>

@ -0,0 +1,9 @@
{
"name": "matrix chat",
"short_name": "[chat]",
"start_url": "/",
"display": "standalone",
"background_color": "#14181b",
"theme_color": "#00BCD4",
"orientation": "portrait-primary"
}

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

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="black" width="24px" height="24px"><path d="M0 0h24v24H0z" fill="none"/><path d="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z"/></svg>

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,30 @@ export default {
} }
</script> </script>
<style> <style lang="scss">
@import "main.scss";
body{ body{
margin: 0; margin: 0;
} }
a{
color: #00BCD4;
}
*{
scrollbar-width: thin;
scrollbar-color: #42b983 #2220;
}
*::-webkit-scrollbar {
width: 0.5rem;
height: 0.5rem;
}
*::-webkit-scrollbar-track {
background: none;
}
*::-webkit-scrollbar-thumb {
background-color: #42b983;
border-radius: 0.25rem;
}
#app { #app {
font-family: Avenir, Helvetica, Arial, sans-serif; font-family: Avenir, Helvetica, Arial, sans-serif;
position: absolute; position: absolute;
@ -30,20 +50,20 @@ body{
min-height: 100%; min-height: 100%;
width: 100%; width: 100%;
} }
.content{ #appContent{
position: absolute; position: absolute;
top: 0; top: 0;
left: calc(50% - 25rem); left: calc(50% - 35rem);
padding: 0; padding: 0;
min-height: calc(100%); min-height: 100%;
width: 50rem; width: 70rem;
min-width: 15rem; min-width: 18rem;
background-color: #313131; background-color: #313131;
box-shadow: 3px 3px 10px #111; box-shadow: 3px 3px 10px #111;
} }
@media (max-width: 55rem){ @media (max-width: 75rem){
.content{ #appContent{
width: calc(100%); width: 100%;
left: 0; left: 0;
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

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

@ -31,6 +31,7 @@ name: "error",
background-color: #E53935; background-color: #E53935;
border-radius: 15px; border-radius: 15px;
box-shadow: 3px 3px 10px #111; box-shadow: 3px 3px 10px #111;
animation: slide-from-left alternate 0.2s;
} }
#errorBox{ #errorBox{
display: none; display: none;

@ -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,45 +0,0 @@
<template>
<div class="messageContainer">
<div class="message">
{{msg}}
</div>
</div>
</template>
<script>
export default {
name: "message",
props: {
msg: String
}
}
</script>
<style scoped>
.messageContainer{
position: relative;
margin-top: 0.25rem;
width: calc(100% - 2rem);
left: 1rem;
}
.message{
margin-left:auto; margin-right:0;
position: relative;
width: max-content;
min-width: 5rem;
max-width: calc(100% - 3rem);
padding: 0.7rem 1rem 0.45rem 1rem;
right: 0;
background-color: #42a7b9;
border-radius: 1rem 1rem 0 1rem;
text-align: left;
word-break: break-word;
}
@media (min-width: 45rem){
.message{
margin-left: 0; margin-right: auto;
border-radius: 1rem 1rem 1rem 0;
}
}
</style>

@ -1,36 +0,0 @@
<template>
<div class="messageContainer">
<div class="message">
{{msg}}
</div>
</div>
</template>
<script>
export default {
name: "messageReceive",
props: {
msg: String
}
}
</script>
<style scoped>
.messageContainer{
position: relative;
margin-top: 0.25rem;
width: calc(100% - 2rem);
left: 1rem;
}
.message{
position: relative;
width: max-content;
min-width: 5rem;
max-width: calc(100% - 3rem);
padding: 0.7rem 1rem 0.45rem 1rem;
left: 0;
background-color: #42b983;
border-radius: 1rem 1rem 1rem 0;
text-align: left;
}
</style>

@ -1,82 +0,0 @@
<template>
<div class="newMessageBanner">
<label for="newMessageInput"></label>
<textarea @input="resizeMessageBanner()" ref="newMessageInput" id="newMessageInput" class="newMessageInput"
autocomplete="off" placeholder="type a message ..." v-model="msg.content.text" />
<icon @click.native="sendMessage()" id="sendMessageBtn" style="position: absolute; right: 1rem; bottom: 0.5rem;"
ic="./sym/ic_send_white_24px.svg" />
</div>
</template>
<script>
import icon from './icon.vue';
import main from '../main.js';
export default {
name: "newMessage",
components: {
icon
},
methods: {
sendMessage(){
if (this.msg.content.text !== "") {
this.msg.time = Date.now()
main.methods.sendWebSocket(this.msg)
this.msg.content.text = ""
this.resizeMessageBanner()
}
},
resizeMessageBanner(){
let id = this.$refs.newMessageInput
id.style.height = '1.25rem'
id.style.height = `${id.scrollHeight}px`
let msgContainer = document.getElementById("messagesContainer")
msgContainer.style.height
= `calc(100% - ${id.parentElement.clientHeight}px - 3rem)`
//msgContainer.scrollTo(0, msgContainer.scrollHeight)
}
},
data(){
return {
msg: {
type: "message",
time: Date.now(),
content: {
text: ""
}
}
}
}
}
</script>
<style scoped>
.newMessageBanner{
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: min-content;
min-height: 4rem;
background-color: #1d1d1d;
border-radius: 1rem 1rem 0 0;
}
.newMessageInput{
position: relative;
margin-top: 1.5rem;
margin-bottom: 1rem;
left: 2rem;
min-height: 1.25rem;
max-height: 14rem;
width: calc(100% - 7rem);
height: 1.25rem;
background-color: #fff0;
border: 0 solid #fff0;
color: #fff;
font-size: 1rem;
resize: none;
vertical-align: middle;
font-family: Avenir, Helvetica, Arial, sans-serif;
}
</style>

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

@ -1,73 +0,0 @@
<template>
<div class="topBanner">
<div>
<icon class="smallIcon" id="icon-arrow" ic="./sym/arrow_back-24px.svg" />
<icon class="smallIcon" id="picTop" ic="./sym/supervisor_account-24px.svg" />
<div id="container">
<div id="chatName">{{roomInfo.name}}</div>
<div id="users">{{roomInfo.user.length}} members</div>
</div>
<icon class="smallIcon" id="icon-menu" ic="./sym/menu-24px.svg" />
</div>
</div>
</template>
<script>
import icon from '@/components/icon.vue';
export default {
name: "topBanner",
components:{
icon
},
data(){
return {
roomInfo: {
name: "open chat",
user: []
}
}
}
}
</script>
<style scoped>
.topBanner{
position: absolute;
width: 100%;
height: 3rem;
background-color: #1d1d1d;
box-shadow: 0 3px 10px #111;
}
.smallIcon{
top: 0.25rem;
background-color: #2d2d2d;
height: 2.5rem;
width: 2.5rem;
}
#icon-arrow{
position: absolute;
left: 1rem;
}
#picTop{
position: absolute;
left: 4rem;
background-color: #42a7b9;
}
#icon-menu{
position: absolute;
right: 1rem;
background-color: #2d2d2d;
}
#container{
position: absolute;
top: 0.55rem;
left: 7.5rem;
}
#chatName{
font-size: 1rem;
color: #ededed;
}
#users{
font-size: 0.75rem;
color: #9c9c9c;
}
</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');
}
}

@ -0,0 +1,86 @@
import matrix from 'matrix-js-sdk';
import {NotificationHandler} from '@/lib/NotificationHandler';
export class MatrixHandler {
constructor(clientDisplayName = 'matrix-chat') {
this.clientDisplayName = clientDisplayName;
this.accessToken;
this.client = undefined;
this.rooms = [];
this.loading = undefined;
this.user = undefined;
this.baseUrl = undefined;
this.notify = new NotificationHandler();
}
login(user, password, baseUrl, onError, callback = ()=>{}){
if (this.client){ console.log('there is already an active session'); return; }
this.client = new matrix.createClient({
baseUrl: baseUrl,
store: new matrix.MemoryStore(window.localStorage)
});
this.client.login('m.login.password', {
user: user,
password: password,
initial_device_display_name: this.clientDisplayName,
}).then((response) => {
if (response.error) {
this.logout();
console.log(`login error => ${response.error}`);
onError(response.error);
}
if (response.access_token){
console.log(`access token => ${response.access_token}`);
callback(response.access_token);
this.user = user;
this.baseUrl = baseUrl;
this.accessToken = response.access_token;
this.startSync()
}
}).catch(error => {
this.logout();
console.log(error);
onError(error.toString());
})
}
tokenLogin(baseUrl, accessToken, userId){
if (this.client){ console.log('there is already an active session'); return; }
this.client = new matrix.createClient({
baseUrl,
accessToken,
userId,
store: new matrix.MemoryStore(window.localStorage),
sessionStore: new matrix.WebStorageSessionStore(window.localStorage)
});
this.user = userId;
this.baseUrl = baseUrl;
this.accessToken = accessToken;
this.startSync();
}
async logout(){
await this.client.stopClient();
this.client = undefined;
}
async startSync(callback = ()=>{}){
this.loading = true;
await this.client.startClient();
this.client.once('sync', (state) => {
console.log(state);
this.rooms = this.client.getRooms();
this.loading = false;
callback();
this.listenToPushEvents();
});
}
listenToPushEvents(){
this.client.on('event', event => {
if (this.client.getPushActionsForEvent(event).notify){
this.notify.showNotification(event.event);
}
});
}
async sendEvent({content, type}, roomId){
return await this.client.sendEvent(roomId, type, content)
.then(() => console.log('message sent successfully'))
.catch((err) => console.log(`error while sending message => ${err}`));
}
}

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

@ -0,0 +1,50 @@
export class cookieHandler {
constructor() {
this.cookies = {};
this.reload();
this.expires = undefined;
this.SameSite = 'Strict';
}
getCookies(){
return this.cookies;
}
setCookies(cookies){
Object.keys(cookies).forEach(key => {
this.cookies[key] = cookies[key];
})
}
set(key, value){
this.cookies[key] = value;
}
get(key){
return this.cookies[key];
}
parseCookie(string){
let cookies = {};
string.replace(/ /g, '').split(';').forEach(cookie => {
let arr = cookie.split('=');
cookies[arr[0]] = arr[1];
})
return cookies;
}
reload(){
if (document.cookie) this.cookies = this.parseCookie(document.cookie);
console.log('cookie loaded')
}
store(){
Object.keys(this.cookies).forEach(key => {
document.cookie = `${key}=${this.cookies[key]}; expires=${this.expires}; SameSite=${this.SameSite}; Secure;`;
});
console.log('cookie stored');
}
toString(cookies = this.cookies){
let string = '';
Object.keys(cookies).forEach(key => {
string += `${key}=${cookies[key]}; `;
})
return string;
}
setExpire(days){
this.expires = new Date(Date.now() + 86400 * 10000 * days);
}
}

@ -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,10 @@
export function getTime(time) {
let date = new Date(time);
return `${date.getHours()}:${(date.getMinutes() < 10) ? '0' : ''}${date.getMinutes()}`;
}
export function getDate(time) {
let months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
let date = new Date(time);
return `${date.getDate()} ${months[date.getMonth()]} ${date.getFullYear()}`;
}

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

@ -0,0 +1,10 @@
export default function splitArray(arr, key, get=obj=>obj){
let payload = [[]];
arr.forEach((obj, i) => {
let nextObj = arr[i+1];
payload[payload.length-1].push(get(obj));
if (!nextObj) return payload;
if (key(obj) !== key(nextObj)) payload.push([]);
})
return payload;
}

@ -1,107 +1,30 @@
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 login from './views/login.vue' import {router} from '@/router'
import chat from './views/chat.vue' import {MatrixHandler} from './lib/MatrixHandler.js'
import {DataStore} from '@/lib/DataStore';
Vue.config.productionTip = false Vue.config.productionTip = false;
Vue.use(VueRouter) Vue.use(VueRouter);
const router = new VueRouter({ export let matrix = new MatrixHandler();
routes: [
{ (async () => {
path: '/', let login = await new DataStore().get('login');
name: 'home', if (login && login.baseUrl && login.accessToken && login.userId) {
component: login matrix.tokenLogin(login.baseUrl, login.accessToken, login.userId);
},
{
path: '/login',
name: 'login',
component: login
},
{
path: '/chat',
name: 'chat',
component: chat
} }
]
})
new Vue({ new Vue({
el: '#app', el: '#app',
router, router,
template: '<App/>', template: '<App/>',
components: {App} components: {App},
}).$mount('#app') data() {
return {}
export default {
mounted() {
sendMessage()
},
methods: {
sendMessage(message){
let msg = {
type: "message",
time: Date.now(),
content: {
message: message
}
}
socket.send(JSON.stringify(msg))
},
sendWebSocket(msg){
socket.send(JSON.stringify(msg))
}
}
}
const wsurl = 'ws://127.0.0.1:8090'
const socket = new WebSocket(wsurl)
function element(id){ return document.getElementById(id)}
socket.onopen = () => {
let msg = {
type: "info",
time: Date.now(),
content: "new session"
} }
socket.send(JSON.stringify(msg)) }).$mount('#app');
} })()
socket.onerror = (error) => {
console.log(`WebSocket error: ${error}`)
}
socket.onclose = () => show_error('session ended (refresh)')
socket.onmessage = (e) => {
console.log(`data received => ${e.data}`)
let msg = JSON.parse(e.data)
if (msg.type === 'error') show_error(msg.content)
else if (msg.type === 'message'){
//just for now, ik it's dirty
element('messages').innerHTML +=
`<div class="messageContainer" data-v-032da2b2="">
<div class="message" data-v-032da2b2="">
${msg.content.text}
</div>
</div>`;
}
}
function show_error(msg) {
let error_style = element('errorBox').style
element('errorMessage').innerText = msg
error_style.display = "block"
error_style.animation = "slide-from-left alternate 0.2s"
setTimeout(() => {error_style.animation = ""}, 200)
}
function sendMessage(message){
let msg = {
type: "message",
time: Date.now(),
content: {
message: message
}
}
socket.send(JSON.stringify(msg))
}

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

@ -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,65 +0,0 @@
<template>
<div>
<div ref="msgContainer" id="messagesContainer" class="messagesContainer">
<div id="messages" class="messages">
<message msg="Hey :D" />
<message msg="Du bist blööööd xD" />
<messageReceive msg="Du auch" />
<message msg="lol" />
<messageReceive msg="Du bist voll blöd, ich hasse dich, warum tust du das?!" />
<message msg="Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Ut imperdiet vel risus tristique mollis. Proin aliquam felis non vehicula ornare.
Fusce scelerisque pellentesque erat quis sollicitudin.
Quisque aliquet, ligula ut volutpat vulputate, ligula lorem dictum velit, et aliquam sapien orci sed magna.
Nam suscipit ex eget urna accumsan pulvinar. Pellentesque fringilla placerat feugiat.
Aenean aliquam vestibulum metus. Nulla augue turpis, consectetur vitae quam ac, porttitor rhoncus nunc.
Nullam non turpis consequat, placerat lectus nec, ornare orci.
Fusce lorem tortor, viverra ac suscipit sit amet, scelerisque id eros.
Suspendisse et ultricies elit, vitae pretium ipsum. Suspendisse vel ex in turpis pulvinar feugiat. "
/>
<messageReceive msg="Du hast Pizza!" />
<message msg="und Kuchen :P" />
<message msg="und Kuchen :P" />
<message msg="und Kuchen :P" />
<message msg="und Kuchen :P" />
<message msg="und Kuchen :P" />
</div>
</div>
<newMessage />
<topBanner />
</div>
</template>
<script>
import message from '@/components/message.vue';
import messageReceive from '@/components/messageReceive.vue';
import newMessage from '@/components/newMessage.vue';
import topBanner from "@/components/topBanner";
export default {
name: 'chat',
components: {
message,
messageReceive,
newMessage,
topBanner
}
}
</script>
<style scoped>
.messagesContainer{
position: absolute;
margin: 0;
left: 0;
top: 3rem;
height: calc(100% - 7rem);
width: 100%;
overflow-y: auto;
}
.messages{
position: relative;
margin-top: 1rem;
margin-bottom: 1rem;
}
</style>

@ -1,28 +1,124 @@
<template> <template>
<div id="login"> <div class="login">
<h1 class="title">short your url</h1> <div class="loginBox">
<div class="input-field" id="longurl"> <h1 class="title">[chat]</h1>
<label for="longurl-input"></label> <form v-if="showLogin()" @submit.prevent="login()">
<input class="input" id="longurl-input" type="text" autocomplete="off" maxlength="20" placeholder="chose nickname"> <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="homeServer" class="input" name="homeserver" placeholder="https://matrix.org"><br>
<div v-if="loginError" class="info">{{loginError}}</div>
<textbtn type="submit" text="login" class="rounded"/>
</form>
<div v-else>
<p>you are already logged in</p>
<textbtn @click.native="$router.push('rooms')" text="chat" />
<textbtn @click.native="logout()" text="logout" class="outline"/>
</div> </div>
<input type="hidden" value="search" name="login"> </div>
<textbtn text="login" /> <overlay v-if="loading"><throbber :text="loading"/></overlay>
</div> </div>
</template> </template>
<script> <script>
import textbtn from '@/components/textbtn'; import textbtn from '@/components/layout/textbtn';
import {matrix} from '@/main.js';
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: {
login(){
// eslint-disable-next-line no-cond-assign
if (this.loginError = this.getInputErrors()) return false;
this.loading = 'logging in';
matrix.login(this.user, this.password, this.homeServer, (error) => {
this.loginError = `login failed: ${error}`;
this.loading = false;
}, token => {
this.store.set('login', {
baseUrl: this.homeServer,
userId: this.user,
accessToken: token
});
this.loading = false;
this.$router.push('/rooms/');
});
},
async 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(){
return {
user: '',
password: '',
homeServer: 'https://adb.sh',
loginError: '',
store,
loading: false
}
} }
} }
</script> </script>
<style scoped> <style scoped>
#login{ input{
padding: 0 2rem 0 2rem;
height: 2.5rem;
color: #fff;
background-color: #1d1d1d;
border-radius: 1.25rem;
border: 0.1rem solid #fff;
text-align: center;
font-size: 1.1rem;
margin: 0.5rem;
appearance: none;
outline: none;
}
input:focus{
color: #000;
background-color: #fff;
}
.login{
width: 100%;
height: 100%;
}
.loginBox{
position: absolute;
top: 40%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center; text-align: center;
height: min-content;
width: 100%;
}
@media (max-width: 35rem) {
input {
width: calc(100% - 8rem);
}
} }
</style> </style>

@ -0,0 +1,266 @@
<template>
<overlay v-if="matrix.loading"><throbber text="loading" class="center"/></overlay>
<div v-else>
<div id="roomList" class="roomList">
<h1 class="wideElement">[chat]</h1><h1 class="smallElement">[c]</h1>
<input v-model="search" class="input wideElement" type="text" maxlength="50" placeholder="search">
<p class="wideElement">- rooms -</p>
<room-list-element
v-for="room in matrix.client.getRooms()
.filter(prop=>matchResults(prop.name, search)||prop.roomId===search)"
:key="room.roomId" @click.native="openChat(room)"
:room="room"
class="roomListElement"
/>
<div v-if="search">
<p class="wideElement">- users -</p><p class="smallElement"></p>
<user-list-element
v-for="user in matrix.client.getUsers()
.filter(prop=>matchResults(prop.displayName, search)||matchResults(prop.userId, search))
.slice(0,10)"
:user="user" :key="user.userId"
@click.native="setQuestion({
title:'New Chat',
question:`Create private chat with '${user.displayName}'?`,
callback:()=>createRoom({users:[user], access: 'private'}).then(openChat)
})"
/>
<p class="wideElement">- suggestions -</p><p class="smallElement"></p>
<div class="wideElement">
<p v-if="isValidUserId(search)" class="suggestion">create chat: {{search}} </p>
<p v-if="isValidRoomId(search)"
class="suggestion"
@click="setQuestion({
title:'Join room',
question:`Join '${search}'?`,
callback:()=>joinRoom(search)
})"
>join room: {{search}} </p>
<p v-if="search.match(/^[a-zA-Z0-9_.+-]+$/)"
@click="setShowCreateRoom({name: search}, openChat)"
class="suggestion"
>create room: {{search}} </p>
</div>
</div>
</div>
<chat
class="chat"
v-if="showRoom && getCurrentRoom()"
:room="getCurrentRoom()"
:user="matrix.user"
:close-chat="closeChat"
:open-chat-info="()=>showChatInfo=true"
/>
<div class="noRoomSelected" v-else>Please select a room to be displayed.</div>
<overlay>
<chatInformation v-if="showRoom && showChatInfo" :room="getCurrentRoom()" :close-chat-info="()=>showChatInfo=false" class="center"/>
<new-room v-if="showCreateRoom.props" :callback="showCreateRoom.callback" :props="showCreateRoom.props" class="center"/>
<popup-question v-if="popup.question" :callback="popup.callback" :question="popup.question" :title="popup.title" class="center"/>
</overlay>
</div>
</template>
<script>
import chat from '@/components/chat/chat.vue';
import chatInformation from '@/components/chat/chatInformation';
import {matrix} from '@/main';
import {getMxcFromRoom} from '@/lib/getMxc';
import roomListElement from '@/components/matrix/roomListElement';
import {getRoom, getUser} from '@/lib/matrixUtils';
import {isValidUserId, isValidRoomId} from '@/lib/matrixUtils';
import userListElement from '@/components/matrix/userListElement';
import PopupQuestion from '@/components/layout/popupQuestion';
import newRoom from '@/components/matrix/newRoom';
import Overlay from '@/components/layout/overlay';
import Throbber from '@/components/layout/throbber';
import {createRoom} from '@/lib/matrixUtils';
export default {
name: 'rooms',
components:{
Throbber,
Overlay,
PopupQuestion,
userListElement,
chat,
chatInformation,
roomListElement,
newRoom
},
methods:{
openChat(room){
this.showChatInfo = false;
this.showRoom = false;
this.$router.push(`/rooms/${room.roomId}`);
this.$nextTick(() => this.showRoom = true);
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());
},
setQuestion({title, question, callback}){
this.popup = {
question,
title,
callback:(res)=>{
this.popup = {};
if (res) callback();
}
}
},
joinRoom(room){
this.matrix.client.join(room).then(()=>{
this.openChat(getRoom(room.room_id));
});
},
setShowCreateRoom(props, callback=()=>{}){
this.showCreateRoom = {
props,
callback:(res)=>{
this.showCreateRoom = {};
if (res) callback(res);
}
}
},
getMxcFromRoom,
getRoom,
getUser,
isValidUserId,
isValidRoomId,
createRoom
},
data(){
return {
matrix,
showChatInfo: false,
showRoom: true,
search: '',
popup:{},
showCreateRoom:{}
}
},
mounted() {
if (matrix.client === undefined) this.$router.push('/login');
}
}
</script>
<style scoped lang="scss">
.roomList{
position: absolute;
left: 0;
top: 0;
width: 18rem;
height: 100%;
background-color: #222;
text-align: center;
overflow-y: auto;
overflow-x: hidden;
text-overflow: ellipsis;
}
.chat{
position: absolute;
right: 0;
top: 0;
width: calc(100% - 18rem);
height: 100%;
background-color: #313131;
}
.roomListElement{
position: relative;
height: 3rem;
margin: 0.5rem 0 0.5rem 0;
font-size: 1.2rem;
cursor: pointer;
background-color: #222;
}
.noRoomSelected{
position: absolute;
width: calc(100% - 20rem);
top: 5rem;
left: 20rem;
text-align: center;
}
input{
position: relative;
margin-left: auto;
margin-right: auto;
width: calc(100% - 4rem);
}
.wideElement{
display: block;
}
.smallElement{
display: none;
}
.center{
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%,-50%);
}
.suggestion{
cursor: pointer;
text-decoration: underline;
}
.suggestion:hover{
background-color: #4444;
}
@media (max-width: 48rem) and (min-width: 30rem) {
.wideElement{
display: none;
}
.smallElement{
display: block;
}
.roomList{
z-index: 30;
width: 4rem;
overflow-y: auto;
scrollbar-width: none;
}
.roomList:hover{
width: 18rem;
scrollbar-width: thin;
box-shadow: 0 0 20px #111;
.wideElement{
display: block;
}
.smallElement{
display: none;
}
}
.chat{
width: calc(100% - 4rem);
}
.noRoomSelected{
left: 4rem;
width: calc(100% - 4rem);
}
}
@media (max-width: 30rem) {
.wideElement{
display: block;
}
.smallElement{
display: none;
}
.roomList{
width: 100%;
}
.chat{
width: 100%;
}
.noRoomSelected{
display: none;
}
}
</style>
Loading…
Cancel
Save