Compare commits

..

133 Commits

Author SHA1 Message Date
adb
9b2b5b8bb9 fix NewRoom popup 2021-06-08 22:18:32 +02:00
adb
eb259bbdae debug membershipEvents / soundRecorder 2021-06-08 22:08:36 +02:00
adb
18bb86a7a9 refactor Components, add newRoom.vue/userSearch.vue/popup.vue/overlay.vue/imageViewer.vue, some design changes 2021-06-08 22:06:00 +02:00
adb
c0d9de4fcf add main.scss 2021-06-08 21:41:29 +02:00
adb
ec4bdbbec4 fix membership and unsupported event info 2021-05-21 02:00:38 +02:00
adb
bf286929cb calc font-width for emojiEvents 2021-05-19 14:00:22 +02:00
adb
a6838e3703 Merge remote-tracking branch 'origin/dev' 2021-05-14 02:16:55 +02:00
adb
9a1aa73cc8 paste images/files as attachment 2021-05-13 01:51:13 +02:00
adb
3a8a25d2e1 add CapacitorStorageApi and some fixes 2021-05-10 00:35:43 +02:00
adb
fdfb9c0361 Merge remote-tracking branch 'origin/master' into matrix-chat-native
# Conflicts:
#	package.json
2021-05-09 16:54:06 +02:00
adb
e48c516211 Merge branch 'dev' 2021-05-09 16:47:44 +02:00
adb
9853c5327e debug and add localStorage 2021-05-09 16:07:40 +02:00
adb
f220bbc63b add submodule matrix-chat-android 2021-05-04 18:23:19 +02:00
adb
520a5ca6d9 Merge remote-tracking branch 'origin/master' into matrix-chat-native 2021-05-04 17:42:27 +02:00
adb
292b123f5a Merge remote-tracking branch 'origin/dev' 2021-05-04 17:30:36 +02:00
adb
40812fc4a2 add createRooms from search 2021-05-03 23:00:47 +02:00
adb
c57c1d348f refactor newMessage, add fileUpload and soundRecorder 2021-05-02 00:28:50 +02:00
adb
c35b5ba446 fix sendEvent, update eventContent file 2021-05-01 23:02:11 +02:00
adb
64d6273a52 send attachments and recordings 2021-05-01 01:05:14 +02:00
adb
cce7af2178 add fileInput, refactor eventContent 2021-04-30 00:33:47 +02:00
adb
fb707fcde3 add emojiPicker and microphone 2021-04-28 18:55:11 +02:00
adb
d3031a1e70 add popupQuestion.vue, search for users, join rooms 2021-04-28 05:59:13 +02:00
adb
286bc25446 some fixes 2021-04-28 05:56:50 +02:00
adb
337a8410cb add screenshot and description to README.md 2021-04-14 10:32:37 +00:00
adb
1d50bae135 fix event overflow 2021-04-14 02:09:48 +02:00
adb
7d9535457f fix media width 2021-04-11 22:40:36 +02:00
adb
1dea2f76ad add solveMarkdownLinks to eventUtils 2021-04-11 01:40:05 +02:00
adb
01fe023d06 fix if prop undefined for getMxc and event 2021-04-11 01:39:25 +02:00
adb
64d52b071d show m.audio, m.image, m.video, m.file / add replyEvent.vue 2021-04-11 00:24:12 +02:00
adb
dfaa87f5e7 fix getMXC add getMediaUrl / fix link color 2021-04-11 00:22:13 +02:00
adb
8a9b5a478b fix setReply / add manageScrollBottom 2021-04-09 21:05:33 +02:00
adb
30a7e4da81 fix login userid regex 2021-04-09 01:03:11 +02:00
adb
22c6170787 extract event from timeline.vue to message.vue & message.vue -> event.vue 2021-04-09 01:02:18 +02:00
adb
2c22cae07f add showNativeNotification 2021-04-09 00:48:06 +02:00
adb
550d79af28 Merge branch 'master' into matrix-chat-native 2021-04-08 23:43:02 +02:00
adb
289a230207 add NotificationHandler and listen to pushEvents 2021-04-07 23:16:51 +02:00
adb
fda133486a simplify loadEvents and load on mounted 2021-04-07 23:14:38 +02:00
adb
72c7886fc9 refactor router.js -> ./router/index.js 2021-04-07 23:11:25 +02:00
adb
f237f7bf7a send typing events 2021-04-07 23:08:41 +02:00
adb
bbad002cec refactor getAvatarUrl 2021-04-07 23:07:02 +02:00
adb
57d6238ed6 Merge branch 'master' into matrix-chat-native 2021-04-06 03:34:04 +02:00
adb
8dd34b58ee fix getReplyId 2021-04-06 03:33:20 +02:00
adb
d66f2c990e add reply to events / update sendEvent / refactor parseMessage to eventUtils / fix quotes 2021-04-06 03:15:23 +02:00
adb
3b70b5b37c load room by url / +getRoom to matrixUtils 2021-04-05 16:51:55 +02:00
adb
35e6bf86df fix icon paths 2021-04-05 16:35:41 +02:00
adb
f1967d4189 add matrixUtils 2021-04-04 04:44:24 +02:00
adb
4a28e22b82 add androidStudioPath to config 2021-04-04 03:32:13 +02:00
adb
a67fa94447 sort rooms by latest event and fix scrolling at reloading 2021-04-04 01:46:41 +02:00
adb
257f0c833c init capacitor 2021-03-27 02:12:58 +01:00
adb
f5388abeb0 fix admin and icons 2021-03-25 22:14:08 +01:00
adb
6a92858ac1 fix update user and input styling 2021-03-24 16:06:18 +01:00
adb
7292700cc9 add class AdminAPI and admin.vue 2021-03-24 01:41:20 +01:00
adb
8ea5c46920 debug login error 2021-03-23 13:17:53 +01:00
adb
9cc93109fc fix smallSideBar and homeServer maxlength 2021-03-23 12:32:05 +01:00
adb
7c1d994dc2 fix 2021-03-22 22:54:23 +01:00
adb
433fbac3fa Merge remote-tracking branch 'origin/master'
# Conflicts:
#	src/views/rooms.vue
2021-03-22 22:46:46 +01:00
adb
63d3f6fb47 refactor/split to roomListElement/userListElement 2021-03-22 22:41:12 +01:00
adb
5651c88298 fix chrome newMessage height 2021-03-20 22:38:03 +01:00
adb
008acbb056 fix scrollBottom 2021-03-20 22:31:33 +01:00
adb
5f536adc8f fix z-index 2021-03-19 23:06:56 +01:00
adb
5d0e96a675 parse reply events 2021-03-19 22:08:36 +01:00
adb
b2691f0c85 few fixes 2021-03-19 22:07:50 +01:00
adb
f7ee0d9695 update login and throbber 2021-03-19 18:08:24 +01:00
adb
31beb2b436 add throbber, room search, room preview 2021-03-19 15:28:54 +01:00
adb
afbb98eb4d fix newMessage 2021-03-19 00:15:22 +01:00
adb
c7c7d2c120 update jdenticon 2021-03-18 20:49:18 +01:00
adb
035f30936e debug sendMessage 2021-03-18 20:48:56 +01:00
adb
4f5f03f65f rm matrix.js 2021-03-18 20:47:42 +01:00
adb
de80233b0d add event time and cleanup 2021-03-16 23:46:26 +01:00
adb
e52ea62fe3 Merge branch 'restructure'
# Conflicts:
#	src/components/chatInformation.vue
2021-03-16 22:43:29 +01:00
adb
3516754139 clenup 2021-03-16 22:39:09 +01:00
adb
a953f9c68e add userThumbnail to rooms 2021-03-16 21:08:09 +01:00
adb
3c07329931 split chat.vue 2021-03-16 20:45:47 +01:00
adb
6e18eab9f8 update user and room icons 2021-03-16 11:52:38 +01:00
adb
d192046f85 debug chatInfo and show profile pictures 2021-03-16 01:45:15 +01:00
adb
75569e2aae load history events 2021-03-16 00:20:23 +01:00
adb
5525e61b47 add events and floating icons 2021-03-15 22:32:12 +01:00
adb
7e611a96f5 update/debug newMessage and chat 2021-03-15 16:38:37 +01:00
adb
4963bec650 add sticky time and update scrollToBottom 2021-03-15 11:54:46 +01:00
adb
5a3a49ab43 ad splitArray method 2021-03-14 19:21:37 +01:00
adb
5da4aaf066 update chat.vue 2021-03-14 16:26:06 +01:00
adb
6e429193f5 debug cookieHandler 2021-03-14 16:25:16 +01:00
adb
a2479875d1 add cookie login 2021-03-13 00:43:18 +01:00
adb
95b8e38dd4 list rooms 2021-03-13 00:42:24 +01:00
adb
4821e08b80 update login 2021-03-11 23:39:26 +01:00
adb
c57d32c450 add matrixHandler and cookieHandler 2021-03-10 01:50:38 +01:00
adb
22c89e6b2e move vue router 2021-03-10 01:49:29 +01:00
adb
08338551ad move vue router 2021-03-10 01:47:43 +01:00
adb
28b5bea1a1 update eslint 2021-03-10 01:46:47 +01:00
adb
52a880da9e fix unnecessary condition 2021-01-15 09:16:45 +00:00
adb
1c0818dc43 old api - not needed anymore 2020-12-29 03:56:11 +00:00
adb
dd5afc5856 Add 'LICENSE' 2020-12-27 02:01:08 +00:00
adb
3e78cb7cdc group messages and user thumbnail 2020-12-25 01:50:22 +01:00
adb
5557163377 Merge remote-tracking branch 'origin/master' 2020-12-24 03:04:51 +01:00
adb
b004f9a782 add userThumbnail 2020-12-24 03:04:37 +01:00
adb
b8ca871e09 debug 2020-12-24 01:34:26 +01:00
adb
33866bfdc1 Update 'README.md' 2020-12-24 00:13:45 +00:00
adb
4090afc9d8 Merge remote-tracking branch 'origin/master' 2020-12-24 01:04:29 +01:00
adb
c964ea93af added icon and login witch other homeservers 2020-12-24 01:04:05 +01:00
adb
cb440efb35 Delete 'package-lock.json' 2020-12-23 23:48:36 +00:00
adb
d16d7153e3 undefined room fix 2020-12-05 03:52:50 +01:00
adb
f39699479e show links 2020-11-24 03:14:03 +01:00
adb
490c4036fe chatInformation render bugfix 2020-11-23 15:40:50 +01:00
adb
68a373cbe5 show time and date of messages 2020-11-22 19:55:30 +01:00
adb
1005837ae5 a bit styling 2020-11-22 15:00:48 +01:00
adb
5825801881 list members 2020-11-19 00:12:49 +01:00
adb
2ba39db752 typo 2020-11-18 01:24:36 +01:00
adb
553caab7f9 send messages 2020-11-18 00:58:32 +01:00
adb
0dbb060cf0 cookie management - stay logged in 2020-11-17 20:49:48 +01:00
adb
8dbe826457 receive and display messages 2020-11-17 16:05:05 +01:00
adb
ee3ff30534 dynamic room view 2020-11-16 21:40:31 +01:00
adb
6018fd554d room and chat view 2020-11-15 22:27:06 +01:00
adb
9747505da1 matrix login and list rooms 2020-11-13 05:18:28 +01:00
adb
ea0653f08b renaming disabled 2020-11-11 00:21:04 +01:00
adb
af35a2e501 submit with shift + enter 2020-11-09 05:26:58 +01:00
adb
d7d1340245 autoscroll and error update 2020-11-09 04:28:41 +01:00
adb
ce860230e8 linebreak update 2020-11-09 01:13:01 +01:00
adb
032e56e5c4 chatInfo styling 2020-11-09 00:37:52 +01:00
techcrafter
766cc06a59 adding ChatInformation Popup 2020-11-09 00:34:45 +01:00
adb
07117763bf integrate master 2020-11-09 00:23:54 +01:00
adb
94eddf81a6 Merge remote-tracking branch 'origin/master' into chatInformations
# Conflicts:
#	src/views/chat.vue
2020-11-08 23:56:37 +01:00
adb
451b30c3f2 dynamic loaded chat and PWA 2020-11-08 23:36:13 +01:00
adb
fd4fff2a78 first working chat version 2020-11-08 15:50:18 +01:00
adb
e86f0635c1 login backend 2020-11-08 14:35:10 +01:00
adb
49aa6731df login 2020-11-07 20:31:55 +01:00
techcrafter
49791b5276 Information-Popup 0.0 2020-11-07 20:20:13 +01:00
5fb2449daf Information-Popup 0.0 2020-11-07 20:09:26 +01:00
adb
5ca45742e4 chatInfo styling 2020-11-06 16:20:42 +01:00
adb
c31a08e5ea Merge remote-tracking branch 'origin/master' into chatInformations
# Conflicts:
#	src/App.vue
2020-11-06 16:00:47 +01:00
adb
a2b82ab615 update min-width 2020-11-06 15:46:48 +01:00
adb
40cf3e9cca Merge branch 'topBanner' into master 2020-11-06 15:43:40 +01:00
adb
6ad113327d login styling 2020-11-06 15:07:47 +01:00
techcrafter
8d1fabf695 adding ChatInformation Popup 2020-11-06 11:21:40 +01:00
75 changed files with 3526 additions and 12463 deletions

