Skip to content

Commit

Permalink
feat: ability to send messages (#124)
Browse files Browse the repository at this point in the history
  • Loading branch information
Binatik authored Dec 1, 2024
1 parent 2ff0be3 commit 55cea05
Show file tree
Hide file tree
Showing 15 changed files with 228 additions and 60 deletions.
6 changes: 3 additions & 3 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ module.exports = {
// fn: async () => {}, ожидая () => void
properties: false
}
}]
}],
// Включено в full файле
'@typescript-eslint/no-unused-vars': 'off'
}
},
{
Expand Down Expand Up @@ -335,8 +337,6 @@ module.exports = {
// Разрешает fn && fn()
allowShortCircuit: true
}],
// Включено в full файле
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/no-useless-constructor': 'error',
'@typescript-eslint/no-unnecessary-template-expression': 'error',
'@typescript-eslint/object-curly-spacing': ['error', 'always'],
Expand Down
2 changes: 1 addition & 1 deletion src/assets/icons/Icon24Info.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions src/assets/icons/Icon24Send.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/assets/icons/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export { default as Icon24HideOutline } from './Icon24HideOutline.svg'
export { default as Icon24Info } from './Icon24Info.svg'
export { default as Icon24LinkExternalOutline } from './Icon24LinkExternalOutline.svg'
export { default as Icon24MuteOutline } from './Icon24MuteOutline.svg'
export { default as Icon24Send } from './Icon24Send.svg'
export { default as Icon24Spinner } from './Icon24Spinner.svg'
export { default as Icon24ViewOutline } from './Icon24ViewOutline.svg'
export { default as Icon24VolumeOutline } from './Icon24VolumeOutline.svg'
Expand Down
1 change: 1 addition & 0 deletions src/misc/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@ export const PEER_FIELDS = [
].join(',')

export const CONVOS_PER_PAGE = 20
export const INTEGER_BOUNDARY = (2 ** 31) - 1
13 changes: 12 additions & 1 deletion src/misc/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as electron from '@electron/remote'
import { ComponentPublicInstance, onScopeDispose, Ref, unref } from 'vue'
import { ComponentPublicInstance, KeyboardEvent, type MouseEvent, onScopeDispose, Ref, unref } from 'vue'

export { debounce } from 'main-process/shared'

Expand Down Expand Up @@ -138,3 +138,14 @@ export function unrefElement(ref: Ref<RefElement>): ExplicitRefElement {

return raw
}

export function isEventWithModifier(event: MouseEvent | KeyboardEvent): boolean {
return (
event.ctrlKey ||
event.metaKey ||
event.altKey ||
event.shiftKey ||
// Нажатие на колесико мыши
(event instanceof MouseEvent && event.button === 1)
)
}
8 changes: 7 additions & 1 deletion src/model/api-types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ import {
MessagesGetLongPollHistoryParams,
MessagesGetLongPollHistoryResponse,
MessagesGetLongPollServerParams,
MessagesGetLongPollServerResponse
MessagesGetLongPollServerResponse,
MessagesSendParams,
MessagesSendResponse
} from 'model/api-types/methods/Messages'
import { UsersGetParams, UsersGetResponse } from 'model/api-types/methods/Users'
import { AccountSetSilenceModeParams, AccountSetSilenceModeResponse } from './methods/Account'
Expand Down Expand Up @@ -121,6 +123,10 @@ export type Methods = {
params: MessagesGetLongPollHistoryParams
response: MessagesGetLongPollHistoryResponse
}
'messages.send': {
params: MessagesSendParams
response: MessagesSendResponse
}

'users.get': {
params: UsersGetParams
Expand Down
12 changes: 12 additions & 0 deletions src/model/api-types/methods/Messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,15 @@ export type MessagesGetLongPollHistoryResponse = {
// contacts?: MessagesContact[]
// incognito_members?: MessagesExtendedIncognitoMember[]
}

// messages.send
export type MessagesSendParams = {
peer_id: number
random_id: number
message: string
}

export type MessagesSendResponse = {
cmid: number
message_id: number
}
1 change: 0 additions & 1 deletion src/ui/app/App/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
user-select: none;
font: var(--font);
text-rendering: optimizeSpeed;
-webkit-rtl-ordering: visual;
}

.App {
Expand Down
13 changes: 13 additions & 0 deletions src/ui/messenger/ConvoComposer/ConvoComposer.css
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,19 @@
color: var(--vkui--color_text_secondary);
}

