Merge branch 'dev'

This commit is contained in:
N-Pex 2023-11-27 18:42:38 +01:00
commit 5857df82ef
94 changed files with 3591 additions and 921 deletions

1
.gitignore vendored
View file

@ -1,6 +1,7 @@
.DS_Store
node_modules
/dist
*.pem
# local env files

View file

@ -59,5 +59,18 @@ The app loads runtime configutation from the server at "./config.json" and merge
* Insert the resulting config blob into the "shortCodeStickers" value of the config file (assets/config.json)
* Rearrange order of sticker packs by editing the config blob above.
### Chat backgrounds
Chat backgrounds can be set using the **chat_backgrounds** config value. It can be set per room type, "direct", "invite" and "public". If no background is set for the current room type, the app will also check if a default "all" value has any backgrounds specified.
Backgrounds are entered as an array. Which of the backgrounds in the array is used for a given room is calculated from the room ID, so that it is constant across the lifetime of the room.
```
chat_backgrounds: {
"direct": ["https://example.com/dm1.png", "data:image/png;base64,yadiyada..."],
"all": ["/default_background.png"]
}
```
### Attributions
Sounds from [Notification Sounds](https://notificationsounds.com)

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

BIN
public/icons/icon-72x72.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6 KiB

BIN
public/icons/icon-96x96.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

View file

@ -1,17 +1,52 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" id="favicon" href="<%= BASE_URL %>favicon.ico">
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<link rel="icon" id="favicon" href="<%= BASE_URL %>favicon.ico" />
<title><%= htmlWebpackPlugin.options.title %></title>
<meta name="apple-mobile-web-app-capable" content="yes" />
<link rel="apple-touch-icon" href="<%= BASE_URL %>icons/icon-72x72.png" sizes="72x72" />
<link rel="apple-touch-icon" href="<%= BASE_URL %>icons/icon-96x96.png" sizes="96x96" />
<link rel="apple-touch-icon" href="<%= BASE_URL %>icons/icon-128x128.png" sizes="128x128" />
<link rel="apple-touch-icon" href="<%= BASE_URL %>icons/icon-144x144.png" sizes="144x144" />
<link rel="apple-touch-icon" href="<%= BASE_URL %>icons/icon-152x152.png" sizes="152x152" />
<link rel="apple-touch-icon" href="<%= BASE_URL %>icons/icon-192x192.png" sizes="192x192" />
<link rel="apple-touch-icon" href="<%= BASE_URL %>icons/icon-384x384.png" sizes="384x384" />
<link rel="apple-touch-icon" href="<%= BASE_URL %>icons/icon-512x512.png" sizes="512x512" />
<link rel="manifest" href="<%= BASE_URL %>manifest.json" />
<script src="./lottie-player.js"></script>
<style>
#loader {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 20;
background-color: rgba(255, 255, 255, 1);
display: flex;
align-items: center;
justify-content: center;
}
</style>
</head>
<body>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
<strong
>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please
enable it to continue.</strong
>
</noscript>
<div id="app"></div>
<div id="app">
<!-- To get rid of blank white screen, show loading indicator before Vue app is initialized -->
<!-- This loader will be replaced by the Vue component on mounted -->
<div id="loader">
<lottie-player autoplay loop mode="normal" src="./loader.json" style="width: 128px"> </lottie-player>
</div>
</div>
<!-- built files will be auto injected -->
</body>
</html>

1
public/loader.json Normal file

File diff suppressed because one or more lines are too long

77
public/lottie-player.js Normal file

File diff suppressed because one or more lines are too long

52
public/manifest.json Normal file
View file

