Add "scroll to end" button

Work on issue #15.
This commit is contained in:
N-Pex 2021-02-17 11:59:07 +01:00
parent b8b67a1d88
commit c44c5c714d
2 changed files with 210 additions and 117 deletions

View file

@ -109,6 +109,7 @@ $admin-fg: white;
} }
.input-area-outer { .input-area-outer {
position: relative;
background-color: #ffffff; background-color: #ffffff;
margin: 0; margin: 0;
padding-left: 2 * $chat-standard-padding-s; padding-left: 2 * $chat-standard-padding-s;
@ -149,6 +150,12 @@ $admin-fg: white;
} }
} }
.scroll-to-end {
position:absolute;
top:-64px;
right:16px;
}
.op-button { .op-button {
position: relative; position: relative;
display: inline-block; display: inline-block;

View file

@ -9,19 +9,19 @@
@click.prevent="closeContextMenuIfOpen" @click.prevent="closeContextMenuIfOpen"
> >
<div ref="messageOperationsStrut" class="message-operations-strut"> <div ref="messageOperationsStrut" class="message-operations-strut">
<message-operations <message-operations
:style="opStyle" :style="opStyle"
v-on:close="showContextMenu = false" v-on:close="showContextMenu = false"
v-if="selectedEvent && showContextMenu" v-if="selectedEvent && showContextMenu"
v-on:addreaction="addReaction" v-on:addreaction="addReaction"
v-on:addreply="addReply(selectedEvent)" v-on:addreply="addReply(selectedEvent)"
v-on:edit="edit(selectedEvent)" v-on:edit="edit(selectedEvent)"
v-on:redact="redact(selectedEvent)" v-on:redact="redact(selectedEvent)"
v-on:download="download(selectedEvent)" v-on:download="download(selectedEvent)"
:event="selectedEvent" :event="selectedEvent"
:incoming="selectedEvent.getSender() != $matrix.currentUserId" :incoming="selectedEvent.getSender() != $matrix.currentUserId"
/> />
</div> </div>
<!-- Handle resizes, e.g. when soft keyboard is shown/hidden --> <!-- Handle resizes, e.g. when soft keyboard is shown/hidden -->
<resize-observer <resize-observer
@ -29,10 +29,17 @@
@notify="handleChatContainerResize" @notify="handleChatContainerResize"
/> />
<div v-for="(event,index) in events" :key="event.getId()" :eventId="event.getId()"> <div
v-for="(event, index) in events"
<!-- DAY Marker, shown for every new day in the timeline --> :key="event.getId()"
<div v-if="showDayMarkerBeforeEvent(event)" class="day-marker" :title="dayForEvent(event)" /> :eventId="event.getId()"
>
<!-- DAY Marker, shown for every new day in the timeline -->
<div
v-if="showDayMarkerBeforeEvent(event)"
class="day-marker"
:title="dayForEvent(event)"
/>
<div <div
v-if=" v-if="
@ -68,7 +75,11 @@
v-on:context-menu="showContextMenuForEvent($event)" v-on:context-menu="showContextMenuForEvent($event)"
/> />
<!-- <div>EventID: {{ event.getId() }}</div> --> <!-- <div>EventID: {{ event.getId() }}</div> -->
<div v-if="event.getId() == readMarker && index < (events.length - 1)" class="read-marker" title="Unread messages" /> <div
v-if="event.getId() == readMarker && index < events.length - 1"
class="read-marker"
title="Unread messages"
/>
</div> </div>
</div> </div>
</div> </div>
@ -76,8 +87,23 @@
<!-- Input area --> <!-- Input area -->
<v-container v-if="room" fluid class="input-area-outer"> <v-container v-if="room" fluid class="input-area-outer">
<!-- "Scroll to end"-button -->
<v-btn
class="scroll-to-end"
v-show="showScrollToEnd"
fab
small
elevation="0"
color="black"
@click.stop="smoothScrollToEnd"
>
<v-icon color="white">arrow_downward</v-icon>
</v-btn>
<v-row class="ma-0 pa-0"> <v-row class="ma-0 pa-0">
<div v-if="replyToEvent">REPLYING TO EVENT: {{ replyToEvent.getContent().body }}</div> <div v-if="replyToEvent">
REPLYING TO EVENT: {{ replyToEvent.getContent().body }}
</div>
<!-- CONTACT IS TYPING --> <!-- CONTACT IS TYPING -->
<div class="typing"> <div class="typing">
@ -87,7 +113,13 @@
<v-row class="input-area-inner align-center"> <v-row class="input-area-inner align-center">
<v-col class="input-area-button text-center flex-grow-0 flex-shrink-1"> <v-col class="input-area-button text-center flex-grow-0 flex-shrink-1">
<label icon flat ref="attachmentLabel"> <label icon flat ref="attachmentLabel">
<v-btn icon large color="black" @click="showAttachmentPicker" :disabled="attachButtonDisabled"> <v-btn
icon
large
color="black"
@click="showAttachmentPicker"
:disabled="attachButtonDisabled"
>
<v-icon x-large>add_circle_outline</v-icon> <v-icon x-large>add_circle_outline</v-icon>
</v-btn> </v-btn>
<input <input
@ -114,18 +146,40 @@
placeholder="Send message" placeholder="Send message"
hide-details hide-details
background-color="white" background-color="white"
v-on:keydown.enter.prevent="() => { sendMessage() }" v-on:keydown.enter.prevent="
() => {
sendMessage();
}
"
/> />
</v-col> </v-col>
<v-col class="input-area-button text-center flex-grow-0 flex-shrink-1" v-if="editedEvent || replyToEvent"> <v-col
<v-btn fab small elevation="0" color="black" @click.stop="cancelEditReply"> class="input-area-button text-center flex-grow-0 flex-shrink-1"
v-if="editedEvent || replyToEvent"
>
<v-btn
fab
small
elevation="0"
color="black"
@click.stop="cancelEditReply"
>
<v-icon color="white">cancel</v-icon> <v-icon color="white">cancel</v-icon>
</v-btn> </v-btn>
</v-col> </v-col>
<v-col class="input-area-button text-center flex-grow-0 flex-shrink-1"> <v-col class="input-area-button text-center flex-grow-0 flex-shrink-1">
<v-btn fab small elevation="0" color="black" @click.stop="sendMessage" :disabled="sendButtonDisabled"> <v-btn
<v-icon color="white">{{ editedEvent ? 'save' : 'arrow_upward' }}</v-icon> fab
small
elevation="0"
color="black"
@click.stop="sendMessage"
:disabled="sendButtonDisabled"
>
<v-icon color="white">{{
editedEvent ? "save" : "arrow_upward"
}}</v-icon>
</v-btn> </v-btn>
</v-col> </v-col>
</v-row> </v-row>
@ -169,19 +223,21 @@
</div> </div>
<!-- "NOT ALLOWED FOR GUEST ACCOUNTS" dialog --> <!-- "NOT ALLOWED FOR GUEST ACCOUNTS" dialog -->
<v-dialog v-model="showNotAllowedForGuests" class="ma-0 pa-0" width="50%"> <v-dialog v-model="showNotAllowedForGuests" class="ma-0 pa-0" width="50%">
<v-card> <v-card>
<v-card-title>You are logged in as a guest</v-card-title> <v-card-title>You are logged in as a guest</v-card-title>
<v-card-text> <v-card-text>
<div>Unfortunately guests are not allowed to upload files.</div> <div>Unfortunately guests are not allowed to upload files.</div>
</v-card-text> </v-card-text>
<v-divider></v-divider> <v-divider></v-divider>
<v-card-actions> <v-card-actions>
<v-spacer></v-spacer> <v-spacer></v-spacer>
<v-btn color="primary" text @click="showNotAllowedForGuests = false">Ok</v-btn> <v-btn color="primary" text @click="showNotAllowedForGuests = false"
</v-card-actions> >Ok</v-btn
</v-card> >
</v-dialog> </v-card-actions>
</v-card>
</v-dialog>
</div> </div>
</template> </template>
@ -281,6 +337,12 @@ export default {
*/ */
chatContainerSize: 0, chatContainerSize: 0,
/**
* True if we should show the "scroll to end" marker in the chat. For now at least, we use a simple
* method here, basically just "if we can scroll, show it".
*/
showScrollToEnd: false,
/** Shows a dialog with info about an operation being disallowed for guests */ /** Shows a dialog with info about an operation being disallowed for guests */
showNotAllowedForGuests: false, showNotAllowedForGuests: false,
@ -333,17 +395,24 @@ export default {
// If we have sent a RR, use that as read marker (so we don't have to wait for server round trip) // If we have sent a RR, use that as read marker (so we don't have to wait for server round trip)
return this.lastRR.getId(); return this.lastRR.getId();
} }
return this.fullyReadMarker || this.room.getEventReadUpTo(this.$matrix.currentUserId, false); return (
this.fullyReadMarker ||
this.room.getEventReadUpTo(this.$matrix.currentUserId, false)
);
}, },
fullyReadMarker() { fullyReadMarker() {
const readEvent = this.room.getAccountData('m.fully_read'); const readEvent = this.room.getAccountData("m.fully_read");
if (readEvent) { if (readEvent) {
return readEvent.getContent().event_id; return readEvent.getContent().event_id;
} }
return null; return null;
}, },
attachButtonDisabled() { attachButtonDisabled() {
return this.editedEvent != null || this.replyToEvent != null || this.currentInput.length > 0; return (
this.editedEvent != null ||
this.replyToEvent != null ||
this.currentInput.length > 0
);
}, },
sendButtonDisabled() { sendButtonDisabled() {
return this.currentInput.length == 0; return this.currentInput.length == 0;
@ -373,8 +442,7 @@ export default {
} }
} }
return "top:" + top + "px;left:" + left + "px"; return "top:" + top + "px;left:" + left + "px";
} },
}, },
watch: { watch: {
@ -384,7 +452,9 @@ export default {
if (value && value == oldValue) { if (value && value == oldValue) {
return; // No change. return; // No change.
} }
console.log("Chat: Current room changed to " + (value ? value : "null")); console.log(
"Chat: Current room changed to " + (value ? value : "null")
);
// Clear old events // Clear old events
this.events = []; this.events = [];
@ -398,76 +468,84 @@ export default {
if (!this.room) { if (!this.room) {
// Public room? // Public room?
if (this.roomId && this.roomId.startsWith('#')) { if (this.roomId && this.roomId.startsWith("#")) {
this.onRoomNotJoined(); this.onRoomNotJoined();
} }
return; // no room return; // no room
} }
// Joined? // Joined?
if (this.room.hasMembershipState(this.currentUser.user_id, "join")) { if (this.room.hasMembershipState(this.currentUser.user_id, "join")) {
// Yes, load everything // Yes, load everything
this.onRoomJoined(); this.onRoomJoined();
} else { } else {
this.onRoomNotJoined(); this.onRoomNotJoined();
} }
} },
}, },
}, },
methods: { methods: {
onRoomJoined() { onRoomJoined() {
var initialEventId = this.readMarker; var initialEventId = this.readMarker;
console.log("Read up to " + initialEventId); console.log("Read up to " + initialEventId);
//initialEventId = null; //initialEventId = null;
this.timelineWindow = new TimelineWindow( this.timelineWindow = new TimelineWindow(
this.$matrix.matrixClient, this.$matrix.matrixClient,
this.room.getUnfilteredTimelineSet(), this.room.getUnfilteredTimelineSet(),
{} {}
); );
const self = this; const self = this;
this.timelineWindow.load(initialEventId, 20).then(() => { this.timelineWindow.load(initialEventId, 20).then(() => {
console.log("This is", self); console.log("This is", self);
self.events = self.timelineWindow.getEvents(); self.events = self.timelineWindow.getEvents();
const getMoreIfNeeded = function _getMoreIfNeeded() { const getMoreIfNeeded = function _getMoreIfNeeded() {
const container = self.$refs.chatContainer; const container = self.$refs.chatContainer;
if (container.scrollHeight <= container.clientHeight && if (
self.timelineWindow && container.scrollHeight <= container.clientHeight &&
self.timelineWindow.canPaginate(EventTimeline.BACKWARDS)) { self.timelineWindow &&
return self.timelineWindow.paginate(EventTimeline.BACKWARDS, 10, true) self.timelineWindow.canPaginate(EventTimeline.BACKWARDS)
.then(success => { ) {
if (success) { return self.timelineWindow
self.events = self.timelineWindow.getEvents(); .paginate(EventTimeline.BACKWARDS, 10, true)
return _getMoreIfNeeded.call(self); .then((success) => {
} else { if (success) {
return Promise.reject("Failed to paginate"); self.events = self.timelineWindow.getEvents();
} return _getMoreIfNeeded.call(self);
}); } else {
} else { return Promise.reject("Failed to paginate");
return Promise.resolve("Done"); }
} });
}.bind(self); } else {
return Promise.resolve("Done");
}
}.bind(self);
getMoreIfNeeded() getMoreIfNeeded()
.catch(err => { .catch((err) => {
console.log("ERROR " + err); console.log("ERROR " + err);
}) })
.finally(() => { .finally(() => {
self.initialLoadDone = true; self.initialLoadDone = true;
if (initialEventId) { if (initialEventId) {
self.scrollToEvent(initialEventId); self.scrollToEvent(initialEventId);
} }
self.restartRRTimer(); self.restartRRTimer();
}); });
}); });
}, },
onRoomNotJoined() { onRoomNotJoined() {
this.$navigation.push({ name: "Join", params: { roomId: util.sanitizeRoomId(this.roomAliasOrId) }}, 0); this.$navigation.push(
{
name: "Join",
params: { roomId: util.sanitizeRoomId(this.roomAliasOrId) },
},
0
);
}, },
touchX(event) { touchX(event) {
@ -594,6 +672,10 @@ export default {
) { ) {
this.handleScrolledToBottom(false); this.handleScrolledToBottom(false);
} }
this.showScrollToEnd =
container.scrollHeight - container.scrollTop.toFixed(0) >
container.clientHeight;
this.restartRRTimer(); this.restartRRTimer();
}, },
onEvent(event) { onEvent(event) {
@ -669,7 +751,7 @@ export default {
// this.showNotAllowedForGuests = true; // this.showNotAllowedForGuests = true;
// return; // return;
// } // }
this.$refs.attachment.click() this.$refs.attachment.click();
}, },
/** /**
@ -776,8 +858,8 @@ export default {
}, },
/** /**
* Scroll so that the given event is at the middle of the chat view (if more events) or else at the bottom. * Scroll so that the given event is at the middle of the chat view (if more events) or else at the bottom.
*/ */
scrollToEvent(eventId) { scrollToEvent(eventId) {
const container = this.$refs.chatContainer; const container = this.$refs.chatContainer;
const ref = this.$refs[eventId]; const ref = this.$refs[eventId];
@ -825,13 +907,14 @@ export default {
}, },
redact(event) { redact(event) {
this.$matrix.matrixClient.redactEvent(event.getRoomId(), event.getId()) this.$matrix.matrixClient
.then(() => { .redactEvent(event.getRoomId(), event.getId())
console.log("Message redacted"); .then(() => {
}) console.log("Message redacted");
.catch(err => { })
console.log("Redaction failed: ", err); .catch((err) => {
}) console.log("Redaction failed: ", err);
});
}, },
download(event) { download(event) {
@ -908,8 +991,8 @@ export default {
}, },
/** /**
* Start/restart the timer to Read Receipts. * Start/restart the timer to Read Receipts.
*/ */
restartRRTimer() { restartRRTimer() {
this.stopRRTimer(); this.stopRRTimer();
this.rrTimer = setInterval(this.rrTimerElapsed, READ_RECEIPT_TIMEOUT); this.rrTimer = setInterval(this.rrTimerElapsed, READ_RECEIPT_TIMEOUT);
@ -919,30 +1002,33 @@ export default {
const container = this.$refs.chatContainer; const container = this.$refs.chatContainer;
const el = util.getLastVisibleElement(container); const el = util.getLastVisibleElement(container);
if (el) { if (el) {
const eventId = el.getAttribute('eventId'); const eventId = el.getAttribute("eventId");
if (eventId && this.room) { if (eventId && this.room) {
const event = this.room.findEventById(eventId); const event = this.room.findEventById(eventId);
if (event && (!this.lastRR || event.getTs() > this.lastRR.getTs())) { if (event && (!this.lastRR || event.getTs() > this.lastRR.getTs())) {
// Disable timer while we are sending // Disable timer while we are sending
clearInterval(this.rrTimer); clearInterval(this.rrTimer);
this.rrTimer = null; this.rrTimer = null;
// Send read receipt // Send read receipt
this.$matrix.matrixClient.sendReadReceipt(event) this.$matrix.matrixClient
.then(() => { .sendReadReceipt(event)
this.$matrix.matrixClient.setRoomReadMarkers(this.room.roomId, eventId) .then(() => {
}) this.$matrix.matrixClient.setRoomReadMarkers(
.then(() => { this.room.roomId,
console.log("RR sent for event: " + eventId); eventId
this.lastRR = event; );
}) })
.catch(err => { .then(() => {
console.log("Failed to update read marker: ", err); console.log("RR sent for event: " + eventId);
}) this.lastRR = event;
.finally(() => { })
this.restartRRTimer(); .catch((err) => {
}); console.log("Failed to update read marker: ", err);
})
.finally(() => {
this.restartRRTimer();
});
} }
} }
} }
@ -959,7 +1045,7 @@ export default {
dayForEvent(event) { dayForEvent(event) {
return util.formatDay(event.getTs()); return util.formatDay(event.getTs());
} },
}, },
}; };
</script> </script>