.ConvoComposer__send {
width: 34px;
height: 34px;
align-self: flex-end;
color: var(--vkui--color_icon_accent);
opacity: 0.8;
transition: opacity var(--fastTransition);
}

.ConvoComposer__send:hover {
opacity: 1;
}

.ConvoComposer__restriction {
display: flex;
align-items: center;
Expand Down
84 changes: 73 additions & 11 deletions src/ui/messenger/ConvoComposer/ConvoComposer.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { defineComponent, ref } from 'vue'
import { ChangeEvent, computed, defineComponent, KeyboardEvent, shallowRef } from 'vue'
import * as Convo from 'model/Convo'
import { useEnv } from 'hooks'
import { isEventWithModifier, random } from 'misc/utils'
import { INTEGER_BOUNDARY } from 'misc/constants'
import { Button } from 'ui/ui/Button/Button'
import { Icon24Info, Icon24MuteOutline, Icon24VolumeOutline } from 'assets/icons'
import { ButtonIcon } from 'ui/ui/ButtonIcon/ButtonIcon'
import { Icon24Info, Icon24MuteOutline, Icon24Send, Icon24VolumeOutline } from 'assets/icons'
import './ConvoComposer.css'

type Props = {
Expand All @@ -11,11 +14,54 @@ type Props = {

export const ConvoComposer = defineComponent<Props>((props) => {
const { lang, api } = useEnv()
const loading = ref(false)
const isNotificationsUpdating = shallowRef(false)
const isMessageSending = shallowRef(false)
const text = shallowRef('')
const $input = shallowRef<HTMLSpanElement | null>(null)

const isEmpty = computed(() => text.value.trim() === '')

const sendMessage = async () => {
try {
isMessageSending.value = true

await api.fetch('messages.send', {
peer_id: props.convo.id,
random_id: random(-INTEGER_BOUNDARY, INTEGER_BOUNDARY),
message: text.value
})

if ($input.value) {
text.value = ''
$input.value.textContent = ''
}
} catch (error) {
console.warn(error)
} finally {
isMessageSending.value = false
}
}

const onKeyDown = (event: KeyboardEvent<HTMLElement>) => {
if (event.code === 'Enter' && !isEventWithModifier(event)) {
// Предотвращаем перенос строки
event.preventDefault()

if (isEmpty.value) {
return
}

sendMessage()
}
}

const onInput = (event: ChangeEvent<HTMLElement>) => {
text.value = event.currentTarget.textContent ?? ''
}

const toggleNotifications = async () => {
try {
loading.value = true
isNotificationsUpdating.value = true

await api.fetch('account.setSilenceMode', {
peer_id: props.convo.id,
Expand All @@ -25,7 +71,7 @@ export const ConvoComposer = defineComponent<Props>((props) => {

props.convo.notifications.enabled = !props.convo.notifications.enabled
} finally {
loading.value = false
isNotificationsUpdating.value = false
}
}

Expand All @@ -46,7 +92,7 @@ export const ConvoComposer = defineComponent<Props>((props) => {
<Button
class="ConvoComposer__muteChannelButton"
mode="tertiary"
loading={loading.value}
loading={isNotificationsUpdating.value}
onClick={toggleNotifications}
before={
props.convo.notifications.enabled
Expand All @@ -62,11 +108,27 @@ export const ConvoComposer = defineComponent<Props>((props) => {
}

return (
<span
class="ConvoComposer__input"
contenteditable="plaintext-only"
placeholder={lang.use('me_convo_composer_placeholder')}
/>
<>
<span
class="ConvoComposer__input"
contenteditable="plaintext-only"
role="textbox"
placeholder={lang.use('me_convo_composer_placeholder')}
ref={$input}
onKeydown={onKeyDown}
onInput={onInput}
/>
<ButtonIcon
class="ConvoComposer__send"
disabled={isEmpty.value}
loading={isMessageSending.value}
icon={<Icon24Send />}
addHoverBackground={false}
onClick={sendMessage}
// Предотвращаем сброс фокуса с поля ввода
onMousedown={(event) => event.preventDefault()}
/>
</>
)
}

Expand Down
35 changes: 34 additions & 1 deletion src/ui/messenger/ConvoHistory/ConvoHistory.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { computed, defineComponent, nextTick, onBeforeUnmount, onMounted, shallowRef } from 'vue'
import { computed, defineComponent, nextTick, onBeforeUnmount, onBeforeUpdate, onMounted, onUpdated, shallowRef } from 'vue'
import * as Convo from 'model/Convo'
import * as History from 'model/History'
import * as Message from 'model/Message'
Expand All @@ -21,6 +21,7 @@ export const ConvoHistory = defineComponent<Props>(({ convo }) => {
const { loadConvoHistoryLock, savedConvoScroll } = useConvosStore()
const historySlice = computed(() => History.around(convo.history, convo.inReadBy))
const $historyElement = shallowRef<HTMLDivElement | null>(null)
const prevScrollHeight = shallowRef(0)

onMounted(() => {
const scrollTop = savedConvoScroll.get(convo.id)
Expand All @@ -35,6 +36,38 @@ export const ConvoHistory = defineComponent<Props>(({ convo }) => {
}
})

onBeforeUpdate(() => {
if ($historyElement.value) {
prevScrollHeight.value = $historyElement.value.scrollHeight
}
})

onUpdated(() => {
if (!$historyElement.value) {
return
}

/**
* Автоматически скроллим до низа истории, если перед ререндером мы находились внизу,
* но впоследствии ререндера оказались выше.
* Основной сценарий - когда мы или собеседник написали новое сообщение
*
* scrollTop - высота от начала контента до начала вьюпорта;
* offsetHeight - высота вьюпорта;
* scrollHeight - общая высота контента.
*
* Сумма scrollTop и offsetHeight равна высоте от начала контента до конца вьюпорта.
* Если мы находимся в самом низу, то она будет совпадать с общей высотой, но если
* мы проскроллим вверх, появляется контент под вьюпортом, который нам не виден.
* Тогда сумма не совпадет и мы поймем что юзер не находится внизу
*/
const upperContentHeight = $historyElement.value.scrollTop + $historyElement.value.offsetHeight

if (prevScrollHeight.value === upperContentHeight) {
$historyElement.value.scrollTo(0, $historyElement.value.scrollHeight)
}
})

const loadHistory = (
direction: 'around' | 'up' | 'down',
startCmid: Message.Cmid,
Expand Down
22 changes: 20 additions & 2 deletions src/ui/ui/ButtonIcon/ButtonIcon.css
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,31 @@
flex: none;
position: relative;
border-radius: 6px;
transition: background-color var(--fastTransition);
transition: background-color var(--fastTransition), opacity var(--fastTransition);
}

.ButtonIcon:hover {
.ButtonIcon[disabled] {
pointer-events: none;
opacity: 0.6;
}

.ButtonIcon--hoverBackground:hover {
background: var(--vkui--color_transparent--hover);
}

.ButtonIcon__spinner {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
margin: auto;
}

.ButtonIcon--loading .ButtonIcon__in {
visibility: hidden;
}

.ButtonIcon--stretched {
width: 100%;
height: 100%;
Expand Down
18 changes: 14 additions & 4 deletions src/ui/ui/ButtonIcon/ButtonIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@ import { ButtonHTMLAttributes, defineComponent } from 'vue'
import { useFocusVisible } from 'hooks'
import { JSXElement } from 'misc/utils'
import { FocusVisible } from 'ui/ui/FocusVisible/FocusVisible'
import { Spinner } from 'ui/ui/Spinner/Spinner'
import './ButtonIcon.css'

type Props = {
icon?: JSXElement
stretched?: boolean
addHoverBackground?: boolean
loading?: boolean
} & ButtonHTMLAttributes

export const ButtonIcon = defineComponent<Props>((props, { slots }) => {
Expand All @@ -15,15 +18,22 @@ export const ButtonIcon = defineComponent<Props>((props, { slots }) => {
return () => (
<button
type="button"
class={['ButtonIcon', props.stretched && 'ButtonIcon--stretched']}
class={['ButtonIcon', {
'ButtonIcon--stretched': props.stretched,
'ButtonIcon--hoverBackground': props.addHoverBackground ?? true,
'ButtonIcon--loading': props.loading
}]}
onBlur={onBlur}
onFocus={onFocus}
>
{props.icon}
{slots.default?.()}
<span class="ButtonIcon__in">
{props.icon}
{slots.default?.()}
</span>
{props.loading && <Spinner class="ButtonIcon__spinner" color="inherit" />}
<FocusVisible isFocused={isFocused.value} />
</button>
)
}, {
props: ['icon', 'stretched']
props: ['icon', 'stretched', 'addHoverBackground', 'loading']
})
Loading

0 comments on commit 55cea05

Please sign in to comment.