35
.eslintrc.js Normal file
View File

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

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

3
.gitmodules vendored Normal file
View File

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

373
LICENSE Normal file
View File

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

View File

@ -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
```
### Compiles and hot-reloads for development
### run for development
```
npm run serve
```
### Compiles and minifies for production
### compile for production
```
npm run build
```
### Lints and fixes files
```
npm run lint
```
### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/).

1
android Submodule

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

37
api.js
View File

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

14
capacitor.config.json Normal file
View File

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

11937
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
{
"name": "basic_messaging",
"name": "matrix-chat",
"version": "0.1.0",
"private": true,
"scripts": {
@ -8,37 +8,39 @@
"lint": "vue-cli-service lint"
},
"dependencies": {
"@capacitor/android": "^2.4.7",
"@capacitor/cli": "^2.4.7",
"@capacitor/core": "^2.4.7",
"@modular-matrix/parse-mxc": "^1.0.1",
"@vue-polkadot/vue-identicon": "^0.0.8",
"core-js": "^3.6.5",
"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",
"ws": "^7.3.1"
},
"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-eslint": "~4.5.0",
"@vue/cli-plugin-pwa": "~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",
"electron": "^11.0.1",
"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-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

View File

@ -3,6 +3,10 @@
<head>
<meta charset="utf-8" />
<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'/>
<link rel="icon" href="<%= BASE_URL %>favicon.ico"/>
<title><%= htmlWebpackPlugin.options.title %></title>

9
public/manifest.json Normal file
View File

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

4
public/sym/ic_add_white.svg Executable file
View File

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

View File

Before

Width:  |  Height:  |  Size: 210 B

After

Width:  |  Height:  |  Size: 210 B

View File

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

After

Width:  |  Height:  |  Size: 409 B

View File

Before

Width:  |  Height:  |  Size: 265 B

After

Width:  |  Height:  |  Size: 265 B

1
public/sym/ic_create_white.svg Executable file
View File

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

4
public/sym/ic_delete_white.svg Executable file
View File

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

View File

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

View File

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

After

Width:  |  Height:  |  Size: 510 B

View File

Before

Width:  |  Height:  |  Size: 350 B

After

Width:  |  Height:  |  Size: 350 B

View File

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

After

Width:  |  Height:  |  Size: 348 B

View File

Before

Width:  |  Height:  |  Size: 201 B

After

Width:  |  Height:  |  Size: 201 B

View File

Before

Width:  |  Height:  |  Size: 465 B

After

Width:  |  Height:  |  Size: 465 B

9
public/sym/throbber.svg Normal file
View File

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

View File

@ -1,6 +1,6 @@
<template>
<div id="app">
<div class="content">
<div id="appContent">
<router-view />
<error />
</div>
@ -8,7 +8,7 @@
</template>
<script>
import error from "@/components/error.vue";
import error from '@/components/layout/error.vue';
export default {
name: 'App',
@ -18,10 +18,30 @@ export default {
}
</script>
<style>
<style lang="scss">
@import "main.scss";
body{
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 {
font-family: Avenir, Helvetica, Arial, sans-serif;
position: absolute;
@ -30,20 +50,20 @@ body{
min-height: 100%;
width: 100%;
}
.content{
#appContent{
position: absolute;
top: 0;
left: calc(50% - 25rem);
left: calc(50% - 35rem);
padding: 0;
min-height: calc(100%);
width: 50rem;
min-width: 15rem;
min-height: 100%;
width: 70rem;
min-width: 18rem;
background-color: #313131;
box-shadow: 3px 3px 10px #111;
}
@media (max-width: 55rem){
.content{
width: calc(100%);
@media (max-width: 75rem){
#appContent{
width: 100%;
left: 0;
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

View File

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

View File

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

View File

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

View File

@ -0,0 +1,149 @@
<template>
<div v-if="content.msgtype==='m.text'" v-html="parseMessage(content.body)" :class="getEmojiClass(content)"/>
<div v-else-if="content.msgtype==='m.notice'" class="notice" v-html="parseMessage(content.body)"/>
<image-viewer v-else-if="content.msgtype==='m.image'" :alt="content.body" class="image" :class="compact?'compact':''">
<img :src="getSource(content.url)" :alt="content.body" :class="compact?'compact':''"/><br>
{{content.body}}
</image-viewer>
<div v-else-if="content.msgtype==='m.file'" :class="`file ${compact?'compact':''}`">
<a :href="getSource(content.url)" target="_blank">
<div class="fileContent">
<icon title="file" ic="./sym/ic_attach_file_white.svg" class="download"/>
<div class="filename">{{content.filename || getSource(content.url)}}</div>
</div>
</a>
<div class="text">{{content.body}}</div>
</div>
<div v-else-if="content.msgtype==='m.audio'" :class="`audio ${compact?'compact':''}`">
<audio controls :class="`${compact?'compact':''}`">
<source :src="getSource(content.url)" :type="content.mimetype">
your browser doesn't support audio
</audio><br>
{{content.body}}
</div>
<div v-else-if="content.msgtype==='m.video'" :class="`video ${compact?'compact':''}`">
<video controls :class="`${compact?'compact':''}`">
<source :src="getSource(content.url)" :type="content.mimetype">
your browser doesn't support video
</video><br>
{{content.body}}
</div>
<div v-else-if="content.msgtype" class="italic">unsupported message type: {{content.msgtype}}</div>
<div v-else class="italic">deleted message</div>
</template>
<script>
import {getMediaUrl} from '@/lib/getMxc';
import {parseMessage} from '@/lib/eventUtils';
import Icon from '@/components/layout/icon';
import imageViewer from '@/components/layout/imageViewer';
export default {
name: 'eventContent',
components: {
Icon,
imageViewer
},
props: {
content: Object,
compact: {
type: Boolean,
default: false
},
onUpdate: {
type: Function,
default: ()=>{}
}
},
methods: {
getSource(url){
return url.includes('mxc')?getMediaUrl(url):url;
},
getEmojiContentLength(content){
return content.body.match(/^(\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])+$/)
&& content.body.match(/\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff]/g).length
|| 0;
},
getEmojiClass(content){
let emojiLength = this.getEmojiContentLength(content);
if (emojiLength > 1) return 'emoji';
if (emojiLength === 1) return 'bigEmoji';
return '';
},
parseMessage
},
updated() {
this.onUpdate();
}
}
</script>
<style scoped lang="scss">
.emoji{
font-size: 2rem;
}
.bigEmoji{
font-size: 3rem;
}
.file{
max-width: 30rem;
.fileContent{
position: relative;
background-color: #1d1d1d;
padding: 0.5rem;
border-radius: 0.5rem;
min-height: 3rem;
.filename{
display: inline-block;
position: relative;
margin-left: 4rem;
top: 0;
height: 100%;
}
.download{
position: absolute;
}
}
.compact{
max-width: 20rem;
}
}
.image{
width: 100%;
img{
max-width: 100%;
height: auto;
max-height: 35rem;
border-radius: 0.5rem;
}
.compact{
max-width: 8rem;
max-height: 8rem;
}
}
.video{
width: 100%;
video{
max-width: 100%;
height: auto;
max-height: 35rem;
border-radius: 0.5rem;
}
.compact{
max-width: 8rem;
max-height: 8rem;
}
}
.audio{
audio{
max-width: 100%;
}
.compact{
max-width: 16rem;
max-height: 8rem;
}
}
.italic{
font-style: italic;
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,53 @@
<template>
<div class="fileUpload">
<icon
title="upload media"
class="leftBtn attachFile"
ic="./sym/ic_attach_file_white.svg"
@click.native="$refs.fileInput.click()"
/>
<input
type="file" id="fileInput" ref="fileInput"
@change="setFile({file: $refs.fileInput.files[0]})"
>
</div>
</template>
<script>
import icon from '@/components/layout/icon';
import {readFileBlob} from '@/lib/readFileBlob';
export default {
name: 'soundRecorder',
components: {
icon
},
props:{
onChange: Function
},
methods: {
setFile({file}){
readFileBlob(file).then(blob => {
blob.name = file.name;
this.onChange({blob})
});
}
}
}
</script>
<style scoped>
.fileUpload{
display: inline-block;
position: relative;
}
.leftBtn{
background-color: unset;
height: 2.5rem;
width: 2.5rem;
box-shadow: none;
}
#fileInput{
display: none;
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,119 @@
<template>
<div class="recorder">
<icon
v-if="!isRecording"
title="record voice"
class="recordBtn start"
ic="./sym/ic_mic_white.svg"
@click.native="startRecording()"
ref="startRecord"
/>
<div v-else class="voiceMeterContainer">
<div class="voiceMeter" ref="voiceMeter"></div>
<icon
title="record voice"
class="recordBtn stop"
ic="./sym/ic_mic_white.svg"
@click.native="stopRecording()"
ref="stopRecord"
/>
</div>
</div>
</template>
<script>
import icon from '@/components/layout/icon';
import Recorder from 'recorder-js';
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
export default {
name: 'soundRecorder',
components: {
icon
},
props: {
onStart: {
type: Function,
default: ()=>{}
},
onStop: {
type: Function,
default: ()=>{}
}
},
methods: {
startRecording(){
this.onStart();
navigator.mediaDevices.getUserMedia({audio: true})
.then(stream => {
this.stream = stream;
this.recorder.init(stream);
this.recorder.start().then(()=>this.isRecording=true);
})
.catch(err => console.log('unable to get stream', err));
},
stopRecording(){
this.recorder.stop()
.then(({blob}) => {
blob.name = `Recording-${new Date().toISOString()}.${blob.type.split('/')[1]}`;
this.onStop({blob});
this.stream.getTracks().map(track => track.stop());
this.isRecording=false;
});
},
setVoiceMeter(value){
if (!this.$refs.stopRecord) return;
this.$refs.voiceMeter.style.height = `calc(3rem + ${value/4}px`;
this.$refs.voiceMeter.style.width = `calc(3rem + ${value/4}px`;
},
},
data(){
return{
recorder: new Recorder(audioContext, {
onAnalysed: data => {
this.setVoiceMeter(data.lineTo);
}
}),
stream: undefined,
isRecording: false
}
}
}
</script>
<style scoped lang="scss">
.recordBtn{
position: absolute;
right: 1rem;
bottom: 0.25rem;
background-color: #1d1d1d;
border-radius: 50%;
}
.recordBtn.stop{
right: 0;
bottom: 0;
background-color: #c63e3e;
box-shadow: none;
}
.voiceMeter{
position: absolute;
background-color: #fff;
top: 50%;
left: 50%;
transform: translate(-50%,-50%);
border-radius: 50%;
box-shadow: 3px 3px 10px #111;
}
.voiceMeterContainer{
position: absolute;
height: 3rem;
width: 3rem;
bottom: 0.25rem;
right: 1rem;
}
.recorder{
height: 100%;
width: 8rem;
overflow: hidden;
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

45
src/lib/AdminAPI.js Normal file
View File

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

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

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

86
src/lib/MatrixHandler.js Normal file
View File

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

View File

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

50
src/lib/cookieHandler.js Normal file
View File

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

28
src/lib/eventUtils.js Normal file
View File

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

33
src/lib/getMxc.js Normal file
View File

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

10
src/lib/getTimeStrings.js Normal file
View File

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

30
src/lib/matrixUtils.js Normal file
View File

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

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

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

14
src/lib/scrollHandler.js Normal file
View 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;
}
}

10
src/lib/splitArray.js Normal file
View File

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

View File

@ -1,107 +1,30 @@
import Vue from 'vue'
import VueRouter from 'vue-router'
import App from './App.vue'
import login from './views/login.vue'
import chat from './views/chat.vue'
import {router} from '@/router'
import {MatrixHandler} from './lib/MatrixHandler.js'
import {DataStore} from '@/lib/DataStore';
Vue.config.productionTip = false
Vue.use(VueRouter)
Vue.config.productionTip = false;
Vue.use(VueRouter);
const router = new VueRouter({
routes: [
{
path: '/',
name: 'home',
component: login
},
{
path: '/login',
name: 'login',
component: login
},
{
path: '/chat',
name: 'chat',
component: chat
export let matrix = new MatrixHandler();
(async () => {
let login = await new DataStore().get('login');
if (login && login.baseUrl && login.accessToken && login.userId) {
matrix.tokenLogin(login.baseUrl, login.accessToken, login.userId);
}
new Vue({
el: '#app',
router,
template: '<App/>',
components: {App},
data() {
return {}
}
]
})
}).$mount('#app');
})()
new Vue({
el: '#app',
router,
template: '<App/>',
components: {App}
}).$mount('#app')
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))
}
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))
}

50
src/main.scss Normal file
View File

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

View File

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

155
src/views/admin.vue Normal file
View File

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

View File

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

View File

@ -1,28 +1,124 @@
<template>
<div id="login">
<h1 class="title">short your url</h1>
<div class="input-field" id="longurl">
<label for="longurl-input"></label>
<input class="input" id="longurl-input" type="text" autocomplete="off" maxlength="20" placeholder="chose nickname">
<div class="login">
<div class="loginBox">
<h1 class="title">[chat]</h1>
<form v-if="showLogin()" @submit.prevent="login()">
<input v-model="user" class="input" name="user" type="text" maxlength="30" placeholder="@user:adb.sh"><br>
<input v-model="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">
<textbtn text="login" />
<overlay v-if="loading"><throbber :text="loading"/></overlay>
</div>
</template>
<script>
import textbtn from '@/components/textbtn';
import textbtn from '@/components/layout/textbtn';
import {matrix} from '@/main.js';
import {isValidUserId} from '@/lib/matrixUtils';
import {DataStore} from '@/lib/DataStore';
import Overlay from '@/components/layout/overlay';
import Throbber from '@/components/layout/throbber';
const store = new DataStore();
export default {
name: "login.vue",
name: 'login.vue',
components: {
Throbber,
Overlay,
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>
<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;
height: min-content;
width: 100%;
}
@media (max-width: 35rem) {
input {
width: calc(100% - 8rem);
}
}
</style>

266
src/views/rooms.vue Normal file
View File

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