Skip to content

Commit d72ce7a

Browse files
committed
add TypingIndicator.vue
Signed-off-by: Maksim Sukharev <[email protected]> - declare translatePlural in d.ts file, improve indicator message - remove HTML tags from direct translations - escape HTML markdown in participant names
1 parent 28f4056 commit d72ce7a

File tree

4 files changed

+187
-3
lines changed

4 files changed

+187
-3
lines changed

src/components/ChatView.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
role="region"
4747
:token="token"
4848
:container="containerId"
49+
has-typing-indicator
4950
:aria-label="t('spreed', 'Post message')" />
5051
</div>
5152
</template>

src/components/NewMessageForm/NewMessageForm.vue

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@
2020
-->
2121

2222
<template>
23-
<div class="wrapper">
23+
<div class="wrapper" :class="{'wrapper--has-typing-indicator': hasTypingIndicator}">
24+
<TypingIndicator v-if="hasTypingIndicator"
25+
:token="token" />
26+
2427
<!--native file picker, hidden -->
2528
<input id="file-upload"
2629
ref="fileUploadInput"
@@ -241,6 +244,7 @@ import NcRichContenteditable from '@nextcloud/vue/dist/Components/NcRichContente
241244
import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
242245
243246
import Quote from '../Quote.vue'
247+
import TypingIndicator from '../TypingIndicator/TypingIndicator.vue'
244248
import AudioRecorder from './AudioRecorder/AudioRecorder.vue'
245249
import SimplePollsEditor from './SimplePollsEditor/SimplePollsEditor.vue'
246250
import TemplatePreview from './TemplatePreview.vue'
@@ -281,6 +285,7 @@ export default {
281285
Quote,
282286
SimplePollsEditor,
283287
TemplatePreview,
288+
TypingIndicator,
284289
// Icons
285290
BellOff,
286291
EmoticonOutline,
@@ -325,6 +330,14 @@ export default {
325330
type: Boolean,
326331
default: false,
327332
},
333+
334+
/**
335+
* Show an indicator if someone is currently typing a message.
336+
*/
337+
hasTypingIndicator: {
338+
type: Boolean,
339+
default: false,
340+
},
328341
},
329342
330343
data() {
@@ -943,10 +956,15 @@ export default {
943956
@import '../../assets/variables';
944957
945958
.wrapper {
959+
position: relative;
946960
display: flex;
947961
justify-content: center;
948-
padding: 12px 0;
962+
padding: 12px 0 12px;
949963
min-height: 69px;
964+
965+
&--has-typing-indicator {
966+
padding: 30px 0 12px;
967+
}
950968
}
951969
952970
.new-message {
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
<!--
2+
- @copyright Copyright (c) 2023 Maksim Sukharev <[email protected]>
3+
-
4+
- @author Maksim Sukharev <[email protected]>
5+
-
6+
- @license GNU AGPL version 3 or any later version
7+
-
8+
- This program is free software: you can redistribute it and/or modify
9+
- it under the terms of the GNU Affero General Public License as
10+
- published by the Free Software Foundation, either version 3 of the
11+
- License, or (at your option) any later version.
12+
-
13+
- This program is distributed in the hope that it will be useful,
14+
- but WITHOUT ANY WARRANTY; without even the implied warranty of
15+
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16+
- GNU Affero General Public License for more details.
17+
-
18+
- You should have received a copy of the GNU Affero General Public License
19+
- along with this program. If not, see <http://www.gnu.org/licenses/>.
20+
-->
21+
22+
<template>
23+
<div v-show="typingParticipants.length" class="indicator">
24+
<div class="indicator__wrapper">
25+
<div class="indicator__avatars">
26+
<AvatarWrapper v-for="(participant, index) in visibleParticipants"
27+
:id="participant.actorId"
28+
:key="index"
29+
:name="participant.displayName"
30+
:source="participant.actorType"
31+
small
32+
condensed
33+
:condensed-overlap="8"
34+
disable-menu
35+
disable-tooltip />
36+
</div>
37+
<p class="indicator__main" v-html="indicatorMessage" />
38+
</div>
39+
</div>
40+
</template>
41+
42+
<script>
43+
import escapeHtml from 'escape-html'
44+
45+
import AvatarWrapper from '../AvatarWrapper/AvatarWrapper.vue'
46+
47+
export default {
48+
name: 'TypingIndicator',
49+
components: { AvatarWrapper },
50+
51+
props: {
52+
/**
53+
* The conversation token
54+
*/
55+
token: {
56+
type: String,
57+
required: true,
58+
},
59+
},
60+
61+
computed: {
62+
participants() {
63+
return this.$store.getters.participantsList(this.token).filter(participant => {
64+
return participant.actorId !== this.$store.getters.getActorId()
65+
})
66+
},
67+
68+
typingParticipants() {
69+
// TODO implement signal receiving here
70+
return this.participants
71+
},
72+
73+
visibleParticipants() {
74+
return this.typingParticipants.slice(0, 3)
75+
},
76+
77+
hiddenParticipantsCount() {
78+
return this.typingParticipants.slice(3).length
79+
},
80+
81+
indicatorMessage() {
82+
if (!this.typingParticipants) {
83+
return ''
84+
}
85+
86+
const [user1, user2, user3] = this.prepareNamesList()
87+
88+
if (this.typingParticipants.length === 1) {
89+
return t('spreed', '{user1} is typing...',
90+
{ user1 }, undefined, { escape: false })
91+
}
92+
93+
if (this.typingParticipants.length === 2) {
94+
return t('spreed', '{user1} and {user2} are typing...',
95+
{ user1, user2 }, undefined, { escape: false })
96+
}
97+
98+
if (this.typingParticipants.length === 3) {
99+
return t('spreed', '{user1}, {user2} and {user3} are typing...',
100+
{ user1, user2, user3 }, undefined, { escape: false })
101+
}
102+
103+
return n('spreed', '{user1}, {user2}, {user3} and %n other are typing...',
104+
'{user1}, {user2}, {user3} and %n others are typing...',
105+
this.hiddenParticipantsCount, { user1, user2, user3 }, { escape: false })
106+
},
107+
},
108+
109+
methods: {
110+
prepareNamesList() {
111+
return this.visibleParticipants.reverse()
112+
.map(participant => this.getParticipantName(participant))
113+
.map(name => name ? `<strong>${escapeHtml(name)}</strong>` : undefined)
114+
},
115+
116+
// TODO implement model from signaling here
117+
getParticipantName(participant) {
118+
if (participant?.displayName) {
119+
return participant.displayName
120+
}
121+
122+
return this.$store.getters.getGuestName(this.token, participant.actorId)
123+
},
124+
},
125+
}
126+
</script>
127+
128+
<style lang="scss" scoped>
129+
.indicator {
130+
position: absolute;
131+
top: 4px;
132+
left: 0;
133+
width: 100%;
134+
padding-right: 12px;
135+
136+
&__wrapper {
137+
max-width: 800px;
138+
display: flex;
139+
align-items: center;
140+
margin: auto;
141+
padding: 0;
142+
line-height: 120%;
143+
color: var(--color-text-maxcontrast);
144+
}
145+
146+
&__avatars {
147+
display: flex;
148+
justify-content: center;
149+
align-items: center;
150+
flex-direction: row-reverse;
151+
flex-shrink: 0;
152+
width: 52px;
153+
padding-left: 8px;
154+
}
155+
156+
&__main {
157+
width: 100%;
158+
padding-left: 8px;
159+
}
160+
}
161+
</style>

src/types/vendor/l10n.d.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,5 @@
1-
declare function t(app: string, string: string, vars?: { [key: string]: string }, count?: number, options?: any): string;
1+
// TODO re-declare functions from upstream library
2+
// import { translate, translatePlural } from '@nextcloud/l10n/dist/translation.d.ts'
3+
4+
declare function t(app: string, text: string, vars?: { [key: string]: string }, number?: number, options?: any): string;
5+
declare function n(app: string, textSingular: string, textPlural: string, number: number, vars?: { [key: string]: string }, options?: any): string;

0 commit comments

Comments
 (0)