@ -0,0 +1,52 @@
{
"id": "/",
"start_url": "/",
"name": "Convene - Chat for everyone ",
"short_name": "Convene",
"theme_color": "#FFFFFF",
"background_color": "#FFFFFF",
"display": "standalone",
"icons": [
{
"src": "/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png"
},
{
"src": "/icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png"
},
{
"src": "/icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png"
},
{
"src": "/icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png"
},
{
"src": "/icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png"
},
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"splash_pages": null
}

22
public/sw.js Normal file
View file

@ -0,0 +1,22 @@
// Notification click event listener
self.addEventListener("notificationclick", (e) => {
// Close the notification popout
e.notification.close();
// Get all the Window clients
e.waitUntil(
clients.matchAll({ type: "window" }).then((clientsArr) => {
// If a Window tab matching the targeted URL already exists, focus that;
const hadWindowToFocus = clientsArr.some((windowClient) =>
windowClient.url === e.notification.data.url
? (windowClient.focus(), true)
: false,
);
// Otherwise, open a new tab to the applicable URL and focus it.
if (!hadWindowToFocus)
clients
.openWindow(e.notification.data.url)
.then((windowClient) => (windowClient ? windowClient.focus() : null));
}),
);
});

View file

@ -7,7 +7,7 @@
<v-container
fluid
fill-height
v-if="loading"
v-if="showLoadingScreen"
class="loading-container"
>
<v-row align="center" justify="center">
@ -23,7 +23,7 @@
<v-skeleton-loader
type="list-item-avatar-two-line, divider, list-item-three-line, card-heading"
v-if="loading"
v-if="showLoadingScreen"
></v-skeleton-loader>
</v-main>
</v-app>
@ -31,10 +31,12 @@
<script>
import stickers from "./plugins/stickers";
import { registerServiceWorker, notificationCount, windowNotificationPermission } from "./plugins/notificationAndServiceWorker.js"
import logoMixin from "./components/logoMixin";
export default {
name: "App",
mixins: [logoMixin],
data() {
return {
loading: true,
@ -42,11 +44,11 @@ export default {
availableJsonTranslation: null
}
},
mixins: [logoMixin],
beforeMount() {
this.setDefaultLanguage();
},
mounted() {
registerServiceWorker();
/**
if (
window.location.protocol == "http" &&
@ -82,6 +84,7 @@ export default {
this.$config.promise.then(this.onConfigLoaded);
},
methods: {
windowNotificationPermission,
onConfigLoaded(config) {
if (config.shortCodeStickers) {
stickers.loadStickersFromConfig(config);
@ -115,41 +118,24 @@ export default {
this.$i18n.locale = this.$store.state.language || "en";
},
showNotification() {
if(document.visibilityState === "visible") {
return;
}
const title = this.$t('notification.title');
const notification = new Notification(title, {icon: this.logotype});
notification.onclick = () => {
notification.close();
window.parent.focus();
}
},
requestAndShowPermission(notificationCount) {
Notification.requestPermission(function (permission) {
if(notificationCount > 0 && permission === "granted") {
this.showNotification();
}
});
},
requestNotificationPermission(notificationCount) {
if ('Notification' in window) {
Notification.requestPermission().then((permission) => {
if(notificationCount > 0 && permission === 'granted') {
this.showNotification();
} else if(permission === "default") {
this.requestAndShowPermission(notificationCount);
} else {
this.requestAndShowPermission(notificationCount);
}
if(document.visibilityState === "hidden") {
const title = this.$t('notification.title');
const self = this;
navigator.serviceWorker.ready.then(function(registration) {
registration.showNotification(title, {
icon: self.logotype,
data: { url: window.location.href }
});
});
}
}
},
},
computed: {
notificationCount() {
return this.$matrix.notificationCount
showLoadingScreen() {
return this.loading || !(this.$config.loaded);
},
notificationCount,
currentUser() {
return this.$store.state.auth.user;
},
@ -191,6 +177,15 @@ export default {
},
},
watch: {
notificationCount: {
handler(nCount) {
// windowNotificationPermission
// return value: 'granted', 'default', 'denied'
if (nCount > 0 && this.windowNotificationPermission() === "granted") {
this.showNotification()
}
}
},
"$i18n.locale": {
handler(val) {
// Locale changed, check file if RTL
@ -217,13 +212,8 @@ export default {
document.getElementById("favicon").setAttribute('href', favicon);
},
immediate: true,
},
notificationCount: {
handler(nCount) {
this.requestNotificationPermission(nCount)
}
}
},
}
};
</script>

View file

@ -48,5 +48,6 @@
"experimental_file_mode": true,
"experimental_read_only_room": true,
"experimental_public_room": true,
"show_status_messages": "never"
"show_status_messages": "never",
"hide_add_room_on_home": false
}

View file

@ -21,7 +21,7 @@
width: 100%;
}
.h-100 {
width: 100%;
height: 100%;
}
.white-space-pre {
white-space: pre;
@ -37,4 +37,4 @@
}
.box-shadow-none {
box-shadow: none !important;
}
}

View file

@ -8,7 +8,7 @@ $admin-fg: white;
body {
--v-app-background: $app-background;
--v-background-color: white;
--v-background-color: rgba(255, 255, 255, 0.8);
--v-foreground-color: black;
--v-secondary-color: #242424;
--v-divider-color: #eeeeee;
@ -86,9 +86,8 @@ body {
font-family: "Inter", sans-serif;
font-weight: 700;
font-size: 11 * $chat-text-size;
color: var(--v-foreground-color);
background-color: var(--v-background-color) !important;
border: 1px solid var(--v-foreground-color);
color: white;
background-color: red !important;
border-radius: $chat-standard-padding / 2;
height: $chat-standard-padding;
margin-top: $chat-standard-padding-xs;
@ -99,6 +98,26 @@ body {
color: var(--v-secondary-color);
}
.leave-button {
font-family: "Inter", sans-serif;
font-weight: 700;
font-size: 11.54 * $chat-text-size;
line-height: 140%;
color: white !important;
background-color: #ff3300 !important;
border-radius: $small-button-height / 2;
min-height: 0;
height: $small-button-height !important;
margin-top: $chat-standard-padding-xs;
margin-bottom: $chat-standard-padding-xs;
padding-left: $chat-standard-padding-s !important;
padding-right: $chat-standard-padding-s !important;
width: auto !important;
.v-icon {
margin-right: 4px;
}
}
.icon-dropdown {
margin: 0px 8px;
}
@ -209,25 +228,20 @@ body {
}
@media #{map-get($display-breakpoints, 'sm-and-down')} {
margin-top: 72px;
//margin-top: 72px;
margin-bottom: 70px;
}
}
.input-area {
background-color: #e2e2e2;
margin: 0;
padding-left: $chat-standard-padding-s;
padding-right: $chat-standard-padding-s;
}
.input-area-outer {
position: relative;
background-color: #ffffff;
background-color: var(--v-background-color);
margin: 0;
margin-bottom: -10px;
padding-left: 2 * $chat-standard-padding-s;
padding-right: 2 * $chat-standard-padding-s;
padding-top: 0;
padding-bottom: 10px;
&.reply-to {
padding: 0;
@ -264,9 +278,9 @@ body {
background-color: white;
border: 1px solid #d4d4d4;
border-radius: 32px;
margin-bottom: 10px;
@media #{map-get($display-breakpoints, 'sm-and-down')} {
margin-bottom: 2px;
margin-bottom: 12px;
}
}
.input-area-button {
@ -336,9 +350,9 @@ body {
display: inline-block;
vertical-align: top !important;
.v-icon {
color: #eeeeee;
color: #595959;
&:hover {
color: #888888;
color: #000000;
}
}
}
@ -404,7 +418,7 @@ body {
}
position: relative;
.bubble {
background-color: #ededed;
background-color: rgba(#ededed,0.8);
border-radius: 0px 10px 10px 10px;
[dir="rtl"] & {
border-radius: 10px 0px 10px 0px;
@ -418,7 +432,7 @@ body {
max-width: 70%;
}
&.from-admin .bubble {
background-color: $admin-bg;
background-color: rgba($admin-bg,0.8);
}
.audio-bubble {
width: 70%;
@ -497,7 +511,7 @@ body {
}
position: relative;
.bubble {
background-color: #e5e5e5;
background-color: rgba(#e5e5e5,0.8);
border-radius: 10px 10px 0 10px;
[dir="rtl"] & {
border-radius: 10px 10px 10px 0px;
@ -508,7 +522,7 @@ body {
max-width: 70%;
}
.audio-bubble {
background-color: #e5e5e5;
background-color: rgba(#e5e5e5,0.8);
border-radius: 10px 10px 0 10px;
[dir="rtl"] & {
border-radius: 10px 10px 10px 0px;
@ -520,13 +534,6 @@ body {
width: 70%;
height: 50px;
}
.video2-bubble {
background-color: #e5e5e5;
border-radius: 10px 10px 0 10px;
[dir="rtl"] & {
border-radius: 10px 10px 10px 0px;
}
}
.bubble.image-bubble {
padding: 0px;
display: inline-block;
@ -747,6 +754,7 @@ body {
height: 34px;
border-radius: 22px;
box-shadow: 4px 4px 8px rgba(0, 0, 0, 0.15);
white-space: nowrap;
}
.avatar-operations {
@ -805,11 +813,8 @@ body {
.read-marker {
margin-left: 20px;
margin-right: 20px;
height: 1px;
width: calc(100% - 40px);
line-height: var(--v-theme-title-featured-line-height);
position: absolute;
bottom: 0;
font-family: sans-serif;
font-style: normal;
font-weight: normal;
@ -818,19 +823,18 @@ body {
/* identical to box height, or 14px */
letter-spacing: 0.29px;
color: #c0c0c0;
background-color: #c0c0c0;
text-align: center;
&::after {
position: absolute;
top: -4px;
background: white;
transform: translate(-50%, 0);
[dir="rtl"] & {
transform: translate(50%, 0);
}
padding-left: 4px;
padding-right: 4px;
content: attr(title);
display: flex;
align-items: center;
& div.text {
flex: 0 0 auto;
padding-left: 10px;
padding-right: 10px;
}
& div.line {
background: #c0c0c0;
height: 1px;
flex: 1 1 auto;
}
}
@ -839,7 +843,6 @@ body {
margin-right: 20px;
margin-top: 20px;
margin-bottom: 20px;
height: 1px;
line-height: var(--v-theme-title-featured-line-height);
font-family: sans-serif;
font-style: normal;
@ -849,20 +852,17 @@ body {
/* identical to box height, or 14px */
letter-spacing: 0.29px;
color: black;
background-color: black;
text-align: center;
position: relative;
&::after {
position: absolute;
top: -8px;
background: white;
transform: translate(-50%, 0);
[dir="rtl"] & {
transform: translate(50%, 0);
}
display: flex;
align-items: center;
& div.text {
flex: 0 0 auto;
padding-left: 10px;
padding-right: 10px;
content: attr(title);
}
& div.line {
background: black;
height: 1px;
flex: 1 1 auto;
}
}
@ -1018,13 +1018,15 @@ body {
& > *:last-child {
flex: 0 0 auto;
}
.option-title {
color: #000;
font-size: 16 * $chat-text-size;
}
.option-text {
font-size: 13 * $chat-text-size;
}
}
.option-text {
font-size: 13 * $chat-text-size;
}
.option-title {
color: #000;
font-size: 16 * $chat-text-size;
}
.header-button-left {
@ -1130,8 +1132,9 @@ body {
&.ptt {
position: absolute;
left: 10px;
bottom: 0px;
bottom: 10px;
right: 10px;
overflow: visible;
}
border-radius: 10px;
background-color: black;
@ -1148,6 +1151,7 @@ body {
}
.will-cancel {
background-color: #ff3300;
border-radius: 10px;
}
.recording-time {
color: white;
@ -1157,9 +1161,11 @@ body {
}
.locked {
background-color: black;
border-radius: 10px;
}
.error {
background-color: orange;
border-radius: 10px;
}
.voice-recorder-lock {
position: relative;
@ -1326,6 +1332,10 @@ body {
}
}
.invisible {
opacity: 0;
}
.new-room {
font-family: "Inter", sans-serif;
font-size: 16px !important;
@ -1351,6 +1361,9 @@ body {
.loading-indicator {
position: absolute;
background-color: rgba(0, 0, 0, 0.2);
&.transparent {
background-color: transparent;
}
}
.exporting-indicator {
@ -1383,6 +1396,24 @@ body {
transition: opacity 0.3s linear;
}
.toast-at-bottom {
position: fixed;
left: 10px;
right: 10px;
bottom: 10px;
background-color: rgba(black, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 40;
color: white;
opacity: 0;
transition: opacity 0.3s ease-in-out;
&.visible {
opacity: 1;
}
}
.auto-audio-player-root {
position: absolute;
top: 72px;
@ -1512,18 +1543,6 @@ body {
.mic-button.dimmed {
opacity: 0.5;
}
.toast-read-only {
position: fixed;
left: 10px;
right: 10px;
bottom: 10px;
background-color: rgba(black, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 40;
color: white;
}
}
.audio-layout.voice-recorder {
@ -1531,4 +1550,18 @@ body {
right: 20px;
bottom: 20px;
position: absolute;
}
.download-all-button {
font-family: "Inter", sans-serif;
font-weight: 700;
font-size: 11.54 * $chat-text-size;
line-height: 140%;
color: white !important;
background-color: #4642f1 !important;
border-radius: $small-button-height / 2;
min-height: 0;
height: $small-button-height !important;
margin-top: $chat-standard-padding-xs;
margin-bottom: $chat-standard-padding-xs;
}

View file

@ -355,6 +355,11 @@ $small-button-height: 36px;
right: unset;
left: 8px;
background: linear-gradient(0deg, #000 0%, #000 100%), #4642f1;
&.close {
right: 8px;
left: unset;
background: $hiliteColor !important;
}
}
}
}

127
src/assets/css/getlink.scss Normal file
View file

@ -0,0 +1,127 @@
@import "@/assets/css/main.scss";
.getlink-root {
background-color: $background;
.getlink-loggedin {
text-align: center;
button {
min-width: 200px !important;
}
}
.getlink-image {
text-align: center;
max-width: 325px;
max-height: 257px;
width: 100%;
.v-icon__component {
width: unset;
height: unset;
}
}
.getlink-title {
color: #000;
text-align: center;
font-family: "Poppins";
font-size: 28px;
font-style: normal;
font-weight: 700;
line-height: 108.5%; /* 30.38px */
letter-spacing: -0.8px;
white-space: pre-line;
margin-top: 50px;
}
.getlink-info {
color: #000;
text-align: center;
font-feature-settings: "clig" off, "liga" off;
font-family: "Inter";
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: 117%; /* 18.72px */
letter-spacing: 0.4px;
margin: 15px 9px 40px 9px;
}
.getlink-subtitle {
color: #000;
text-align: center;
font-feature-settings: "clig" off, "liga" off;
font-family: "Inter";
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: 117%; /* 18.72px */
letter-spacing: 0.4px;
margin: 13px 20px 18px 20px;
}
.qr-container {
flex-direction: column;
align-items: stretch;
border-radius: 18px;
border: 1px solid #ededed;
background: #fff;
box-shadow: 0px 1px 4px 0px rgba(0, 0, 0, 0.07);
.col:nth-child(1) {
border-bottom: 1px solid rgba(0, 0, 0, 0.07);
text-align: center;
.qr {
width: 120px !important;
height: 120px !important;
}
}
.public-link {
background-color: transparent;
border: none !important;
text-align: center;
}
}
#btn-copy-room-link {
background-color: var(--v-primary-base) !important;
}
.link-copied-in-place .v-btn__content {
font-family: "Inter", sans-serif !important;
font-size: 12px !important;
text-transform: none !important;
color: black;
}
.getlink-share {
color: #161616;
text-align: center;
font-family: "Inter";
font-size: 11.541px;
font-style: normal;
font-weight: 700;
line-height: 140%; /* 16.158px */
letter-spacing: 0.34px;
text-transform: uppercase;
color: black;
display: flex;
align-items: center;
justify-content: center;
.v-image {
flex: 0 0 14px;
width: 14px;
height: 10px;
object-fit: contain;
margin-left: 4px;
}
}
.getlink-buttons {
display: flex;
align-items: center;
flex-direction: column;
margin-top: -20px;
}
}

View file

@ -105,7 +105,7 @@ body { position:absolute; top:0; bottom:0; right:0; left:0; }
.v-btn.text-button {
font-family: 'Inter', sans-serif;
font-weight: 700;
font-size: 11 * $chat-text-size;
font-size: 11 * $chat-text-size !important;
border: none;
height: $chat-standard-padding;
margin-top: $chat-standard-padding-xs;

File diff suppressed because one or more lines are too long

View file

@ -2,6 +2,6 @@
<svg width="18" height="22" viewBox="0 0 18 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M16.0247 8.80006H15.4908V6.42878C15.4908 2.88379 12.5789 0 9.00064 0C5.42053 0 2.50904 2.88379 2.50904 6.42878V8.80047H1.97615C0.88518 8.80047 0 9.67678 0 10.7572V20.0423C0 21.1231 0.884952 22 1.97615 22H16.0248C17.1153 22 18 21.1232 18 20.0423L17.9999 10.7568C17.9999 9.67638 17.1151 8.80006 16.025 8.80006H16.0247ZM9.82754 15.5262V16.7406C9.82754 16.9215 9.6795 17.0687 9.4959 17.0687H8.50277C8.32063 17.0687 8.17185 16.9216 8.17185 16.7406V15.5262C7.7193 15.2498 7.41503 14.7589 7.41503 14.193C7.41503 13.3265 8.12456 12.6242 9.0001 12.6242C9.87461 12.6242 10.5841 13.3265 10.5841 14.193C10.584 14.7589 10.2795 15.2498 9.82754 15.5262ZM12.6451 8.80006H5.35551V6.42878C5.35551 4.43786 6.99007 2.81942 9.00073 2.81942C11.0097 2.81942 12.6451 4.43763 12.6451 6.42878V8.80006Z"
fill="white" />
fill="currentColor" />
</svg>
</template>

View file

@ -0,0 +1,3 @@
<svg width="24" height="17" viewBox="0 0 24 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.2477 0.000670823C16.1384 0.00531075 16.0311 0.0307591 15.9314 0.0754688C15.7815 0.142957 15.6546 0.25192 15.5656 0.389005C15.4766 0.526231 15.4291 0.685951 15.4291 0.849038V3.32107C14.7529 3.28142 14.0829 3.28494 13.4234 3.33598C6.62122 3.86253 0.885374 9.07191 0.00664931 16.0431H0.00679109C-0.0251098 16.2933 0.0564145 16.5444 0.229528 16.7288C0.402641 16.9133 0.649181 17.0122 0.902999 16.9988C1.15679 16.9853 1.39143 16.8609 1.54355 16.6591C4.61977 12.5918 9.44481 10.1994 14.572 10.1994H15.4292V12.7494C15.4295 12.9682 15.5147 13.1784 15.6674 13.3364C15.8199 13.4945 16.0281 13.5881 16.2484 13.5978C16.4687 13.6075 16.6845 13.5326 16.8505 13.3886L23.7079 7.43851C23.8935 7.2771 24 7.04413 24 6.79934C24 6.55455 23.8935 6.3216 23.7079 6.16016L16.8505 0.210072C16.6844 0.0659577 16.4685 -0.00898088 16.2478 0.000859754L16.2477 0.000670823Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1,022 B

View file

@ -12,12 +12,7 @@
"room_info": {
"title": "ཁ་བརྡ་ཁང་གི་ཞིབ་ཕྲའི་གནས་ཚུལ།",
"version_info": "གྷར་ཌིན་ལས་འཆར་གྱིས་ནུས་ཤུགས་བསྩལ། ཐོན་རིམ། : {version}",
"leave_room_info": "གསལ་བཤད། གོ་རིམ་འདི་ཕྱིར་ཟློག་ཐབས་མེད། ཁྱེད་རང་ཕྱིར་ཐོན་རྒྱུ་ཡིན་མིན་དང་གླེང་མོལ་ཁག་གཏན་དུ་གསུབ་རྒྱུ་ཡིན་མིན་ཁག་ཐེག་བྱོས།",
"leave_room": "ཕྱིར་ཐོན།",
"view_profile": "ལྟ་ཞིབ།",
"identity_temporary": "ཁྱེད་ཀྱི་ངོ་བོ {displayName} འདི་གནས་སྐབས་ཙམ་ཡིན། ཁྱེད་ཀྱིས་སོ་སོའི་མིང་དང་གསང་ཚིག་བརྗེས་ཏེ་འདི་ཉར་ཚགས་བྱེད་ཆོག",
"identity": "ཁྱེད་རང་{displayName} མིང་ཐོག་ནས་ནང་འཛུལ་བྱེད་བཞིན་འདུག",
"my_profile": "ངའི་ཡིག་ཆ།",
"show_all": "ཚང་མ་སྟོན། >",
"hide_all": "སྦེད།",
"user_you": "{user} (ཁྱེད།)",
@ -69,7 +64,6 @@
},
"new_room": {
"next": "རྗེས་མ།",
"done": "ཚར་སོང་།",
"status_avatar": "མགོ་པར་ཡར་འཇུག་བྱེད་བཞིན་པ།: {count}",
"status_avatar_total": "མགོ་པར་ཡར་འཇུག་བྱེད་བཞིན་པ། {total} ཡི་{count}",
"status_creating": "ཁ་བརྡ་ཁང་བཟོ་བཞིན་པ།",
@ -143,7 +137,6 @@
"join_invite": "ཁྱེད་ཀྱིས་གདན་ཞུ་གནང་བའི་མི་ཁོ་ན་མ་གཏོགས་འཛུལ་མི་ཐུབ།",
"join_public": "སུ་ཡིན་རུང་འབྲེལ་ཐག་འདིའི་ཐོག་ལ་མནན་ཏེ་འཛུལ་ཆོག: {link}.",
"info": "འདིར་ཁྱེད་ཀྱིས་སོ་སོའི་ཚོགས་པའི་སྐོར་ལ་ཤེས་དགོས་པའི་དོན་གནད་འགའ་ཡོད།:",
"welcome": "དགའ་བསུ་ཞུ།",
"got_it": "ཧ་གོ་སོང་།",
"room_history_joined": "ཚོགས་མི་ཁག་ཁ་བརྡ་ཁང་དུ་ཞུགས་པའི་རྗེས་སུ། ད་གཟོད་དེའི་ནང་དུ་བཏང་ཡོད་པའི་འཕྲིན་ཐུང་ཁག་མཐོང་ཐུབ།",
"room_history_is": "ཁ་བརྡ་ཁང་གི་ཟིན་ཐོ་ཁག {type}.",
@ -155,13 +148,13 @@
"room_list_rooms": "ཁ་བརྡ་ཁང་།",
"room_list_invites": "གདན་ཞུ་ཁག",
"purge_failed": "ཁ་བརྡ་ཁང་བཤིག་ཐུབ་མ་སོང་།",
"purge_removing_members": "ཚོགས་མི་ཁག་ཕྱིར་འདོན། {total})་ཀྱི་({members}",
"purge_removing_members": "ཚོགས་མི་ཁག་ཕྱིར་འདོན། ({total}་ཀྱི་({count})",
"purge_redacting_events": "ཁ་བརྡ་གཙང་གསུབ། {total})་ཀྱི་({count}",
"purge_set_room_state": "ཁ་བརྡ་ཁང་གི་རྣམ་པ་སྒྲིག་འགོད།",
"room_list_new_messages": "{count} ཆ་འཕྲིན་གསར་པ།",
"room_topic_required": "ཁ་བརྡ་ཁང་ལ་འགྲེལ་བཤད་དགོས།",
"room_name_required": "ཁ་བརྡ་ཁང་ལ་མིང་ཞིག་དགོས།",
"invitations": "ཁྱེད་ལ་གྲོགས་པོའི་གདན་ཞུ་གང་ཡང་མི་འདུག | ཁྱེད་ལ་གྲོགས་པོའི་གདན་ཞུ་གྲངས་གཅིག་འདུག | ཁྱེད་ལ་གྲོགས་པོའི་གདན་ཞུ་{གྲངས}་འདུག",
"invitations": "ཁྱེད་ལ་གྲོགས་པོའི་གདན་ཞུ་གང་ཡང་མི་འདུག | ཁྱེད་ལ་གྲོགས་པོའི་གདན་ཞུ་གྲངས་གཅིག་འདུག | ཁྱེད་ལ་གྲོགས་པོའི་གདན་ཞུ་{count}་འདུག",
"unseen_messages": "ཁྱེད་ཀྱིས་མཐོང་མེད་པའི་ཆ་འཕྲིན་གང་ཡང་མི་འདུག | ཁྱེད་ཀྱིས་མཐོང་མེད་པའི་ཆ་འཕྲིན་གཅིག་འདུག | ཁྱེད་ཀྱིས་མཐོང་མེད་པའི་ཆ་འཕྲིན་{count}འདུག"
},
"message": {
@ -208,11 +201,11 @@
"incoming_message_deleted_text": "ཆ་འཕྲིན་འདི་བསུབས་ཟིན།",
"reply_poll": "བསམ་ཚུལ་བསྡུ་ལེན།",
"not_allowed_to_send": "དོ་དམ་པ་དང་གཙོ་སྐྱོང་བ་ཁོ་ནས་མ་གཏོགས་ཁ་བརྡ་ཁང་དུ་གཏོང་མི་ཆོག",
"user_was_kicked_by_you": "ཁྱེད་ཀྱིས་{སྤྱོད་མཁན}་འདི་ཁ་བརྡའི་ཁོངས་ནས་སྒོར་ཕུད་སོང་།",
"user_was_kicked": "{སྤྱོད་མཁན} ་འདི་ཁ་བརྡའི་ཁོངས་ནས་སྒོར་ཕུད་ཟིན།",
"user_was_kicked_by_you": "ཁྱེད་ཀྱིས་{user}་འདི་ཁ་བརྡའི་ཁོངས་ནས་སྒོར་ཕུད་སོང་།",
"user_was_kicked": "{user} ་འདི་ཁ་བརྡའི་ཁོངས་ནས་སྒོར་ཕུད་ཟིན།",
"user_was_kicked_you": "ཁྱེད་རང་ཁ་བརྡའི་ཁོངས་ནས་སྒོར་ཕུད་སོང་།",
"user_was_banned": "{སྤྱོད་མཁན}་འདི་ཁ་བརྡའི་ཁོངས་ནས་བཀག་སྡོམ་བྱས་ཏེ་སྒོར་ཕུད་ཟིན།",
"user_was_banned_by_you": "ཁྱེད་ཀྱིས་{སྤྱོད་མཁན} ་འདི་ཁ་བརྡའི་ཁོངས་ནས་བཀག་སྡོམ་བྱས་ཏེ་སྒོར་ཕུད་ཟིན།",
"user_was_banned": "{user}་འདི་ཁ་བརྡའི་ཁོངས་ནས་བཀག་སྡོམ་བྱས་ཏེ་སྒོར་ཕུད་ཟིན།",
"user_was_banned_by_you": "ཁྱེད་ཀྱིས་{user} ་འདི་ཁ་བརྡའི་ཁོངས་ནས་བཀག་སྡོམ་བྱས་ཏེ་སྒོར་ཕུད་ཟིན།",
"time_ago": "དེ་རིང་། | ཁ་སང་། | ཉིན་གྲངས་{count} གོང་།",
"outgoing_message_deleted_text": "ཁྱེད་ཀྱིས་ཆ་འཕྲིན་འདི་བསུབས་སོང་།",
"reaction_count_more": "{reactionCount} མང་བ།"
@ -232,7 +225,6 @@
"swipe_to_cancel": "ཤུད་འདེད་བྱས་ཏེ་སུབས།"
},
"room_info_sheet": {
"create_room": "ཚོགས་པ་བཟོས།",
"view_details": "ཞིབ་ཕྲར་གཟིགས།",
"this_room": "ཁ་བརྡ་ཁང་འདི།"
},

View file

@ -1,7 +1,5 @@
{
"language_display_name": "Deutsch",
"global": {
},
"menu": {
"start_private_chat": "Private Diskussion mit diesem Benutzer",
"reply": "Antworten",
@ -18,12 +16,13 @@
"undo": "Rückgängig",
"join": "Beitreten",
"ignore": "Ignorieren",
"loading": "{appName} wird geladen"
"loading": "{appName} wird geladen",
"done": "Fertig"
},
"message": {
"you": "Du",
"user_created_room": "{user} hat den Raum erstellt",
"user_aliased_room": "{Benutzer} hat den Raumalias {alias} erstellt",
"user_aliased_room": "{user} hat den Raumalias {alias} erstellt",
"user_changed_display_name": "{user} hat den Anzeigenamen in {displayName} geändert",
"user_changed_avatar": "{user} hat den Avatar geändert",
"user_changed_room_avatar": "{user} hat den Raumavatar geändert",
@ -44,7 +43,6 @@
"room_joinrule_public": "öffentlich",
"user_changed_room_topic": "{user} hat das Raumthema auf {topic} geändert",
"unread_messages": "Ungelesene Nachrichten",
"replying_to_event": "ANTWORT AUF EREIGNIS: {message}",
"your_message": "Deine Nachricht …",
"scale_image": "Bild skalieren",
"user_is_typing": "{user} schreibt",
@ -54,9 +52,14 @@
"user_changed_guest_access_open": "{user} hat Gästen erlaubt, den Raum beizutreten",
"room_powerlevel_change": "{user} hat den Status von {changes} geändert",
"user_left": "{user} hat das Gespräch verlassen",
"user_joined": "{Benutzer} ist dem Gespräch beigetreten",
"user_joined": "{user} ist dem Gespräch beigetreten",
"download_progress": "{percentage} % heruntergeladen",
"user_changed_room_name": "{user} hat den Raumnamen in {name} geändert"
"user_changed_room_name": "{user} hat den Raumnamen in {name} geändert",
"replying_to": "Antwort an {user}",
"reply_image": "Bild",
"reply_audio_message": "Sprachnachricht",
"reply_video": "Video",
"time_ago": "Heute | Gestern | Vor {count} Tagen"
},
"room": {
"leave": "Verlassen",
@ -66,7 +69,8 @@
"members": "keine Mitglieder | 1 Mitglied | {count} Mitglieder",
"purge_removing_members": "Entfernen von Mitgliedern",
"purge_failed": "Fehler beim Bereinigen des Raums!",
"room_list_rooms": "Räume"
"room_list_rooms": "Räume",
"invitations": "Du hast keine Einladungen | Du hast 1 Einladung | Du hast {count} Einladungen"
},
"room_welcome": {
"info": "Herzlich willkommen! Hier sind ein paar Dinge, die du über deinen Raum wissen solltest:",
@ -96,7 +100,8 @@
"create": "Erstellen",
"next": "Nächste",
"name_room": "Raum benennen",
"room_topic": "Füge eine Beschreibung hinzu, wenn du möchtest"
"room_topic": "Füge eine Beschreibung hinzu, wenn du möchtest",
"room_name_limit_error_msg": "Maximal 50 Zeichen erlaubt"
},
"device_list": {
"title": "GERÄTE",
@ -112,7 +117,8 @@
"password_required": "Das Passwort ist erforderlich",
"login": "Anmelden",
"create_room": "Registrieren und Raum erstellen",
"or": "ODER"
"or": "ODER",
"invalid_message": "Benutzername oder Passwort falsch"
},
"profile": {
"title": "Mein Profil",
@ -124,7 +130,10 @@
"password_old": "Altes Passwort",
"password_new": "Neues Kennwort",
"password_repeat": "Wiederhole das neue Passwort",
"display_name": "Anzeigename"
"display_name": "Anzeigename",
"set_language": "Stelle deine Sprache ein",
"language_description": "Convene ist in vielen Sprachen verfügbar.",
"tell_us": "Teile uns mit."
},
"profile_info_popup": {
"you_are": "Du bist",
@ -138,14 +147,15 @@
},
"join": {
"user_name_label": "Benutzername",
"shared_computer": "Dies ist ein gemeinsam genutztes Gerät",
"joining_as": "Du trittst bei als:",
"join": "Raum beitreten",
"join_guest": "Als Gast beitreten",
"status_logging_in": "Wird angemeldet …",
"status_joining": "Raum beitreten …",
"join_failed": "Beitritt zum Raum fehlgeschlagen.",
"title": "Willkommen in {roomName}"
"title": "Willkommen in {roomName}",
"enter_room": "Raum betreten",
"remember_me": "Mich merken",
"choose_name": "Wähle einen zu nutzenden Namen"
},
"invite": {
"title": "Freunde hinzufügen",
@ -164,8 +174,6 @@
"leave": "Verlassen",
"title_invite": "Bist du sicher, dass du gehen willst?"
},
"logout": {
},
"purge_room": {
"info": "Alle Mitglieder und Nachrichten werden entfernt. Diese Aktion kann nicht rückgängig gemacht werden.",
"button": "Löschen",
@ -197,7 +205,8 @@
"show_all": "Alle anzeigen >",
"leave_room": "Verlassen",
"version_info": "Angetrieben von Guardian Project. Version: {version}",
"scan_code": "Scannen, um den Raum zu betreten"
"scan_code": "Scannen, um den Raum zu betreten",
"export_room": "Chat exportieren"
},
"room_info_sheet": {
"this_room": "Dieser Raum",
@ -222,5 +231,36 @@
"video_file": "Videodatei",
"original_text": "<Originaltext>",
"download_name": "Herunterladen"
},
"project": {
"name": "Convene",
"tag_line": "Einfach miteinander verbinden"
},
"poll_create": {
"title": "Umfrage erstellen",
"create": "Erstellen",
"poll_undisclosed": "Geschlossen Nutzer werden die Ergebnisse sehen, sobald die Umfrage geschlossen ist.",
"failed": "Konnte Umfrage nicht erstellen, bitte versuche es später erneut.",
"creating": "Umfrage erstellen",
"poll_disclosed": "Offen aktuelle Ergebnisse werden immer angezeigt.",
"add_answer": "Antwort hinzufügen",
"create_poll_menu_option": "Umfrage erstellen",
"poll_status_closed": "Umfrage geschlossen",
"question_required": "Du musst eine Frage eingeben!",
"answer_required": "Antwort kann nicht leer sein. Bitte gib einigen Text ein oder entferne diese Option.",
"poll_submit": "Absenden",
"num_answered": "{count} haben geantwortet",
"question_label": "Gib deine Frage hier ein",
"poll_status_open": "Umfrage ist offen",
"poll_status_disclosed": "Ergebnisse werden angezeigt, sobald die Umfrage geschlossen wurde.",
"poll_status_open_not_voted": "Umfrage ist offen stimme ab, um die Ergebnisse zu sehen",
"close_poll": "Umfrage schließen"
},
"export": {
"fetched_n_of_total_events": "{count} von {total} Ereignissen geladen",
"exported_date": "Am {date} exportiert",
"processed_n_of_total_events": "Medien für {count} von {total} Ereignissen verarbeitet",
"fetched_n_events": "{count} Ereignisse geladen",
"export_filename": "Chat exportiert: {date}"
}
}

View file

@ -7,7 +7,7 @@
"global": {
"save": "Save",
"password_didnot_match": "Password didn't match",
"password_hint": "Minimum 12 character containing atleast one numeric, one uppercase and one lowercase letter",
"password_hint": "Minimum 12 character containing at least one numeric, one uppercase and one lowercase letter",
"show_less": "Show less",
"show_more": "Show more",
"add_reaction": "Add reaction",
@ -17,10 +17,12 @@
"minutes": "1 minute ago | {n} minutes ago",
"hours": "1 hour ago | {n} hours ago",
"days": "1 day ago | {n} days ago"
}
},
"close": "close",
"notify": "Notify"
},
"menu": {
"start_private_chat": "Private chat with this user",
"start_private_chat": "Direct Message with this user",
"reply": "Reply",
"edit": "Edit",
"delete": "Delete",
@ -34,6 +36,7 @@
"logout": "Logout",
"new_room": "New Room",
"undo": "Undo",
"delete_now": "Delete now",
"join": "Join",
"ignore": "Ignore",
"loading": "Loading {appName}",
@ -60,11 +63,15 @@
"user_was_banned_you": "You were kicked and banned from the chat.",
"user_joined": "{user} joined the chat",
"user_left": "{user} left the chat",
"someone": "Someone",
"user_said": "{user} said:",
"sent_media": "Sent {count} media items.",
"file_prefix": "File: ",
"edited": "(edited)",
"download_progress": "{percentage}% downloaded",
"preparing_to_upload": "Preparing to upload...",
"upload_file_too_large": "File is too large to upload!",
"upload_exceeded_file_limit": "Maximum file size of ({configFormattedUploadSize}) exceeded. ",
"upload_progress": "Uploaded {count}",
"upload_progress_with_total": "Uploaded {count} of {total}",
"user_changed_room_history": "{user} made room history {type}",
@ -101,7 +108,8 @@
"file": "File",
"files": "Files",
"images": "Images",
"send_attachements_dialog_title": "Do you want to send following attachments ?"
"send_attachements_dialog_title": "Do you want to send following attachments ?",
"download_all": "Download all"
},
"room": {
"invitations": "You have no invitations | You have 1 invitation | You have {count} invitations",
@ -127,7 +135,9 @@
"join_invite": "Only people you invite can join.",
"info_permissions": "You can change join permissions at any time in the room settings.",
"got_it": "Got it",
"no_past_messages": "Welcome! For your security, past messages are not available."
"no_past_messages": "Welcome! For your security, past messages are not available.",
"direct_info": "Hi, {you}. Youre in a private chat with {user}.",
"direct_private_chat": "Direct Message"
},
"new_room": {
"new_room": "New Room",
@ -180,6 +190,19 @@
"send_token": "Send token",
"token_not_valid": "Invalid token"
},
"getlink": {
"title": "Get a Direct Link",
"info": "Direct links give people a secure line of communication with you. To start, choose a screen name to show when people enter a chat with you.",
"username": "Enter a screen name (ex: waku)",
"next": "Next",
"hello": "Hello {user},\nHeres your Direct Link",
"ready_to_share": "Its ready to share! A new direct room will open each time someone opens the link.",
"scan_title": "Scan this code to start a direct chat",
"continue": "Continue",
"different_link": "Get a different link",
"share_qr": "Share QR",
"qr_image_copied": "Image copied to clipboard"
},
"profile": {
"title": "My Profile",
"temporary_identity": "This identity is temporary. Set a password to use it again",
@ -248,7 +271,7 @@
"info": "All members and messages will be removed. This action cannot be undone.",
"button": "Delete",
"n_seconds": "{seconds} seconds",
"self_destruct": "Room will self destruct in seconds.",
"self_destruct": "Your room will self destruct in seconds.",
"deleting": "Deleting room:",
"notified": "We've notified members.",
"room_deletion_notice": "Time to say goodbye! This room has been deleted by {user}. It will self destruct in seconds."
@ -291,8 +314,8 @@
"read_only_room_info": "Only admins and moderators are allowed to send to the room",
"make_public": "Make Public",
"make_public_warning": "warning: Full message history will be visible to new participants",
"contact_link": "My Contact Link",
"contact_link_desc": "Share your contact link. When opened, a direct message will be started with you."
"direct_link": "My Direct Link",
"direct_link_desc": "It's ready to share! A new direct room will open each time someone opens the link."
},
"room_info_sheet": {
"this_room": "This room",
@ -353,7 +376,13 @@
"export_filename": "Exported chat {date}"
},
"notification": {
"title": "New message received"
"title": "New message received",
"dialog" : {
"title": "Stay Connected with Chat Notifications!",
"body": "Never miss a message or important conversation again! Be notified whenever someone sends you a message or replies to your chat.",
"enable": "Enable"
},
"blocked_message": "Notifications blocked. Please reset the permissions"
},
"emoji": {
"search": "Search...",
@ -378,7 +407,8 @@
"sending": "Sending",
"files_sent":"1 file sent! | {count} files sent!",
"files_sent_with_note":"1 file sent with a note! | {count} files sent with a note!",
"return_to_home": "Return to home",
"send_more_files": "Send more files",
"close": "Close",
"files": "Files"
}
}

View file

@ -4,14 +4,10 @@
"name": "Convene",
"tag_line": "Simplemente conectar"
},
"global": {
},
"room_info": {
"identity": "Has iniciado sesión como {displayName}.",
"my_profile": "Mi perfil",
"show_all": "Mostrar todos>",
"hide_all": "Ocultar",
"user_you": "{user} (you)",
"user_you": "{user} ()",
"user": "{user}",
"members": "Miembros",
"purge": "Borrar la sala",
@ -22,12 +18,27 @@
"created_by": "Creado por {user}",
"title": "Detalles de la sala",
"version_info": "Creado por Guardian Project. Version: {version}",
"leave_room_info": "Nota: este paso no se puede deshacer. Asegúrate de que deseas cerrar la sesión y eliminar el chat para siempre.",
"leave_room": "Salir",
"view_profile": "Vista",
"identity_temporary": "Tu identidad {displayName} es temporal. Puedes cambiar tu nombre o establecer una contraseña para conservarla.",
"copy_invite_link": "Copiar el enlace de invitación",
"scan_code": "Escanear para unirse a la sala"
"scan_code": "Escanear para unirse a la sala",
"user_admin": "Administrador/a",
"user_moderator": "Moderador/a",
"room_type_default": "Por Defecto",
"copy_link": "Copiar enlace",
"direct_link": "Mi enlace directo",
"read_only_room": "Sala de sólo lectura",
"voice_mode": "Por voz",
"make_public_warning": "Advertencia: El historial completo de mensajes será visible para los nuevos participantes",
"room_type": "Genero de la sala",
"experimental_features": "Funciones experimentales",
"file_mode_info": "Cambia la interfaz del chat al modo \"soltar archivos\"",
"read_only_room_info": "Sólo los administradores y moderadores pueden escribir en la sala",
"file_mode": "Modo del archivo",
"direct_link_desc": "¡Ya está listo para compartir! Se abrirá una nueva sala privada cada vez que alguien abra el enlace.",
"download_chat": "Descargar el chat",
"make_public": "Hacer público",
"voice_mode_info": "Cambia la interfaz del chat al modo \"escuchar y grabar\"",
"export_room": "Exportar el chat"
},
"purge_room": {
"button": "Borrar",
@ -47,9 +58,7 @@
"title_invite": "Estas seguro que deseeas salir?",
"text_public_lastroom": "Si deseas unirte a esta sala de nuevo, puedes hacerlo con una nueva identidad. Para mantener {user}, {action}.",
"text_public": "Siempre puedes volver a unirte a esta sala si conoces el enlace.",
"title_public": "Adios, [user}"
},
"logout": {
"title_public": "Adiós, {user}"
},
"invite": {
"status_error": "No se pudo invitar a uno o más amigos!",
@ -65,9 +74,14 @@
"joining_as": "Te estas uniendo como:",
"join": "Unirse a la sala",
"enter_room": "Entrar habitacion",
"status_logging_in": "Iniciando sesión...",
"status_joining": "Uniendose a la sala...",
"join_failed": "No se pudo unir a la sala."
"status_logging_in": "Iniciando la sesión...",
"status_joining": "Uniendose a la sala de chat...",
"join_failed": "No se pudo unir a la sala.",
"choose_name": "Elige un nombre",
"title_user": "Bienvenido, has sido invitado a chatear con",
"enter_room_user": "Empezar un chat",
"you_have_been_banned": "Has sido expulsado de esta sala.",
"join_user": "Empezar un chat"
},
"profile": {
"display_name": "Nombre para mostrar",
@ -83,7 +97,8 @@
"set_language": "Establece tu Idioma",
"language_description": "Convine esta disponible en varios Idiomas.",
"dont_see_yours": "¿No ves el tuyo?",
"tell_us": "Dinos."
"tell_us": "Dinos.",
"display_name_required": "El nombre para mostrar es obligatorio"
},
"login": {
"login": "Iniciar sesión",
@ -94,7 +109,18 @@
"title": "Iniciar sesión",
"create_room": "Registrarse y crear una sala",
"or": "O",
"invalid_message": "Nombre de usuario o contraseña incorrecto"
"invalid_message": "Nombre de usuario o contraseña incorrecto",
"accept_terms": "Aceptar",
"send_verification": "Enviar un correo electrónico de verificación",
"resend_verification": "Reenviar mensaje de verificación",
"token_not_valid": "Token inválido",
"email": "Debe verificar su dirección de correo electrónico",
"sent_verification": "Se ha enviado un correo electrónico a {email}. Utilice su cliente de correo electrónico habitual para verificar la dirección.",
"no_supported_flow": "La aplicación no puede iniciar sesión en el servidor",
"email_not_valid": "Correo electrónico no válido",
"registration_token": "Por favor, introduzca la clave de registro",
"send_token": "Enviar un token",
"terms": "El servidor requiere que revises y aceptes las siguientes políticas:"
},
"device_list": {
"not_verified": "No ha sido Verificado",
@ -116,33 +142,41 @@
"join_permissions_info": "Estos permisos determinan cómo las personas pueden unirse a la sala y con qué facilidad se puede invitar a otras personas. Pueden cambiarse en cualquier momento.",
"set_join_permissions": "Establecer permisos para unirse",
"join_permissions": "Permisos para unirse",
"name_room": "Nombra la sala",
"name_room": "Nombre de la sala de chat",
"next": "Siguiente",
"done": "Listo",
"new_room": "Nueva Sala",
"create": "Crear",
"room_topic": "Añade una descripción si quieres"
"room_topic": "Añade una descripción si quieres",
"options": "Opciones",
"room_name_limit_error_msg": "50 caracteres como máximo"
},
"room_welcome": {
"join_public": "Cualquiera puede unirse abriendo este vínculo: {link}",
"join_public": "Cualquiera puede unirse abriendo este vínculo: {link}.",
"got_it": "Entiendo",
"info_permissions": "Puedes cambiar los 'permisos de participación' en cualquier momento en la configuración de la sala.",
"join_invite": "Solo personas que invitas se pueden unir.",
"info": "Bienvenido. He aquí algunas cosas que debes saber sobre tu sala:",
"welcome": "Bienvenido!",
"room_history_is": "El historial de la sala es {type}.",
"encrypted": "Los mensajes están encriptados de extremo a extremo.",
"room_history_joined": "La gente sólo puede ver los mensajes enviados después de unirse."
"room_history_joined": "La gente sólo puede ver los mensajes enviados después de unirse.",
"no_past_messages": "¡Bienvenido! Por su seguridad, los mensajes anteriores no están disponibles.",
"direct_info": "Hola, {you}. Estás en un chat privado con {user}.",
"direct_private_chat": "Mensaje directo"
},
"room": {
"leave": "Salir",
"members": "no miembros | 1 miembro| {count} miembros",
"purge_redacting_events": "Redactar eventos",
"purge_redacting_events": "Redactar eventos ({count} de {total})",
"room_list_invites": "Invita",
"purge_set_room_state": "Estado de la sala",
"purge_removing_members": "Eliminar miembros",
"purge_removing_members": "Eliminar miembros ({count} de {total})",
"purge_failed": "¡Fallo en la purga de la sala!",
"room_list_rooms": "Salas"
"room_list_rooms": "Salas",
"room_name_required": "Nombre de la sala obligatorio",
"room_list_new_messages": "{count} nuevos mensajes",
"invitations": "No tiene invitaciones | Tiene 1 invitación | Tiene {count} invitaciones",
"unseen_messages": "No tiene mensajes sin ver | Tiene 1 mensaje sin ver | Tiene {count} mensajes sin ver",
"room_topic_required": "Se requiere la descripción de la sala"
},
"message": {
"user_powerlevel_change_from_to": "{user} de {powerOld} a {powerNew}",
@ -169,21 +203,43 @@
"edited": "(editado)",
"file_prefix": "Archivo: ",
"user_said": "{user} dijo:",
"user_left": "{user} abandono el chat",
"user_joined": "{user} se unio al chat",
"user_left": "{user} abandonó el chat",
"user_joined": "{user} se unió al chat",
"user_was_invited": "{user} ha sido invitado al chat...",
"user_encrypted_room": "{user} hizo la habitación encriptada",
"user_changed_room_avatar": "{user} cambio el avatar de la sala",
"user_changed_avatar": "{user} cambio su avatar",
"user_created_room": "{user} creo la sala",
"user_aliased_room": "{user} hizo el alias de la sala {alias}",
"user_changed_display_name": "{user}cambio su nombre para mostrar a {displayName}",
"user_encrypted_room": "{user} encriptó la habitación",
"user_changed_room_avatar": "{user} cambió el avatar de la sala",
"user_changed_avatar": "{user} cambió su avatar",
"user_created_room": "{user} creó la sala",
"user_aliased_room": "{user} creó el alias de sala {alias}",
"user_changed_display_name": "{user} cambió su nombre a {displayName}",
"you": "Tú",
"reply_image": "Imagen",
"reply_audio_message": "Mensaje de audio",
"reply_video": "Vídeo",
"user_changed_guest_access_closed": "{user} no has permitido que los invitados se unan a la sala",
"user_changed_guest_access_open": "{user} has permitido que los invitados se unieran a la sala"
"user_changed_guest_access_open": "{user} ha permitido que los invitados se unieran a la sala",
"seen_by": "Visto por",
"reply_poll": "Encuesta",
"file": "Archivo",
"files": "Archivos",
"images": "Imágenes",
"incoming_message_deleted_text": "Este mensaje ha sido borrado.",
"user_was_kicked": "{user} ha sido expulsado del chat.",
"send_attachements_dialog_title": "¿Desea enviar los siguientes archivos adjuntos?",
"preparing_to_upload": "Preparando para subir...",
"not_allowed_to_send": "Sólo los administradores y moderadores pueden enviar mensajes a la sala",
"reaction_count_more": "{reactionCount} más",
"user_was_kicked_you": "Has sido expulsado del chat.",
"outgoing_message_deleted_text": "Borraste este mensaje.",
"user_was_banned_you": "Fuiste expulsado y baneado del chat.",
"user_was_banned_by_you": "Has expulsado y baneado a {user} del chat.",
"seen_by_count": "No lo vió ningún miembro | Visto por 1 miembro | Visto por {count} miembros",
"user_was_banned": "{user} fue expulsado y baneado del chat.",
"download_all": "Descargar todo",
"user_was_kicked_by_you": "Has expulsado a {user} del chat.",
"upload_file_too_large": "¡El archivo es demasiado grande para subirlo!",
"time_ago": "Hoy | Ayer | Hace {count} días",
"upload_exceeded_file_limit": "Se ha superado el tamaño máximo para el archivo ({configFormattedUploadSize}). "
},
"menu": {
"login": "Iniciar sesión",
@ -202,7 +258,12 @@
"new_room": "Nueva Sala",
"loading": "Cargando {appName}",
"undo": "Deshacer",
"join": "Unirse"
"join": "Unirse",
"user_kick": "Expulsa a este usuario",
"user_kick_and_ban": "Expulsar y banear a este usuario",
"user_make_moderator": "Hacer moderador",
"user_make_admin": "Hacer administrador",
"user_revoke_moderator": "Revocar al moderador"
},
"fallbacks": {
"download_name": "Descargar",
@ -225,7 +286,6 @@
"swipe_to_cancel": "Desliza para cancelar"
},
"room_info_sheet": {
"create_room": "Crear Grupo",
"view_details": "Ver detalles",
"this_room": "Esta sala"
},
@ -241,7 +301,110 @@
"you_are": "Tu eres",
"logout": "Cerrar sesión",
"want_more": "¿Quieren más?",
"powered_by": "Esta sala funciona con {producto}. ¡Obtenga más información en {productLink} o continúe y cree otra sala!",
"new_room": "+ Nueva habitación"
"powered_by": "Esta sala funciona con {product}. ¡Obtenga más información en {productLink} o continúe y cree otra sala!",
"new_room": "Nueva sala de chat"
},
"global": {
"save": "Guardar",
"password_didnot_match": "La contraseña no coincide",
"time": {
"recently": "ahora mismo",
"minutes": "hace 1 minuto | hace {n} minutos",
"hours": "hace 1 hora | hace {n} horas",
"days": "hace 1 día | hace {n} días"
},
"show_less": "Mostrar menos",
"show_more": "Mostrar más",
"password_hint": "Mínimo de 12 caracteres que contengan al menos un número, una mayúscula y una minúscula",
"close": "cerrar",
"notify": "Notificar",
"add_reaction": "Añadir una reacción",
"click_to_remove": "Haz clic para eliminar"
},
"poll_create": {
"create": "Publicar",
"poll_submit": "Enviar",
"answer_label_n": "Respuesta",
"poll_status_open": "Encuesta abierta",
"view_results": "Ver los resultados",
"answer_label_1": "Respuesta*",
"please_complete": "Por favor, rellene",
"creating": "Creando la encuesta",
"answer_required": "La respuesta no puede estar vacía. Por favor, introduzca algún texto o elimine esta opción.",
"poll_undisclosed": "Cerrada: los usuarios verán los resultados cuando se cierre la encuesta.",
"question_label": "Formule su pregunta*",
"add_answer": "Añadir una respuesta",
"results_shared": "Resultados compartidos con la sala.",
"poll_status_open_not_voted": "La encuesta está abierta - vote para ver los resultados",
"close_poll": "Cerrar la encuesta",
"title": "Crear una nueva encuesta",
"failed": "No se ha podido crear la encuesta, inténtelo más tarde.",
"num_answered": "{count} respuestas",
"create_poll_menu_option": "Crear una encuesta",
"question_required": "¡Tienes que introducir una pregunta!",
"poll_disclosed": "Abierta: los resultados actuales se muestran en todo momento.",
"tip_title": "CONSEJO PROFESIONAL",
"tip_text": "Los miembros verán los resultados de la encuesta después de responder. Cierra la encuesta cuando hayas terminado para mostrar los resultados a todos los presentes.",
"poll_status_disclosed": "Los resultados se mostrarán cuando se cierre la encuesta.",
"poll_status_closed": "Encuesta cerrada"
},
"emoji": {
"categories": {
"objects": "Objetos",
"nature": "Naturaleza",
"symbols": "Símbolos",
"places": "Lugares",
"activity": "Actividad",
"flags": "Indicadores",
"foods": "Alimentos",
"peoples": "Personas",
"frequently": "Utilizado con frecuencia"
},
"search": "Buscar..."
},
"file_mode": {
"sending_progress": "Enviando…",
"sending": "Enviando",
"files": "Archivos",
"close": "Cerrar",
"add_a_message": "Añadir un mensaje",
"send_more_files": "Enviar más archivos",
"choose_files": "Seleccione los archivos",
"secure_file_send": "envío seguro de los archivos",
"any_file_format_accepted": "Se admite cualquier formato para un archivo",
"files_sent_with_note": "¡1 archivo enviado con una nota! | ¡ {count} archivos enviados con una nota!",
"files_sent": "¡1 archivo enviado! | ¡ {count} archivos enviados!"
},
"getlink": {
"continue": "Continuar",
"title": "Conseguir un enlace directo",
"info": "Los enlaces directos ofrecen a la gente una línea segura de comunicación contigo. Para empezar, elige un nombre para mostrar cuando la gente entre en un chat contigo.",
"share_qr": "Compartir el QR",
"next": "Siguiente",
"scan_title": "Escanee este código para iniciar un chat privado",
"ready_to_share": "¡Ya está listo para compartir! Se abrirá una nueva sala privada cada vez que alguien abra el enlace.",
"qr_image_copied": "Imagen copiada en el portapapeles",
"hello": "Hola {user},\nAquí está su enlace directo",
"username": "Introduce un nombre de usuario (ej: waku)",
"different_link": "Obtener un enlace distinto"
},
"notification": {
"dialog": {
"enable": "Activar",
"body": "¡No vuelvas a perderte un mensaje o una conversación importante! Recibe una notificación cada vez que alguien te envíe un mensaje o responda a tu chat.",
"title": "¡Manténgase conectado con las notificaciones para el chat!"
},
"title": "Nuevo mensaje recibido",
"blocked_message": "Notificaciones bloqueadas. Por favor, restablezca los permisos"
},
"export": {
"fetched_n_of_total_events": "{count} de {total} eventos recuperados",
"export_filename": "Chat exportado el {date}",
"exported_date": "Exportado el {date}",
"fetched_n_events": "{count} evento(s) recuperado(s)",
"processed_n_of_total_events": "Medios procesados para {count} de {total} eventos"
},
"logout": {
"confirm_text": "¿Seguro que quieres cerrar la sesión?"
}
}

View file

@ -1,6 +1,4 @@
{
"global": {
},
"menu": {
"back": "TAKAISIN",
"start_private_chat": "Yksityinen keskustelu tämän käyttäjän kanssa",
@ -81,12 +79,10 @@
"leave": "Poistu",
"text_invite": "Tämä huone on lukittu. Et pääse takaisin ilman erillistä lupaa."
},
"logout": {
},
"message": {
"you": "Sinä",
"user_created_room": "{user} loi huoneen",
"user_left": "{käyttäjä} poistui keskustelusta",
"user_left": "{user} poistui keskustelusta",
"user_said": "{user} sanoi:",
"download_progress": "{percentage} % ladattu",
"unread_messages": "Lukemattomat viestit",
@ -158,6 +154,7 @@
"view_details": "Näytä tiedot"
},
"voice_recorder": {
"swipe_to_cancel": "Peruuta pyyhkäisemällä"
"swipe_to_cancel": "Peruuta pyyhkäisemällä",
"not_supported_title": "Ei tuettu"
}
}

View file

@ -1,6 +1,4 @@
{
"global": {
},
"menu": {
"edit": "Modifier",
"start_private_chat": "Discussion privée avec cet utilisateur",
@ -42,7 +40,6 @@
"user_changed_join_rules": "{user} a rendu le salon {type}",
"user_changed_room_name": "{user} a changé le nom du salon en {name}",
"user_changed_room_topic": "{user} a changé le thème du salon en {topic}",
"replying_to_event": "RÉPONSE À LÉVÈNEMENT : {message}",
"your_message": "Votre message…",
"scale_image": "Image à léchelle",
"user_is_typing": "{user} écrit",
@ -162,8 +159,6 @@
"text_invite": "Ce salon est verrouillé. Vous ne pouvez pas le rejoindre sans une autorisation spéciale.",
"go_back": "Retour"
},
"logout": {
},
"purge_room": {
"title": "Supprimer le salon ?",
"info": "Tous les membres et les messages seront supprimés. Cette action ne peut être annulée.",

View file

@ -1,6 +1,4 @@
{
"global": {
},
"message": {
"file_prefix": "File: ",
"unread_messages": "Messaggi non letti",
@ -20,7 +18,6 @@
"user_changed_join_rules": "{user} ha reso la stanza {type}",
"room_joinrule_invite": "solo su invito",
"user_changed_room_name": "{user} ha cambiato il nome della stanza in {name}",
"replying_to_event": "RISPOSTA ALLEVENTO: {message}",
"your_message": "Il tuo messaggio…",
"scale_image": "Ridimensiona limmagine",
"room_powerlevel_change": "{user} ha cambiato lo statuto di {changes}",
@ -88,7 +85,7 @@
"link_copied": "Collegamento copiato!",
"invite_description": "Scegli da un elenco o cerca per identificativo di account",
"status_creating": "Creazione della stanza",
"status_avatar_total": "Caricamento dellavatar: {conteggio} di {totale}",
"status_avatar_total": "Caricamento dellavatar: {count} di {total}",
"status_avatar": "Caricamento dellavatar: {count}",
"create": "Crea",
"public_description": "Ottieni un collegamento da condividere",
@ -161,8 +158,6 @@
"leave": "Lascia",
"text_public_lastroom": "Se vuoi unirti di nuovo a questa stanza, puoi farlo con una nuova identità. Per mantenere {user}, {action}."
},
"logout": {
},
"purge_room": {
"info": "Tutti i membri e i messaggi saranno rimossi. Questa azione non può essere annullata.",
"button": "Elimina",
@ -220,5 +215,8 @@
"room_info_sheet": {
"this_room": "Questa stanza",
"view_details": "Visualizza i dettagli"
},
"project": {
"name": "Convene"
}
}

View file

@ -2,8 +2,6 @@
"project": {
"name": "Convene"
},
"global": {
},
"message": {
"user_changed_guest_access_open": "{user} tillot gjester å ta del i rommet",
"user_powerlevel_change_from_to": "{user} fra {powerOld} til {powerNew}",
@ -62,7 +60,8 @@
"link_copied": "Lenke kopiert.",
"copy_invite_link": "Kopier invitasjonslenke",
"created_by": "Opprettet av {user}",
"title": "Romdetaljer"
"title": "Romdetaljer",
"user": "{user}"
},
"goodbye": {
"view_other_rooms": "Vis andre rom",
@ -127,8 +126,6 @@
"create_account": "opprett en konto",
"title_public": "Adjø, {user}"
},
"logout": {
},
"invite": {
"status_inviting": "Inviterer venn {index} av {count}",
"send_invites_to": "Send invitasjoner til",

View file

@ -10,7 +10,15 @@
"show_less": "Mostrar menos",
"show_more": "Mostrar mais",
"add_reaction": "Adicionar reação",
"click_to_remove": "Clique para remover"
"click_to_remove": "Clique para remover",
"time": {
"hours": "1 hora atrás | {n} horas atrás",
"days": "1 dia atrás | {n} dias atrás",
"recently": "agora mesmo",
"minutes": "1 minuto atrás | {n} minutos atrás"
},
"close": "fechar",
"notify": "Notificação"
},
"invite": {
"title": "Adiciona amigos",
@ -101,7 +109,17 @@
"user_was_banned": "{user} foi expulso e banido do chat.",
"user_was_kicked": "{user} foi expulso do chat.",
"user_was_banned_you": "Você foi expulso e banido do chat.",
"reaction_count_more": "{reactionCount} mais"
"reaction_count_more": "{reactionCount} mais",
"seen_by": "Visto por",
"file": "Arquivo",
"send_attachements_dialog_title": "Deseja enviar os seguintes anexos?",
"upload_file_too_large": "O arquivo é grande demais para ser enviado!",
"images": "Imagens",
"seen_by_count": "Visto por nenhum membro | Visto por 1 membro | Visto por {count} membros",
"files": "Arquivos",
"download_all": "Baixar tudo",
"upload_exceeded_file_limit": "O tamanho máximo do arquivo ({configFormattedUploadSize}) foi excedido. ",
"preparing_to_upload": "Preparando para enviar..."
},
"room": {
"members": "sem membros | 1 membro | {count} membros",
@ -126,7 +144,10 @@
"join_public": "Qualquer pessoa pode participar abrindo este link: {link}.",
"join_invite": "Apenas as pessoas que você convidar podem participar.",
"info_permissions": "Você pode alterar as \"permissões de participação\" a qualquer momento nas configurações da sala.",
"got_it": "Entendi"
"got_it": "Entendi",
"no_past_messages": "Bem-vindo! Para a sua segurança, as mensagens anteriores não estão disponíveis.",
"direct_info": "Olá {you}. Você está num chat privado com {user}.",
"direct_private_chat": "Mensagem direta"
},
"new_room": {
"new_room": "Nova sala",
@ -174,7 +195,10 @@
"resend_verification": "Reenviar o e-mail de verificação",
"email_not_valid": "O endereço de e-mail não é válido",
"sent_verification": "Um e-mail foi enviado para {email}. Use o seu cliente de e-mail para verificar o endereço.",
"no_supported_flow": "O aplicativo não pode fazer login no servidor informado"
"no_supported_flow": "O aplicativo não pode fazer login no servidor informado",
"registration_token": "Insira o token de registro",
"send_token": "Enviar o token",
"token_not_valid": "Token inválido"
},
"profile": {
"title": "Meu perfil",
@ -216,7 +240,8 @@
"status_logging_in": "Fazendo login...",
"status_joining": "Entrando na sala...",
"join_failed": "Houve uma falha ao entrar na sala.",
"choose_name": "Escolha um nome para usar"
"choose_name": "Escolha um nome para usar",
"you_have_been_banned": "Você foi banido desta sala."
},
"leave": {
"title_public": "Adeus, {user}",
@ -271,7 +296,16 @@
"user_moderator": "Moderador",
"experimental_features": "Recursos experimentais",
"download_chat": "Baixar o chat",
"read_only_room_info": "Apenas administradores e moderadores podem postar na sala"
"read_only_room_info": "Apenas administradores e moderadores podem postar na sala",
"copy_link": "Copiar o link",
"make_public": "Tornar público",
"room_type": "Tipo de sala",
"file_mode_info": "Alterna a interface de bate-papo para um modo de 'baixa de arquivo'",
"make_public_warning": "aviso: o histórico completo das mensagens ficará visível para os novos participantes",
"room_type_default": "Padrão",
"file_mode": "Modo de arquivo",
"direct_link": "Meu link direto",
"direct_link_desc": "Ele está pronto para ser compartilhado! Uma nova sala com contato direto com você será aberta sempre que alguém abrir o link."
},
"room_info_sheet": {
"this_room": "Esta sala",
@ -293,7 +327,6 @@
},
"poll_create": {
"title": "Criar uma nova enquete",
"intro": "Preencha os detalhes abaixo.",
"create": "Publicar",
"creating": "Criando a enquete",
"poll_disclosed": "Aberto - os resultados atuais são mostrados em todos os momentos.",
@ -301,7 +334,6 @@
"failed": "Houve uma falha ao criar a enquete. Tente novamente mais tarde.",
"question_label": "Faça a sua pergunta*",
"question_required": "Você precisa inserir uma pergunta!",
"answer_label": "Responda sem {index}",
"answer_required": "A resposta não pode estar vazia. Insira algum texto ou remova esta opção.",
"create_poll_menu_option": "Criar uma enquete",
"poll_status_closed": "A enquete foi encerrada",
@ -326,5 +358,54 @@
"fetched_n_of_total_events": "Obteve {count} de {total} eventos",
"processed_n_of_total_events": "Mídia processada para {count} de {total} eventos",
"export_filename": "Bate-papo exportado {date}"
},
"emoji": {
"search": "Buscar...",
"categories": {
"foods": "Alimentos",
"objects": "Objetos",
"symbols": "Símbolos",
"places": "Locais",
"peoples": "Pessoas",
"activity": "Atividade",
"flags": "Marcadores",
"frequently": "Usado com frequência",
"nature": "Natural"
}
},
"file_mode": {
"choose_files": "Escolha os arquivos",
"secure_file_send": "Envio seguro de arquivos",
"sending_progress": "Enviando...",
"files_sent": "1 arquivo enviado! | {count} arquivos enviados!",
"files_sent_with_note": "1 arquivo enviado com uma nota! | {count} arquivos enviados com uma nota!",
"add_a_message": "Adicionar uma mensagem",
"sending": "Enviando",
"any_file_format_accepted": "Qualquer formato de arquivo é aceito",
"files": "Arquivos",
"close": "Fechar",
"send_more_files": "Enviar mais arquivos"
},
"notification": {
"title": "Uma nova mensagem foi recebida",
"dialog": {
"body": "Nunca mais perca uma mensagem ou uma conversa importante! Seja notificado sempre que alguém lhe enviar uma mensagem ou responder ao seu bate-papo.",
"title": "Manter-se conectado com as notificações do chat!",
"enable": "Ativar"
},
"blocked_message": "Notificações bloqueadas. Redefina as permissões"
},
"getlink": {
"title": "Obter um link direto",
"scan_title": "Leia este código para iniciar um bate-papo direto",
"ready_to_share": "Ele está pronto para ser compartilhado! Uma nova sala será aberta sempre que alguém abrir o link.",
"qr_image_copied": "A imagem foi copiada para a área de transferência",
"hello": "Olá {user},\nAqui está o seu link direto",
"continue": "Continuar",
"info": "Os links diretos oferecem às pessoas uma linha segura de comunicação com você. Para começar, escolha um nome que será exibido quando as pessoas entrarem num chat com você.",
"share_qr": "Compartilhar um código QR",
"username": "Insira um nome (ex: waku)",
"different_link": "Obter um link diferente",
"next": "Próximo"
}
}

View file

@ -3,8 +3,6 @@
"name": "Convene",
"tag_line": "Conectați pur și simplu"
},
"global": {
},
"menu": {
"ok": "OK",
"done": "Realizat",
@ -26,7 +24,7 @@
},
"power_level": {
"restricted": "restricționat",
"custom": "personalizat ({nivel})",
"custom": "personalizat ({level})",
"default": "implicit",
"moderator": "coordonator",
"admin": "administrator"
@ -85,8 +83,6 @@
"text_public": "Puteți oricând să vă alăturați din nou acestei camere dacă știți link-ul.",
"title_public": "La revedere, {user}"
},
"logout": {
},
"invite": {
"status_error": "Nu ați reușit să invitați unul sau mai mulți prieteni!",
"status_inviting": "Invitați prietenul {index} din {count}",

View file

@ -22,10 +22,7 @@
"file_prefix": "ගොනුව: ",
"you": "ඔබ"
},
"login": {
},
"join": {
},
"logout": {
"room_info": {
"user": "{user}"
}
}

View file

@ -2,9 +2,6 @@
"project": {
"name": "Convene"
},
"global": {
},
"language_is_rtl": true,
"menu": {
"ok": "تامام",
"download": "چۈشۈرۈش",
@ -24,7 +21,7 @@
"start_private_chat": "قوللانغۇچى بىلەن شەخسى ئۇچۇرلاشماق"
},
"message": {
"upload_progress_with_total": "{number} نىڭ {total} يۈكلەندى",
"upload_progress_with_total": "{count} نىڭ {total} يۈكلەندى",
"upload_progress": "يۈكلەندى {count}",
"download_progress": "{percentage}% چۈشۈرۈلدى",
"edited": "تەھرىرلەندى",
@ -74,7 +71,7 @@
"purge_redacting_events": "پائالىيەتلەرنى تەھرىرلەش",
"purge_set_room_state": "مۇنازىرەخانىنىڭ شەرتىنى قۇرۇش",
"leave": "كېتىش",
"members": "ئەزالار يوق | بىر ئەزا | [نەپەر] ئەزا"
"members": "ئەزالار يوق | بىر ئەزا | {count} ئەزا"
},
"leave": {
"text_public_lastroom": "ئەگەر بۇ ئۆيگە يەنە قوشۇلماقچى بولسىڭىز ، يېڭى سالاھىيەت ئاستىدا قاتناشسىڭىز بولىدۇ. {user} ، {action} نى ساقلاش.",
@ -86,8 +83,6 @@
"text_public": "ئۇلىنىشنى بىلسىڭىز ھەمىشە بۇ ئۆيگە قايتا كىرەلەيسىز.",
"title_public": "خەير خوش ، {user}"
},
"logout": {
},
"join": {
"join_failed": "مۇنازىرە ئۆيىگە قوشۇلۇش مەغلۇب بولدى.",
"status_joining": "مۇنازىرىگە كىرىش...",
@ -96,14 +91,13 @@
"join": "مۇنازىرىگە قوشۇلۇڭ",
"joining_as": "سىز تۆۋەندىكىدەك قاتنىشىۋاتىسىز:",
"user_name_label": "قوللانغۇچى ئىسمى",
"title": "{ياتاق ئىسمى} غا خۇش كەپسىز",
"user_name_label": "قوللانغۇچى ئىسمى"
"title": "{ياتاق ئىسمى} غا خۇش كەپسىز"
},
"room_welcome": {
"info_permissions": "ياتاق تەڭشىكىدە خالىغان ۋاقىتتا «قوشۇلۇش ئىجازەتنامىسى» نى ئۆزگەرتەلەيسىز.",
"got_it": "چۈشەندىم",
"join_invite": "سىز تەكلىپ قىلغان كىشىلەرلا قاتناشسا بولىدۇ.",
"join_public": "ھەركىم بۇ ئۇلىنىشنى ئېچىش ئارقىلىق قوشۇلالايدۇ: {ئۇلىنىش}.",
"join_public": "ھەركىم بۇ ئۇلىنىشنى ئېچىش ئارقىلىق قوشۇلالايدۇ: {link}.",
"room_history_joined": "ئەزالارقوشۇلغاندىن كېيىنلا ئەۋەتىلگەن ئۇچۇرلارنى كۆرەلەيدۇ.",
"room_history_is": "مۇنازىرەخانا تارىخى {type}.",
"encrypted": "ئۇچۇرلار ئاخىرىغىچە مەخپىيلەشتۈرۈلگەن.",
@ -139,8 +133,8 @@
"leave_room": "ئايرىلماق",
"show_all": "<ھەممىنى كۆرسەتمەك",
"hide_all": "يوشۇرۇن",
"user_you": "قوللانغۇچى ( سىز)",
"user": "قوللانغۇچى",
"user_you": "{user} ( سىز)",
"user": "{user}",
"members": "ئەزالار",
"purge": "ئۆينى ئۆچۈرۈڭ",
"link_copied": "ئۇلىنىش كۆچۈرۈلدى!",
@ -174,13 +168,13 @@
"title": "دوست قوشۇڭ"
},
"profile_info_popup": {
"new_room": "+ يېڭى ئۆي",
"new_room": "يېڭى ئۆي",
"powered_by": "بۇ مۇنازىرە ئۆيى {product} ئىشلىتىلگەن.xx دىن تېخىمۇ كۆپ بىلىمگە ئېرىشىڭ ياكى ئىلگىرىلەپ باشقا ئۆي قۇرۇڭ!",
"want_more": "تېخىمۇ كۆپ خالامسىز؟",
"logout": "چېكىنىش",
"edit_profile": "ئارخىپنى تەھرىرلەش",
"identity_temporary": "{displayName}",
"identity": "{displayName}",
"identity_temporary": "{displayName}",
"identity": "{displayName}",
"you_are": "سىز"
},
"profile": {

View file

@ -13,12 +13,7 @@
"hide_all": "隐藏",
"title": "聊天室详情",
"version_info": "由守护者计划提供支持.版本:{version}",
"leave_room_info": "注意:此步骤无法撤消。 确保您要注销并永久删除聊天记录。",
"leave_room": "离开",
"view_profile": "查看",
"identity_temporary": "您的身份 {displayName} 是临时的。 您可以更改您的姓名或设置密码来保留它。",
"identity": "您以 {displayName} 的身份登录。",
"my_profile": "我的简历",
"show_all": "显示所有 >",
"user_you": "{user} (你)",
"user": "{user}",
@ -183,7 +178,6 @@
"swipe_to_cancel": "滑动取消"
},
"room_info_sheet": {
"create_room": "创建群组",
"view_details": "查看详情",
"this_room": "这个聊天室"
},
@ -252,7 +246,6 @@
"join_permissions": "加入权限",
"name_room": "聊天室名称",
"next": "下一步",
"done": "完毕",
"new_room": "新的聊天室",
"room_topic": "如果您愿意,请添加说明",
"create": "创建",
@ -266,7 +259,6 @@
"info": "欢迎!关于您的群组,您需要了解以下几点:",
"join_invite": "只有您邀请的人可以加入。",
"join_public": "任何人都可以加入通过打开此链接: {link}。",
"welcome": "欢迎!",
"room_history_joined": "只有加入后,人们才可以看到发送的信息。",
"room_history_is": "聊天室纪录是{type}.",
"encrypted": "信息是端到端加密的。"
@ -294,7 +286,11 @@
"show_less": "显示较少",
"show_more": "展示更多",
"add_reaction": "添加反应",
"click_to_remove": "点击删除"
"click_to_remove": "点击删除",
"time": {
"recently": "刚才"
},
"close": "关闭"
},
"logout": {
"confirm_text": "您确定要注销吗?"

View file

@ -4,6 +4,7 @@
no-gutters
align-content="center"
v-on="$listeners"
v-show="icon === 'notifications_active' ? this.windowNotificationPermission() !== 'granted' : true"
>
<v-col cols="auto" class="me-2">
<v-icon :size="iconSize">{{ icon }}</v-icon>
@ -13,6 +14,8 @@
</template>
<script>
import { windowNotificationPermission } from "../plugins/notificationAndServiceWorker.js"
export default {
name: "ActionRow",
props: {
@ -35,6 +38,9 @@ export default {
},
},
},
methods: {
windowNotificationPermission
}
};
</script>

View file

@ -78,7 +78,7 @@
<v-icon class="clickable" @click="loadNext" color="white" size="28">expand_more</v-icon>
</div>
<div v-if="showReadOnlyToast" class="toast-read-only">{{ $t("message.not_allowed_to_send") }}</div>
<div v-if="showReadOnlyToast" class="toast-at-bottom visible">{{ $t("message.not_allowed_to_send") }}</div>
</div>
</template>

View file

@ -1,6 +1,15 @@
<template>
<div class="chat-root fill-height d-flex flex-column">
<ChatHeader class="chat-header flex-grow-0 flex-shrink-0" v-on:header-click="onHeaderClick" v-on:view-room-details="viewRoomDetails" v-if="!useFileModeNonAdmin" />
<div class="chat-root fill-height d-flex flex-column" :style="chatContainerStyle">
<ChatHeaderPrivate class="chat-header flex-grow-0 flex-shrink-0"
v-on:header-click="onHeaderClick"
v-on:view-room-details="viewRoomDetails"
v-on:notify="onNotificationDialog"
v-if="!useFileModeNonAdmin && $matrix.isDirectRoom(room)" />
<ChatHeader class="chat-header flex-grow-0 flex-shrink-0"
v-on:header-click="onHeaderClick"
v-on:view-room-details="viewRoomDetails"
v-on:notify="onNotificationDialog"
v-else-if="!useFileModeNonAdmin" />
<AudioLayout ref="chatContainer" class="auto-audio-player-root" v-if="useVoiceMode" :room="room"
:events="events" :autoplay="!showRecorder"
:timelineSet="timelineSet"
@ -15,15 +24,15 @@
<VoiceRecorder class="audio-layout" v-if="useVoiceMode" :micButtonRef="$refs.mic_button" :ptt="showRecorderPTT" :show="showRecorder"
v-on:close="showRecorder = false" v-on:file="onVoiceRecording" :sendTypingIndicators="useVoiceMode" />
<FileDropLayout class="file-drop-root" v-if="useFileModeNonAdmin" :room="room"
<FileDropLayout class="file-drop-root" v-if="useFileModeNonAdmin" :room="room"
v-on:pick-file="showAttachmentPicker()"
v-on:add-file="addAttachment($event)"
v-on:remove-file="currentFileInputs.splice($event, 1)"
v-on:remove-file="currentFileInputs.splice($event, 1)"
v-on:reset="resetAttachments"
:attachments="currentFileInputs"
/>
<div v-if="!useVoiceMode && !useFileModeNonAdmin" class="chat-content flex-grow-1 flex-shrink-1" ref="chatContainer"
<div v-if="!useVoiceMode && !useFileModeNonAdmin" :class="{'chat-content': true, 'flex-grow-1': true, 'flex-shrink-1': true, 'invisible': !initialLoadDone}" ref="chatContainer"
v-on:scroll="onScroll" @click="closeContextMenusIfOpen">
<div ref="messageOperationsStrut" class="message-operations-strut">
<message-operations ref="messageOperations" :style="opStyle" :emojis="recentEmojis" v-on:close="
@ -33,8 +42,8 @@
v-on:addreply="addReply(selectedEvent)" v-on:edit="edit(selectedEvent)" v-on:redact="redact(selectedEvent)"
v-on:download="download(selectedEvent)" v-on:more="
isEmojiQuickReaction= true
showMoreMessageOperations($event)
" :originalEvent="selectedEvent" />
showMoreMessageOperations({event: selectedEvent, anchor: $event.anchor})
" :originalEvent="selectedEvent" :timelineSet="timelineSet" />
</div>
<div ref="avatarOperationsStrut" class="avatar-operations-strut">
@ -49,10 +58,11 @@
<resize-observer ref="chatContainerResizer" @notify="handleChatContainerResize" />
<CreatedRoomWelcomeHeader v-if="showCreatedRoomWelcomeHeader" v-on:close="closeCreateRoomWelcomeHeader" />
<DirectChatWelcomeHeader v-if="showDirectChatWelcomeHeader" v-on:close="closeDirectChatWelcomeHeader" />
<div v-for="(event, index) in filteredEvents" :key="event.getId()" :eventId="event.getId()">
<!-- DAY Marker, shown for every new day in the timeline -->
<div v-if="showDayMarkerBeforeEvent(event) && !!componentForEvent(event, isForExport = false)" class="day-marker" :title="dayForEvent(event)" />
<div v-if="showDayMarkerBeforeEvent(event) && !!componentForEvent(event, isForExport = false)" class="day-marker"><div class="line"></div><div class="text">{{ dayForEvent(event) }}</div><div class="line"></div></div>
<div v-if="!event.isRelation() && !event.isRedaction()" :ref="event.getId()">
<div class="message-wrapper" v-on:touchstart="
@ -60,20 +70,28 @@
touchStart(e, event);
}
" v-on:touchend="touchEnd" v-on:touchcancel="touchCancel" v-on:touchmove="touchMove">
<!-- Note: For threaded media messages, IF there is only one item we show that media item as a single component.
We might therefore get calls to v-on:context-menu that has the event set to that single media item, not the top level thread event
that is really displayed in the flow. Therefore, we rewrite these events with "{event: event, anchor: $event.anchor}",
see below. Otherwise things like context menus won't work as designed.
-->
<component :is="componentForEvent(event)" :room="room" :originalEvent="event" :nextEvent="filteredEvents[index + 1]"
:timelineSet="timelineSet" v-on:send-quick-reaction.stop="sendQuickReaction"
v-on:context-menu="showContextMenuForEvent($event)" v-on:own-avatar-clicked="viewProfile"
v-on:other-avatar-clicked="showAvatarMenuForEvent($event)" v-on:download="download(event)"
:componentFn="componentForEvent"
v-on:context-menu="showContextMenuForEvent({event: event, anchor: $event.anchor})"
v-on:own-avatar-clicked="viewProfile"
v-on:other-avatar-clicked="showAvatarMenuForEvent({event: event, anchor: $event.anchor})"
v-on:download="download(event)"
v-on:poll-closed="pollWasClosed(event)"
v-on:more="
isEmojiQuickReaction = true
showMoreMessageOperations($event)
showMoreMessageOperations({event: event, anchor: $event.anchor})
"
v-on:layout-change="onLayoutChange"
/>
<!-- <div v-if="debugging" style="user-select:text">EventID: {{ event.getId() }}</div> -->
<!-- <div v-if="debugging" style="user-select:text">Event: {{ JSON.stringify(event) }}</div> -->
<div v-if="event.getId() == readMarker && index < filteredEvents.length - 1" class="read-marker"
:title="$t('message.unread_messages')" />
<div v-if="event.getId() == readMarker && index < filteredEvents.length - 1" class="read-marker"><div class="line"></div><div class="text">{{ $t('message.unread_messages') }}</div><div class="line"></div></div>
</div>
</div>
</div>
@ -97,6 +115,7 @@
<div v-if="replyToContentType === 'm.text'" class="reply-text" :title="replyToEvent.getContent().body">
{{ replyToEvent.getContent().body | latestReply }}
</div>
<div v-if="replyToContentType === 'm.thread'">{{ replyToThreadMessage }}</div>
<div v-if="replyToContentType === 'm.image'">{{ $t("message.reply_image") }}</div>
<div v-if="replyToContentType === 'm.audio'">{{ $t("message.reply_audio_message") }}</div>
<div v-if="replyToContentType === 'm.video'">{{ $t("message.reply_video") }}</div>
@ -121,7 +140,7 @@
{{ typingMembersString }}
</div>
</v-row>
<v-row class="input-area-inner align-center" v-if="!showRecorder && !$matrix.currentRoomIsReadOnlyForUser">
<v-row class="input-area-inner align-center" v-show="!showRecorder" v-if="!$matrix.currentRoomIsReadOnlyForUser">
<v-col class="flex-grow-1 flex-shrink-1 ma-0 pa-0">
<v-textarea height="undefined" ref="messageInput" full-width auto-grow rows="1" v-model="currentInput"
no-resize class="input-area-text" :placeholder="$t('message.your_message')" hide-details
@ -201,50 +220,72 @@
<div v-if="currentFileInputsDialog && !useFileModeNonAdmin">
<v-dialog v-model="currentFileInputsDialog" class="ma-0 pa-0" :width="$vuetify.breakpoint.smAndUp ? '50%' : '85%'" persistent scrollable>
<v-card class="ma-0 pa-0">
<v-card-title>{{ $t('message.send_attachements_dialog_title') }}</v-card-title>
<v-divider></v-divider>
<template v-if="imageFiles && imageFiles.length">
<v-card-title v-if="imageFiles.length > 1"> {{ $t('message.images') }} </v-card-title>
<v-card-text :class="{'ma-0 pa-2' : true, 'd-flex flex-wrap justify-center': imageFiles.length > 1}">
<div :class="{'col-4': imageFiles.length > 1}" v-for="(currentImageInput, id) in imageFiles" :key="id">
<v-img v-if="currentImageInput && currentImageInput.image" :aspect-ratio="1" :src="currentImageInput.image"
contain class="current-image-input-path" />
<div>
<span v-if="currentImageInput && currentImageInput.scaled && currentImageInput.useScaled">
{{ currentImageInput.scaledDimensions.width }} x {{ currentImageInput.scaledDimensions.height }}</span>
<span v-else-if="currentImageInput && currentImageInput.dimensions">
{{ currentImageInput.dimensions.width }} x {{ currentImageInput.dimensions.height }}</span>
<span v-if="currentImageInput && currentImageInput.scaled && currentImageInput.useScaled">
({{ formatBytes(currentImageInput.scaledSize) }})</span>
<v-switch v-if="currentImageInput && currentImageInput.scaled" :label="$t('message.scale_image')"
v-model="currentImageInput.useScaled" />
<v-card-text v-if="!currentFileInputs.length">
{{ this.$t("message.preparing_to_upload")}}
<v-progress-linear
indeterminate
class="mb-0"
></v-progress-linear>
</v-card-text>
<template v-else>
<v-card-title>
<div v-if="currentSendErrorExceededFile" class="red--text">{{ currentSendErrorExceededFile }}</div>
<span v-else> {{ $t('message.send_attachements_dialog_title') }} </span>
</v-card-title>
<v-divider></v-divider>
<template v-if="imageFiles && imageFiles.length">
<v-card-title v-if="imageFiles.length > 1"> {{ $t('message.images') }} </v-card-title>
<v-card-text :class="{'ma-0 pa-2' : true, 'd-flex flex-wrap justify-center': imageFiles.length > 1}">
<div :class="{'col-4': imageFiles.length > 1}" v-for="(currentImageInput, id) in imageFiles" :key="id">
<div style="position: relative">
<v-img v-if="currentImageInput && currentImageInput.image" :aspect-ratio="1" :src="currentImageInput.image"
contain class="current-image-input-path" />
<v-progress-linear :style="{ position: 'absolute', left: '0', right: '0', bottom: '0', opacity: currentImageInput.sendInfo ? '1' : '0' }" :value="currentImageInput.sendInfo ? currentImageInput.sendInfo.progress : 0"></v-progress-linear>
</div>
<div>
<span v-if="currentImageInput && currentImageInput.scaled && currentImageInput.useScaled">
{{ currentImageInput.scaledDimensions.width }} x {{ currentImageInput.scaledDimensions.height }}</span>
<span v-else-if="currentImageInput && currentImageInput.dimensions">
{{ currentImageInput.dimensions.width }} x {{ currentImageInput.dimensions.height }}
</span>
<span v-if="currentImageInput && currentImageInput.scaled && currentImageInput.useScaled">
({{ formatBytes(currentImageInput.scaledSize) }})
</span>
<span v-else>
({{ formatBytes(currentImageInput.actualSize) }})
</span>
<v-switch v-if="currentImageInput && currentImageInput.scaled" :label="$t('message.scale_image')"
v-model="currentImageInput.useScaled" :disabled="currentImageInput.sendInfo" />
</div>
</div>
</div>
</v-card-text>
</template>
<template v-if="Array.isArray(currentFileInputs) && currentFileInputs.length">
<v-card-title v-if="nonImageFiles.length > 1">{{ $t('message.files') }}</v-card-title>
<v-card-text>
<div v-for="(currentImageInputPath, id) in currentFileInputs" :key="id">
<div v-if="!currentImageInputPath.type.includes('image/')">
<span> {{ $t('message.file') }}: {{ currentImageInputPath.name }}</span>
<span> ({{ formatBytes(currentImageInputPath.size) }})</span>
</v-card-text>
</template>
<template v-if="Array.isArray(currentFileInputs) && currentFileInputs.length">
<v-card-title v-if="nonImageFiles.length > 1">{{ $t('message.files') }}</v-card-title>
<v-card-text>
<div v-for="(currentImageInputPath, id) in currentFileInputs" :key="id">
<div v-if="!currentImageInputPath.type.includes('image/')">
<span> {{ $t('message.file') }}: {{ currentImageInputPath.name }}</span>
<span> ({{ formatBytes(currentImageInputPath.size) }})</span>
<v-progress-linear :style="{ opacity: currentImageInputPath.sendInfo ? '1' : '0' }" :value="currentImageInputPath.sendInfo ? currentImageInputPath.sendInfo.progress : 0"></v-progress-linear>
</div>
</div>
</div>
</v-card-text>
</template>
<v-divider></v-divider>
<v-card-actions>
<v-spacer>
<div v-if="currentSendError">{{ currentSendError }}</div>
<div v-else>{{ currentSendProgress }}</div>
</v-spacer>
<v-btn color="primary" text @click="cancelSendAttachment" id="btn-attachment-cancel">
{{ $t("menu.cancel") }}
</v-btn>
<v-btn id="btn-attachment-send" color="primary" text @click="sendAttachment"
v-if="currentSendShowSendButton" :disabled="currentSendOperation != null">{{ $t("menu.send") }}</v-btn>
</v-card-actions>
</v-card-text>
</template>
<v-divider></v-divider>
<v-card-actions>
<v-spacer>
<div v-if="currentSendError">{{ currentSendError }}</div>
</v-spacer>
<v-btn color="primary" text @click="cancelSendAttachment" id="btn-attachment-cancel" :disabled="sendingStatus != sendStatuses.SENDING && sendingStatus != sendStatuses.INITIAL">
{{ $t("menu.cancel") }}
</v-btn>
<v-btn id="btn-attachment-send" color="primary" text @click="sendAttachment(undefined)"
v-if="currentSendShowSendButton" :disabled="sendingStatus != sendStatuses.INITIAL">{{ $t("menu.send") }}</v-btn>
</v-card-actions>
</template>
</v-card>
</v-dialog>
</div>
@ -282,6 +323,46 @@
</v-dialog>
<CreatePollDialog :show="showCreatePollDialog" @close="showCreatePollDialog = false" />
<!-- Dialog for request Notification and register service worker-->
<v-dialog
v-model="notificationDialog"
persistent
class="ma-0 pa-0"
:width="$vuetify.breakpoint.smAndUp ? '688px' : '95%'"
>
<div class="dialog-content text-center">
<v-icon size="30">notifications_active</v-icon>
<h2 class="dialog-title">
{{ $t("notification.dialog.title") }}
</h2>
<div class="dialog-text">{{ $t("notification.dialog.body") }}</div>
<v-container fluid>
<v-row cols="12">
<v-col cols="6">
<v-btn
depressed
text
block
class="text-button"
@click="notificationDialog = false"
>{{ $t("global.close") }}</v-btn
>
</v-col>
<v-col cols="6" align="center">
<v-btn
color="primary"
depressed
block
class="filled-button"
@click.stop="onNotifyRequest"
>{{ $t("notification.dialog.enable") }}</v-btn
>
</v-col>
</v-row>
</v-container>
</div>
</v-dialog>
</div>
</template>
@ -292,9 +373,11 @@ import util, { ROOM_TYPE_VOICE_MODE, ROOM_TYPE_FILE_MODE } from "../plugins/util
import MessageOperations from "./messages/MessageOperations.vue";
import AvatarOperations from "./messages/AvatarOperations.vue";
import ChatHeader from "./ChatHeader";
import ChatHeaderPrivate from "./ChatHeaderPrivate.vue";
import VoiceRecorder from "./VoiceRecorder";
import RoomInfoBottomSheet from "./RoomInfoBottomSheet";
import CreatedRoomWelcomeHeader from "./CreatedRoomWelcomeHeader";
import DirectChatWelcomeHeader from "./DirectChatWelcomeHeader";
import NoHistoryRoomWelcomeHeader from "./NoHistoryRoomWelcomeHeader.vue";
import MessageOperationsBottomSheet from "./MessageOperationsBottomSheet";
import StickerPickerBottomSheet from "./StickerPickerBottomSheet";
@ -302,8 +385,12 @@ import BottomSheet from "./BottomSheet.vue";
import ImageResize from "image-resize";
import CreatePollDialog from "./CreatePollDialog.vue";
import chatMixin from "./chatMixin";
import sendAttachmentsMixin from "./sendAttachmentsMixin";
import AudioLayout from "./AudioLayout.vue";
import FileDropLayout from "./file_mode/FileDropLayout";
import { requestNotificationPermission, windowNotificationPermission } from "../plugins/notificationAndServiceWorker.js"
import roomTypeMixin from "./roomTypeMixin";
import roomMembersMixin from "./roomMembersMixin";
const sizeOf = require("image-size");
const dataUriToBuffer = require("data-uri-to-buffer");
@ -339,13 +426,15 @@ ScrollPosition.prototype.prepareFor = function (direction) {
export default {
name: "Chat",
mixins: [chatMixin],
mixins: [chatMixin, roomTypeMixin, sendAttachmentsMixin, roomMembersMixin],
components: {
ChatHeader,
ChatHeaderPrivate,
MessageOperations,
VoiceRecorder,
RoomInfoBottomSheet,
CreatedRoomWelcomeHeader,
DirectChatWelcomeHeader,
NoHistoryRoomWelcomeHeader,
MessageOperationsBottomSheet,
StickerPickerBottomSheet,
@ -370,10 +459,9 @@ export default {
scrollPosition: null,
currentFileInputs: null,
currentSendOperation: null,
currentSendProgress: null,
currentSendShowSendButton: true,
currentSendError: null,
currentSendErrorExceededFile: null,
showEmojiPicker: false,
selectedEvent: null,
editedEvent: null,
@ -411,7 +499,10 @@ export default {
lastRR: null,
/** If we just created this room, show a small welcome header with info */
showCreatedRoomWelcomeHeader: false,
hideCreatedRoomWelcomeHeader: false,
/** For direct chats, show a small welcome header with info about the other party */
hideDirectChatWelcomeHeader: false,
/** An array of recent emojis. Used in the "message operations" popup. */
recentEmojis: [],
@ -433,7 +524,8 @@ export default {
Symbols: this.$t("emoji.categories.symbols"),
Places: this.$t("emoji.categories.places")
}
}
},
notificationDialog: false
};
},
@ -443,7 +535,7 @@ export default {
if (contentArr[0] === "") {
contentArr.shift();
}
return contentArr[0].replace(/^> (<.*> )?/g, "");
return (contentArr && contentArr.length > 0) ? contentArr[0].replace(/^> (<.*> )?/g, "") : "";
},
},
@ -471,10 +563,10 @@ export default {
computed: {
nonImageFiles() {
return this.isCurrentFileInputsAnArray && this.currentFileInputs.filter(file => !file.type.includes("image/"))
return this.isCurrentFileInputsAnArray && this.currentFileInputs.filter(file => !file?.type.includes("image/"))
},
imageFiles() {
return this.isCurrentFileInputsAnArray && this.currentFileInputs.filter(file => file.type.includes("image/"))
return this.isCurrentFileInputsAnArray && this.currentFileInputs.filter(file => file?.type.includes("image/"))
},
isCurrentFileInputsAnArray() {
return Array.isArray(this.currentFileInputs)
@ -595,13 +687,13 @@ export default {
useVoiceMode: {
get: function () {
if (!this.$config.experimental_voice_mode) return false;
return util.roomDisplayType(this.room) === ROOM_TYPE_VOICE_MODE;
return (util.roomDisplayTypeOverride(this.room) || this.roomDisplayType) === ROOM_TYPE_VOICE_MODE;
},
},
useFileModeNonAdmin: {
get: function() {
if (!this.$config.experimental_file_mode) return false;
return util.roomDisplayType(this.room) === ROOM_TYPE_FILE_MODE && !this.canCreatePoll; // TODO - Check user or admin
return (util.roomDisplayTypeOverride(this.room) || this.roomDisplayType) === ROOM_TYPE_FILE_MODE && !this.canCreatePoll; // TODO - Check user or admin
}
},
@ -632,6 +724,88 @@ export default {
}
}
return this.events;
},
roomCreatedByUsRecently() {
const createEvent = this.room && this.room.currentState.getStateEvents("m.room.create", "");
if (createEvent) {
const creatorId = createEvent.getContent().creator;
return (creatorId == this.$matrix.currentUserId && createEvent.getLocalAge() < 5 * 60000 /* 5 minutes */);
}
return false;
},
isDirectRoom() {
return this.room && this.room.getJoinRule() == "invite" && this.joinedAndInvitedMembers.length == 2;
},
isPublicRoom() {
return this.room && this.room.getJoinRule() == "public";
},
showCreatedRoomWelcomeHeader() {
return !this.hideCreatedRoomWelcomeHeader && this.roomCreatedByUsRecently && !this.isDirectRoom;
},
showDirectChatWelcomeHeader() {
return !this.hideDirectChatWelcomeHeader && this.roomCreatedByUsRecently && this.isDirectRoom;
},
chatContainerStyle() {
if (this.$config.chat_backgrounds && this.room && this.roomId) {
const roomType = this.isDirectRoom ? "direct" : this.isPublicRoom ? "public" : "invite";
let backgrounds = this.$config.chat_backgrounds[roomType] || this.$config.chat_backgrounds["all"];
if (backgrounds) {
const numBackgrounds = backgrounds.length;
// If we have several backgrounds set, use the room ID to calculate
// an int hash value, then take mod of that to select a background to use.
// That way, we always get the same one, since room IDs don't change.
// From: https://stackoverflow.com/questions/7616461/generate-a-hash-from-string-in-javascript
const hashCode = function (s) {
var hash = 0,
i, chr;
if (s.length === 0) return hash;
for (i = 0; i < s.length; i++) {
chr = s.charCodeAt(i);
hash = ((hash << 5) - hash) + chr;
hash |= 0; // Convert to 32bit integer
}
return hash;
}
// Adapted from: https://stackoverflow.com/questions/5717093/check-if-a-javascript-string-is-a-url
const validUrl = function (s) {
let url;
try {
url = new URL(s, window.location);
} catch (err) {
return false;
}
return url.protocol === "http:" || url.protocol === "https:" || url.protocol === "data:";
}
const index = Math.abs(hashCode(this.roomId)) % numBackgrounds;
const background = backgrounds[index];
if (background && validUrl(background)) {
return "background-image: url(" + background + ");background-repeat: repeat";
}
}
}
return "";
},
/**
* If we are replying to a (media) thread, this is the hint we show when replying.
*/
replyToThreadMessage() {
if (this.replyToEvent && this.timelineSet) {
return this.$t("message.sent_media", {count: this.timelineSet.relations
.getAllChildEventsForEvent(this.replyToEvent.getId())
.filter((e) => util.downloadableTypes().includes(e.getContent().msgtype)).length});
}
return "";
}
},
@ -640,6 +814,8 @@ export default {
immediate: true,
handler(value, oldValue) {
if (value && !oldValue) {
this.events.filter(event => (event.threadRootId && !event.parentThread)).forEach(event => this.setParentThread(event));
this.events.filter(event => (event.replyEventId && !event.replyEvent)).forEach(event => this.setReplyToEvent(event));
console.log("Loading finished!");
}
}
@ -661,7 +837,8 @@ export default {
this.timelineWindow = null;
this.typingMembers = [];
this.initialLoadDone = false;
this.showCreatedRoomWelcomeHeader = false;
this.hideDirectChatWelcomeHeader = false;
this.hideCreatedRoomWelcomeHeader = false;
// Stop RR timer
this.stopRRTimer();
@ -681,7 +858,7 @@ export default {
}
});
} else {
this.initialLoadDone = true;
this.setInitialLoadDone();
return; // no room
}
},
@ -708,8 +885,10 @@ export default {
var rectOps = this.$refs.messageOperations.$el.getBoundingClientRect();
top = rectAnchor.top - rectChat.top - 50;
left = rectAnchor.left - rectChat.left - 75;
if (left + rectOps.width >= rectChat.right) {
if (left + rectOps.width + 10 >= rectChat.right) {
left = rectChat.right - rectOps.width - 10; // No overflow
} else if (left < 0) {
left = 0;
}
}
}
@ -726,17 +905,28 @@ export default {
},
methods: {
onRoomJoined(initialEventId) {
// Was this room just created (by you)? Show a small info header in
// that case!
const createEvent = this.room.currentState.getStateEvents("m.room.create", "");
if (createEvent) {
const creatorId = createEvent.getContent().creator;
if (creatorId == this.$matrix.currentUserId && createEvent.getLocalAge() < 5 * 60000 /* 5 minutes */) {
this.showCreatedRoomWelcomeHeader = true;
}
/**
* Set initialLoadDone to 'true'. First process all events, setting threadParent and replyEvent if needed.
*/
setInitialLoadDone() {
this.events.filter(event => (event.threadRootId && !event.parentThread)).forEach(event => this.setParentThread(event));
this.events.filter(event => (event.replyEventId && !event.replyEvent)).forEach(event => this.setReplyToEvent(event));
this.initialLoadDone = true;
console.log("Loading finished!");
},
windowNotificationPermission,
onNotificationDialog() {
if(this.windowNotificationPermission() === 'denied') {
alert(this.$t("notification.blocked_message"));
} else if(this.windowNotificationPermission() === 'default') {
this.notificationDialog = true;
}
},
onNotifyRequest() {
requestNotificationPermission()
this.notificationDialog = false;
},
onRoomJoined(initialEventId) {
// Listen to events
this.$matrix.on("Room.timeline", this.onEvent);
this.$matrix.on("RoomMember.typing", this.onUserTyping);
@ -778,10 +968,24 @@ export default {
console.log("ERROR " + err);
})
.finally(() => {
self.initialLoadDone = true;
if (initialEventId && !this.showCreatedRoomWelcomeHeader) {
self.scrollToEvent(initialEventId);
} else if (this.showCreatedRoomWelcomeHeader) {
// const [timelineEvents, threadedEvents, unknownRelations] =
// this.room.partitionThreadedEvents(self.events);
// this.$matrix.matrixClient.processAggregatedTimelineEvents(this.room, timelineEvents);
// //room.addEventsToTimeline(timelineEvents, true, room.getLiveTimeline());
// this.$matrix.matrixClient.processThreadEvents(this.room, threadedEvents, true);
// unknownRelations.forEach((event) => this.room.relations.aggregateChildEvent(event));
this.setInitialLoadDone();
if (initialEventId && !this.showCreatedRoomWelcomeHeader) {
const event = this.room.findEventById(initialEventId);
this.$nextTick(() => {
if (event && event.parentThread) {
self.scrollToEvent(event.parentThread.getId());
} else {
self.scrollToEvent(initialEventId);
}
});
} else if (this.showCreatedRoomWelcomeHeader || this.showDirectChatWelcomeHeader) {
self.onScroll();
}
self.restartRRTimer();
@ -795,7 +999,7 @@ export default {
} else {
// Error. Done loading.
this.events = this.timelineWindow.getEvents();
this.initialLoadDone = true;
this.setInitialLoadDone();
}
})
.finally(() => {
@ -929,12 +1133,83 @@ export default {
this.restartRRTimer();
},
setParentThread(event) {
const parentEvent = this.timelineSet.findEventById(event.threadRootId) || this.room.findEventById(event.threadRootId);
if (parentEvent) {
Vue.set(parentEvent, "isMxThread", true);
Vue.set(event, "parentThread", parentEvent);
} else {
// Try to load from server.
this.$matrix.matrixClient.getEventTimeline(this.timelineSet, event.threadRootId).then((tl) => {
if (tl) {
const parentEvent = tl.getEvents().find((e) => e.getId() === event.threadRootId);
if (parentEvent) {
this.events = this.timelineWindow.getEvents();
const fn = () => {
Vue.set(parentEvent, "isMxThread", true);
Vue.set(event, "parentThread", parentEvent);
};
if (this.initialLoadDone) {
const sel = "[eventId=\"" + parentEvent.getId() + "\"]";
const element = document.querySelector(sel);
if (element) {
this.onLayoutChange(fn, element);
} else {
fn();
}
} else {
fn();
}
}
}
});
}
},
setReplyToEvent(event) {
const parentEvent = this.timelineSet.findEventById(event.replyEventId) || this.room.findEventById(event.replyEventId);
if (parentEvent) {
Vue.set(event, "replyEvent", parentEvent);
} else {
// Try to load from server.
this.$matrix.matrixClient.getEventTimeline(this.timelineSet, event.replyEventId)
.then((tl) => {
if (tl) {
const parentEvent = tl.getEvents().find((e) => e.getId() === event.replyEventId);
if (parentEvent) {
this.events = this.timelineWindow.getEvents();
const fn = () => {Vue.set(event, "replyEvent", parentEvent);};
if (this.initialLoadDone) {
const sel = "[eventId=\"" + parentEvent.getId() + "\"]";
const element = document.querySelector(sel);
if (element) {
this.onLayoutChange(fn, element);
} else {
fn();
}
} else {
fn();
}
}
}
}).catch(e => console.error(e));
}
},
onEvent(event) {
//console.log("OnEvent", JSON.stringify(event));
if (event.getRoomId() !== this.roomId) {
return; // Not for this room
}
if (this.initialLoadDone && event.threadRootId && !event.parentThread) {
this.setParentThread(event);
}
if (this.initialLoadDone && event.replyEventId && !event.replyEvent) {
this.setReplyToEvent(event);
}
const loadingDone = this.initialLoadDone;
this.$matrix.matrixClient.decryptEventIfNeeded(event, {});
@ -942,7 +1217,7 @@ export default {
this.paginateBackIfNeeded();
}
if (loadingDone && event.forwardLooking && !event.isRelation()) {
if (loadingDone && event.forwardLooking && (!event.isRelation() || event.isMxThread || event.threadRootId || event.parentThread )) {
// If we are at bottom, scroll to see new events...
var scrollToSeeNew = event.getSender() == this.$matrix.currentUserId; // When we sent, scroll
const container = this.chatContainer;
@ -954,7 +1229,7 @@ export default {
this.handleScrolledToBottom(scrollToSeeNew);
// If kick or ban event, redirect to "goodbye"...
if (event.getType() === "m.room.member" &&
if (event.getType() === "m.room.member" &&
event.getStateKey() == this.$matrix.currentUserId &&
(event.getPrevContent() || {}).membership == "join" &&
(
@ -1015,15 +1290,19 @@ export default {
this.$refs.attachment.click();
},
optimizeImage(e,event,file) {
file.image = e.target.result;
file.dimensions = null;
optimizeImage(evt,file) {
let fileObj = {}
fileObj.image = evt.target.result;
fileObj.dimensions = null;
fileObj.type = file.type;
fileObj.actualSize = file.size;
fileObj.actualFile = file
try {
file.dimensions = sizeOf(dataUriToBuffer(e.target.result));
fileObj.dimensions = sizeOf(dataUriToBuffer(evt.target.result));
// Need to resize?
const w = file.dimensions.width;
const h = file.dimensions.height;
const w = fileObj.dimensions.width;
const h = fileObj.dimensions.height;
if (w > 640 || h > 640) {
var aspect = w / h;
var newWidth = parseInt((w > h ? 640 : 640 * aspect).toFixed());
@ -1035,19 +1314,19 @@ export default {
outputType: "blob",
});
imageResize
.play(event.target)
.play(evt.target.result)
.then((img) => {
Vue.set(
file,
fileObj,
"scaled",
new File([img], file.name, {
type: img.type,
lastModified: Date.now(),
})
);
Vue.set(file, "useScaled", true);
Vue.set(file, "scaledSize", img.size);
Vue.set(file, "scaledDimensions", {
Vue.set(fileObj, "useScaled", true);
Vue.set(fileObj, "scaledSize", img.size);
Vue.set(fileObj, "scaledDimensions", {
width: newWidth,
height: newHeight,
});
@ -1059,24 +1338,19 @@ export default {
} catch (error) {
console.error("Failed to get image dimensions: " + error);
}
return file
return fileObj
},
handleFileReader(event, file) {
handleFileReader(file) {
if (file) {
let optimizedFileObj;
var reader = new FileReader();
reader.onload = (e) => {
reader.onload = (evt) => {
if (file.type.startsWith("image/")) {
this.optimizeImage(e, event, file)
optimizedFileObj = this.optimizeImage(evt, file)
} else {
optimizedFileObj = file
}
this.$matrix.matrixClient.getMediaConfig().then((config) => {
this.currentFileInputs = Array.isArray(this.currentFileInputs) ? [...this.currentFileInputs, file] : [file];
if (config["m.upload.size"] && file.size > config["m.upload.size"]) {
this.currentSendError = this.$t("message.upload_file_too_large");
this.currentSendShowSendButton = false;
} else {
this.currentSendShowSendButton = true;
}
});
this.currentFileInputs = Array.isArray(this.currentFileInputs) ? [...this.currentFileInputs, optimizedFileObj] : [optimizedFileObj];
};
reader.readAsDataURL(file);
}
@ -1085,66 +1359,62 @@ export default {
* Handle picked attachment
*/
handlePickedAttachment(event) {
Object.values(event.target.files).forEach(file => this.handleFileReader(event, file));
this.currentFileInputs = []
const uploadedFiles = Object.values(event.target.files);
this.$matrix.matrixClient.getMediaConfig().then((config) => {
const configUploadSize = config["m.upload.size"];
const configFormattedUploadSize = this.formatBytes(configUploadSize);
uploadedFiles.every(file => {
if (configUploadSize && file.size > configUploadSize) {
this.currentSendError = this.$t("message.upload_file_too_large");
this.currentSendErrorExceededFile = this.$t("message.upload_exceeded_file_limit", { configFormattedUploadSize });
this.currentSendShowSendButton = false;
return false;
} else {
this.currentSendShowSendButton = true;
}
return true;
});
uploadedFiles.forEach(file => this.handleFileReader(file));
});
},
showStickerPicker() {
this.$refs.stickerPickerSheet.open();
},
onUploadProgress(p) {
if (p.total) {
this.currentSendProgress = this.$t("message.upload_progress_with_total", {
count: p.loaded || 0,
total: p.total,
});
} else {
this.currentSendProgress = this.$t("message.upload_progress", {
count: p.loaded || 0,
});
}
},
sendAttachment(withText) {
this.$refs.attachment.value = null;
if (this.isCurrentFileInputsAnArray) {
let inputFiles = this.currentFileInputs.map(entry => {
if (entry.scaled && entry.useScaled) {
// Send scaled version of image instead!
return entry.scaled;
}
return entry;
const text = withText || "";
const promise = this.sendAttachments(text, this.currentFileInputs);
promise.then(() => {
this.currentFileInputs = null;
this.sendingStatus = this.sendStatuses.INITIAL;
})
const promises = inputFiles.map(inputFile => util.sendImage(this.$matrix.matrixClient, this.roomId, inputFile, this.onUploadProgress));
Promise.all(promises).then(() => {
this.currentSendOperation = null;
this.currentFileInputs = null;
this.currentSendProgress = null;
if (withText) {
this.sendMessage(withText);
}
})
.catch((err) => {
if (err.name === "AbortError" || err === "Abort") {
this.currentSendError = null;
} else {
this.currentSendError = err.LocaleString();
}
this.currentSendOperation = null;
this.currentSendProgress = null;
});
.catch((err) => {
if (err.name === "AbortError" || err === "Abort") {
this.currentSendError = null;
this.currentSendErrorExceededFile = null;
} else {
this.currentSendError = err.LocaleString();
this.currentSendErrorExceededFile = err.LocaleString();
}
});
}
},
cancelSendAttachment() {
this.$refs.attachment.value = null;
if (this.currentSendOperation) {
this.currentSendOperation.abort();
}
this.currentSendOperation = null;
this.cancelSendAttachments();
this.currentFileInputs = null;
this.currentSendProgress = null;
this.currentSendError = null;
this.currentSendErrorExceededFile = null;
this.sendingStatus = this.sendStatuses.INITIAL;
},
addAttachment(file) {
@ -1155,6 +1425,28 @@ export default {
this.cancelSendAttachment();
},
/**
* Called by message components that need to change their layout. This will avoid "jumping" in the UI, because
* we remember scroll position, apply the layout change, then restore the scroll.
* NOTE: we use "parentElement" below, because it is expected to be called with "element" set to the message component
* and the message component in turn being wrapped by a "message-wrapper" element (see html above).
* @param {} action A function that performs desired layout changes.
* @param {*} element Root element for the chat message.
*/
onLayoutChange(action, element) {
if (!element || !element.parentElemen || this.useVoiceMode || this.useFileModeNonAdmin) {
action();
return
}
const container = this.chatContainer;
this.scrollPosition.prepareFor(element.parentElement.offsetTop >= container.scrollTop ? "down" : "up");
action();
this.$nextTick(() => {
// restore scroll position!
this.scrollPosition.restore();
});
},
handleScrolledToTop() {
if (
this.timelineWindow &&
@ -1165,7 +1457,7 @@ export default {
this.timelineWindow
.paginate(EventTimeline.BACKWARDS, 10, true)
.then((success) => {
if (success) {
if (success && this.scrollPosition) {
this.scrollPosition.prepareFor("up");
this.events = this.timelineWindow.getEvents();
this.$nextTick(() => {
@ -1193,7 +1485,7 @@ export default {
.then((success) => {
if (success) {
this.events = this.timelineWindow.getEvents();
if (!this.useVoiceMode) {
if (!this.useVoiceMode && this.scrollPosition) {
this.scrollPosition.prepareFor("down");
this.$nextTick(() => {
// restore scroll position!
@ -1219,9 +1511,18 @@ export default {
const container = this.chatContainer;
const ref = this.$refs[eventId];
if (container && ref) {
const targetY = container.clientHeight / 2;
const sourceY = ref[0].offsetTop;
container.scrollTo(0, sourceY - targetY);
const parent = container.getBoundingClientRect();
const item = ref[0].getBoundingClientRect();
let offsetY = (parent.bottom - parent.top) / 2;
if (ref[0].clientHeight > offsetY) {
offsetY = Math.max(0, (parent.bottom - parent.top) - ref[0].clientHeight);
}
const targetY = parent.top + offsetY;
const currentY = item.top;
const y = container.scrollTop + (currentY - targetY);
this.$nextTick(() => {
container.scrollTo(0, y);
});
}
},
@ -1273,7 +1574,11 @@ export default {
addReply(event) {
this.replyToEvent = event;
this.$refs.messageInput.focus();
this.replyToContentType = event.getContent().msgtype || 'm.poll';
if (event.parentThread || event.isThreadRoot || event.isMxThread) {
this.replyToContentType = 'm.thread';
} else {
this.replyToContentType = event.getContent().msgtype || 'm.poll';
}
this.setReplyToImage(event);
},
@ -1295,24 +1600,12 @@ export default {
},
download(event) {
util
.getAttachment(this.$matrix.matrixClient, event)
.then((url) => {
const link = document.createElement("a");
link.href = url;
link.target = "_blank";
link.download = event.getContent().body || this.$t("fallbacks.download_name");
document.body.appendChild(link);
link.click();
setTimeout(function () {
document.body.removeChild(link);
URL.revokeObjectURL(url);
}, 200);
})
.catch((err) => {
console.log("Failed to fetch attachment: ", err);
});
if ((event.isThreadRoot || event.isMxThread) && this.timelineSet) {
const children = this.timelineSet.relations.getAllChildEventsForEvent(event.getId()).filter(e => util.downloadableTypes().includes(e.getContent().msgtype));
children.forEach(child => util.download(this.$matrix.matrixClient, child));
} else {
util.download(this.$matrix.matrixClient, event);
}
},
cancelEditReply() {
@ -1555,7 +1848,17 @@ export default {
},
closeCreateRoomWelcomeHeader() {
this.showCreatedRoomWelcomeHeader = false;
this.hideCreatedRoomWelcomeHeader = true;
this.$nextTick(() => {
// We change the layout when removing the welcome header, so call
// onScroll here to handle updates (e.g. remove the "scroll to last" if we now
// can see all messages).
this.onScroll();
});
},
closeDirectChatWelcomeHeader() {
this.hideDirectChatWelcomeHeader = true;
this.$nextTick(() => {
// We change the layout when removing the welcome header, so call
// onScroll here to handle updates (e.g. remove the "scroll to last" if we now

View file

@ -5,7 +5,7 @@
cols="auto"
class="chat-header-members text-start ma-0 pa-0"
>
<v-avatar size="40" class="clickable me-2 chat-header-avatar" color="grey" @click.stop="onAvatarClicked">
<v-avatar size="48" class="clickable me-2 chat-header-avatar" color="grey" @click.stop="onAvatarClicked">
<v-img v-if="roomAvatar" :src="roomAvatar" />
<span v-else class="white--text headline">{{
room.name.substring(0, 1).toUpperCase()
@ -48,7 +48,7 @@
</v-col>
<v-col cols="auto" class="text-end ma-0 pa-0 ms-1 clickable close-button more-menu-button">
<div :class="{ 'popup-open': showMoreMenu }">
<v-btn class="mx-2 box-shadow-none" fab dark small color="transparent" @click.stop="showMoreMenu = true">
<v-btn class="mx-2 box-shadow-none" fab dark small color="transparent" @click.stop="onShowMoreMenu">
<v-icon size="15">$vuetify.icons.ic_more</v-icon>
</v-btn>
</div>
@ -177,6 +177,11 @@ export default {
this.$emit("view-room-details", { event: this.event });
}
});
items.push({
icon: 'notifications_active', text: this.$t('global.notify'), handler: () => {
this.$emit("notify");
}
});
items.push({
icon: '$vuetify.icons.ic_member-leave', text: this.$t('leave.leave'), handler: () => {
this.leaveRoom();
@ -227,6 +232,12 @@ export default {
},
methods: {
onShowMoreMenu() {
if(this.publicRoomLink == null) {
this.roomJoinRule = this.getRoomJoinRule();
}
this.showMoreMenu = true
},
setHasShownMissedItemsHint() {
this.$store.commit('setHasShownMissedItemsHint', "1");
this.showMissedItemsInfo = false;

View file

@ -0,0 +1,164 @@
<template>
<v-container fluid v-if="room">
<v-row class="chat-header-row flex-nowrap">
<v-col
cols="auto"
class="chat-header-members text-start ma-0 pa-0"
>
<v-avatar size="48" class="clickable me-2 chat-header-avatar rounded-circle" color="grey" @click.stop="onAvatarClicked">
<v-img v-if="privatePartyAvatar(40)" :src="privatePartyAvatar(40)" />
<span v-else class="white--text headline">{{
privateParty.name.substring(0, 1).toUpperCase()
}}</span>
</v-avatar>
</v-col>
<v-col class="chat-header-name ma-0 pa-0 flex-shrink-1 flex-grow-1 flex-nowrap" @click.stop="onHeaderClicked">
<div class="room-title-row">
<div class="room-name-inline text-truncate" :title="privateParty.name">
{{ privateParty.name }}
</div>
<v-icon v-if="$matrix.joinedAndInvitedRooms.length > 1" class="icon-dropdown" size="11">$vuetify.icons.ic_dropdown</v-icon>
<div v-if="$matrix.joinedAndInvitedRooms.length > 1 && notifications" :class="{ 'notification-alert': true, 'popup-open': showMissedItemsInfo }">
<v-icon class="icon-circle" size="11">circle</v-icon>
<!-- MISSED ITEMS POPUP -->
<!-- <div class="missed-items-popup-background" v-if="showMissedItemsInfo" @click.stop="setHasShownMissedItemsHint()"></div> -->
<div class="missed-items-popup" v-if="showMissedItemsInfo" @click.stop="setHasShownMissedItemsHint()">
<div class="text">{{ notificationsText }}</div>
<div class="button clickable" @click.stop="setHasShownMissedItemsHint()">{{$t('menu.ok')}}</div>
</div>
</div>
</div>
<div class="num-members">
<div v-if="roomIsEncrypted" class="private-chat"><v-icon color="#616161">$vuetify.icons.ic_lock</v-icon>{{ $t("room_welcome.direct_private_chat") }}</div>
</div>
</v-col>
<v-col v-if="$matrix.joinedRooms.length > 1" cols="auto" class="text-end ma-0 pa-0 ms-1">
<v-avatar :class="{ 'avatar-32': true, 'clickable': true, 'popup-open': showProfileInfo }" size="26"
color="#e0e0e0" @click.stop="showProfileInfo = true">
<img v-if="userAvatar" :src="userAvatar" />
<span v-else class="white--text">{{ userAvatarLetter }}</span>
</v-avatar>
</v-col>
<v-col cols="auto" class="text-end ma-0 pa-0 ms-1">
<v-btn id="btn-purge-room" v-if="userCanPurgeRoom" class="mx-2 box-shadow-none" fab dark small color="red"
@click.stop="showPurgeConfirmation = true">
<v-icon light>$vuetify.icons.ic_moderator-delete</v-icon>
</v-btn>
<template v-else>
<v-btn v-if="$matrix.joinedRooms.length == 1" id="btn-leave-room" class="box-shadow-none leave-button" color="red" @click.stop="leaveRoom">
<v-icon color="white">$vuetify.icons.ic_member-leave</v-icon>{{ $t('room.leave') }}
</v-btn>
<v-btn id="btn-leave-room" class="mx-2 box-shadow-none" fab dark small color="red" @click.stop="leaveRoom" v-else>
<v-icon color="white">$vuetify.icons.ic_member-leave</v-icon>
</v-btn>
</template>
</v-col>
<v-col v-if="$matrix.joinedRooms.length > 1" cols="auto" class="text-end ma-0 pa-0 ms-1 clickable close-button more-menu-button">
<div :class="{ 'popup-open': showMoreMenu }">
<v-btn class="mx-2 box-shadow-none" fab dark small color="transparent" @click.stop="onShowMoreMenu">
<v-icon size="15">$vuetify.icons.ic_more</v-icon>
</v-btn>
</div>
</v-col>
</v-row>
<!-- "REALLY LEAVE?" dialog -->
<LeaveRoomDialog :show="showLeaveConfirmation" :room="room" @close="showLeaveConfirmation = false" />
<!-- PROFILE INFO POPUP -->
<ProfileInfoPopup :show="showProfileInfo" @close="showProfileInfo = false" />
<!-- MORE MENU POPUP -->
<MoreMenuPopup :show="showMoreMenu" :menuItems="moreMenuItems" @close="showMoreMenu = false"
v-on:leave="showLeaveConfirmation = true" />
<!-- PURGE ROOM POPUP -->
<PurgeRoomDialog :show="showPurgeConfirmation" :room="room" @close="showPurgeConfirmation = false" />
<RoomExport :room="room" v-if="downloadingChat" v-on:close="downloadingChat = false" />
</v-container>
</template>
<script>
import LeaveRoomDialog from "../components/LeaveRoomDialog";
import ProfileInfoPopup from "../components/ProfileInfoPopup";
import MoreMenuPopup from "../components/MoreMenuPopup";
import PurgeRoomDialog from "../components/PurgeRoomDialog";
import RoomExport from "../components/RoomExport";
import ChatHeader from "./ChatHeader.vue";
export default {
name: "ChatHeaderPrivate",
extends: ChatHeader,
components: {
LeaveRoomDialog,
ProfileInfoPopup,
MoreMenuPopup,
PurgeRoomDialog,
RoomExport
},
};
</script>
<style lang="scss">
@import "@/assets/css/chat.scss";
.popup-open {
position: relative;
overflow: visible;
color: white;
}
.popup-open::after {
position: absolute;
left: 50%;
// Need to move the "more items" arrow to the left, since it's too close to the edge
// and would interfere with the dialog rounding...
.more-menu-button & {
left: calc(50% - 4px);
}
content: " ";
top: 42px;
margin-left: -10px;
width: 16px;
height: 16px;
transform: rotate(45deg);
border-radius: 2px;
background-color: currentColor;
z-index: 400;
pointer-events: none;
animation-duration: 0.3s;
animation-delay: 0.2s;
animation-fill-mode: both;
animation-name: fadein;
animation-iteration-count: 1;
}
@keyframes fadein {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.private-chat {
display: flex;
align-items: center;
}
.private-chat .v-icon {
width: 9px;
height: 11px;
margin-right: 6px;
}
</style>

View file

@ -7,11 +7,17 @@
<v-row cols="12" class="qr-container ma-3">
<v-col cols="auto">
<canvas
@click.stop="showFullScreenQR = true"
v-longTap:250="[
()=> {
showFullScreenQR = true;
},
(el) => { $emit('long-tap', el); }
]"
ref="roomQr"
class="qr"
id="room-qr"
></canvas>
<slot name="share"></slot>
</v-col>
<v-col align-self="center" class="public-link">
<div class="link">{{ locationLink }}</div>
@ -32,7 +38,7 @@
</v-container>
</v-card>
</v-expand-transition>
<QRCodePopup :show="showFullScreenQR" :message="locationLink" @close="showFullScreenQR = false" />
<QRCodePopup :show="showFullScreenQR" :message="locationLink" :title="popupTitle" @close="showFullScreenQR = false" />
</div>
</template>
@ -51,7 +57,12 @@ export default {
i18nCopyLinkKey: {
type: String,
default: 'copy_link'
},
i18nQrPopupTitleKey: {
type: String,
default: 'room_info.scan_code'
}
},
data() {
return {
@ -59,6 +70,11 @@ export default {
showFullScreenQR: false,
}
},
computed: {
popupTitle() {
return this.$t(this.i18nQrPopupTitleKey);
},
},
methods: {
copyRoomLink() {
if(this.locationLinkCopied) return
@ -87,7 +103,7 @@ export default {
{
type: "image/png",
margin: 1,
width: 60,
width: canvas.getBoundingClientRect().width,
},
function (error) {
if (error) console.error(error);

View file

@ -65,11 +65,15 @@
</v-card>
<v-card v-if="availableRoomTypes.length > 1" v-show="showOptions" class="room-option account ma-0" flat>
<v-card-text class="with-right-label">
<div>
<div class="option-title">{{ $t('room_info.room_type') }}</div>
<v-card-text>
<div class="d-flex flex-wrap text-left">
<div class="col-12 col-md-6 mr-auto pa-0">
<div class="option-title">{{ $t('room_info.room_type') }}</div>
</div>
<div class="col-12 col-md-6 pa-0">
<RoomTypeSelector v-model="roomType" />
</div>
</div>
<RoomTypeSelector v-model="roomType" />
</v-card-text>
</v-card>
<v-card v-if="$config.experimental_read_only_room" v-show="showOptions" class="room-option account ma-0" flat>
@ -333,7 +337,7 @@ export default {
visibility: "private", // Not listed!
name: this.roomName,
preset: "public_chat",
initial_state:
initial_state:
this.unencryptedRoom ? [
{
type: "m.room.history_visibility",
@ -342,7 +346,7 @@ export default {
history_visibility: "shared"
}
}
] :
] :
[
{
type: "m.room.encryption",

View file

@ -0,0 +1,65 @@
<template>
<div style="text-align: center;">
<div class="created-room-welcome-header">
<div class="mt-2" v-if="privateParty">{{ $t("room_welcome.direct_info", { you: $matrix.currentUserDisplayName, user: privateParty.name }) }}</div>
</div>
</div>
</template>
<script>
import roomInfoMixin from "./roomInfoMixin";
export default {
name: "CreatedRoomWelcomeHeader",
mixins: [roomInfoMixin],
computed: {
roomHistoryDescription() {
const visibility = this.$matrix.getRoomHistoryVisibility(this.room);
switch (visibility) {
case "world_readable":
return this.$t("room_welcome.room_history_is", {
type: this.$t("message.room_history_world_readable"),
});
case "shared":
return this.$t("room_welcome.room_history_is", {
type: this.$t("message.room_history_shared"),
});
case "invited":
return this.$t("room_welcome.room_history_is", {
type: this.$t("message.room_history_invited"),
});
case "joined":
return this.$t("room_welcome.room_history_joined");
}
return null;
},
},
data() {
return {
publicRoomLinkCopied: false
}
},
methods: {
copyPublicLink() {
const self = this;
this.$copyText(this.publicRoomLink).then(
function (ignored) {
// Success!
self.publicRoomLinkCopied = true;
setInterval(() => {
// Hide again
self.publicRoomLinkCopied = false;
}, 3000);
},
function (e) {
console.log(e);
}
);
}
}
};
</script>
<style lang="scss">
@import "@/assets/css/chat.scss";
</style>

251
src/components/GetLink.vue Normal file
View file

@ -0,0 +1,251 @@
<template>
<div class="pa-4 getlink-root fill-height">
<div v-if="!loggedIn" class="text-center">
<v-icon class="getlink-image">$vuetify.icons.getlink</v-icon>
<div class="getlink-title">{{ $t("getlink.title") }}</div>
<div class="getlink-info">{{ $t("getlink.info") }}</div>
<div color="rgba(255,255,255,0.1)" class="text-center">
<v-form v-model="isValid" ref="form">
<v-text-field v-model="user.user_id" :label="$t('getlink.username')" color="black" background-color="white" solo
:rules="[(v) => !!v || $t('login.username_required')]" :error="userErrorMessage != null"
:error-messages="userErrorMessage" required v-on:keyup.enter="onUsernameEnter" v-on:keydown="hasError = false"></v-text-field>
<!-- <div class="error--text" v-if="loadingLoginFlows">Loading login flows...</div> -->
<div class="error--text" v-if="hasError">{{ this.message }}</div>
<interactive-auth ref="interactiveAuth" />
<v-btn id="btn-login" :disabled="!isValid || loading" color="primary" depressed block @click.stop="handleLogin"
:loading="loading" class="filled-button mt-4">{{ $t("getlink.next") }}</v-btn>
<v-btn color="black" depressed text block @click.stop="goToLoginPage" class="text-button">{{ $t("menu.login")
}}</v-btn>
</v-form>
</div>
</div>
<div v-else style="position:relative" class="getlink-loggedin">
<!-- Logged in/account created -->
<div class="getlink-title">{{ $t("getlink.hello", { user: $matrix.currentUserDisplayName }) }}</div>
<div class="getlink-subtitle">{{ $t("getlink.ready_to_share") }}</div>
<copy-link ref="qr" :locationLink="directMessageLink" i18nQrPopupTitleKey="getlink.scan_title"
v-on:long-tap="copyQRImage">
<template v-slot:share>
<div v-if="shareSupported" class="clickable getlink-share" @click="shareLink">
<div>{{ $t("getlink.share_qr") }}</div>
<v-img src="@/assets/icons/ic_share.svg" />
</div>
</template>
</copy-link>
<div class="getlink-buttons">
<v-btn color="black" depressed @click.stop="goHome" class="outlined-button">{{ $t("getlink.continue") }}</v-btn>
<v-btn color="black" depressed text block @click.stop="getDifferentLink" class="text-button">{{
$t("getlink.different_link") }}</v-btn>
</div>
</div>
<div :class="{ 'toast-at-bottom': true, 'visible': showQRCopiedToast }">{{ $t("getlink.qr_image_copied") }}</div>
</div>
</template>
<script>
import User from "../models/user";
import rememberMeMixin from "./rememberMeMixin";
import * as sdk from "matrix-js-sdk";
import logoMixin from "./logoMixin";
import InteractiveAuth from './InteractiveAuth.vue';
import CopyLink from "./CopyLink.vue"
import utils from "../plugins/utils";
export default {
name: "GetLink",
mixins: [rememberMeMixin, logoMixin],
components: { InteractiveAuth, CopyLink },
data() {
return this.defaultData();
},
computed: {
loggedIn() {
return this.$store.state.auth.status.loggedIn;
},
currentUser() {
return this.$store.state.auth.user;
},
showCloseButton() {
return this.$navigation && this.$navigation.canPop();
},
directMessageLink() {
return this.$router.getDMLink(this.$matrix.currentUser, this.$config);
},
shareSupported() {
return !!navigator.share;
},
},
watch: {
user: {
handler() {
// Reset manual errors
this.userErrorMessage = null;
},
deep: true,
}
},
methods: {
defaultData() {
return {
user: new User(this.$config.defaultServer, "", utils.randomPass()),
isValid: false,
loading: false,
message: "",
userErrorMessage: null,
hasError: false,
currentLoginServer: "",
loadingLoginFlows: false,
loginFlows: null,
showQRCopiedToast: false
};
},
goHome() {
this.$navigation.push({ name: "Home" }, -1);
},
getDifferentLink() {
this.$store.dispatch("logout");
this.$nextTick(() => {
// Reset
const obj = this.defaultData();
Object.keys(obj).forEach(k => this[k] = obj[k]);
this.$refs.form.reset();
})
},
goToLoginPage() {
this.$navigation.push({ name: "Login", params: { showCreateRoomOption: false, redirect: "GetLink" } }, 1);
},
handleLogin() {
if (this.user.user_id && this.user.password) {
// Reset errors
this.message = null;
// Is it a full matrix user id? Modify a copy, so that the UI will still show the full ID.
const userDisplayName = this.user.user_id;
var user = Object.assign({}, this.user);
let prefix = userDisplayName.toLowerCase().replaceAll(" ", "-").replaceAll(utils.invalidUserIdChars(), "");
if (prefix.length == 0) {
prefix = this.$config.userIdPrefix;
user.user_id = utils.randomUser(prefix);
} else {
// We first try with a username that is just a processed version of the display name.
// If it is already taken, try again with random characters appended.
user.user_id = prefix;
prefix = prefix + "-";
}
user.normalize();
this.loading = true;
this.loadLoginFlows().then(() => {
return this.$store.dispatch("createUser", { user, registrationFlowHandler: this.$refs.interactiveAuth.registrationFlowHandler })
})
.then(
(ignoreduser) => {
this.$matrix.setUserDisplayName(userDisplayName);
},
(error) => {
this.message =
(error.data && error.data.error) ||
error.message ||
error.toString();
if (error.data && error.data.errcode === 'M_FORBIDDEN') {
this.message = this.$i18n.messages[this.$i18n.locale].login.invalid_message;
this.hasError = true;
} else if (error.data && error.data.errcode === 'M_USER_IN_USE') {
// Try again with (other/new) random chars appended
user.user_id = utils.randomUser(prefix);
return this.$store.dispatch("createUser", { user, registrationFlowHandler: this.$refs.interactiveAuth.registrationFlowHandler }).then(
(ignoreduser) => {
this.$matrix.setUserDisplayName(userDisplayName);
},
(error) => {
this.message =
(error.data && error.data.error) ||
error.message ||
error.toString();
if (error.data && error.data.errcode === 'M_FORBIDDEN') {
this.message = this.$i18n.messages[this.$i18n.locale].login.invalid_message;
this.hasError = true;
}
}
);
}
})
.finally(() => {
this.loading = false;
})
}
},
handleCreateRoom() {
this.$navigation.push({ name: "CreateRoom" });
},
onUsernameEnter() {
this.handleLogin();
},
loadLoginFlows() {
var user = Object.assign({}, this.user);
user.normalize();
const server = user.home_server || this.$config.defaultServer;
if (server !== this.currentLoginServer) {
this.currentLoginServer = server;
this.loadingLoginFlows = true;
const matrixClient = sdk.createClient({ baseUrl: server });
return matrixClient.loginFlows().then((response) => {
console.log("FLOWS", response.flows);
this.loginFlows = response.flows.filter(this.supportedLoginFlow);
this.loadingLoginFlows = false;
if (this.loginFlows.length == 0) {
this.message = this.$t('login.no_supported_flow')
this.hasError = true;
} else {
this.message = "";
this.hasError = false;
}
});
} else {
return Promise.resolve();
}
},
supportedLoginFlow(flow) {
return ["m.login.password"].includes(flow.type);
},
shareLink() {
const canvas = this.$refs.qr.$refs.roomQr;
if (canvas) {
canvas.toBlob((blob) => {
let file = new File([blob], encodeURIComponent(this.$matrix.currentUserDisplayName || User.localPart(this.currentUser.user_id)) + ".png", { type: "image/png" })
const shareData = { files: [file] };
if (navigator.canShare && navigator.canShare(shareData)) {
navigator.share(shareData);
}
}, 'image/png');
}
},
copyQRImage(canvas) {
if (canvas && typeof window.ClipboardItem !== "undefined" && navigator.clipboard) {
canvas.toBlob(blob => {
const clipboardItemInput = new window.ClipboardItem({ "image/png": blob });
navigator.clipboard.write([clipboardItemInput]).then(() => {
this.showQRCopiedToast = true;
setTimeout(() => {
this.showQRCopiedToast = false;
}, 3000);
}).catch(err => console.error(err));
});
}
}
},
};
</script>
<style lang="scss">
@import "@/assets/css/getlink.scss";
</style>

View file

@ -14,7 +14,7 @@
<v-card-text class="pa-0">
<RoomList
showInvites
showCreate
:showCreate="!$config.hide_add_room_on_home"
v-on:newroom="createRoom"
/>
</v-card-text>

View file

@ -132,6 +132,24 @@
</div>
</div>
</div>
<!-- Loading indicator -->
<v-container
v-else
fluid
fill-height
class="loading-indicator transparent"
>
<v-row align="center" justify="center">
<v-col class="text-center">
<interactive-auth ref="interactiveAuth" v-if="waitingForRoomCreation" />
<v-progress-circular v-else
indeterminate
color="primary"
></v-progress-circular>
</v-col>
</v-row>
</v-container>
</template>
<script>
@ -157,6 +175,7 @@ export default {
loadingMessage: null,
waitingForInfo: true,
waitingForMembership: false,
waitingForRoomCreation: false,
availableAvatars: [],
selectedProfile: null,
showEditDisplaynameDialog: false,
@ -236,6 +255,7 @@ export default {
this.roomName = this.removeHomeServer(this.roomId);
this.waitingForInfo = true;
this.waitingForRoomCreation = false;
const self = this;
this.waitingForMembership = true;
if (this.currentUser) {
@ -304,19 +324,10 @@ export default {
});
} else if (this.roomId.startsWith("@")) {
// Direct chat with user
this.$matrix
.getPublicUserInfo(this.roomId)
.then((info) => {
console.log("Got user info:", info);
this.roomName = info.displayname;
this.roomAvatar = info.avatar;
})
.catch((err) => {
console.log("Failed to get user info: ", err);
})
.finally(() => {
this.waitingForInfo = false;
});
this.waitingForRoomCreation = true;
this.$nextTick(() => {
this.handleJoin();
});
} else {
// Private room, try to get name
const room = this.$matrix.getRoom(this.roomId);
@ -421,6 +432,8 @@ export default {
console.log("Failed to join room", err);
this.loading = false;
this.loadingMessage = this.$t("join.join_failed");
this.waitingForInfo = false;
this.waitingForMembership = false;
});
},

View file

@ -117,22 +117,10 @@ export default {
},
onLeaveRoom() {
const lastRoom = this.onlyJoinedToThisRoom();
//this.$matrix.matrixClient.forget(this.room.roomId, true, undefined)
const roomId = this.room.roomId;
this.$matrix
.leaveRoom(roomId)
this.$matrix.leaveRoomAndNavigate(this.room.roomId)
.then(() => {
this.showDialog = false;
console.log("Left room");
if (lastRoom) {
this.$navigation.push({ name: "Goodbye" }, -1);
} else {
this.$navigation.push(
{ name: "Home", params: { roomId: null } },
-1
);
}
})
.catch((err) => {
console.log("Error leaving", err);

View file

@ -86,7 +86,7 @@
class="filled-button mt-4"
>{{ $t("login.login") }}</v-btn
>
<div class="mt-2 overline">{{ $t("login.or") }}</div>
<div class="mt-2 overline" v-if="showCreateRoom">{{ $t("login.or") }}</div>
<v-btn
id="btn-create-room"
color="primary"
@ -94,6 +94,7 @@
block
@click.stop="handleCreateRoom"
class="filled-button mt-2"
v-if="showCreateRoom"
>{{ $t("login.create_room") }}</v-btn
>
</v-form>
@ -111,6 +112,20 @@ import logoMixin from "./logoMixin";
export default {
name: "Login",
mixins:[rememberMeMixin, logoMixin],
props: {
showCreateRoomOption: {
type: Boolean,
default: function () {
return true;
},
},
redirect: {
type: String,
default: function() {
return null;
},
}
},
data() {
return {
user: new User(this.$config.defaultServer, "", ""),
@ -136,6 +151,9 @@ export default {
showCloseButton() {
return this.$navigation && this.$navigation.canPop();
},
showCreateRoom() {
return this.showCreateRoomOption && !this.$config.hide_add_room_on_home;
}
},
created() {
if (this.loggedIn) {
@ -171,7 +189,10 @@ export default {
this.loading = true;
this.$store.dispatch("login", { user }).then(
() => {
if (this.$matrix.currentRoomId) {
if (this.redirect) {
this.$navigation.push({ name: this.redirect }, -1);
}
else if (this.$matrix.currentRoomId) {
this.$navigation.push(
{
name: "Chat",

View file

@ -4,10 +4,10 @@
<v-card flat>
<v-card-text>
<v-container class="mt-0 pa-0 pt-3 action-row-container-no-dividers">
<v-container class="mt-0 pa-0 pt-3 pb-3 action-row-container-no-dividers">
<ActionRow v-for="item in menuItems" :key="item.name" :icon="item.icon" :iconSize="16" :text="item.text" @click="$emit('close');item.handler()" />
<v-row class="profile-row clickable" @click="viewProfile" no-gutters align-content="center">
<v-row v-if="showProfile" class="profile-row clickable" @click="viewProfile" no-gutters align-content="center">
<v-col cols="auto" class="me-2">
<v-avatar class="avatar-32" size="32" color="#e0e0e0" @click.stop="viewProfile">
<img v-if="userAvatar" :src="userAvatar" />
@ -40,6 +40,12 @@ export default {
return false;
},
},
showProfile: {
type: Boolean,
default: function () {
return true;
},
},
menuItems: {
type: Array,
default: function() {
@ -116,7 +122,7 @@ export default {
.profile-row {
border-top: 1px solid rgba(0, 0, 0, 0.1);
padding: 20px 20px !important;
padding: 20px 20px 8px 20px !important;
}
.action-row:after {

View file

@ -66,8 +66,8 @@
</v-container>
<copy-link :locationLink="directMessageLink" >
<v-card-title class="h2">{{ $t("room_info.contact_link") }}</v-card-title>
<v-card-text>{{ $t("room_info.contact_link_desc") }}</v-card-text>
<v-card-title class="h2">{{ $t("room_info.direct_link") }}</v-card-title>
<v-card-text>{{ $t("room_info.direct_link_desc") }}</v-card-text>
</copy-link>
<v-container class="mt-2 pa-5">
@ -243,7 +243,7 @@ export default {
return this.$matrix.currentUser.user_id
},
directMessageLink() {
return `${window.location.origin + window.location.pathname}#/user/${this.currentUserId}`
return this.$router.getDMLink(this.$matrix.currentUser, this.$config);
},
passwordsMatch() {
return (

View file

@ -50,7 +50,7 @@
<a :href="'//' + productLink">{{ productLink }}</a>
</template>
</i18n>
<div class="text-end">
<div class="text-end" v-if="!$config.hide_add_room_on_home">
<v-btn id="btn-new-room" class="new_room" text @click="createRoom">
{{ $t("profile_info_popup.new_room") }}
</v-btn>

View file

@ -1,5 +1,6 @@
<template>
<v-dialog
persistent
v-model="showDialog"
v-show="room" class="ma-0 pa-0"
:width="$vuetify.breakpoint.smAndUp ? '688px' : '95%'"
@ -54,8 +55,8 @@
class="d-inline-block me-2"
src="@/assets/icons/timer.svg"
/>{{ $t("purge_room.n_seconds", { seconds: timeout }) }}
<h2 class="dialog-title">{{ $t("purge_room.self_destruct") }}</h2>
<div class="dialog-text">
<h2 class="dialog-title mb-0">{{ $t("purge_room.self_destruct") }}</h2>
<div class="dialog-text text-center mb-5">
{{ $t("purge_room.notified") }}
</div>
<div class="dialog-text">
@ -64,7 +65,7 @@
</template>
<v-container fluid>
<v-row cols="12">
<v-col cols="12">
<v-col cols="6">
<v-btn
id="btn-purge-room-undo"
depressed
@ -76,6 +77,11 @@
>{{ $t("menu.undo") }}</v-btn
>
</v-col>
<v-col cols="6">
<v-btn depressed block class="outlined-button" @click="onDoPurgeRoom">
{{ $t("menu.delete_now") }}
</v-btn>
</v-col>
</v-row>
</v-container>
</div>
@ -91,6 +97,7 @@
</template>
<script>
import roomInfoMixin from "./roomInfoMixin";
import { STATE_EVENT_ROOM_DELETION_NOTICE } from "../plugins/utils";
export default {
name: "LeaveRoomDialog",
@ -118,9 +125,13 @@ export default {
this.showDialog = newVal;
},
},
showDialog() {
if (!this.showDialog) {
showDialog(val, oldVal) {
if (!val && oldVal) {
this.undo();
this.$emit("close");
} else if (val && !oldVal) {
// Showing, reset
this.status = null;
}
},
},
@ -136,7 +147,7 @@ export default {
// Cancel the state event for deletion
this.$matrix.matrixClient.sendStateEvent(
this.room.roomId,
"im.keanu.room_deletion_notice",
STATE_EVENT_ROOM_DELETION_NOTICE,
{ status: "cancel" }
);
@ -146,21 +157,22 @@ export default {
// Send custom state event!
this.$matrix.matrixClient.sendStateEvent(
this.room.roomId,
"im.keanu.room_deletion_notice",
STATE_EVENT_ROOM_DELETION_NOTICE,
{ status: "delete" }
);
this.timeout = 10;
this.timeout = 7;
this.timeoutTimer = setInterval(() => {
this.timeout = this.timeout - 1;
if (this.timeout == 0) {
clearInterval(this.timeoutTimer);
this.timeoutTimer = null;
this.onDoPurgeRoom();
}
}, 1000);
},
onDoPurgeRoom() {
this.timeout = 0
clearInterval(this.timeoutTimer);
this.timeoutTimer = null;
this.isPurging = true;
this.$matrix
.purgeRoom(this.room.roomId, this.onPurgeStatus)

View file

@ -8,7 +8,7 @@
<div class="d-flex justify-center">
<canvas ref="qr" class="qr" id="qr" :style="qrStyle"></canvas>
</div>
<div>{{ $t("room_info.scan_code") }}</div>
<div>{{ title }}</div>
</div>
</v-dialog>
</template>
@ -32,6 +32,12 @@ export default {
return null;
},
},
title: {
type: String,
default: function () {
return "";
},
},
},
data() {
return {

View file

@ -39,7 +39,8 @@
<transition name="slow-fade">
<div
v-if="mounted"
class="goodbye-profile"
class="goodbye-profile clickable"
@click.stop="viewOtherRooms"
>
<div class="d-inline-block me-2 white--text">
{{ $t("profile_info_popup.you_are") }}
@ -66,7 +67,6 @@
class="avatar-32 d-inline-block"
size="32"
color="#e0e0e0"
@click.stop="showProfileInfo = true"
>
<img v-if="userAvatar" :src="userAvatar" />
<span v-else class="white--text">{{ userAvatarLetter }}</span>

View file

@ -1,6 +1,6 @@
<template>
<div class="chat-root">
<div class="chat-root d-flex flex-column" ref="exportRoot">
<div class="chat-root export d-flex flex-column" ref="exportRoot">
<!-- Header-->
<v-container fluid class="chat-header flex-grow-0 flex-shrink-0">
<v-row class="chat-header-row flex-nowrap">
@ -18,18 +18,17 @@
<div class="chat-content flex-grow-1 flex-shrink-1" ref="chatContainer">
<div v-for="(event, index) in events" :key="event.getId()" :eventId="event.getId()">
<!-- DAY Marker, shown for every new day in the timeline -->
<div v-if="showDayMarkerBeforeEvent(event)" class="day-marker" :title="dateForEvent(event)" />
<div v-if="showDayMarkerBeforeEvent(event)" class="day-marker">
<div class="line"></div>
<div class="text">{{ dayForEvent(event) }}</div>
<div class="line"></div>
</div>
<div v-if="!event.isRelation() && !event.isRedacted() && !event.isRedaction()" :ref="event.getId()">
<div class="message-wrapper">
<component
:is="componentForEvent(event, true)"
:room="room"
:originalEvent="event"
:nextEvent="events[index + 1]"
:timelineSet="timelineSet"
ref="exportedEvent"
/>
<component :is="componentForEvent(event, true)" :room="room" :originalEvent="event"
:nextEvent="events[index + 1]" :timelineSet="timelineSet" :componentFn="componentForEventForExport"
ref="exportedEvent" v-on:layout-change="onLayoutChange" />
<!-- <div v-if="debugging" style="user-select:text">EventID: {{ event.getId() }}</div> -->
<!-- <div v-if="debugging" style="user-select:text">Event: {{ JSON.stringify(event) }}</div> -->
</div>
@ -54,6 +53,7 @@
</template>
<script>
import Vue from "vue";
import MessageIncomingText from "./messages/MessageIncomingText.vue";
import MessageIncomingFile from "./messages/MessageIncomingFile.vue";
import MessageIncomingImage from "./messages/MessageIncomingImage.vue";
@ -98,6 +98,7 @@ import util from "../plugins/utils";
import JSZip from "jszip";
import { saveAs } from "file-saver";
import { EventTimelineSet } from "matrix-js-sdk";
import axios from 'axios';
export default {
name: "RoomExport",
@ -146,7 +147,7 @@ export default {
props: {
room: {
type: Object,
default: function() {
default: function () {
return null;
},
},
@ -181,6 +182,9 @@ export default {
},
},
methods: {
componentForEventForExport(event) {
return this.componentForEvent(event, true);
},
cancelExport() {
this.cancelled = true;
},
@ -254,6 +258,21 @@ export default {
this.timelineSet.addEventsToTimeline(events.reverse(), true, this.timelineSet.getLiveTimeline(), "");
this.events = events;
// Need to set thread root events and replyEvents so stuff is rendered correctly.
this.events.filter(event => (event.threadRootId && !event.parentThread)).forEach(event => {
const parentEvent = this.timelineSet.findEventById(event.threadRootId) || this.room.findEventById(event.threadRootId);
if (parentEvent) {
Vue.set(parentEvent, "isMxThread", true);
Vue.set(event, "parentThread", parentEvent);
}
});
this.events.filter(event => (event.replyEventId && !event.replyEvent)).forEach(event => {
const parentEvent = this.timelineSet.findEventById(event.replyEventId) || this.room.findEventById(event.replyEventId);
if (parentEvent) {
Vue.set(event, "replyEvent", parentEvent);
}
});
// Wait a tick so UI is updated.
return new Promise((resolve, ignoredReject) => {
this.$nextTick(() => {
@ -264,129 +283,192 @@ export default {
.then(() => {
// UI updated, start processing events
zip = new JSZip();
var avatarFolder = zip.folder("avatars");
var imageFolder = zip.folder("images");
var audioFolder = zip.folder("audio");
var videoFolder = zip.folder("video");
var downloadPromises = [];
let components = this.$refs.exportedEvent;
for (const comp of components) {
let componentClass = comp.$vnode.tag.split("-").reverse()[0];
switch (componentClass) {
case "MessageIncomingImageExport":
case "MessageOutgoingImageExport":
// TODO - maybe consider what media to download based on the file size we already have?
// info = comp.event.getContent().info;
// if (info && info.size && currentMediaSize + info.size > maxMediaSize) {
// // No need to even download.
// console.log("Dont download!");
// continue;
// }
for (const parentComp of components) {
let childComponents = [parentComp];
downloadPromises.push(
util
.getAttachment(this.$matrix.matrixClient, comp.event, null, true)
.then((blob) => {
return new Promise((resolve, ignoredReject) => {
let mime = blob.type;
var extension = ".png";
switch (mime) {
case "image/jpeg":
case "image/jpg":
extension = ".jpg";
break;
case "image/gif":
extension = ".gif";
// Some components, i.e. the media threads, have subcomponents
// that we want to export. So pickup subcomponents here as well.
if (parentComp.$refs && parentComp.$refs.exportedEvent) {
if (Array.isArray(parentComp.$refs.exportedEvent)) {
for (const child of parentComp.$refs.exportedEvent) {
childComponents.push(child);
}
} else {
childComponents.push(parentComp.$refs.exportedEvent);
}
}
for (const comp of childComponents) {
// Avatars need downloading?
if (comp.$el) {
const avatars = comp.$el.getElementsByClassName("v-avatar");
if (avatars && avatars.length > 0) {
const member = this.room.getMember(comp.event.getSender());
if (member) {
const fileName = comp.event.getSender() + ".png";
const setSource = (fileName) => {
for (let avatarIndex = 0; avatarIndex < avatars.length; avatarIndex++) {
const avatarElement = avatars[avatarIndex];
const images = avatarElement.getElementsByTagName("img");
for (let imageIndex = 0; imageIndex < images.length; imageIndex++) {
const img = images[imageIndex];
img.onerror = undefined;
img.src = './avatars/' + fileName;
}
}
}
if (!avatarFolder.file(fileName)) {
const url = member.getAvatarUrl(this.$matrix.matrixClient.getHomeserverUrl(), 40, 40, "scale", true);
if (url) {
avatarFolder.file(fileName, "empty");
downloadPromises.push(
axios.get(url, {
responseType: 'blob'
})
.then(result => {
if (result.data) {
avatarFolder.file(fileName, result.data);
setSource(fileName);
}
})
.catch(err => {
console.error("Download error: ", err);
avatarFolder.remove(fileName);
}));
}
} else {
setSource(fileName);
}
}
}
}
let componentClass = comp.$vnode.tag.split("-").reverse()[0];
switch (componentClass) {
case "MessageIncomingImageExport":
case "MessageOutgoingImageExport":
// TODO - maybe consider what media to download based on the file size we already have?
// info = comp.event.getContent().info;
// if (info && info.size && currentMediaSize + info.size > maxMediaSize) {
// // No need to even download.
// console.log("Dont download!");
// continue;
// }
downloadPromises.push(
util
.getAttachment(this.$matrix.matrixClient, comp.event, null, true)
.then((blob) => {
return new Promise((resolve, ignoredReject) => {
let mime = blob.type;
var extension = ".png";
switch (mime) {
case "image/jpeg":
case "image/jpg":
extension = ".jpg";
break;
case "image/gif":
extension = ".gif";
}
if (currentMediaSize + blob.size <= maxMediaSize) {
currentMediaSize += blob.size;
let fileName = comp.event.getId() + extension;
imageFolder.file(fileName, blob); // TODO calc bytes
let blobUrl = URL.createObjectURL(blob);
comp.src = blobUrl;
this.$nextTick(() => {
// Update source
let elements = comp.$el.getElementsByClassName("v-image__image");
let element = elements && elements[0];
if (element) {
element.style.backgroundImage = 'url("./images/' + fileName + '")';
element.classList.remove("v-image__image--preload");
}
URL.revokeObjectURL(blobUrl); // Give the blob back
this.processedEvents += 1;
resolve(true);
});
}
});
})
.catch((ignoredErr) => {
this.processedEvents += 1;
})
);
break;
case "MessageIncomingAudioExport":
case "MessageOutgoingAudioExport":
downloadPromises.push(
util
.getAttachment(this.$matrix.matrixClient, comp.event, null, true)
.then((blob) => {
if (currentMediaSize + blob.size <= maxMediaSize) {
currentMediaSize += blob.size;
let fileName = comp.event.getId() + extension;
imageFolder.file(fileName, blob); // TODO calc bytes
let blobUrl = URL.createObjectURL(blob);
comp.src = blobUrl;
this.$nextTick(() => {
// Update source
let elements = comp.$el.getElementsByClassName("v-image__image");
return new Promise((resolve, ignoredReject) => {
//let mime = blob.type;
var extension = ".mp3";
let fileName = comp.event.getId() + extension;
audioFolder.file(fileName, blob); // TODO calc bytes
let elements = comp.$el.getElementsByTagName("audio");
let element = elements && elements[0];
if (element) {
element.style.backgroundImage = 'url("./images/' + fileName + '")';
element.classList.remove("v-image__image--preload");
element.src = "./audio/" + fileName;
}
URL.revokeObjectURL(blobUrl); // Give the blob back
this.processedEvents += 1;
resolve(true);
});
}
});
})
.catch((ignoredErr) => {
this.processedEvents += 1;
})
);
break;
case "MessageIncomingAudioExport":
case "MessageOutgoingAudioExport":
downloadPromises.push(
util
.getAttachment(this.$matrix.matrixClient, comp.event, null, true)
.then((blob) => {
if (currentMediaSize + blob.size <= maxMediaSize) {
currentMediaSize += blob.size;
return new Promise((resolve, ignoredReject) => {
//let mime = blob.type;
var extension = ".mp3";
let fileName = comp.event.getId() + extension;
audioFolder.file(fileName, blob); // TODO calc bytes
let elements = comp.$el.getElementsByTagName("audio");
let element = elements && elements[0];
if (element) {
element.src = "./audio/" + fileName;
}
this.processedEvents += 1;
resolve(true);
});
}
})
.catch((ignoredErr) => {
this.processedEvents += 1;
})
);
break;
case "MessageIncomingVideoExport":
case "MessageOutgoingVideoExport":
downloadPromises.push(
util
.getAttachment(this.$matrix.matrixClient, comp.event, null, true)
.then((blob) => {
if (currentMediaSize + blob.size <= maxMediaSize) {
currentMediaSize += blob.size;
return new Promise((resolve, ignoredReject) => {
//let mime = blob.type;
var extension = ".mp4";
let fileName = comp.event.getId() + extension;
videoFolder.file(fileName, blob); // TODO calc bytes
let elements = comp.$el.getElementsByTagName("video");
let element = elements && elements[0];
if (element) {
element.src = "./video/" + fileName;
}
this.processedEvents += 1;
})
.catch((ignoredErr) => {
this.processedEvents += 1;
})
);
break;
case "MessageIncomingVideoExport":
case "MessageOutgoingVideoExport":
downloadPromises.push(
util
.getAttachment(this.$matrix.matrixClient, comp.event, null, true)
.then((blob) => {
if (currentMediaSize + blob.size <= maxMediaSize) {
currentMediaSize += blob.size;
return new Promise((resolve, ignoredReject) => {
//let mime = blob.type;
var extension = ".mp4";
let fileName = comp.event.getId() + extension;
videoFolder.file(fileName, blob); // TODO calc bytes
let elements = comp.$el.getElementsByTagName("video");
let element = elements && elements[0];
if (element) {
element.src = "./video/" + fileName;
}
this.processedEvents += 1;
resolve(true);
});
}
})
.catch((ignoredErr) => {
this.processedEvents += 1;
})
);
break;
default:
this.processedEvents += 1;
break;
resolve(true);
});
}
})
.catch((ignoredErr) => {
this.processedEvents += 1;
})
);
break;
default:
this.processedEvents += 1;
break;
}
}
}
return Promise.all(downloadPromises);
@ -410,7 +492,7 @@ export default {
}
doc +=
"</head><body><div class='v-application v-application--is-ltr theme--light' style='height:100%;overflow-y:auto'>";
const getCssRules = function(el) {
const getCssRules = function (el) {
if (el.classList.contains("op-button")) {
el.innerHTML = "";
} else {
@ -441,6 +523,30 @@ export default {
this.$emit("close");
});
},
onLayoutChange(action, ignoredelement) {
action();
},
},
};
</script>
<style lang="scss">
.chat-root.export {
.messageIn-thread, .messageOut-thread {
/** For media threads, hide all duplicated metadata, like
sender, sender avatar, time, quick reactions etc. They are
shown for the root thread event */
.messageIn {
margin-left: 50px !important;
}
.messageOut {
margin-right: 50px !important;
}
.messageIn, .messageOut {
.quick-reaction-container, .senderAndTime, .avatar {
display: none;
}
}
}
}
</style>

View file

@ -13,6 +13,7 @@
</v-btn>
<div class="room-name no-upper">{{ $t("room_info.title") }}</div>
<v-btn
v-if="!userCanPurgeRoom"
id="btn-leave-room"
color="black"
depressed
@ -20,6 +21,16 @@
@click.stop="showLeaveConfirmation = true"
>👋 {{ $t("room_info.leave_room") }}</v-btn
>
<!-- PURGE ROOM -->
<v-btn
v-else
id="btn-purge-room"
color="red"
class="filled-button"
@click.stop="showPurgeConfirmation = true"
>
<v-icon light>$vuetify.icons.ic_moderator-delete</v-icon> {{ $t("room_info.purge") }}
</v-btn>
</v-container>
</div>
@ -127,13 +138,17 @@
</v-card-text>
</v-card>
<v-card class="account ma-3" flat v-if="availableRoomTypes.length > 1 || canChangeReadOnly()">
<v-card class="account ma-3" flat v-if="(iAmAdmin() && availableRoomTypes.length > 1) || canChangeReadOnly()">
<v-card-title class="h2 with-right-label"><div>{{ $t("room_info.experimental_features") }}</div><div></div></v-card-title>
<v-card-text class="with-right-label" v-if="availableRoomTypes.length > 1">
<div>
<div class="option-title">{{ $t('room_info.room_type') }}</div>
<v-card-text v-if="iAmAdmin() && availableRoomTypes.length > 1">
<div class="d-flex flex-wrap">
<div class="col-12 col-md-6 mr-auto pa-0">
<div class="option-title">{{ $t('room_info.room_type') }}</div>
</div>
<div class="col-12 col-md-6 pa-0">
<RoomTypeSelector v-model="roomType" />
</div>
</div>
<RoomTypeSelector v-model="roomType" />
</v-card-text>
<v-card-text class="with-right-label" v-if="canChangeReadOnly()">
<div>
@ -216,20 +231,6 @@
>{{ $t("room_info.export_room") }}</v-btn>
</div>
<!-- PURGE ROOM -->
<div class="members ma-3 pa-3 text-center">
<v-btn
id="btn-purge-room"
v-if="userCanPurgeRoom"
color="red"
fab
class="filled-button"
@click.stop="showPurgeConfirmation = true"
>
<v-icon light>$vuetify.icons.ic_moderator-delete</v-icon> {{ $t("room_info.purge") }}
</v-btn>
</div>
<div class="build-version">
{{ $t("room_info.version_info", { version: buildVersion }) }}
</div>
@ -260,7 +261,7 @@ import CopyLink from "../components/CopyLink.vue"
import RoomTypeSelector from "./RoomTypeSelector.vue";
import roomInfoMixin from "./roomInfoMixin";
import roomTypeMixin from "./roomTypeMixin";
import util, { ROOM_TYPE_DEFAULT, ROOM_TYPE_FILE_MODE, ROOM_TYPE_VOICE_MODE } from "../plugins/utils";
import util, { STATE_EVENT_ROOM_TYPE } from "../plugins/utils";
export default {
name: "RoomInfo",
@ -340,25 +341,29 @@ export default {
roomType: {
get: function () {
if (this.room && this.room.tags) {
let options = this.room.tags["ui_options"] || {}
if (options["voice_mode"]) {
return ROOM_TYPE_VOICE_MODE;
} else if (options["file_mode"]) {
return ROOM_TYPE_FILE_MODE;
}
}
return ROOM_TYPE_DEFAULT;
// if (this.room && this.room.tags) {
// let options = this.room.tags["ui_options"] || {}
// if (options["voice_mode"]) {
// return ROOM_TYPE_VOICE_MODE;
// } else if (options["file_mode"]) {
// return ROOM_TYPE_FILE_MODE;
// }
// }
// return ROOM_TYPE_DEFAULT;
return util.roomDisplayTypeOverride(this.room) || this.roomDisplayType;
},
set: function (roomType) {
if (this.room) {
let tags = this.room.tags || {};
let options = tags["ui_options"] || {}
options["voice_mode"] = (roomType == ROOM_TYPE_VOICE_MODE ? 1 : 0);
options["file_mode"] = (roomType == ROOM_TYPE_FILE_MODE ? 1 : 0);
tags["ui_options"] = options;
this.room.tags = tags;
this.$matrix.matrixClient.setRoomTag(this.room.roomId, "ui_options", options);
// let tags = this.room.tags || {};
// let options = tags["ui_options"] || {}
// options["voice_mode"] = (roomType == ROOM_TYPE_VOICE_MODE ? 1 : 0);
// options["file_mode"] = (roomType == ROOM_TYPE_FILE_MODE ? 1 : 0);
// tags["ui_options"] = options;
// this.room.tags = tags;
// this.$matrix.matrixClient.setRoomTag(this.room.roomId, "ui_options", options);
if (this.iAmAdmin()) {
this.$matrix.matrixClient.sendStateEvent(this.room.roomId, STATE_EVENT_ROOM_TYPE, { type: roomType });
}
}
},
},
@ -542,6 +547,12 @@ export default {
}
return false;
},
iAmAdmin() {
if (this.room) {
return this.room.currentState && this.room.currentState.maySendStateEvent("m.room.power_levels", this.$matrix.currentUserId);
}
return false;
},
// TODO - following power level comparisons assume that default power levels are used in the room!
isAdmin(member) {
return member.powerLevelNorm > 50;

View file

@ -6,7 +6,7 @@
:showCloseButton="false"
>
<div class="room-info-sheet" ref="roomInfoSheetContent">
<room-list v-on:close="close" v-on:newroom="createRoom" :showCreate="true" />
<room-list v-on:close="close" v-on:newroom="createRoom" :showCreate="!$config.hide_add_room_on_home" />
</div>
</BottomSheet>
</template>

View file

@ -15,7 +15,7 @@
<!-- invites -->
<v-list-item :disabled="roomsProcessing[room.roomId]" v-for="room in invitedRooms" :key="room.roomId"
:value="room.roomId" class="room-list-room">
:value="room" class="room-list-room">
<v-list-item-avatar size="42" color="#d9d9d9">
<v-img v-if="roomAvatar(room)" :src="roomAvatar(room)" />
<span v-else class="white--text headline">{{
@ -29,13 +29,13 @@
<v-list-item-action>
<v-btn id="btn-accept" class="filled-button" depressed color="black" @click.stop="acceptInvitation(room)">{{
$t("menu.join") }}</v-btn>
<v-btn id="btn-reject" class="filled-button" color="black" @click.stop="rejectInvitation(room)" text>{{
<v-btn v-if="!room.isServiceNoticeRoom" id="btn-reject" class="filled-button" color="black" @click.stop="rejectInvitation(room)" text>{{
$t("menu.ignore") }}</v-btn>
</v-list-item-action>
</v-list-item>
<v-list-item v-for="room in joinedRooms" :key="room.roomId" :value="room.roomId" class="room-list-room">
<v-list-item-avatar size="42" color="#d9d9d9">
<v-list-item v-for="room in joinedRooms" :key="room.roomId" :value="room" class="room-list-room">
<v-list-item-avatar size="42" color="#d9d9d9" :class="[{'rounded-circle': isDirect(room)}]">
<v-img v-if="roomAvatar(room)" :src="roomAvatar(room)" />
<span v-else class="white--text headline">{{
room.name.substring(0, 1).toUpperCase()
@ -181,16 +181,19 @@ export default {
return this.$matrix.isDirectRoom(room);
},
roomChange(roomId) {
if (roomId == null || roomId == undefined) {
roomChange(room) {
if (room == null || room == undefined) {
// Ignore, this is caused by "new room" etc.
return;
}
if (room.isServiceNoticeRoom && room.selfMembership === "invite") {
return; // Nothing should happen when click on invite to server notices room, just the "join" button is enabled.
}
this.$emit("close");
this.$navigation.push(
{
name: "Chat",
params: { roomId: util.sanitizeRoomId(roomId) },
params: { roomId: util.sanitizeRoomId(room.roomId) },
},
-1
);

View file

@ -25,6 +25,9 @@
<v-btn v-show="state == states.IMPORTED" icon @click.stop="previewAudio">
<v-icon color="white">$vuetify.icons.audio_import_play</v-icon>
</v-btn>
<v-btn v-show="state == states.PLAYING" icon @click.stop="pauseAudio">
<v-icon color="white">pause</v-icon>
</v-btn>
</v-col>
<v-col cols="4" align="center">
<v-btn
@ -37,7 +40,7 @@
<v-icon color="white">stop</v-icon>
</v-btn>
<v-btn
v-else-if="state == states.RECORDED || state == states.IMPORTED"
v-else-if="state == states.RECORDED || state == states.IMPORTED || state == states.PLAYING"
id="btn-send"
class="voice-recorder-btn recorded"
icon
@ -110,12 +113,12 @@
{{ recordingTime }}
</div>
</v-col>
<v-col cols="3">
<v-col cols="3" class="pa-0">
<v-btn id="btn-record-cancel" @click.stop="cancelRecording" text class="swipe-info">{{
$t("menu.cancel")
}}</v-btn>
</v-col>
<v-col cols="3">
<v-col cols="3" class="pa-0">
<v-btn id="btn-record-stop" @click.stop="stopRecording" icon class="swipe-info"
><v-icon color="white">stop</v-icon></v-btn
>
@ -159,7 +162,8 @@ const State = {
RECORDING: "recording",
RECORDED: "recorded",
ERROR: "error",
IMPORTED: "imported"
IMPORTED: "imported",
PLAYING: "playing"
};
import util from "../plugins/utils";
import VoiceRecorderLock from "./VoiceRecorderLock";
@ -253,6 +257,7 @@ export default {
this.state = State.INITIAL;
this.errorMessage = null;
this.recordedFile = null;
this.previewPlayer = null;
this.recordingTime = String.fromCharCode(160);
if (this.usePTT) {
document.addEventListener("mouseup", this.mouseUp, false);
@ -321,6 +326,11 @@ export default {
this.stopRecordTimer();
this.recordingTime = String.fromCharCode(160); // nbsp;
this.$emit("close");
this.previewPlayer = null;
this.recordedFile = null;
if (this.$refs.audio_import) {
this.$refs.audio_import.value = null;
}
},
mouseUp(ignoredEvent) {
document.removeEventListener("mouseup", this.mouseUp, false);
@ -385,7 +395,7 @@ export default {
});
},
screenLocked() {
if (document.visibilityState === "hidden" && this.state == State.RECORDING) {
if (document.visibilityState === "hidden" && this.state == State.RECORDING) {
this.pauseRecording();
}
},
@ -451,6 +461,12 @@ export default {
},
send() {
this.$emit("file", { file: this.recordedFile });
if (this.previewPlayer) {
this.previewPlayer.pause();
}
if (this.$refs.audio_import) {
this.$refs.audio_import.value = null;
}
},
getFile(send) {
const duration = Date.now() - this.recordStartedAt;
@ -532,6 +548,7 @@ export default {
},
previewAudio() {
this.state = State.PLAYING;
if (this.recordedFile) {
if (!this.previewPlayer) {
this.previewPlayer = new Audio();
@ -543,6 +560,10 @@ export default {
};
reader.readAsDataURL(this.recordedFile);
}
},
pauseAudio() {
this.state = State.IMPORTED;
this.previewPlayer.pause();
}
},
};

View file

@ -1,4 +1,4 @@
import util from "../plugins/utils";
import util, { STATE_EVENT_ROOM_DELETION_NOTICE } from "../plugins/utils";
import MessageIncomingText from "./messages/MessageIncomingText";
import MessageIncomingFile from "./messages/MessageIncomingFile";
import MessageIncomingImage from "./messages/MessageIncomingImage.vue";
@ -14,12 +14,15 @@ import MessageOutgoingAudio from "./messages/MessageOutgoingAudio.vue";
import MessageOutgoingVideo from "./messages/MessageOutgoingVideo.vue";
import MessageOutgoingSticker from "./messages/MessageOutgoingSticker.vue";
import MessageOutgoingPoll from "./messages/MessageOutgoingPoll.vue";
import MessageOutgoingThread from "./messages/MessageOutgoingThread.vue";
import MessageIncomingImageExport from "./messages/export/MessageIncomingImageExport";
import MessageIncomingAudioExport from "./messages/export/MessageIncomingAudioExport";
import MessageIncomingVideoExport from "./messages/export/MessageIncomingVideoExport";
import MessageIncomingThreadExport from "./messages/export/MessageIncomingThreadExport";
import MessageOutgoingImageExport from "./messages/export/MessageOutgoingImageExport";
import MessageOutgoingAudioExport from "./messages/export/MessageOutgoingAudioExport";
import MessageOutgoingVideoExport from "./messages/export/MessageOutgoingVideoExport";
import MessageOutgoingThreadExport from "./messages/export/MessageOutgoingThreadExport";
import ContactJoin from "./messages/ContactJoin.vue";
import ContactLeave from "./messages/ContactLeave.vue";
import ContactInvited from "./messages/ContactInvited.vue";
@ -49,8 +52,10 @@ import RoomGuestAccessChanged from "./messages/RoomGuestAccessChanged.vue";
import RoomEncrypted from "./messages/RoomEncrypted.vue";
import RoomDeletionNotice from "./messages/RoomDeletionNotice.vue";
import DebugEvent from "./messages/DebugEvent.vue";
import roomDisplayOptionsMixin from "./roomDisplayOptionsMixin";
export default {
mixins: [ roomDisplayOptionsMixin ],
components: {
ChatHeader,
MessageIncomingText,
@ -66,6 +71,7 @@ export default {
MessageOutgoingAudio,
MessageOutgoingVideo,
MessageOutgoingSticker,
MessageOutgoingThread,
MessageOutgoingPoll,
ContactJoin,
ContactLeave,
@ -96,15 +102,6 @@ export default {
CreatePollDialog,
},
methods: {
showOnlyUserStatusMessages() {
// We say that if you can redact events, you are allowed to create polls.
// NOTE!!! This assumes that there is a property named "room" on THIS.
const me = this.room && this.room.getMember(this.$matrix.currentUserId);
let isModerator =
me && this.room.currentState && this.room.currentState.hasSufficientPowerLevelFor("redact", me.powerLevel);
const show = this.$config.show_status_messages;
return show === "never" || (show === "moderators" && !isModerator)
},
showDayMarkerBeforeEvent(event) {
const idx = this.events.indexOf(event);
if (idx <= 0) {
@ -128,6 +125,13 @@ export default {
},
componentForEvent(event, isForExport = false) {
if (!event.isRelation() && !event.isRedaction() && event.isRedacted()) {
const redaction = event.getRedactionEvent();
if (redaction && redaction.content && redaction.content.reason === "cancel") {
return null; // Show nothing, it was canceled!
}
}
switch (event.getType()) {
case "m.room.member":
if (event.getContent().membership == "join") {
@ -135,6 +139,9 @@ export default {
// We we already joined, so this must be a display name and/or avatar update!
return ContactChanged;
} else {
if (event.getSender() == this.$matrix.currentUserId && !this.showOwnJoins) {
return null;
}
return ContactJoin;
}
} else if (event.getContent().membership == "leave") {
@ -143,7 +150,7 @@ export default {
return ContactKicked;
}
return ContactLeave;
} else if (!this.showOnlyUserStatusMessages()) {
} else if (this.showAllStatusMessages) {
if (event.getContent().membership == "invite") {
return ContactInvited;
} else if (event.getContent().membership == "ban") {
@ -154,9 +161,9 @@ export default {
case "m.room.message":
if (event.getSender() != this.$matrix.currentUserId) {
if (event.isThreadRoot) {
if (event.isMxThread) {
// Incoming thread, e.g. a file drop!
return MessageIncomingThread;
return isForExport ? MessageIncomingThreadExport : MessageIncomingThread;
}
if (event.getContent().msgtype == "m.image") {
// For SVG, make downloadable
@ -188,6 +195,10 @@ export default {
}
return MessageIncomingText;
} else {
if (event.isMxThread) {
// Outgoing thread
return isForExport ? MessageOutgoingThreadExport : MessageOutgoingThread;
}
if (event.getContent().msgtype == "m.image") {
// For SVG, make downloadable
if (
@ -220,61 +231,61 @@ export default {
}
case "m.room.create":
if (!this.showOnlyUserStatusMessages()) {
if (this.showAllStatusMessages) {
return RoomCreated;
}
break;
case "m.room.canonical_alias":
if (!this.showOnlyUserStatusMessages()) {
if (this.showAllStatusMessages) {
return RoomAliased;
}
break;
case "m.room.name":
if (!this.showOnlyUserStatusMessages()) {
if (this.showAllStatusMessages) {
return RoomNameChanged;
}
break;
case "m.room.topic":
if (!this.showOnlyUserStatusMessages()) {
if (this.showAllStatusMessages) {
return RoomTopicChanged;
}
break;
case "m.room.avatar":
if (!this.showOnlyUserStatusMessages()) {
if (this.showAllStatusMessages) {
return RoomAvatarChanged;
}
break;
case "m.room.history_visibility":
if (!this.showOnlyUserStatusMessages()) {
if (this.showAllStatusMessages) {
return RoomHistoryVisibility;
}
break;
case "m.room.join_rules":
if (!this.showOnlyUserStatusMessages()) {
if (this.showAllStatusMessages) {
return RoomJoinRules;
}
break;
case "m.room.power_levels":
if (!this.showOnlyUserStatusMessages()) {
if (this.showAllStatusMessages) {
return RoomPowerLevelsChanged;
}
break;
case "m.room.guest_access":
if (!this.showOnlyUserStatusMessages()) {
if (this.showAllStatusMessages) {
return RoomGuestAccessChanged;
}
break;
case "m.room.encryption":
if (!this.showOnlyUserStatusMessages()) {
if (this.showAllStatusMessages) {
return RoomEncrypted;
}
break;
@ -287,9 +298,9 @@ export default {
return MessageOutgoingPoll;
}
case "im.keanu.room_deletion_notice": {
case STATE_EVENT_ROOM_DELETION_NOTICE: {
// Custom event for notice 30 seconds before a room is deleted/purged.
const deletionNotices = this.room.currentState.getStateEvents("im.keanu.room_deletion_notice");
const deletionNotices = this.room.currentState.getStateEvents(STATE_EVENT_ROOM_DELETION_NOTICE);
if (deletionNotices && deletionNotices.length > 0 && deletionNotices[deletionNotices.length - 1] == event) {
// This is the latest/last one. Look at the status flag. Show nothing if it is "cancel".
if (event.getContent().status != "cancel") {

View file

@ -44,7 +44,7 @@
sendCurrentTextMessage();
}
" />
<v-btn @click="send" :disabled="!attachments || attachments.length == 0">{{ $t("menu.send") }}</v-btn>
<v-btn @click="sendAll" :disabled="!attachments || attachments.length == 0">{{ $t("menu.send") }}</v-btn>
</div>
</div>
</template>
@ -54,13 +54,13 @@
v-if="attachments && attachments.length > 0 && (status == mainStatuses.SENDING || status == mainStatuses.SENT)">
<div class="attachment-wrapper">
<div class="file-drop-sent-stack" ref="stackContainer">
<div v-if="status == mainStatuses.SENDING && countSent == 0" class="no-items">
<div v-if="status == mainStatuses.SENDING && attachmentsSentCount == 0" class="no-items">
<div class="file-drop-stack-item direct" :style="stackItemTransform(null, -1)"></div>
<div>{{ $t('file_mode.sending_progress') }}</div>
</div>
<div v-else v-for="(item, index) in sentItems" :key="item.id" class="file-drop-stack-item animated"
:style="stackItemTransform(item, index)">
<v-img v-if="item.attachment && item.attachment.image" :src="item.attachment.image" />
<div v-else v-for="(info, index) in attachmentsSent" :key="info.id" class="file-drop-stack-item animated"
:style="stackItemTransform(info, index)">
<v-img v-if="info.preview" :src="info.preview" />
</div>
<div v-if="status == mainStatuses.SENT" class="items-sent" :style="stackItemTransform(null, -1)">
<v-icon>$vuetify.icons.ic_check_circle</v-icon>
@ -69,18 +69,18 @@
<!-- Middle section -->
<div v-if="status == mainStatuses.SENDING" class="file-drop-sending-container">
<div class="file-drop-sending-item" v-for="(info, index) in sendingItems" :key="index">
<v-img v-if="info.attachment && info.attachment.image" :src="info.attachment.image" />
<div class="file-drop-sending-item" v-for="(info, index) in attachmentsSending" :key="index">
<v-img v-if="info.preview" :src="info.preview" />
<div v-else class="filename">{{ info.attachment.name }}</div>
<v-progress-linear :value="info.progress"></v-progress-linear>
<div class="file-drop-cancel clickable" @click.stop="cancelSendingItem(info)">
<div class="file-drop-cancel clickable" @click.stop="cancelSendAttachmentItem(info)">
<v-icon size="14" color="white">close</v-icon>
</div>
</div>
</div>
<div v-else-if="status == mainStatuses.SENT" class="file-drop-sending-container">
<div class="file-drop-files-sent">{{ $tc((this.messageInput && this.messageInput.length > 0) ?
"file_mode.files_sent_with_note" : "file_mode.files_sent", sentItems.length) }}</div>
"file_mode.files_sent_with_note" : "file_mode.files_sent", attachmentsSent.length) }}</div>
<div class="file-drop-section">
<v-textarea disabled full-width solo flat auto-grow v-model="messageInput" no-resize class="input-area-text"
rows="1" hide-details background-color="transparent" />
@ -95,7 +95,8 @@
color="#4642F1"></v-progress-circular></v-btn>
</div>
<div v-else-if="status == mainStatuses.SENT" class="file-drop-sent-input-container">
<v-btn @click.stop="reset">{{ $t("file_mode.return_to_home") }}</v-btn>
<v-btn @click.stop="reset">{{ $t("file_mode.send_more_files") }}</v-btn>
<v-btn class="close" @click.stop="close">{{ $t("file_mode.close") }}</v-btn>
</div>
</div>
</template>
@ -104,11 +105,11 @@
<script>
import messageMixin from "../messages/messageMixin";
import util from "../../plugins/utils";
import sendAttachmentsMixin from "../sendAttachmentsMixin";
const prettyBytes = require("pretty-bytes");
export default {
mixins: [messageMixin],
mixins: [messageMixin, sendAttachmentsMixin],
components: {},
props: {
attachments: {
@ -128,13 +129,6 @@ export default {
SENT: 2,
}),
status: 0,
statuses: Object.freeze({
INITIAL: 0,
SENT: 1,
CANCELED: 2,
FAILED: 3,
}),
sendInfo: [],
dropTarget: false,
};
},
@ -150,20 +144,6 @@ export default {
return this.currentItemIndex >= 0 && this.currentItemIndex < this.attachments.length &&
this.attachments[this.currentItemIndex].image
},
countSent() {
return this.sendInfo ? this.sendInfo.reduce((a, elem, ignoredidx, ignoredarray) => elem.status == this.statuses.SENT ? a + 1 : a, 0) : 0
},
sendingItems() {
return this.sendInfo ? this.sendInfo.filter(elem => elem.status == this.statuses.INITIAL) : []
},
sentItems() {
this.sortSendinfo();
return this.sendInfo ? this.sendInfo.filter(elem => elem.status == this.statuses.SENT) : []
},
sentItemsReversed() {
const array = this.sentItems;
return array.map((ignoreditem, idx) => array[array.length - 1 - idx])
}
},
watch: {
attachments(newValue, oldValue) {
@ -206,101 +186,23 @@ export default {
},
reset() {
this.$emit('reset');
this.sendInfo = [];
this.sendingAttachments = [];
this.status = this.mainStatuses.SELECTING;
this.messageInput = "";
this.currentItemIndex = 0;
},
send() {
this.status = this.mainStatuses.SENDING;
this.sendInfo = this.attachments.map((attachment) => {
return {
id: attachment.name,
status: this.statuses.INITIAL,
statusDate: Date.now,
attachment: attachment,
progress: 0,
randomRotation: 0,
randomTranslationX: 0,
randomTranslationY: 0
}
});
const text = (this.messageInput && this.messageInput.length > 0) ? this.messageInput : this.$t('file_mode.files');
util.sendTextMessage(this.$matrix.matrixClient, this.room.roomId, text)
.then((eventId) => {
// Use the eventId as a thread root for all the media
let promiseChain = Promise.resolve();
const getItemPromise = (index) => {
if (index < this.sendInfo.length) {
const item = this.sendInfo[index];
if (item.status !== this.statuses.INITIAL) {
return getItemPromise(++index);
}
const itemPromise = util.sendImage(this.$matrix.matrixClient, this.room.roomId, item.attachment, ({ loaded, total }) => {
if (loaded == total) {
item.progress = 100;
} else if (total > 0) {
item.progress = 100 * loaded / total;
}
}, eventId)
.then(() => {
// Look at last item rotation, flipping the sign on this, so looks more like a true stack
let signR = 1;
let signX = 1;
let signY = 1;
if (this.sentItems.length > 0) {
if (this.sentItems[0].randomRotation >= 0) {
signR = -1;
}
if (this.sentItems[0].randomTranslationX >= 0) {
signX = -1;
}
if (this.sentItems[0].randomTranslationY >= 0) {
signY = -1;
}
}
item.randomRotation = signR * (2 + Math.random() * 10);
item.randomTranslationX = signX * Math.random() * 20;
item.randomTranslationY = signY * Math.random() * 20;
item.status = this.statuses.SENT;
item.statusDate = Date.now;
}).catch(ignorederr => {
if (item.promise.aborted) {
item.status = this.statuses.CANCELED;
} else {
console.error("ERROR", ignorederr);
item.status = this.statuses.FAILED;
}
});
item.promise = itemPromise;
return itemPromise.then(() => getItemPromise(++index));
}
else return Promise.resolve();
};
return promiseChain.then(() => getItemPromise(0));
})
.then(() => {
this.status = this.mainStatuses.SENT;
})
close() {
this.$matrix.leaveRoomAndNavigate(this.room.roomId)
.catch((err) => {
console.error("ERROR", err);
console.log("Error leaving", err);
});
},
cancelSendingItem(item) {
if (item.promise && item.status == this.statuses.INITIAL) {
item.promise.abort();
}
item.status = this.statuses.CANCELED;
},
checkDone() {
if (!this.sendInfo.some(a => a.status == this.statuses.INITIAL)) {
this.status = this.mainStatuses.SENT;
}
},
sortSendinfo() {
this.sendInfo.sort((a, b) => b.statusDate - a.statusDate);
sendAll() {
this.status = this.mainStatuses.SENDING;
this.sendAttachments((this.messageInput && this.messageInput.length > 0) ? this.messageInput : this.$t('file_mode.files'), this.attachments)
.then(() => {
this.status = this.mainStatuses.SENT;
});
},
stackItemTransform(item, index) {
const size = 0.6 * (this.$refs.stackContainer ? Math.min(this.$refs.stackContainer.clientWidth, this.$refs.stackContainer.clientHeight) : 176);

View file

@ -0,0 +1,143 @@
<template>
<div class="fill-screen file-drop-root">
<div class="chat-header">
<v-container fluid class="d-flex justify-space-between align-center">
<v-icon @click.stop="$emit('close')" color="white" class="clickable">arrow_back</v-icon>
<div class="room-name no-upper">{{ displayDate }}</div>
<v-icon @click.stop="showMoreMenu = true" color="white" class="clickable">more_vert</v-icon>
</v-container>
</div>
<div class="file-drop-current-item">
<ThumbnailView :item="items[currentItemIndex]" />
<div class="download-button clickable" @click.stop="downloadOne">
<v-icon color="black">arrow_downward</v-icon>
</div>
</div>
<div class="file-drop-thumbnail-container">
<div :class="{ 'file-drop-thumbnail': true, 'clickable': true, 'current': id == currentItemIndex }"
@click="currentItemIndex = id" v-for="(currentImageInput, id) in items" :key="id">
<v-img v-if="currentImageInput && currentImageInput.src" :src="currentImageInput.src" />
</div>
</div>
<!-- MORE MENU POPUP -->
<MoreMenuPopup :show="showMoreMenu" :menuItems="moreMenuItems" :showProfile="false" @close="showMoreMenu = false" />
</div>
</template>
<script>
import MoreMenuPopup from "../MoreMenuPopup";
import messageMixin from "../messages/messageMixin";
import util from "../../plugins/utils";
import ThumbnailView from './ThumbnailView.vue';
export default {
mixins: [messageMixin],
components: { MoreMenuPopup, ThumbnailView },
props: {
items: {
type: Array,
default: function () {
return []
}
},
initialItem: {
type: Object,
default: function() {
return null;
}
}
},
data() {
return {
currentItemIndex: 0,
showMoreMenu: false,
};
},
mounted() {
document.body.classList.add("dark");
if (this.initialItem) {
this.currentItemIndex = this.items.findIndex((v) => v === this.initialItem);
if (this.currentItemIndex < 0) {
this.currentItemIndex = 0;
}
}
},
beforeDestroy() {
document.body.classList.remove("dark");
},
computed: {
displayDate() {
return util.formatRecordStartTime(this.originalEvent.getTs())
},
moreMenuItems() {
let items = [];
items.push({
icon: '$vuetify.icons.ic_download', text: this.$t("message.download_all"), handler: () => {
this.downloadAll();
}
});
return items;
},
},
watch: {
items(newValue, oldValue) {
// Added or removed?
if (newValue && oldValue && newValue.length > oldValue.length) {
this.currentItemIndex = oldValue.length;
} else if (newValue) {
this.currentItemIndex = newValue.length - 1;
}
},
},
methods: {
downloadOne() {
if (this.currentItemIndex >= 0 && this.currentItemIndex < this.items.length) {
util.download(this.$matrix.matrixClient, this.items[this.currentItemIndex].event);
}
},
downloadAll() {
this.items.forEach(item => util.download(this.$matrix.matrixClient, item.event));
}
}
};
</script>
<style lang="scss">
@import "@/assets/css/chat.scss";
.chat-header {
position: relative !important;
width: 100%;
}
.file-drop-current-item {
position: relative;
}
.download-button {
position: absolute;
right: 21px;
bottom: 21px;
width: 34px;
height: 34px;
background: rgba(255,255,255,0.8);
border-radius: 17px;
display: flex;
align-items: center;
justify-content: center;
}
.fill-screen {
position: fixed !important;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: black;
z-index: 20;
justify-content: space-between !important;
}
</style>

View file

@ -0,0 +1,61 @@
<template>
<v-responsive v-if="item.event.getContent().msgtype == 'm.video'" :class="{'thumbnail-item': true, 'preview': previewOnly}"
@click.stop="$emit('itemclick', {item: item})">
<video :src="item.src" :controls="!previewOnly" class="w-100 h-100">
{{ $t('fallbacks.video_file') }}
</video>
</v-responsive>
<v-img v-else-if="item.event.getContent().msgtype == 'm.image'" :aspect-ratio="previewOnly ? (16 / 9) : undefined" :class="{'thumbnail-item': true, 'preview': previewOnly}" :src="item.src" :contain="!previewOnly" :cover="previewOnly"
@click.stop="$emit('itemclick', {item: item})" />
<div v-else :class="{'thumbnail-item': true, 'preview': previewOnly, 'file-item': true}" @click.stop="$emit('itemclick', {item: item})">
<v-icon>description</v-icon>
{{ $sanitize(item.event.getContent().body) }}
</div>
</template>
<script>
export default {
props: {
/**
* Item is an object of { event: MXEvent, src: URL }
*/
item: {
type: Object,
default: function () {
return {}
}
},
previewOnly: {
type: Boolean,
default: function() {
return false;
}
},
},
}
</script>
<style lang="scss">
@import "@/assets/css/chat.scss";
.thumbnail-item {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
}
.file-item {
display: flex;
align-items: center;
justify-content: center;
font-size: 0.6rem;
flex-direction: column;
padding: 20px;
.v-icon {
margin-bottom: 10px;
color: currentColor;
}
}
</style>

View file

@ -34,8 +34,12 @@ export default {
info: this.install(),
};
},
mounted() {
this.event.on("Event.localEventIdReplaced", this.onLocalEventIdReplaced);
},
beforeDestroy() {
this.$audioPlayer.removeListener(this._uid);
this.event.off("Event.localEventIdReplaced", this.onLocalEventIdReplaced);
},
computed: {
currentTime() {
@ -62,6 +66,12 @@ export default {
},
seeked(percent) {
this.$audioPlayer.seek(this.event, percent);
},
onLocalEventIdReplaced() {
// This happens when we are the sending party and the message has been sent and the local echo has been updated with the new real id.
// Since we use the event id to register with the audio player, we need to update.
this.$audioPlayer.removeListener(this._uid);
this.info = this.$audioPlayer.addListener(this._uid, this.event);
}
},
};

View file

@ -8,7 +8,7 @@
</div>
</div>
<v-avatar class="avatar" ref="avatar" size="32" color="#ededed" @click.stop="otherAvatarClicked($refs.avatar.$el)">
<img v-if="messageEventAvatar(event)" :src="messageEventAvatar(event)" />
<img v-if="messageEventAvatar(event)" :src="messageEventAvatar(event)" onerror="this.style.display='none'" />
<span v-else class="white--text headline">{{
eventSenderDisplayName(event).substring(0, 1).toUpperCase()
}}</span>
@ -20,7 +20,7 @@
<v-icon>more_vert</v-icon>
</v-btn>
</div>
<QuickReactions :event="event" :timelineSet="timelineSet" v-on="$listeners"/>
<QuickReactions :event="eventForReactions" :timelineSet="timelineSet" v-on="$listeners"/>
<SeenBy :room="room" :event="event"/>
</div>
</template>

View file

@ -2,14 +2,13 @@
<message-incoming v-bind="{...$props, ...$attrs}" v-on="$listeners">
<div class="bubble">
<div class="original-message" v-if="inReplyToText">
<div class="original-message-sender">
{{ $t('message.user_said', {user: inReplyToSender || "Someone"}) }}
</div>
<div class="original-message-sender">{{ inReplyToSender }}</div>
<div
class="original-message-text"
v-html="linkify($sanitize(inReplyToText))"
/>
</div>
<div class="message">
<span>{{ $t('message.file_prefix') }}</span>
<span

View file

@ -2,14 +2,13 @@
<message-incoming v-bind="{...$props, ...$attrs}" v-on="$listeners">
<div class="bubble">
<div class="original-message" v-if="inReplyToText">
<div class="original-message-sender">
{{ $t('message.user_said', {user: inReplyToSender || "Someone"}) }}
</div>
<div class="original-message-sender">{{ inReplyToSender }}</div>
<div
class="original-message-text"
v-html="linkify($sanitize(inReplyToText))"
/>
</div>
<div class="message">
<i v-if="event.isRedacted()" class="deleted-text">
<v-icon :color="this.senderIsAdminOrModerator(this.event)?'white':''" size="small">block</v-icon>

View file

@ -1,11 +1,19 @@
<template>
<message-incoming v-bind="{ ...$props, ...$attrs }" v-on="$listeners">
<message-incoming v-bind="{ ...$props, ...$attrs }" v-on="$listeners" v-if="items.length > 1">
<div class="bubble">
<div class="original-message" v-if="inReplyToText">
<div class="original-message-sender">{{ inReplyToSender }}</div>
<div
class="original-message-text"
v-html="linkify($sanitize(inReplyToText))"
/>
</div>
<div class="message">
<v-container fluid class="imageCollection">
<v-row wrap>
<v-col v-for="({ size, item }) in layoutedItems()" :key="item.event.getId()" :cols="size">
<v-img :aspect-ratio="16 / 9" :src="item.src" cover />
<ThumbnailView :item="item" :previewOnly="true" v-on:itemclick="onItemClick($event)" />
</v-col>
</v-row>
</v-container>
@ -19,42 +27,70 @@
</span>
</div>
</div>
<GalleryItemsView :originalEvent="originalEvent" :items="items" :initialItem="showItem" v-if="!!showItem" v-on:close="showItem = null" />
</message-incoming>
<component v-else-if="items.length == 1" :is="componentFn(items[0].event)"
:originalEvent="items[0].event"
v-bind="{...$props, ...$attrs}" v-on="$listeners"
/>
</template>
<script>
import MessageIncoming from "./MessageIncoming.vue";
import messageMixin from "./messageMixin";
import util from "../../plugins/utils";
import GalleryItemsView from '../file_mode/GalleryItemsView.vue';
import ThumbnailView from '../file_mode/ThumbnailView.vue';
export default {
extends: MessageIncoming,
components: { MessageIncoming },
components: { MessageIncoming, GalleryItemsView, ThumbnailView },
mixins: [messageMixin],
data() {
return {
items: []
items: [],
showItem: null,
}
},
mounted() {
this.items = this.timelineSet.relations.getAllChildEventsForEvent(this.event.getId()).map(e => {
let ret = {
event: e,
src: null,
};
ret.promise =
util
.getThumbnail(this.$matrix.matrixClient, e, 100, 100)
.then((url) => {
ret.src = url;
})
.catch((err) => {
console.log("Failed to fetch thumbnail: ", err);
});
return ret;
})
this.thread = this.timelineSet.relations.getChildEventsForEvent(this.event.getId(), "m.thread", "m.room.message");
if (!this.thread) {
this.event.on("Event.relationsCreated", this.onRelationsCreated);
}
},
beforeDestroy() {
this.event.off("Event.relationsCreated", this.onRelationsCreated);
},
methods: {
onRelationsCreated() {
this.thread = this.timelineSet.relations.getChildEventsForEvent(this.event.getId(), "m.thread", "m.room.message");
this.event.off("Event.relationsCreated", this.onRelationsCreated);
},
onItemClick(event) {
this.showItem = event.item;
},
processThread() {
this.$emit('layout-change', () => {
this.items = this.timelineSet.relations.getAllChildEventsForEvent(this.event.getId())
.filter(e => util.downloadableTypes().includes(e.getContent().msgtype))
.map(e => {
let ret = {
event: e,
src: null,
};
ret.promise =
util
.getThumbnail(this.$matrix.matrixClient, e, 100, 100)
.then((url) => {
ret.src = url;
})
.catch((err) => {
console.log("Failed to fetch thumbnail: ", err);
});
return ret;
});
}, this.$el);
},
layoutedItems() {
if (!this.items || this.items.length == 0) { return [] }
let array = this.items.slice(0);
@ -84,6 +120,9 @@ export default {
}
}
return rows
},
downloadAll() {
this.items.forEach(item => util.download(this.$matrix.matrixClient, item.event));
}
}
};
@ -94,7 +133,6 @@ export default {
</style>
<style lang="scss" scoped>
.bubble {
width: 100%;
}
@ -103,10 +141,12 @@ export default {
border-radius: 15px;
padding: 0;
overflow: hidden;
.row {
margin: -4px; // Compensate for column padding, so the border-radius above looks round!
padding: 0;
}
.col {
padding: 2px;
}

View file

@ -24,7 +24,7 @@
<img v-if="userAvatar" :src="userAvatar" />
<span v-else class="white--text headline">{{ userAvatarLetter }}</span>
</v-avatar>
<QuickReactions :event="event" :timelineSet="timelineSet" v-on="$listeners"/>
<QuickReactions :event="eventForReactions" :timelineSet="timelineSet" v-on="$listeners"/>
<SeenBy :room="room" :event="event"/>
</div>
</template>

View file

@ -2,15 +2,14 @@
<message-outgoing v-bind="{ ...$props, ...$attrs }" v-on="$listeners">
<div class="bubble">
<div class="original-message" v-if="inReplyToText">
<div class="original-message-sender">
{{ $t('message.user_said', {user: inReplyToSender || "Someone"}) }}
</div>
<div class="original-message-sender">{{ inReplyToSender }}</div>
<div
class="original-message-text"
v-html="linkify($sanitize(inReplyToText))"
/>
</div>
<div class="message">
<span>{{ $t('message.file_prefix') }}</span>
<span

View file

@ -2,9 +2,7 @@
<message-outgoing v-bind="{ ...$props, ...$attrs }" v-on="$listeners">
<div class="bubble">
<div class="original-message" v-if="inReplyToText">
<div class="original-message-sender">
{{ $t('message.user_said', {user: inReplyToSender || "Someone"}) }}
</div>
<div class="original-message-sender">{{ inReplyToSender }}</div>
<div
class="original-message-text"
v-html="linkify($sanitize(inReplyToText))"

View file

@ -0,0 +1,160 @@
<template>
<message-outgoing v-bind="{ ...$props, ...$attrs }" v-on="$listeners" v-if="items.length > 1">
<div class="bubble">
<div class="original-message" v-if="inReplyToText">
<div class="original-message-sender">{{ inReplyToSender }}</div>
<div
class="original-message-text"
v-html="linkify($sanitize(inReplyToText))"
/>
</div>
<div class="message">
<v-container fluid class="imageCollection">
<v-row wrap>
<v-col v-for="({ size, item }) in layoutedItems()" :key="item.event.getId()" :cols="size">
<ThumbnailView :item="item" :previewOnly="true" v-on:itemclick="onItemClick($event)" />
</v-col>
</v-row>
</v-container>
<i v-if="event.isRedacted()" class="deleted-text">
<v-icon size="small">block</v-icon>
{{ $t('message.outgoing_message_deleted_text') }}
</i>
<span v-html="linkify($sanitize(messageText))" v-else />
<span class="edit-marker" v-if="event.replacingEventId() && !event.isRedacted()">
{{ $t('message.edited') }}
</span>
</div>
</div>
<GalleryItemsView :originalEvent="originalEvent" :items="items" :initialItem="showItem" v-if="!!showItem" v-on:close="showItem = null" />
</message-outgoing>
<component v-else-if="items.length == 1" :is="componentFn(items[0].event)"
:originalEvent="items[0].event"
v-bind="{...$props, ...$attrs}" v-on="$listeners"
/>
</template>
<script>
import MessageOutgoing from "./MessageOutgoing.vue";
import messageMixin from "./messageMixin";
import util from "../../plugins/utils";
import GalleryItemsView from '../file_mode/GalleryItemsView.vue';
import ThumbnailView from '../file_mode/ThumbnailView.vue';
export default {
extends: MessageOutgoing,
components: { MessageOutgoing, GalleryItemsView, ThumbnailView },
mixins: [messageMixin],
data() {
return {
items: [],
showItem: null,
}
},
mounted() {
this.thread = this.timelineSet.relations.getChildEventsForEvent(this.event.getId(), "m.thread", "m.room.message");
if (!this.thread) {
this.event.on("Event.relationsCreated", this.onRelationsCreated);
}
},
beforeDestroy() {
this.event.off("Event.relationsCreated", this.onRelationsCreated);
},
methods: {
onRelationsCreated() {
this.thread = this.timelineSet.relations.getChildEventsForEvent(this.event.getId(), "m.thread", "m.room.message");
this.event.off("Event.relationsCreated", this.onRelationsCreated);
},
onItemClick(event) {
this.showItem = event.item;
},
processThread() {
this.$emit('layout-change', () => {
this.items = this.timelineSet.relations.getAllChildEventsForEvent(this.event.getId())
.filter(e => util.downloadableTypes().includes(e.getContent().msgtype))
.map(e => {
let ret = {
event: e,
src: null,
};
ret.promise =
util
.getThumbnail(this.$matrix.matrixClient, e, 100, 100)
.then((url) => {
ret.src = url;
})
.catch((err) => {
console.log("Failed to fetch thumbnail: ", err);
});
return ret;
});
}, this.$el);
},
layoutedItems() {
if (!this.items || this.items.length == 0) { return [] }
let array = this.items.slice(0);
let rows = []
while (array.length > 0) {
if (array.length >= 7) {
rows.push({ size: 6, item: array[0] });
rows.push({ size: 6, item: array[1] });
rows.push({ size: 12, item: array[2] });
rows.push({ size: 3, item: array[3] });
rows.push({ size: 3, item: array[4] });
rows.push({ size: 3, item: array[5] });
rows.push({ size: 3, item: array[6] });
array = array.slice(7);
} else if (array.length >= 3) {
rows.push({ size: 6, item: array[0] });
rows.push({ size: 6, item: array[1] });
rows.push({ size: 12, item: array[2] });
array = array.slice(3);
} else if (array.length >= 2) {
rows.push({ size: 6, item: array[0] });
rows.push({ size: 6, item: array[1] });
array = array.slice(2);
} else {
rows.push({ size: 12, item: array[0] });
array = array.slice(1);
}
}
return rows
},
}
};
</script>
<style lang="scss">
@import "@/assets/css/chat.scss";
</style>
<style lang="scss" scoped>
.bubble {
width: 100%;
}
.imageCollection {
border-radius: 15px;
padding: 0;
overflow: hidden;
.row {
margin: -4px; // Compensate for column padding, so the border-radius above looks round!
padding: 0;
}
.col {
padding: 2px;
}
.file-item {
display: flex;
align-items: center;
justify-content: center;
font-size: 0.6rem;
flex-direction: column;
padding: 20px;
}
}
</style>

View file

@ -0,0 +1,59 @@
<template>
<message-incoming class="messageIn-thread" v-bind="{ ...$props, ...$attrs }" v-on="$listeners">
<component v-for="item in items" :is="componentFn(item.event)" :originalEvent="item.event" :key="item.event.getId()"
v-bind="{ ...$props }" v-on="$listeners" ref="exportedEvent" />
</message-incoming>
</template>
<script>
import MessageIncoming from "../MessageIncoming.vue";
import messageMixin from "./../messageMixin";
import util from "../../../plugins/utils";
export default {
extends: MessageIncoming,
components: { MessageIncoming },
mixins: [messageMixin],
data() {
return {
items: [],
}
},
mounted() {
this.thread = this.timelineSet.relations.getChildEventsForEvent(this.event.getId(), "m.thread", "m.room.message");
if (!this.thread) {
this.event.on("Event.relationsCreated", this.onRelationsCreated);
}
},
beforeDestroy() {
this.event.off("Event.relationsCreated", this.onRelationsCreated);
},
methods: {
onRelationsCreated() {
this.thread = this.timelineSet.relations.getChildEventsForEvent(this.event.getId(), "m.thread", "m.room.message");
this.event.off("Event.relationsCreated", this.onRelationsCreated);
},
processThread() {
this.items = this.timelineSet.relations.getAllChildEventsForEvent(this.event.getId())
.filter(e => util.downloadableTypes().includes(e.getContent().msgtype))
.map(e => {
let ret = {
event: e,
src: null,
};
return ret;
});
},
}
};
</script>
<style lang="scss">
@import "@/assets/css/chat.scss";
</style>
<style lang="scss" scoped>
.bubble {
width: 100%;
}
</style>

View file

@ -0,0 +1,59 @@
<template>
<message-outgoing class="messageOut-thread" v-bind="{ ...$props, ...$attrs }" v-on="$listeners">
<component v-for="item in items" :is="componentFn(item.event)" :originalEvent="item.event" :key="item.event.getId()"
v-bind="{ ...$props }" v-on="$listeners" ref="exportedEvent" />
</message-outgoing>
</template>
<script>
import MessageOutgoing from "../MessageOutgoing.vue";
import messageMixin from "./../messageMixin";
import util from "../../../plugins/utils";
export default {
extends: MessageOutgoing,
components: { MessageOutgoing },
mixins: [messageMixin],
data() {
return {
items: [],
}
},
mounted() {
this.thread = this.timelineSet.relations.getChildEventsForEvent(this.event.getId(), "m.thread", "m.room.message");
if (!this.thread) {
this.event.on("Event.relationsCreated", this.onRelationsCreated);
}
},
beforeDestroy() {
this.event.off("Event.relationsCreated", this.onRelationsCreated);
},
methods: {
onRelationsCreated() {
this.thread = this.timelineSet.relations.getChildEventsForEvent(this.event.getId(), "m.thread", "m.room.message");
this.event.off("Event.relationsCreated", this.onRelationsCreated);
},
processThread() {
this.items = this.timelineSet.relations.getAllChildEventsForEvent(this.event.getId())
.filter(e => util.downloadableTypes().includes(e.getContent().msgtype))
.map(e => {
let ret = {
event: e,
src: null,
};
return ret;
});
},
}
};
</script>
<style lang="scss">
@import "@/assets/css/chat.scss";
</style>
<style lang="scss" scoped>
.bubble {
width: 100%;
}
</style>

View file

@ -2,6 +2,7 @@ import QuickReactions from "./QuickReactions.vue";
import * as linkify from 'linkifyjs';
import linkifyHtml from 'linkify-html';
import utils from "../../plugins/utils"
import util from "../../plugins/utils";
linkify.options.defaults.className = "link";
linkify.options.defaults.target = { url: "_blank" };
@ -35,28 +36,24 @@ export default {
return null;
},
},
componentFn: {
type: Function,
default: function () {
return () => {};
},
},
},
data() {
return {
event: {},
inReplyToEvent: null,
inReplyToSender: null,
utils
thread: null,
utils,
};
},
mounted() {
const relatesTo = this.validEvent && this.event.getWireContent()["m.relates_to"];
if (relatesTo && relatesTo["m.in_reply_to"]) {
// Can we find the original message?
const originalEventId = relatesTo["m.in_reply_to"].event_id;
if (originalEventId && this.timelineSet) {
const originalEvent = this.timelineSet.findEventById(originalEventId);
if (originalEvent) {
this.inReplyToEvent = originalEvent;
this.inReplyToSender = this.eventSenderDisplayName(originalEvent);
}
}
}
},
beforeDestroy() {
this.thread = null;
},
watch: {
originalEvent: {
@ -71,6 +68,18 @@ export default {
}
},
},
thread: {
handler(newValue, oldValue) {
if (oldValue) {
oldValue.off("Relations.add", this.onAddRelation);
}
if (newValue) {
newValue.on("Relations.add", this.onAddRelation);
}
this.processThread();
},
immediate: true,
},
},
computed: {
/**
@ -81,6 +90,16 @@ export default {
return this.event && Object.keys(this.event).length !== 0;
},
/**
* If this is a thread event, we return the root here, so all reactions will land on the root event.
*/
eventForReactions() {
if (this.event.parentThread) {
return this.event.parentThread;
}
return this.event;
},
incoming() {
return this.event && this.event.getSender() != this.$matrix.currentUserId;
},
@ -97,11 +116,34 @@ export default {
return true;
},
inReplyToSender() {
const originalEvent = this.validEvent && this.event.replyEvent;
if (originalEvent) {
const sender = this.eventSenderDisplayName(originalEvent);
if (originalEvent.isThreadRoot || originalEvent.isMxThread) {
return sender || this.$t("message.someone");
} else {
return this.$t("message.user_said", { user: sender || this.$t("message.someone") });
}
}
return null;
},
inReplyToEvent() {
return this.validEvent && this.event.replyEvent;
},
inReplyToText() {
const relatesTo = this.event.getWireContent()["m.relates_to"];
if (relatesTo && relatesTo["m.in_reply_to"]) {
if (this.inReplyToEvent && (this.inReplyToEvent.isThreadRoot || this.inReplyToEvent.isMxThread)) {
const children = this.timelineSet.relations
.getAllChildEventsForEvent(this.inReplyToEvent.getId())
.filter((e) => util.downloadableTypes().includes(e.getContent().msgtype));
return this.$t("message.sent_media", { count: children.length });
}
const content = this.event.getContent();
if ('body' in content) {
if ("body" in content) {
const lines = content.body.split("\n").reverse() || [];
while (lines.length && !lines[0].startsWith("> ")) lines.shift();
// Reply fallback has a blank line after it, so remove it to prevent leading newline
@ -111,12 +153,10 @@ export default {
return text;
}
}
if (this.inReplyToEvent) {
var c = this.inReplyToEvent.getContent();
return c.body;
}
// We don't have the original text (at the moment at least)
return this.$t("fallbacks.original_text");
}
@ -127,7 +167,7 @@ export default {
const relatesTo = this.event.getWireContent()["m.relates_to"];
if (relatesTo && relatesTo["m.in_reply_to"]) {
const content = this.event.getContent();
if ('body' in content) {
if ("body" in content) {
// Remove the new text and strip "> " from the old original text
const lines = content.body.split("\n");
while (lines.length && lines[0].startsWith("> ")) lines.shift();
@ -164,6 +204,10 @@ export default {
},
},
methods: {
onAddRelation() {
console.error("onAddRelation");
this.processThread();
},
ownAvatarClicked() {
this.$emit("own-avatar-clicked", { event: this.event });
},
@ -277,5 +321,10 @@ export default {
linkify(text) {
return linkifyHtml(text);
},
/**
* Override this to handle updates to (the) message thread.
*/
processThread() {},
},
};

View file

@ -1,3 +1,4 @@
import util from "../../plugins/utils";
export default {
computed: {
@ -5,8 +6,12 @@ export default {
return !this.incoming && this.event.getContent().msgtype == "m.text";
},
isDownloadable() {
if ((this.event.isThreadRoot || this.event.isMxThread) && this.timelineSet) {
const children = this.timelineSet.relations.getAllChildEventsForEvent(this.event.getId()).filter(e => util.downloadableTypes().includes(e.getContent().msgtype));
return children.length > 0;
}
const msgtype = this.event.getContent().msgtype;
return ['m.video','m.audio','m.image','m.file'].includes(msgtype);
return util.downloadableTypes().includes(msgtype);
},
isRedactable() {
const room = this.$matrix.matrixClient.getRoom(this.event.getRoomId());

View file

@ -0,0 +1,18 @@
export default {
computed: {
showOwnJoins() {
return !this.$matrix.isDirectRoom(this.room);
},
showAllStatusMessages() {
// We say that if you can redact events, you are ad admin.
// NOTE!!! This assumes that there is a property named "room" on THIS.
const me = this.room && this.room.getMember(this.$matrix.currentUserId);
let isModerator =
me && this.room.currentState && this.room.currentState.hasSufficientPowerLevelFor("redact", me.powerLevel);
const show = this.$config.show_status_messages;
return show !== "never" && (show !== "moderators" || isModerator)
},
},
}

View file

@ -1,6 +1,8 @@
import utils from "../plugins/utils";
import roomTypeMixin from "./roomTypeMixin";
export default {
mixins: [roomTypeMixin],
data() {
return {
roomJoinRule: null,
@ -59,7 +61,7 @@ export default {
publicRoomLink() {
if (this.room && this.roomJoinRule == "public") {
return this.$router.getRoomLink(
this.room.getCanonicalAlias(), this.room.roomId, this.room.name, utils.roomDisplayTypeToQueryParam(this.room)
this.room.getCanonicalAlias(), this.room.roomId, this.room.name, utils.roomDisplayTypeToQueryParam(this.room, this.roomDisplayType)
);
}
return null;
@ -80,6 +82,23 @@ export default {
return isAdmin;
},
isPrivate() {
return this.$matrix.isDirectRoom(this.room);
},
/**
* If this is a direct chat with someone, return that member here.
* @returns MXMember of the one we are chatting with, or 'undefined'.
*/
privateParty() {
if (this.isPrivate) {
const membersButMe = this.room.getMembers().filter(m => m.userId != this.$matrix.currentUserId);
if (membersButMe.length == 1) {
return membersButMe[0];
}
}
return undefined;
},
},
watch: {
room: {
@ -163,5 +182,19 @@ export default {
this.updatePermissions();
}
},
privatePartyAvatar(size) {
const other = this.privateParty;
if (other) {
return other.getAvatarUrl(
this.$matrix.matrixClient.getHomeserverUrl(),
size,
size,
"scale",
true
);
}
return undefined;
},
},
}

View file

@ -0,0 +1,69 @@
export default {
data() {
return {
joinedAndInvitedMembers: [],
};
},
mounted() {
this.$matrix.on("Room.timeline", this.roomMembersMixinOnEvent);
this.updateMembers();
},
destroyed() {
this.$matrix.off("Room.timeline", this.roomMembersMixinOnEvent);
},
computed: {
joinedMembers() {
return this.joinedAndInvitedMembers.filter(m => m.membership === "join");
}
},
watch: {
room: {
handler() {
this.updateMembers();
},
},
},
methods: {
roomMembersMixinOnEvent(event) {
if (this.room && this.room.roomId == event.getRoomId()) {
// For this room
if (event.getType() == "m.room.member") {
this.updateMembers();
}
}
},
sortMemberFunction(a, b) {
const myUserId = this.$matrix.currentUserId;
// Place ourselves at the top!
if (a.userId == myUserId) {
return -1;
} else if (b.userId == myUserId) {
return 1;
}
// Then sort by power level
if (a.powerLevel > b.powerLevel) {
return -1;
} else if (b.powerLevel > a.powerLevel) {
return 1;
}
// Then by name
const aName = a.user ? a.user.displayName : a.name;
const bName = b.user ? b.user.displayName : b.name;
return aName.localeCompare(bName);
},
updateMembers() {
if (this.room) {
this.joinedAndInvitedMembers = this.room.getMembers().sort(this.sortMemberFunction);
} else {
this.joinedAndInvitedMembers = [];
}
},
},
};

View file

@ -1,6 +1,47 @@
import { ROOM_TYPE_VOICE_MODE, ROOM_TYPE_FILE_MODE, ROOM_TYPE_DEFAULT } from "../plugins/utils";
import { ROOM_TYPE_VOICE_MODE, ROOM_TYPE_FILE_MODE, ROOM_TYPE_DEFAULT, STATE_EVENT_ROOM_TYPE } from "../plugins/utils";
export default {
data() {
return {
roomDisplayType: ROOM_TYPE_DEFAULT,
};
},
mounted() {
this.$matrix.on("Room.timeline", this.onRoomTypeMixinEvent);
},
destroyed() {
this.$matrix.off("Room.timeline", this.onRoomTypeMixinEvent);
},
watch: {
room: {
immediate: true,
handler(newVal) {
if (newVal) {
this.onRoomTypeMixinTypeEvent(newVal.currentState.getStateEvents(STATE_EVENT_ROOM_TYPE, "") || newVal.currentState.getStateEvents("m.room.create", ""));
} else {
this.roomDisplayType = ROOM_TYPE_DEFAULT;
}
},
},
},
methods: {
onRoomTypeMixinEvent(e) {
if (this.room && this.room.roomId == e.getRoomId() && e && e.getType() == STATE_EVENT_ROOM_TYPE) {
this.onRoomTypeMixinTypeEvent(e);
}
},
onRoomTypeMixinTypeEvent(e) {
if (e) {
const roomType = e.getContent().type;
// Validate value, or return default
if ([ROOM_TYPE_FILE_MODE, ROOM_TYPE_VOICE_MODE].includes(roomType)) {
this.roomDisplayType = roomType;
} else {
this.roomDisplayType = ROOM_TYPE_DEFAULT;
}
}
},
},
computed: {
availableRoomTypes() {
let types = [{ title: this.$t("room_info.room_type_default"), description: "", value: ROOM_TYPE_DEFAULT }];

View file

@ -0,0 +1,159 @@
import util from "../plugins/utils";
export default {
data() {
return {
sendStatuses: Object.freeze({
INITIAL: 0,
SENDING: 1,
SENT: 2,
CANCELED: 3,
FAILED: 4,
}),
sendingStatus: 0,
sendingPromise: null,
sendingRootEventId: null,
sendingAttachments: [],
}
},
computed: {
attachmentsSentCount() {
return this.sendingAttachments ? this.sendingAttachments.reduce((a, elem, ignoredidx, ignoredarray) => elem.status == this.sendStatuses.SENT ? a + 1 : a, 0) : 0
},
attachmentsSending() {
return this.sendingAttachments ? this.sendingAttachments.filter(elem => elem.status == this.sendStatuses.INITIAL || elem.status == this.sendStatuses.SENDING) : []
},
attachmentsSent() {
this.sortSendingAttachments();
return this.sendingAttachments ? this.sendingAttachments.filter(elem => elem.status == this.sendStatuses.SENT) : []
},
},
methods: {
sendAttachments(text, attachments) {
this.sendingStatus = this.sendStatuses.SENDING;
this.sendingAttachments = attachments.map((attachment) => {
let file = (() => {
// other than file type image
if(attachment instanceof File) {
return attachment;
} else {
if (attachment.scaled && attachment.useScaled) {
// Send scaled version of image instead!
return attachment.scaled;
} else {
// Send actual file image when not scaled!
return attachment.actualFile;
}
}
})();
let sendInfo = {
id: attachment.name,
status: this.sendStatuses.INITIAL,
statusDate: Date.now,
attachment: file,
preview: attachment.image,
progress: 0,
randomRotation: 0,
randomTranslationX: 0,
randomTranslationY: 0
};
attachment.sendInfo = sendInfo;
return sendInfo;
});
this.sendingPromise = util.sendTextMessage(this.$matrix.matrixClient, this.room.roomId, text)
.then((eventId) => {
this.sendingRootEventId = eventId;
// Use the eventId as a thread root for all the media
let promiseChain = Promise.resolve();
const getItemPromise = (index) => {
if (index < this.sendingAttachments.length) {
const item = this.sendingAttachments[index];
if (item.status !== this.sendStatuses.INITIAL) {
return getItemPromise(++index);
}
const itemPromise = util.sendImage(this.$matrix.matrixClient, this.room.roomId, item.attachment, ({ loaded, total }) => {
if (loaded == total) {
item.progress = 100;
} else if (total > 0) {
item.progress = 100 * loaded / total;
}
}, eventId)
.then(() => {
// Look at last item rotation, flipping the sign on this, so looks more like a true stack
let signR = 1;
let signX = 1;
let signY = 1;
if (this.attachmentsSent.length > 0) {
if (this.attachmentsSent[0].randomRotation >= 0) {
signR = -1;
}
if (this.attachmentsSent[0].randomTranslationX >= 0) {
signX = -1;
}
if (this.attachmentsSent[0].randomTranslationY >= 0) {
signY = -1;
}
}
item.randomRotation = signR * (2 + Math.random() * 10);
item.randomTranslationX = signX * Math.random() * 20;
item.randomTranslationY = signY * Math.random() * 20;
item.status = this.sendStatuses.SENT;
item.statusDate = Date.now;
}).catch(ignorederr => {
if (item.promise.aborted) {
item.status = this.sendStatuses.CANCELED;
} else {
console.error("ERROR", ignorederr);
item.status = this.sendStatuses.FAILED;
}
});
item.promise = itemPromise;
return itemPromise.then(() => getItemPromise(++index));
}
else return Promise.resolve();
};
return promiseChain.then(() => getItemPromise(0));
})
.then(() => {
this.sendingStatus = this.sendStatuses.SENT;
})
.catch((err) => {
console.error("ERROR", err);
});
return this.sendingPromise;
},
cancelSendAttachments() {
this.sendingAttachments.toReversed().forEach(item => {
this.cancelSendAttachmentItem(item);
});
this.sendingStatus = this.sendStatuses.CANCELED;
if (this.sendingRootEventId && this.room) {
// Redact the root event.
this.$matrix.matrixClient
.redactEvent(this.room.roomId, this.sendingRootEventId, undefined, { reason: "cancel" })
.then(() => {
console.log("Message redacted");
})
.catch((err) => {
console.log("Redaction failed: ", err);
});
}
},
cancelSendAttachmentItem(item) {
if (item.promise && item.status != this.sendStatuses.INITIAL) {
item.promise.abort();
}
item.status = this.sendStatuses.CANCELED;
},
sortSendingAttachments() {
this.sendingAttachments.sort((a, b) => b.statusDate - a.statusDate);
},
}
}

View file

@ -101,7 +101,7 @@ Vue.directive('longTap', {
*/
const touchTimerElapsed = function () {
el.longTapHandled = true;
el.longTapCallbacks[1] && el.longTapCallbacks[1].call();
el.longTapCallbacks[1] && el.longTapCallbacks[1].call(el, el);
el.longTapTimer = null;
el.classList.remove("waiting-for-long-tap");
};
@ -127,7 +127,7 @@ Vue.directive('longTap', {
el.longTapTimer = null;
if (!el.longTapHandled) {
// Not canceled or long tapped. Just a single tap. Do we have a handler?
el.longTapCallbacks[0] && el.longTapCallbacks[0].call();
el.longTapCallbacks[0] && el.longTapCallbacks[0].call(el, el);
}
el.classList.remove("waiting-for-long-tap");
};

View file

@ -29,4 +29,12 @@ export default class User {
}
return user_id;
}
// Get the domain out of the home_server, so if that one is e.g.
// "https://yourdomain.com:8008" then we return "yourdomain.com"
static serverDomain(home_server) {
const parts = home_server.split("://");
const serverAndPort = parts[parts.length - 1].split(/:|\//);
return serverAndPort[0];
}
}

View file

@ -0,0 +1,23 @@
export function registerServiceWorker() {
if("serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js");
} else {
console.log("No Service Worker support!");
}
}
export function requestNotificationPermission() {
if("PushManager" in window) {
window.Notification.requestPermission();
} else {
console.log("No Push API Support!");
}
}
export function windowNotificationPermission() {
return window.Notification.permission
}
export function notificationCount() {
return this.$matrix.notificationCount
}

View file

@ -21,7 +21,7 @@ class Stickers {
}
isStickerShortcode(messageBody) {
if (messageBody && messageBody.startsWith(":") && messageBody.startsWith(":") && messageBody.length >= 5) {
if (messageBody && typeof messageBody === "string" && messageBody.startsWith(":") && messageBody.startsWith(":") && messageBody.length >= 5) {
const image = this.getStickerImage(messageBody);
return image != undefined && image != null;
}

View file

@ -3,10 +3,15 @@ import * as ContentHelpers from "matrix-js-sdk/lib/content-helpers";
import dataUriToBuffer from "data-uri-to-buffer";
import ImageResize from "image-resize";
export const STATE_EVENT_ROOM_DELETION_NOTICE = "im.keanu.room_deletion_notice";
export const STATE_EVENT_ROOM_DELETED = "im.keanu.room_deleted";
export const ROOM_TYPE_DEFAULT = "im.keanu.room_type_default";
export const ROOM_TYPE_VOICE_MODE = "im.keanu.room_type_voice";
export const ROOM_TYPE_FILE_MODE = "im.keanu.room_type_file";
export const STATE_EVENT_ROOM_TYPE = "im.keanu.room_type";
const sizeOf = require("image-size");
var dayjs = require('dayjs');
@ -482,19 +487,19 @@ class Util {
/**
* Return what "mode" to use for the given room.
*
* The default value is given by the room itself. If the "type" of the
* room is set to 'im.keanu.room_type_voice' then we default to voice mode,
* else if set to 'im.keanu.room_type_file' we default to file mode.
* The user can then override this default by changing the "room type"
* in room settings (it will be persisted as a user specific tag on the room)
* The default value is given by the room itself (as state events, see roomTypeMixin).
* This method just returns if the user has overridden this in room settings (this
* fact will be persisted as a user specific tag on the room). Note: currently override
* is disabled in the UI...
*/
roomDisplayType(roomOrNull) {
roomDisplayTypeOverride(roomOrNull) {
if (roomOrNull) {
const room = roomOrNull;
// Have we changed our local view mode of this room?
const tags = room.tags;
if (tags && tags["ui_options"]) {
console.error("We have a tag!");
if (tags["ui_options"]["voice_mode"] === 1) {
return ROOM_TYPE_VOICE_MODE;
} else if (tags["ui_options"]["file_mode"] === 1) {
@ -504,30 +509,16 @@ class Util {
return ROOM_TYPE_DEFAULT;
}
}
// Was the room created with a voice mode type?
const createEvent = room.currentState.getStateEvents(
"m.room.create",
""
);
if (createEvent) {
const roomType = createEvent.getContent().type;
// Validate value, or return default
if ([ROOM_TYPE_FILE_MODE, ROOM_TYPE_VOICE_MODE].includes(roomType)) {
return roomType;
}
}
}
return ROOM_TYPE_DEFAULT;
return null;
}
/**
* Return the room type for the current room
* @param {*} roomOrNull
*/
roomDisplayTypeToQueryParam(roomOrNull) {
const roomType = this.roomDisplayType(roomOrNull);
roomDisplayTypeToQueryParam(roomOrNull, roomDisplayType) {
const roomType = this.roomDisplayTypeOverride(roomOrNull) || roomDisplayType;
if (roomType === ROOM_TYPE_FILE_MODE) {
// Send "file" here, so the receiver of the invite link knows to display the "file drop" join page
// instead of the standard one.
@ -583,12 +574,16 @@ class Util {
}
sanitizeUserId(userId) {
if (userId && userId.match(/^@.+$/)) {
if (userId && userId.match(/^([0-9a-z-.=_/]+|@[0-9a-z-.=_/]+:.+)$/)) {
return userId;
}
return null;
}
invalidUserIdChars() {
return /[^0-9a-z-.=_/]+/g;
}
getFirstVisibleElement(parentNode, where) {
let visible = this.findVisibleElements(parentNode);
if (visible) {
@ -901,6 +896,31 @@ class Util {
})
});
}
downloadableTypes() {
return ['m.video','m.audio','m.image','m.file'];
}
download(matrixClient, event) {
this
.getAttachment(matrixClient, event)
.then((url) => {
const link = document.createElement("a");
link.href = url;
link.target = "_blank";
link.download = event.getContent().body || this.$t("fallbacks.download_name");
document.body.appendChild(link);
link.click();
setTimeout(function () {
document.body.removeChild(link);
URL.revokeObjectURL(url);
}, 200);
})
.catch((err) => {
console.log("Failed to fetch attachment: ", err);
});
}
}
export default new Util();

View file

@ -6,7 +6,8 @@ import Join from '../components/Join.vue'
import Login from '../components/Login.vue'
import Profile from '../components/Profile.vue'
import CreateRoom from '../components/CreateRoom.vue'
import GetLink from '../components/GetLink.vue'
import User from '../models/user'
import util from '../plugins/utils'
Vue.use(VueRouter)
@ -54,6 +55,11 @@ const routes = [
title: 'Create room'
}
},
{
path: '/getlink',
name: 'GetLink',
component: GetLink,
},
{
path: '/login',
name: 'Login',
@ -91,7 +97,7 @@ const router = new VueRouter({
});
router.beforeEach((to, from, next) => {
const publicPages = ['/login', '/createroom'];
const publicPages = ['/login', '/createroom', '/getlink'];
var authRequired = !publicPages.includes(to.path);
const loggedIn = router.app.$store.state.auth.user;
@ -120,15 +126,33 @@ router.beforeEach((to, from, next) => {
}
} else if (to.name == 'User') {
if (to.params.userId) {
const roomId = util.sanitizeUserId(to.params.userId);
router.app.$matrix.setCurrentRoomId(roomId);
authRequired = false;
let roomId = util.sanitizeUserId(to.params.userId);
if (roomId && !roomId.startsWith("@")) {
// Not a full username. Assume local name on this server.
return router.app.$config.promise.then((config) => {
const user = new User(config.defaultServer, roomId, "");
user.normalize();
roomId = "@" + roomId + ":" + User.serverDomain(user.home_server);
router.app.$matrix.setCurrentRoomId(roomId);
}).catch(err => console.error(err)).finally(() => next());
} else {
router.app.$matrix.setCurrentRoomId(roomId);
authRequired = false;
}
}
} else if (to.name == 'Invite') {
if (to.params.roomId) {
const roomId = util.sanitizeRoomId(to.params.roomId);
router.app.$matrix.setCurrentRoomId(roomId);
}
} else if (to.name == 'CreateRoom') {
return router.app.$config.promise.then((config) => {
if (config.hide_add_room_on_home) {
next('/');
} else {
next();
}
}).catch(err => { console.error(err); next('/'); });
}
// trying to access a restricted page + not logged in
@ -160,4 +184,13 @@ router.getRoomLink = function (alias, roomId, roomName, mode) {
return window.location.origin + window.location.pathname + "#/room/" + encodeURIComponent(util.sanitizeRoomId(alias || roomId));
}
router.getDMLink = function (user, config) {
let userId = user.user_id;
if (user.home_server === config.defaultServer) {
// Using default server, don't include it in the link
userId = User.localPart(user.user_id);
}
return `${window.location.origin + window.location.pathname}#/user/${encodeURIComponent(userId)}`
}
export default router

View file

@ -1,8 +1,9 @@
export default {
install(Vue, defaultServerFromLocation, onloaded) {
var config = Vue.observable(require('@/assets/config.json'));
Vue.set(config, "loaded", false);
const getRuntimeConfig = () => {
return fetch('./config.json').then((res) => res.json()).catch(err => {
return fetch('./config.json?ms=' + Date.now()).then((res) => res.json()).catch(err => {
console.error("Failed to get config:", err);
return {};
});
@ -17,6 +18,7 @@ export default {
if (!json.defaultServer) {
Vue.set(config, "defaultServer", defaultServerFromLocation);
}
Vue.set(config, "loaded", true);
// Tell callback we are done loading runtime config
if (onloaded) {

View file

@ -2,7 +2,7 @@ import olm from "@matrix-org/olm/olm";
global.Olm = olm;
import * as sdk from "matrix-js-sdk";
import { TimelineWindow, EventTimeline } from "matrix-js-sdk";
import util from "../plugins/utils";
import util, { STATE_EVENT_ROOM_DELETED } from "../plugins/utils";
import User from "../models/user";
const LocalStorageCryptoStore =
@ -87,6 +87,12 @@ export default {
return room.selfMembership === "invite";
});
},
joinedAndInvitedRooms() {
return this.rooms.filter((room) => {
return room.selfMembership === "join" || room.selfMembership === "invite";
});
}
},
watch: {
@ -113,7 +119,7 @@ export default {
console.log("create crypto store");
return new LocalStorageCryptoStore(this.$store.getters.storage);
},
login(user, registrationFlowHandler) {
login(user, registrationFlowHandler, createUser = false) {
const tempMatrixClient = sdk.createClient({baseUrl: user.home_server, idBaseUrl: this.$config.identityServer});
var promiseLogin;
@ -121,14 +127,14 @@ export default {
if (user.access_token) {
// Logged in on "real" account
promiseLogin = Promise.resolve(user);
} else if (user.is_guest && (!user.user_id || user.registration_session)) {
} else if (createUser || (user.is_guest && (!user.user_id || user.registration_session))) {
// Generate random username and password. We don't user REAL matrix
// guest accounts because 1. They are not allowed to post media, 2. They
// can not use avatars and 3. They can not seamlessly be upgraded to real accounts.
//
// Instead, we use an ILAG approach, Improved Landing as Guest.
const userId = user.registration_session ? user.user_id : util.randomUser(this.$config.userIdPrefix);
const pass = user.registration_session ? user.password : util.randomPass();
const userId = (createUser || user.registration_session) ? user.user_id : util.randomUser(this.$config.userIdPrefix);
const pass = (createUser || user.registration_session) ? user.password : util.randomPass();
const extractAndSaveUser = (response) => {
var u = Object.assign({}, response);
@ -376,11 +382,38 @@ export default {
}
}
break;
case STATE_EVENT_ROOM_DELETED:
{
const room = this.matrixClient.getRoom(event.getRoomId());
if (room && room.currentState) {
// Before we do anything, make sure the sender is an admin!
// Also, do not react if WE are the sender, since we are probably
// busy doing the rest of the purging process...
if (room.currentState.maySendStateEvent("m.room.power_levels", event.getSender())) {
if (event.getSender() !== this.currentUserId) {
this.leaveRoomAndNavigate(room.roomId).then(() => {
this.matrixClient.store.removeRoom(room.roomId);
})
}
}
}
}
break;
}
this.updateNotificationCount();
},
onRoom(ignoredroom) {
onRoom(room) {
if (room.selfMembership === "invite") {
this.matrixClient.getRoomTags(room.roomId).then(reply => {
if (Object.keys(reply.tags).includes("m.server_notice")) {
Vue.set(room, "isServiceNoticeRoom", true);
}
}).catch((error => {
console.error(error);
}))
}
this.reloadRooms();
this.updateNotificationCount();
},
@ -526,6 +559,31 @@ export default {
});
},
/**
* Leave the room, and if this is the last room we are in, navigate to the "goodbye" page.
* Otherwise, navigate to home.
* @param roomId
*/
leaveRoomAndNavigate(roomId) {
const joinedRooms = this.joinedRooms;
const isLastRoomWeAreJoinedTo = (
joinedRooms &&
joinedRooms.length == 1 &&
joinedRooms[0].roomId == roomId
);
return this.leaveRoom(roomId)
.then(() => {
if (isLastRoomWeAreJoinedTo) {
this.$navigation.push({ name: "Goodbye" }, -1);
} else {
this.$navigation.push(
{ name: "Home", params: { roomId: null } },
-1
);
}
})
},
kickUser(roomId, userId) {
if (this.matrixClient && roomId && userId) {
this.matrixClient.kick(roomId, userId, "");
@ -700,6 +758,13 @@ export default {
history_visibility: "joined",
});
})
.then(() => {
return this.matrixClient.sendStateEvent(
roomId,
STATE_EVENT_ROOM_DELETED,
{ status: "deleted" }
);
})
.then(() => {
//console.log("Purge: create timeline");
return timelineWindow.load(null, 100);
@ -783,6 +848,13 @@ export default {
return kickFirstMember(allMembers);
})
.then(() => {
return withRetry(() => this.matrixClient.sendStateEvent(
roomId,
STATE_EVENT_ROOM_DELETED,
{ status: "deleted" }
));
})
.then(() => {
statusCallback(null);
this.matrixClient.setGlobalErrorOnUnknownDevices(oldGlobalErrorSetting);
@ -883,7 +955,7 @@ export default {
* @param {*} userId
*/
isDirectRoomWith(room, userId) {
if (room.getJoinRule() == "invite" && room.getMembers().length == 2) {
if (room && room.getJoinRule() == "invite" && room.getMembers().length == 2) {
let other = room.getMember(userId);
if (other) {
if (room.getMyMembership() == "invite" && other.membership == "join") {
@ -906,7 +978,7 @@ export default {
isDirectRoom(room) {
// TODO - Use the is_direct accountData flag (m.direct). WE (as the client)
// apprently need to set this...
if (room.getJoinRule() == "invite" && room.getMembers().length == 2) {
if (room && room.getJoinRule() == "invite" && room.getMembers().length == 2) {
return true;
}
return false;

View file

@ -113,6 +113,18 @@ export default new Vuex.Store({
}
);
},
createUser({ commit }, { user, registrationFlowHandler }) {
return this._vm.$matrix.login(user, registrationFlowHandler, true).then(
user => {
commit('loginSuccess', user);
return Promise.resolve(user);
},
error => {
commit('loginFailure');
return Promise.reject(error);
}
);
},
logout({ commit }) {
this._vm.$matrix.logout();
commit('logout');

View file

@ -1,5 +1,6 @@
const CopyWebpackPlugin = require("copy-webpack-plugin");
const webpack = require("webpack");
//const fs = require('fs')
module.exports = {
transpileDependencies: ["vuetify"],
@ -47,5 +48,15 @@ module.exports = {
devServer: {
//https: true,
},
/***
* For testing notification via service worker in Mobile
* Run your site locally with secure HTTPS using mkcert
* https://web.dev/how-to-use-local-https/#running-your-site-locally-with-https-using-mkcert-recommended
*/
// https: {
// key: fs.readFileSync('./your-local-ip-address-key.pem'),
// cert: fs.readFileSync('./your-local-ip-address.pem'),
// }
}
};