Merge branch '485-mvp-for-journalist-mode' into 'dev'

"Private chat" header for direct chats

See merge request keanuapp/keanuapp-weblite!229
This commit is contained in:
N Pex 2023-08-30 08:53:12 +00:00
commit 098a9a2fdf
9 changed files with 437 additions and 37 deletions

View file

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

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

@ -132,7 +132,10 @@
"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_hi": "Hi!",
"direct_info": "Youve connected to {user}. Leave a message and theyll be notified.",
"direct_private_chat": "Private Chat"
},
"new_room": {
"new_room": "New Room",

View file

@ -1,10 +1,15 @@
<template>
<div class="chat-root fill-height d-flex flex-column">
<ChatHeader class="chat-header flex-grow-0 flex-shrink-0"
<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" />
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"
@ -53,6 +58,7 @@
<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 -->
@ -358,9 +364,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";
@ -374,6 +382,7 @@ import FileDropLayout from "./file_mode/FileDropLayout";
import { requestNotificationAndServiceWorker, windowNotificationPermission, notificationCount } from "../plugins/notificationAndServiceWorker.js"
import logoMixin from "./logoMixin";
import roomTypeMixin from "./roomTypeMixin";
import roomMembersMixin from "./roomMembersMixin";
const sizeOf = require("image-size");
const dataUriToBuffer = require("data-uri-to-buffer");
@ -409,13 +418,15 @@ ScrollPosition.prototype.prepareFor = function (direction) {
export default {
name: "Chat",
mixins: [chatMixin, logoMixin, roomTypeMixin, sendAttachmentsMixin],
mixins: [chatMixin, logoMixin, roomTypeMixin, sendAttachmentsMixin, roomMembersMixin],
components: {
ChatHeader,
ChatHeaderPrivate,
MessageOperations,
VoiceRecorder,
RoomInfoBottomSheet,
CreatedRoomWelcomeHeader,
DirectChatWelcomeHeader,
NoHistoryRoomWelcomeHeader,
MessageOperationsBottomSheet,
StickerPickerBottomSheet,
@ -480,7 +491,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: [],
@ -703,6 +717,27 @@ 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.getJoinRule() == "invite" && this.joinedAndInvitedMembers.length == 2;
},
showCreatedRoomWelcomeHeader() {
return !this.hideCreatedRoomWelcomeHeader && this.roomCreatedByUsRecently && !this.isDirectRoom;
},
showDirectChatWelcomeHeader() {
return !this.hideDirectChatWelcomeHeader && this.roomCreatedByUsRecently && this.isDirectRoom;
}
},
@ -739,7 +774,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();
@ -833,16 +869,6 @@ export default {
this.notificationDialog = false;
},
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;
}
}
// Listen to events
this.$matrix.on("Room.timeline", this.onEvent);
this.$matrix.on("RoomMember.typing", this.onUserTyping);
@ -887,7 +913,7 @@ export default {
self.initialLoadDone = true;
if (initialEventId && !this.showCreatedRoomWelcomeHeader) {
self.scrollToEvent(initialEventId);
} else if (this.showCreatedRoomWelcomeHeader) {
} else if (this.showCreatedRoomWelcomeHeader || this.showDirectChatWelcomeHeader) {
self.onScroll();
}
self.restartRRTimer();
@ -1266,7 +1292,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(() => {
@ -1294,7 +1320,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!
@ -1639,7 +1665,17 @@ export default {
},
closeCreateRoomWelcomeHeader() {
this.showCreatedRoomWelcomeHeader = false;
this.hideCreatedRoomWelcomeHeader = false;
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

@ -0,0 +1,162 @@
<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="40" class="clickable me-2 chat-header-avatar" 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.joinedRooms.length > 1" class="icon-dropdown" size="11">$vuetify.icons.ic_dropdown</v-icon>
<div v-if="$matrix.joinedRooms.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> -->
<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>
</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

@ -0,0 +1,72 @@
<template>
<div style="text-align: center;">
<div class="created-room-welcome-header">
<v-avatar class="typing-user" size="40" color="grey" v-if="isPrivate">
<img v-if="privatePartyAvatar(80)" :src="privatePartyAvatar(80)" />
<span v-else class="white--text headline">{{
privateParty.name.substring(0, 1).toUpperCase()
}}</span>
</v-avatar>
<h2>{{ $t("room_welcome.direct_hi") }}</h2>
<div class="mt-2" v-if="privateParty">{{ $t("room_welcome.direct_info", { 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>

View file

@ -132,6 +132,23 @@
</div>
</div>
</div>
<!-- Loading indicator -->
<v-container
v-else
fluid
fill-height
class="loading-indicator"
>
<v-row align="center" justify="center">
<v-col class="text-center">
<v-progress-circular
indeterminate
color="primary"
></v-progress-circular>
</v-col>
</v-row>
</v-container>
</template>
<script>
@ -304,19 +321,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.waitingForInfo = false;
this.$nextTick(() => {
this.handleJoin();
});
} else {
// Private room, try to get name
const room = this.$matrix.getRoom(this.roomId);

View file

@ -82,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: {
@ -165,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 = [];
}
},
},
};