Merge branch 'get-dm-link' into 'dev'

Get dm link

See merge request keanuapp/keanuapp-weblite!234
This commit is contained in:
N Pex 2023-09-29 15:13:12 +00:00
commit 7ed3e80b9c
14 changed files with 422 additions and 26 deletions

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

@ -1405,6 +1405,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;
@ -1534,18 +1552,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 {

View file

@ -0,0 +1,82 @@
@import "@/assets/css/main.scss";
.getlink-root {
background-color: $background;
.getlink-loggedin {
text-align: center;
button {
min-width: 200px !important;
}
}
.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-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 {
position: absolute;
right: 20px;
bottom: 24px;
width: 24px;
height: 17px;
object-fit: contain;
}
}

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="#074EE8"/>
</svg>

After

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

View file

@ -188,6 +188,17 @@
"send_token": "Send token",
"token_not_valid": "Invalid token"
},
"getlink": {
"title": "Get a Direct Link",
"password": "Set your password",
"password_repeat": "Confirm your password",
"create": "Create",
"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",
"qr_image_copied": "Image copied to clipboard"
},
"profile": {
"title": "My Profile",
"temporary_identity": "This identity is temporary. Set a password to use it again",

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

@ -7,7 +7,12 @@
<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"
@ -32,7 +37,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 +56,12 @@ export default {
i18nCopyLinkKey: {
type: String,
default: 'copy_link'
},
i18nQrPopupTitleKey: {
type: String,
default: 'room_info.scan_code'
}
},
data() {
return {
@ -59,6 +69,11 @@ export default {
showFullScreenQR: false,
}
},
computed: {
popupTitle() {
return this.$t(this.i18nQrPopupTitleKey);
}
},
methods: {
copyRoomLink() {
if(this.locationLinkCopied) return
@ -87,7 +102,7 @@ export default {
{
type: "image/png",
margin: 1,
width: 60,
width: canvas.getBoundingClientRect().width,
},
function (error) {
if (error) console.error(error);

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

@ -0,0 +1,254 @@
<template>
<div class="pa-4 getlink-root fill-height">
<div v-if="!loggedIn">
<div class="getlink-title">{{ $t("getlink.title") }}</div>
<div color="rgba(255,255,255,0.1)" class="text-center">
<v-form v-model="isValid">
<v-text-field v-model="user.user_id" :label="$t('login.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-on:blur="onUsernameBlur"></v-text-field>
<div class="error--text" v-if="loadingLoginFlows">Loading login flows...</div>
<v-text-field v-show="showPasswordField" solo v-model="user.password"
:append-icon="showPassword1 ? 'visibility' : 'visibility_off'" :hint="$t('global.password_hint')"
:rules="[(v) => v ? !!v.match(passwordValidation) || $t('global.password_hint') : true]"
:label="$t('getlink.password')" counter="20" maxlength="20" :type="showPassword1 ? 'text' : 'password'"
@click:append="showPassword1 = !showPassword1" ref="password" :error="passErrorMessage != null"
:error-messages="passErrorMessage" required v-on:keydown="hasError = false"
v-on:keyup.enter="onPasswordEntered" v-on:blur="onPasswordEntered" />
<v-text-field v-show="showPasswordConfirmField" solo v-model="passwordConfirm"
:append-icon="showPassword2 ? 'visibility' : 'visibility_off'"
:rules="[(v) => v === user.password || $t('global.password_didnot_match')]"
:label="$t('getlink.password_repeat')" counter="20" maxlength="20"
:type="showPassword2 ? 'text' : 'password'" @click:append="showPassword2 = !showPassword2"
ref="passwordConfirm" :error="passErrorMessage != null" :error-messages="passErrorMessage" required
v-on:keydown="hasError = false" v-on:keyup.enter="() => {
if (isValid && !loading) {
handleLogin();
}
}
" />
<div class="error--text" v-if="hasError">{{ this.message }}</div>
<interactive-auth ref="interactiveAuth" />
<!--
<v-checkbox
id="chk-remember-me"
class="mt-0"
v-model="rememberMe"
@change="onRememberMe"
:label="$t('join.remember_me')"
/>
-->
<v-btn id="btn-login" :disabled="!isValid || !passwordsSetAndMatching || loading || loadingLoginFlows" color="primary" depressed block
@click.stop="handleLogin" :loading="loading" class="filled-button mt-4">{{ $t("getlink.create") }}</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">
<v-img v-if="shareSupported" class="clickable getlink-share" src="@/assets/icons/ic_share.svg"
@click="shareLink" />
</copy-link>
<v-btn color="black" depressed @click.stop="goHome" class="outlined-button">{{ $t("getlink.continue") }}</v-btn>
</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 {
user: new User(this.$config.defaultServer, "", ""),
isValid: false,
loading: false,
message: "",
userErrorMessage: null,
passErrorMessage: null,
hasError: false,
currentLoginServer: "",
loadingLoginFlows: false,
loginFlows: null,
showPasswordField: false,
showPasswordConfirmField: false,
passwordConfirm: "",
showPassword1: false,
showPassword2: false,
passwordValidation: /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{12,20}$/,
showQRCopiedToast: false
};
},
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 `${window.location.origin + window.location.pathname}#/user/${encodeURIComponent(this.$matrix.currentUser.user_id)}`
},
shareSupported() {
return !!navigator.share;
},
passwordsSetAndMatching() {
return this.user.password.match(this.passwordValidation) && this.user.password == this.passwordConfirm;
}
},
watch: {
user: {
handler() {
// Reset manual errors
this.userErrorMessage = null;
this.passErrorMessage = null;
},
deep: true,
}
},
methods: {
goHome() {
this.$navigation.push({ name: "Home" }, -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);
user.user_id = utils.randomUser(this.$config.userIdPrefix);
user.normalize();
this.loading = true;
this.$store.dispatch("createUser", { user, registrationFlowHandler: this.$refs.interactiveAuth.registrationFlowHandler }).then(
(ignoreduser) => {
this.$matrix.setUserDisplayName(userDisplayName);
this.loading = false;
},
(error) => {
console.error(error);
this.loading = false;
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;
}
console.log("Message set to ", this.message);
}
);
}
},
handleCreateRoom() {
this.$navigation.push({ name: "CreateRoom" });
},
onUsernameEnter() {
this.$refs.password.focus();
this.onUsernameBlur();
},
validPassword(pass) {
return !!pass;
},
onPasswordEntered() {
if (this.validPassword(this.user.password)) {
this.showPasswordConfirmField = true;
this.$nextTick(() => {
this.$refs.passwordConfirm.focus();
});
}
},
onUsernameBlur() {
var user = Object.assign({}, this.user);
user.normalize();
const server = user.home_server || this.$config.defaultServer;
if (server !== this.currentLoginServer) {
this.showPasswordField = false;
this.showPasswordConfirmField = false;
this.currentLoginServer = server;
this.loadingLoginFlows = true;
const matrixClient = sdk.createClient({ baseUrl: server });
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;
this.showPasswordField = this.loginFlows.some(f => f.type == "m.login.password");
if (this.showPasswordField) {
this.$nextTick(() => {
this.$refs.password.focus();
});
}
}
});
}
},
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

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

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

@ -6,6 +6,7 @@ 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 util from '../plugins/utils'
@ -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;

View file

@ -113,7 +113,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 +121,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);

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