Prepare inbox (#65)

Chat client - MVP
This commit is contained in:
Tony 2022-12-17 06:27:00 +03:00 committed by GitHub
parent 2e74624240
commit 0b70289195
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
67 changed files with 13114 additions and 430 deletions

View File

@ -12,7 +12,7 @@ const getDevCssClassPrefix = (filename: string): string => {
return filename
.slice(filename.indexOf(PATH_PREFIX) + PATH_PREFIX.length)
.replace('.module.scss', '')
.replace(/[/?\\]/g, '-')
.replaceAll(/[/?\\]/g, '-')
}
const devGenerateScopedName = (name: string, filename: string, css: string) =>

View File

@ -1,5 +1,6 @@
overwrite: true
schema: 'http://localhost:8080'
#schema: 'http://localhost:8080'
schema: 'https://v2.discours.io'
generates:
src/graphql/introspec.gen.ts:
plugins:

View File

@ -84,6 +84,7 @@
"eslint-plugin-sonarjs": "^0.16.0",
"eslint-plugin-unicorn": "^45.0.0",
"graphql": "^16.6.0",
"graphql-sse": "^1.3.1",
"graphql-tag": "^2.12.6",
"graphql-ws": "^5.11.2",
"hast-util-select": "^5.0.2",
@ -140,7 +141,6 @@
"unique-names-generator": "^4.7.1",
"uuid": "^9.0.0",
"vite": "^3.2.4",
"vite-plugin-html-purgecss": "^0.1.1",
"ws": "^8.11.0",
"y-prosemirror": "^1.2.0",
"y-protocols": "^1.0.5",

12034
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,4 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.1746 7.75L7.84131 13.25L15.1746 18.75L15.1746 7.75Z" fill="#9FA1A7"/>
<path d="M23.4255 24.2497C26.6333 15.0828 19.7596 10.5 15.1764 10.9573C15.1766 12.79 15.1764 15.5414 15.1764 15.5414C16.0922 15.5414 22.508 15.9997 23.4255 24.2497Z" fill="#9FA1A7"/>
</svg>

After

Width:  |  Height:  |  Size: 369 B

View File

@ -0,0 +1,3 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M20 18.4444L25.4444 13L27 14.5556L21.5556 20L27 25.4444L25.4444 27L20 21.5556L14.5556 27L13 25.4444L18.4444 20L13 14.5556L14.5556 13L20 18.4444Z" fill="#9FA1A7"/>
</svg>

After

Width:  |  Height:  |  Size: 315 B

5
public/icons/menu.svg Normal file
View File

@ -0,0 +1,5 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16 20C17.1046 20 18 20.8954 18 22C18 23.1046 17.1046 24 16 24C14.8954 24 14 23.1046 14 22C14 20.8954 14.8954 20 16 20Z" fill="#9FA1A7"/>
<path d="M16 14C17.1046 14 18 14.8954 18 16C18 17.1046 17.1046 18 16 18C14.8954 18 14 17.1046 14 16C14 14.8954 14.8954 14 16 14Z" fill="#9FA1A7"/>
<path d="M16 8C17.1046 8 18 8.89543 18 10C18 11.1046 17.1046 12 16 12C14.8954 12 14 11.1046 14 10C14 8.89543 14.8954 8 16 8Z" fill="#9FA1A7"/>
</svg>

After

Width:  |  Height:  |  Size: 540 B

View File

@ -54,6 +54,7 @@
.commentControls {
@include font-size(1.2rem);
margin-bottom: 0.5em;
}
@ -120,6 +121,7 @@
.commentBody {
@include font-size(1.5rem);
line-height: 1.47;
}
@ -196,6 +198,7 @@
button {
@include font-size(1.6rem);
margin-left: 1.2rem;
}
}

View File

@ -1,6 +1,5 @@
import styles from './RatingControl.module.scss'
import { clsx } from 'clsx'
import { Icon } from '../_shared/Icon'
interface RatingControlProps {
rating?: number

View File

@ -9,7 +9,7 @@ type SharePopupProps = Omit<PopupProps, 'children'>
export const SharePopup = (props: SharePopupProps) => {
return (
<Popup {...props}>
<Popup {...props} variant="bordered">
<ul class="nodash">
<li>
<a href="#">

View File

@ -265,6 +265,7 @@
.authorComments {
.authorName {
@include font-size(1.2rem);
margin-bottom: 0;
}

View File

@ -10,8 +10,10 @@ import { follow, unfollow } from '../../stores/zine/common'
import { clsx } from 'clsx'
import { useSession } from '../../context/session'
import { StatMetrics } from '../_shared/StatMetrics'
import { FollowingEntity } from '../../graphql/types.gen'
import { ShowOnlyOnClient } from '../_shared/ShowOnlyOnClient'
import { FollowingEntity } from '../../graphql/types.gen'
import { router, useRouter } from '../../stores/router'
import { openPage } from '@nanostores/router'
interface AuthorCardProps {
caption?: string
@ -68,6 +70,11 @@ export const AuthorCard = (props: AuthorCardProps) => {
})
// TODO: reimplement AuthorCard
const { changeSearchParam } = useRouter()
const initChat = () => {
openPage(router, `inbox`)
changeSearchParam('initChat', `${props.author.id}`)
}
return (
<div
class={clsx(styles.author)}
@ -163,6 +170,7 @@ export const AuthorCard = (props: AuthorCardProps) => {
'button--subscribe-topic': props.isAuthorsList,
[styles.buttonWrite]: props.liteButtons && props.isAuthorsList
}}
onClick={initChat}
>
<Icon name="comment" class={styles.icon} />
<Show when={!props.liteButtons}>{t('Write')}</Show>

View File

@ -1,5 +1,6 @@
.CreateModalContent {
padding: 24px;
.footer {
padding-top: 12px;
display: flex;

View File

@ -6,69 +6,59 @@ import type { Author } from '../../graphql/types.gen'
import { hideModal } from '../../stores/ui'
import { useInbox } from '../../context/inbox'
type InvitingUser = Author & {
selected: boolean
}
type query =
| {
theme: string
members: string[]
}
| undefined
type inviteUser = Author & { selected: boolean }
type Props = {
users: Author[]
}
const CreateModalContent = (props: Props) => {
const inviteUsers: InvitingUser[] = props.users.map((user) => ({ ...user, selected: false }))
const [title, setTitle] = createSignal<string>('')
const [uids, setUids] = createSignal<number[]>([])
const [collectionToInvite, setCollectionToInvite] = createSignal<InvitingUser[]>(inviteUsers)
const inviteUsers: inviteUser[] = props.users.map((user) => ({ ...user, selected: false }))
const [theme, setTheme] = createSignal<string>(' ')
const [usersId, setUsersId] = createSignal<number[]>([])
const [collectionToInvite, setCollectionToInvite] = createSignal<inviteUser[]>(inviteUsers)
let textInput: HTMLInputElement
const reset = () => {
setTitle('')
setUids([])
setTheme('')
setUsersId([])
hideModal()
}
createEffect(() => {
console.log(collectionToInvite())
setUids(() => {
setUsersId(() => {
return collectionToInvite()
.filter((user: InvitingUser) => {
.filter((user) => {
return user.selected === true
})
.map((user: InvitingUser) => {
return user.id
.map((user) => {
return user['id']
})
})
if (uids().length > 1 && title().length === 0) {
setTitle(t('group_chat'))
if (usersId().length > 1 && theme().length === 1) {
setTheme(t('group_chat'))
}
})
const handleSetTheme = () => {
setTitle(textInput.value.length > 0 && textInput.value)
setTheme(textInput.value.length > 0 && textInput.value)
}
const handleClick = (user) => {
setCollectionToInvite((userCollection: InvitingUser[]) => {
return userCollection.map((clickedUser: InvitingUser) =>
user.slug === clickedUser.slug ? { ...clickedUser, selected: !clickedUser.selected } : clickedUser
setCollectionToInvite((userCollection) => {
return userCollection.map((clickedUser) =>
user.id === clickedUser.id ? { ...clickedUser, selected: !clickedUser.selected } : clickedUser
)
})
}
const { chatEntities, actions } = useInbox()
console.log('!!! chatEntities:', chatEntities)
const { actions } = useInbox()
const handleCreate = async () => {
try {
const initChat = await actions.createChat(uids(), title())
const initChat = await actions.createChat(usersId(), theme())
console.debug('[initChat]', initChat)
hideModal()
await actions.loadChats()
} catch (error) {
console.error(error)
}
@ -77,7 +67,7 @@ const CreateModalContent = (props: Props) => {
return (
<div class={styles.CreateModalContent}>
<h4>{t('create_chat')}</h4>
{uids().length > 1 && (
{usersId().length > 1 && (
<input
ref={textInput}
onInput={handleSetTheme}
@ -90,7 +80,7 @@ const CreateModalContent = (props: Props) => {
<div class="invite-recipients" style={{ height: '400px', overflow: 'auto' }}>
<For each={collectionToInvite()}>
{(author: InvitingUser) => (
{(author) => (
<InviteUser onClick={() => handleClick(author)} author={author} selected={author.selected} />
)}
</For>
@ -104,9 +94,9 @@ const CreateModalContent = (props: Props) => {
type="button"
class="btn btn-lg fs-3 btn-outline-primary"
onClick={handleCreate}
disabled={uids().length === 0}
disabled={usersId().length === 0}
>
{uids().length > 1 ? t('create_group') : t('create_chat')}
{usersId().length > 1 ? t('create_group') : t('create_chat')}
</button>
</div>
</div>

View File

@ -18,6 +18,7 @@
border-radius: 50%;
border: 3px solid #fff;
}
.imageHolder {
background-size: cover;
width: 100%;
@ -29,9 +30,6 @@
.letter {
display: block;
border-radius: 100%;
}
.letter {
margin-bottom: -2px;
font-weight: 500;
font-size: 18px;
@ -47,4 +45,8 @@
font-size: 14px;
}
}
&.bordered {
border: 2px solid #fff;
}
}

View File

@ -8,6 +8,8 @@ type Props = {
url?: string
online?: boolean
size?: 'small'
bordered?: boolean
className?: string
}
const colors = [
@ -36,8 +38,9 @@ const DialogAvatar = (props: Props) => {
return (
<div
class={clsx(styles.DialogAvatar, {
class={clsx(styles.DialogAvatar, props.className, {
[styles.online]: props.online,
[styles.bordered]: props.bordered,
[styles.small]: props.size === 'small'
})}
style={{ 'background-color': `${randomBg()}` }}

View File

@ -6,9 +6,10 @@
font-size: 14px;
padding: 12px;
transition: background 0.3s ease-in-out;
cursor: pointer;
width: 100%;
&:hover {
&.hovered:hover {
cursor: pointer;
background: #f7f7f7;
}
@ -24,6 +25,7 @@
.name,
.message {
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@ -67,4 +69,14 @@
}
}
}
&.opened,
&.opened:hover {
background: #000;
.name,
.message,
.time {
color: #fff !important;
}
}
}

View File

@ -1,36 +1,72 @@
import styles from './DialogCard.module.scss'
import { Show, Switch, Match, createMemo } from 'solid-js'
import DialogAvatar from './DialogAvatar'
import type { Author, ChatMember, User } from '../../graphql/types.gen'
import { t } from '../../utils/intl'
import { Show } from 'solid-js'
import { useSession } from '../../context/session'
import type { ChatMember } from '../../graphql/types.gen'
import GroupDialogAvatar from './GroupDialogAvatar'
import formattedTime from '../../utils/formatDateTime'
import { clsx } from 'clsx'
import styles from './DialogCard.module.scss'
type DialogProps = {
online?: boolean
message?: string
counter?: number
title?: string
ownId: number
members: ChatMember[]
onClick?: () => void
isChatHeader?: boolean
lastUpdate?: number
isOpened?: boolean
}
const DialogCard = (props: DialogProps) => {
console.log('!!! participants:', props.members)
const companions = createMemo(
() => props.members && props.members.filter((member) => member.id !== props.ownId)
)
const names = createMemo(() =>
companions()
?.map((companion) => companion.name)
.join(', ')
)
return (
//DialogCardView - подумать
<Show when={props.members?.length > 0}>
<div class={styles.DialogCard}>
<Show when={props.members}>
<div
class={clsx(styles.DialogCard, {
[styles.header]: props.isChatHeader,
[styles.opened]: props.isOpened,
[styles.hovered]: !props.isChatHeader
})}
onClick={props.onClick}
>
<div class={styles.avatar}>
<DialogAvatar name={props.members[0].name} online={props.online} />
<Switch fallback={<DialogAvatar name={props.members[0].name} url={props.members[0].userpic} />}>
<Match when={props.members.length >= 3}>
<GroupDialogAvatar users={props.members} />
</Match>
</Switch>
</div>
<div class={styles.row}>
<div class={styles.name}>{props.members[0].name}</div>
<div class={styles.message}>{t('You can announce your languages in profile')}</div>
</div>
<div class={styles.activity}>
<div class={styles.time}>22:22</div>
<div class={styles.counter}>
<span>12</span>
<div class={styles.name}>{props.title}</div>
<div class={styles.message}>
<Switch>
<Match when={props.message && !props.isChatHeader}>{props.message}</Match>
<Match when={props.isChatHeader && companions().length > 1}>{names()}</Match>
</Switch>
</div>
</div>
<Show when={!props.isChatHeader}>
<div class={styles.activity}>
<Show when={props.lastUpdate}>
<div class={styles.time}>{formattedTime(props.lastUpdate * 1000)}</div>
</Show>
<Show when={props.counter > 0}>
<div class={styles.counter}>
<span>{props.counter}</span>
</div>
</Show>
</div>
</Show>
</div>
</Show>
)

View File

@ -0,0 +1,9 @@
.DialogHeader {
display: flex;
align-items: center;
border-bottom: 3px solid #141414;
.avatar {
width: 40px;
}
}

View File

@ -0,0 +1,22 @@
import type { Chat } from '../../graphql/types.gen'
import styles from './DialogHeader.module.scss'
import DialogCard from './DialogCard'
type DialogHeader = {
chat: Chat
ownId: number
}
const DialogHeader = (props: DialogHeader) => {
return (
<header class={styles.DialogHeader}>
<DialogCard
isChatHeader={true}
title={props.chat.title || props.chat.members[0].name}
members={props.chat.members}
ownId={props.ownId}
/>
</header>
)
}
export default DialogHeader

View File

@ -0,0 +1,47 @@
.GroupDialogAvatar {
position: relative;
height: 40px;
width: 40px;
.grouped {
position: absolute;
&:nth-child(1) {
top: 0;
left: 50%;
transform: translateX(-50%);
}
&:nth-child(2) {
bottom: 0;
left: 0;
}
&:nth-child(3) {
bottom: 0;
right: 0;
}
}
.counter {
width: 24px;
height: 24px;
position: absolute;
bottom: 0;
right: 0;
text-align: center;
line-height: 21px;
background: #fff;
border: 2px solid #fff;
box-shadow: inset 0 0 0 2px #000;
border-radius: 50%;
box-sizing: border-box;
font-size: 12px;
font-weight: 600;
&.hundred {
font-size: 7px;
line-height: 20px;
}
}
}

View File

@ -0,0 +1,41 @@
import { For } from 'solid-js'
import './DialogCard.module.scss'
import styles from './GroupDialogAvatar.module.scss'
import { clsx } from 'clsx'
import type { ChatMember } from '../../graphql/types.gen'
import DialogAvatar from './DialogAvatar'
type Props = {
users: ChatMember[]
}
const GroupDialogAvatar = (props: Props) => {
const slicedUsers = () => {
if (props.users.length > 3) {
return props.users.slice(0, 2)
}
return props.users.slice(0, 3)
}
return (
<div class={styles.GroupDialogAvatar}>
<For each={slicedUsers()}>
{(user) => (
<DialogAvatar
className={styles.grouped}
bordered={true}
size="small"
name={user.name}
url={user.userpic}
/>
)}
</For>
{props.users.length > 3 && (
<div class={clsx(styles.counter, { [styles.hundred]: props.users.length >= 100 })}>
{++props.users.length}
</div>
)}
</div>
)
}
export default GroupDialogAvatar

View File

@ -1,25 +1,66 @@
$actionsWidth: 32px * 2;
.Message {
display: flex;
flex-direction: column;
margin: 3.2rem 0;
.body {
background: #f6f6f6;
font-size: 14px;
max-width: 60%;
border-radius: 16px;
padding: 12px 16px;
position: relative;
display: flex;
flex-direction: row;
p {
margin: 0;
}
a {
color: inherit;
text-decoration: underline;
&:hover {
.text {
display: inline-flex;
flex-direction: column;
max-width: 60%;
margin-right: auto;
background: #f6f6f6;
font-size: 14px;
border-radius: 16px;
padding: 12px 16px;
position: relative;
z-index: 1;
word-wrap: break-word;
p {
margin: 0;
}
a {
color: inherit;
text-decoration: underline;
&:hover {
color: inherit;
}
}
}
.actions {
position: absolute;
display: flex;
flex-direction: row;
width: $actionsWidth;
height: 32px;
cursor: pointer;
top: 50%;
transform: translateY(-50%);
opacity: 0;
right: -$actionsWidth/2;
z-index: -1;
transition: 0.3s ease-in-out;
}
&.popupVisible {
position: relative;
z-index: 100;
}
&.popupVisible,
&:hover {
.actions {
z-index: 10000;
opacity: 1;
right: -$actionsWidth;
}
}
}
@ -44,13 +85,35 @@
line-height: 20px;
}
}
&.own {
.body {
justify-content: flex-end;
margin-left: auto;
background: #000;
color: #fff;
.text {
margin-left: auto;
margin-right: unset;
background: #000;
color: #fff;
}
.actions {
right: unset;
left: -$actionsWidth/2;
flex-direction: row-reverse;
.reply {
transform: scaleX(-1);
}
}
&.popupVisible,
&:hover {
.actions {
left: -$actionsWidth;
}
}
}
.time {
text-align: right;
}

View File

@ -1,31 +1,59 @@
import { Show } from 'solid-js'
import { createEffect, createMemo, createSignal, Show } from 'solid-js'
import MarkdownIt from 'markdown-it'
import { clsx } from 'clsx'
import styles from './Message.module.scss'
import DialogAvatar from './DialogAvatar'
import type { Message, ChatMember } from '../../graphql/types.gen'
import formattedTime from '../../utils/formatDateTime'
import { Icon } from '../_shared/Icon'
import { MessageActionsPopup } from './MessageActionsPopup'
import QuotedMessage from './QuotedMessage'
type Props = {
body: string
isOwn: boolean
content: Message
ownId: number
members: ChatMember[]
replyClick?: () => void
replyBody?: string
replyAuthor?: string
}
const md = new MarkdownIt({
linkify: true
linkify: true,
breaks: true
})
const Message = (props: Props) => {
const isOwn = props.ownId === Number(props.content.author)
const user = props.members?.find((m) => m.id === Number(props.content.author))
const [isPopupVisible, setIsPopupVisible] = createSignal<boolean>(false)
return (
<div class={clsx(styles.Message, props.isOwn && styles.own)}>
<Show when={!props.isOwn}>
<div class={clsx(styles.Message, isOwn && styles.own)}>
<Show when={!isOwn}>
<div class={styles.author}>
<DialogAvatar size="small" name={'Message Author'} />
<div class={styles.name}>Message Author</div>
<DialogAvatar size="small" name={user.name} url={user.userpic} />
<div class={styles.name}>{user.name}</div>
</div>
</Show>
<div class={styles.body}>
<div innerHTML={md.render(props.body)} />
<div class={clsx(styles.body, { [styles.popupVisible]: isPopupVisible() })}>
<div class={styles.text}>
<div class={styles.actions}>
<div onClick={props.replyClick}>
<Icon name="chat-reply" class={styles.reply} />
</div>
<MessageActionsPopup
onVisibilityChange={(isVisible) => setIsPopupVisible(isVisible)}
trigger={<Icon name="menu" />}
/>
</div>
<Show when={props.replyBody}>
<QuotedMessage body={props.replyBody} variant="inline" isOwn={isOwn} />
</Show>
<div innerHTML={md.render(props.content.body)} />
</div>
</div>
<div class={styles.time}>12:24</div>
<div class={styles.time}>{formattedTime(props.content.createdAt * 1000)}</div>
</div>
)
}

View File

@ -0,0 +1,43 @@
import { createEffect, createSignal, For } from 'solid-js'
import type { PopupProps } from '../_shared/Popup'
import { Popup } from '../_shared/Popup'
import { t } from '../../utils/intl'
export type MessageActionType = 'reply' | 'copy' | 'pin' | 'forward' | 'select' | 'delete'
type MessageActionsPopup = {
actionSelect?: (selectedAction) => void
} & Omit<PopupProps, 'children'>
const actions: { name: string; action: MessageActionType }[] = [
{ name: t('Reply'), action: 'reply' },
{ name: t('Copy'), action: 'copy' },
{ name: t('Pin'), action: 'pin' },
{ name: t('Forward'), action: 'forward' },
{ name: t('Select'), action: 'select' },
{ name: t('Delete'), action: 'delete' }
]
export const MessageActionsPopup = (props: MessageActionsPopup) => {
const [selectedAction, setSelectedAction] = createSignal<MessageActionType | null>(null)
createEffect(() => {
if (props.actionSelect) props.actionSelect(selectedAction())
})
return (
<Popup {...props} variant="tiny">
<ul class="nodash">
<For each={actions}>
{(item) => (
<li
style={item.action === 'delete' && { color: 'red' }}
onClick={() => setSelectedAction(item.action)}
>
{item.name}
</li>
)}
</For>
</ul>
</Popup>
)
}

View File

@ -0,0 +1,26 @@
.MessagesFallback {
display: flex;
align-items: center;
justify-content: center;
min-height: 100%;
$width: 10.5em;
.text {
font-weight: 500;
font-size: 18px;
line-height: 24px;
width: $width;
}
.button {
background: #141414;
border-radius: 12px;
width: 100%;
max-width: $width;
text-align: center;
color: #fff;
height: 48px;
text-transform: none;
}
}

View File

@ -0,0 +1,25 @@
import { Show } from 'solid-js'
import styles from './MessagesFallback.module.scss'
type MessagesFallback = {
message: string
onClick?: () => void
actionText?: string
}
const MessagesFallback = (props: MessagesFallback) => {
return (
<div class={styles.MessagesFallback}>
<div>
<p class={styles.text}>{props.message}</p>
<Show when={props.onClick}>
<button class={styles.button} type="button" onClick={props.onClick}>
{props.actionText}
</button>
</Show>
</div>
</div>
)
}
export default MessagesFallback

View File

@ -0,0 +1,49 @@
.QuotedMessage {
display: flex;
flex-direction: row;
align-items: center;
font-size: 14px;
&.inline {
color: #696969;
border-left: 2px solid #404040;
padding-left: 12px;
margin-bottom: 8px;
&.own {
color: #9fa1a7;
border-color: #fff;
}
}
&.reply {
border-top: 2px solid #ccc;
padding: 12px 0;
gap: 12px;
.icon {
width: 40px;
height: 40px;
flex-basis: 40px;
&.cancel {
cursor: pointer;
}
}
}
.body {
flex-grow: 1;
overflow: hidden;
.author {
font-weight: 600;
}
.quote {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}

View File

@ -0,0 +1,43 @@
import { Show } from 'solid-js'
import styles from './QuotedMessage.module.scss'
import { Icon } from '../_shared/Icon'
import { clsx } from 'clsx'
type QuotedMessage = {
body: string
cancel?: () => void
author?: string
variant: 'inline' | 'reply'
isOwn?: boolean
}
const QuotedMessage = (props: QuotedMessage) => {
return (
<div
class={clsx(styles.QuotedMessage, {
[styles.reply]: props.variant === 'reply',
[styles.inline]: props.variant === 'inline',
[styles.own]: props.isOwn
})}
>
<Show when={props.variant === 'reply'}>
<div class={styles.icon}>
<Icon name="chat-reply" />
</div>
</Show>
<div class={styles.body}>
<Show when={props.author}>
<div class={styles.author}>{props.author}</div>
</Show>
<div class={styles.quote}>{props.body}</div>
</div>
<Show when={props.cancel && props.variant === 'reply'}>
<div class={clsx(styles.cancel, styles.icon)} onClick={props.cancel}>
<Icon name="close-gray" />
</div>
</Show>
</div>
)
}
export default QuotedMessage

View File

@ -7,3 +7,7 @@ export type AuthModalSearchParams = {
export type ConfirmEmailSearchParams = {
token: string
}
export type CreateChatSearchParams = {
id: number
}

View File

@ -3,7 +3,7 @@ import { clsx } from 'clsx'
import { useRouter } from '../../stores/router'
import { t } from '../../utils/intl'
import { Icon } from '../_shared/Icon'
import { createEffect, createSignal, Show } from 'solid-js'
import { createSignal, Show } from 'solid-js'
import Notifications from './Notifications'
import { ProfilePopup } from './ProfilePopup'
import Userpic from '../Author/Userpic'

View File

@ -17,7 +17,7 @@ interface ModalProps {
export const Modal = (props: ModalProps) => {
const { modal } = useModalStore()
const backdropClick = (event: Event) => {
const backdropClick = () => {
hideModal()
}

View File

@ -14,7 +14,7 @@ export const ProfilePopup = (props: ProfilePopupProps) => {
} = useSession()
return (
<Popup {...props} horizontalAnchor="right">
<Popup {...props} horizontalAnchor="right" variant="bordered">
{/*TODO: l10n*/}
<ul class="nodash">
<li>

View File

@ -72,7 +72,7 @@ export const ProfileSecurityPage = (props: PageProps) => {
<h5>Google</h5>
<div class="pretty-form__item">
<p>
<button class={clsx('button button--light', styles.socialButton)} type="button">
<button class={clsx('button', 'button--light', styles.socialButton)} type="button">
<Icon name="google" class={styles.icon} />
Привязать
</button>
@ -82,7 +82,7 @@ export const ProfileSecurityPage = (props: PageProps) => {
<h5>VK</h5>
<div class="pretty-form__item">
<p>
<button class={clsx(styles.socialButton, 'button button--light')} type="button">
<button class={clsx(styles.socialButton, 'button', 'button--light')} type="button">
<Icon name="vk" class={styles.icon} />
Привязать
</button>
@ -92,7 +92,7 @@ export const ProfileSecurityPage = (props: PageProps) => {
<h5>Facebook</h5>
<div class="pretty-form__item">
<p>
<button class={clsx(styles.socialButton, 'button button--light')} type="button">
<button class={clsx(styles.socialButton, 'button', 'button--light')} type="button">
<Icon name="facebook" class={styles.icon} />
Привязать
</button>

View File

@ -9,6 +9,7 @@ h4 {
h5 {
@include font-size(1.7rem);
margin: 0 0 0.8rem;
}

View File

@ -1,6 +1,6 @@
import { capitalize } from '../../utils'
import styles from './Card.module.scss'
import { createEffect, createMemo, createSignal, Show } from 'solid-js'
import { createMemo, createSignal, Show } from 'solid-js'
import type { Topic } from '../../graphql/types.gen'
import { FollowingEntity } from '../../graphql/types.gen'
import { t } from '../../utils/intl'
@ -8,7 +8,6 @@ import { follow, unfollow } from '../../stores/zine/common'
import { getLogger } from '../../utils/logger'
import { clsx } from 'clsx'
import { useSession } from '../../context/session'
import { StatMetrics } from '../_shared/StatMetrics'
import { ShowOnlyOnClient } from '../_shared/ShowOnlyOnClient'
import { Icon } from '../_shared/Icon'

View File

@ -2,7 +2,6 @@ import { createEffect, createMemo, createSignal, onMount, Show, Suspense } from
import { FullArticle } from '../Article/FullArticle'
import { t } from '../../utils/intl'
import type { Shout, Reaction } from '../../graphql/types.gen'
import { useReactionsStore } from '../../stores/zine/reactions'
interface ArticlePageProps {
article: Shout

View File

@ -1,51 +1,27 @@
import { For, createSignal, Show, onMount, createEffect, createMemo } from 'solid-js'
import type { Author, Chat, ChatMember } from '../../graphql/types.gen'
import { AuthorCard } from '../Author/Card'
import { Icon } from '../_shared/Icon'
import { Loading } from '../Loading'
import type { Author, Chat, Message as MessageType } from '../../graphql/types.gen'
import DialogCard from '../Inbox/DialogCard'
import Search from '../Inbox/Search'
import { useSession } from '../../context/session'
import { createClient } from '@urql/core'
import Message from '../Inbox/Message'
import { loadRecipients, loadChats } from '../../stores/inbox'
import CreateModalContent from '../Inbox/CreateModalContent'
import DialogHeader from '../Inbox/DialogHeader'
import MessagesFallback from '../Inbox/MessagesFallback'
import QuotedMessage from '../Inbox/QuotedMessage'
import { Icon } from '../_shared/Icon'
import { useSession } from '../../context/session'
import { loadMessages, loadRecipients } from '../../stores/inbox'
import { t } from '../../utils/intl'
import '../../styles/Inbox.scss'
import { useInbox } from '../../context/inbox'
import { Modal } from '../Nav/Modal'
import { showModal } from '../../stores/ui'
import InviteUser from '../Inbox/InviteUser'
import CreateModalContent from '../Inbox/CreateModalContent'
import { useInbox } from '../../context/inbox'
import { useRouter } from '../../stores/router'
import { clsx } from 'clsx'
import styles from '../../styles/Inbox.module.scss'
const OWNER_ID = '501'
const client = createClient({
url: 'https://graphqlzero.almansi.me/api'
})
const messageQuery = `
query Comments ($options: PageQueryOptions) {
comments(options: $options) {
data {
id
body
email
}
}
type InboxSearchParams = {
initChat: string
chat: string
}
`
const newMessageQuery = `
mutation postComment($messageBody: String!) {
createComment(
input: { body: $messageBody, email: "test@test.com", name: "User" }
) {
id
body
name
email
}
}
`
const userSearch = (array: Author[], keyword: string) => {
const searchTerm = keyword.toLowerCase()
return array.filter((value) => {
@ -53,161 +29,267 @@ const userSearch = (array: Author[], keyword: string) => {
})
}
const postMessage = async (msg: string) => {
const response = await client.mutation(newMessageQuery, { messageBody: msg }).toPromise()
return response.data.createComment
}
export const InboxView = () => {
const [messages, setMessages] = createSignal([])
const [recipients, setRecipients] = createSignal<Author[]>([])
const [chats, setChats] = createSignal<Chat[]>([])
const [cashedRecipients, setCashedRecipients] = createSignal<Author[]>([])
const [postMessageText, setPostMessageText] = createSignal('')
const [loading, setLoading] = createSignal<boolean>(false)
const { session } = useSession()
const {
chats,
messages,
actions: { loadChats, getMessages, sendMessage, createChat }
} = useInbox()
const [recipients, setRecipients] = createSignal<Author[]>([])
const [postMessageText, setPostMessageText] = createSignal('')
const [sortByGroup, setSortByGroup] = createSignal<boolean>(false)
const [sortByPerToPer, setSortByPerToPer] = createSignal<boolean>(false)
const [currentDialog, setCurrentDialog] = createSignal<Chat>()
const [messageToReply, setMessageToReply] = createSignal<MessageType | null>(null)
const { session } = useSession()
const currentUserId = createMemo(() => session()?.user.id)
// Поиск по диалогам
const getQuery = (query) => {
if (query().length >= 2) {
const match = userSearch(recipients(), query())
setRecipients(match)
} else {
setRecipients(cashedRecipients())
}
}
const fetchMessages = async (query) => {
const response = await client
.query(query, {
options: { slice: { start: 0, end: 3 } }
})
.toPromise()
if (response.error) console.debug('getMessages', response.error)
setMessages(response.data.comments.data)
// if (query().length >= 2) {
// const match = userSearch(recipients(), query())
// setRecipients(match)
// } else {
// setRecipients(cashedRecipients())
// }
}
let chatWindow
onMount(async () => {
setLoading(true)
const handleOpenChat = async (chat: Chat) => {
setCurrentDialog(chat)
changeSearchParam('chat', `${chat.id}`)
try {
await fetchMessages(messageQuery)
await getMessages(chat.id)
} catch (error) {
setLoading(false)
console.error([fetchMessages], error)
console.error('[getMessages]', error)
} finally {
setLoading(false)
chatWindow.scrollTop = chatWindow.scrollHeight
}
}
// TODO: удалить когда будет готова подписка
createEffect(() => {
setInterval(async () => {
if (!currentDialog()) return
try {
await getMessages(currentDialog().id)
} catch (error) {
console.error('[getMessages]', error)
} finally {
chatWindow.scrollTop = chatWindow.scrollHeight
}
}, 2000)
})
onMount(async () => {
try {
const response = await loadRecipients({ days: 365 })
setRecipients(response as unknown as Author[])
setCashedRecipients(response as unknown as Author[])
} catch (error) {
console.log(error)
}
try {
const response = await loadChats()
setChats(response as unknown as Chat[])
} catch (error) {
console.log(error)
}
await loadChats()
})
const handleSubmit = async () => {
try {
const post = await postMessage(postMessageText())
setMessages((prev) => [...prev, post])
setPostMessageText('')
chatWindow.scrollTop = chatWindow.scrollHeight
} catch (error) {
console.error('[post message error]:', error)
}
await sendMessage({
body: postMessageText().toString(),
chat: currentDialog().id.toString(),
replyTo: messageToReply()?.id
})
setPostMessageText('')
setMessageToReply(null)
chatWindow.scrollTop = chatWindow.scrollHeight
}
let textareaParent // textarea autoresize ghost element
const handleChangeMessage = (event) => {
setPostMessageText(event.target.value)
}
createEffect(() => {
textareaParent.dataset.replicatedValue = postMessageText()
const { changeSearchParam, searchParams } = useRouter<InboxSearchParams>()
createEffect(async () => {
if (textareaParent) {
textareaParent.dataset.replicatedValue = postMessageText()
}
if (searchParams().chat) {
const chatToOpen = chats()?.find((chat) => chat.id === searchParams().chat)
if (!chatToOpen) return
await handleOpenChat(chatToOpen)
return
}
if (searchParams().initChat) {
try {
const newChat = await createChat([Number(searchParams().initChat)], '')
await loadChats()
changeSearchParam('initChat', null)
changeSearchParam('chat', newChat.chat.id)
const chatToOpen = chats().find((chat) => chat.id === newChat.chat.id)
await handleOpenChat(chatToOpen)
} catch (error) {
console.error(error)
}
}
})
const handleOpenInviteModal = (event: Event) => {
event.preventDefault()
const handleOpenInviteModal = () => {
showModal('inviteToChat')
}
const chatsToShow = () => {
const sorted = chats().sort((a, b) => {
return b.updatedAt - a.updatedAt
})
if (sortByPerToPer()) {
return sorted.filter((chat) => chat.title.trim().length === 0)
} else if (sortByGroup()) {
return sorted.filter((chat) => chat.title.trim().length > 0)
} else {
return sorted
}
}
const findToReply = (messageId) => {
return messages().find((message) => message.id === messageId)
}
const handleKeyDown = (event) => {
if (event.keyCode === 13 && event.shiftKey) return
if (event.keyCode === 13 && !event.shiftKey && postMessageText().trim().length > 0) {
event.preventDefault()
handleSubmit()
}
}
return (
<div class="messages container">
<div class={clsx('container', styles.Inbox)}>
<Modal variant="narrow" name="inviteToChat">
<CreateModalContent users={recipients()} />
</Modal>
<div class="row">
<div class="chat-list col-md-4">
<div class="sidebar-header">
<div class={clsx('row', styles.row)}>
<div class={clsx(styles.chatList, 'col-md-4')}>
<div class={styles.sidebarHeader}>
<Search placeholder="Поиск" onChange={getQuery} />
<div onClick={handleOpenInviteModal}>
<button type="button" onClick={handleOpenInviteModal}>
<Icon name="plus-button" style={{ width: '40px', height: '40px' }} />
</div>
</button>
</div>
<div class="chat-list__types">
<ul>
<li>
<strong>{t('All')}</strong>
</li>
<li>{t('Personal')}</li>
<li>{t('Groups')}</li>
</ul>
</div>
<div class="holder">
<div class="dialogs">
<For each={chats()}>{(chat: Chat) => <DialogCard members={chat.members} />}</For>
<Show when={chatsToShow}>
<div class={styles.chatListTypes}>
<ul>
<li
class={clsx({ [styles.selected]: !sortByPerToPer() && !sortByGroup() })}
onClick={() => {
setSortByPerToPer(false)
setSortByGroup(false)
}}
>
<span>{t('All')}</span>
</li>
<li
class={clsx({ [styles.selected]: sortByPerToPer() })}
onClick={() => {
setSortByPerToPer(true)
setSortByGroup(false)
}}
>
<span>{t('Personal')}</span>
</li>
<li
class={clsx({ [styles.selected]: sortByGroup() })}
onClick={() => {
setSortByGroup(true)
setSortByPerToPer(false)
}}
>
<span>{t('Groups')}</span>
</li>
</ul>
</div>
</Show>
<div class={styles.holder}>
<div class={styles.dialogs}>
<For each={chatsToShow()}>
{(chat) => (
<DialogCard
onClick={() => handleOpenChat(chat)}
isOpened={chat.id === currentDialog()?.id}
title={chat.title || chat.members[0].name}
members={chat.members}
ownId={currentUserId()}
lastUpdate={chat.updatedAt}
counter={chat.unread}
message={chat.messages.pop()?.body}
/>
)}
</For>
</div>
</div>
</div>
<div class="col-md-8 conversation">
<div class="interlocutor user--online">
<AuthorCard author={{} as Author} hideFollow={true} />
<div class="user-status">Online</div>
</div>
<div class="conversation__messages">
<div class="conversation__messages-container" ref={chatWindow}>
<Show when={loading()}>
<Loading />
</Show>
<For each={messages()}>
{(comment: { body: string; id: string; email: string }) => (
<Message body={comment.body} isOwn={OWNER_ID === comment.id} />
)}
</For>
{/*<div class="conversation__date">*/}
{/* <time>12 сентября</time>*/}
{/*</div>*/}
</div>
</div>
<div class="message-form">
<div class="wrapper">
<div class="grow-wrap" ref={textareaParent}>
<textarea
value={postMessageText()}
rows={1}
onInput={(event) => handleChangeMessage(event)}
placeholder="Написать сообщение"
/>
<div class={clsx('col-md-8', styles.conversation)}>
<Show
when={currentDialog()}
fallback={
<MessagesFallback
message={t('Choose who you want to write to')}
onClick={handleOpenInviteModal}
actionText={t('Start conversation')}
/>
}
>
<DialogHeader ownId={currentUserId()} chat={currentDialog()} />
<div class={styles.conversationMessages}>
<div class={styles.messagesContainer} ref={chatWindow}>
<For each={messages()}>
{(message) => (
<Message
content={message}
ownId={currentUserId()}
members={currentDialog().members}
replyBody={message.replyTo && findToReply(message.replyTo).body}
replyClick={() => setMessageToReply(message)}
/>
)}
</For>
{/*<div class={styles.conversationDate}>*/}
{/* <time>12 сентября</time>*/}
{/*</div>*/}
</div>
<button type="submit" disabled={postMessageText().length === 0} onClick={handleSubmit}>
<Icon name="send-message" />
</button>
</div>
</div>
<div class={styles.messageForm}>
<Show when={messageToReply()}>
<QuotedMessage
variant="reply"
author={
currentDialog().members.find((member) => member.id === Number(messageToReply().author))
.name
}
body={messageToReply().body}
cancel={() => setMessageToReply(null)}
/>
</Show>
<div class={styles.wrapper}>
<div class={styles.growWrap} ref={textareaParent}>
<textarea
class={styles.textInput}
value={postMessageText()}
rows={1}
onKeyDown={handleKeyDown}
onInput={(event) => handleChangeMessage(event)}
placeholder={t('Write message')}
/>
</div>
<button type="submit" disabled={postMessageText().length === 0} onClick={handleSubmit}>
<Icon name="send-message" />
</button>
</div>
</div>
</Show>
</div>
</div>
</div>

View File

@ -4,9 +4,49 @@
.popup {
background: #fff;
border: 2px solid #000;
top: calc(100% + 8px);
opacity: 1;
color: #000;
position: absolute;
z-index: 100;
min-width: 144px;
ul {
margin-bottom: 0;
li {
position: relative;
&:last-child {
margin-bottom: 0;
}
}
}
&.bordered {
@include font-size(1.6rem);
border: 2px solid #000;
padding: 2.4rem;
ul li {
margin-bottom: 1.6rem;
&:last-child {
margin-bottom: 0;
}
}
}
&.tiny {
@include font-size(1.4rem);
box-shadow: 0 4px 60px rgba(0, 0, 0, 0.1);
padding: 1rem;
ul li {
margin-bottom: 1rem;
&:last-child {
margin-bottom: 0;
}
}
}
&.horizontalAnchorCenter {
left: 50%;
@ -17,25 +57,6 @@
right: 0;
}
@include font-size(1.6rem);
padding: 2.4rem;
position: absolute;
z-index: 10;
ul {
margin-bottom: 0;
}
li {
margin-bottom: 1.6rem;
position: relative;
&:last-child {
margin-bottom: 0;
}
}
.topBorderItem {
border-top: 2px solid;
padding-top: 1em;

View File

@ -2,6 +2,7 @@ import { createEffect, createSignal, JSX, Show } from 'solid-js'
import styles from './Popup.module.scss'
import { clsx } from 'clsx'
import { useOutsideClickHandler } from '../../../utils/useOutsideClickHandler'
import { set } from 'husky'
type HorizontalAnchor = 'center' | 'right'
@ -11,6 +12,7 @@ export type PopupProps = {
children: JSX.Element
onVisibilityChange?: (isVisible) => void
horizontalAnchor?: HorizontalAnchor
variant?: 'bordered' | 'tiny'
}
export const Popup = (props: PopupProps) => {
@ -40,7 +42,9 @@ export const Popup = (props: PopupProps) => {
<div
class={clsx(styles.popup, {
[styles.horizontalAnchorCenter]: horizontalAnchor === 'center',
[styles.horizontalAnchorRight]: horizontalAnchor === 'right'
[styles.horizontalAnchorRight]: horizontalAnchor === 'right',
[styles.bordered]: props.variant === 'bordered',
[styles.tiny]: props.variant === 'tiny'
})}
>
{props.children}

View File

@ -1,7 +1,7 @@
import styles from './SearchField.module.scss'
import { Icon } from './Icon'
import { t } from '../../utils/intl'
import clsx from 'clsx'
import { clsx } from 'clsx'
type SearchFieldProps = {
onChange: (value: string) => void

View File

@ -4,7 +4,7 @@ import 'swiper/scss'
import 'swiper/scss/navigation'
import 'swiper/scss/pagination'
import './Slider.scss'
import { createEffect, createMemo, createSignal, Show, For, JSX } from 'solid-js'
import { createEffect, createSignal, JSX } from 'solid-js'
import { Icon } from './Icon'
interface SliderProps {

View File

@ -1,13 +1,22 @@
import type { JSX } from 'solid-js'
import { createContext, useContext } from 'solid-js'
import type { Message } from '../graphql/types.gen'
import { createContext, createSignal, useContext } from 'solid-js'
import type { Accessor, JSX } from 'solid-js'
// import { createChatClient } from '../graphql/privateGraphQLClient'
import type { Chat, Message, MutationCreateMessageArgs } from '../graphql/types.gen'
import { apiClient } from '../utils/apiClient'
import { createStore } from 'solid-js/store'
// import newMessage from '../graphql/subs/new-message'
// import type { Client } from '@urql/core'
import { pipe, subscribe } from 'wonka'
import { loadMessages } from '../stores/inbox'
type InboxContextType = {
chatEntities: { [chatId: string]: Message[] }
chats: Accessor<Chat[]>
messages?: Accessor<Message[]>
actions: {
createChat: (members: number[], title: string) => Promise<void>
createChat: (members: number[], title: string) => Promise<{ chat: Chat }>
loadChats: () => Promise<void>
getMessages?: (chatId: string) => Promise<void>
sendMessage?: (args: MutationCreateMessageArgs) => void
// unsubscribe: () => void
}
}
@ -18,20 +27,66 @@ export function useInbox() {
}
export const InboxProvider = (props: { children: JSX.Element }) => {
const [chatEntities, setChatEntities] = createStore({})
const [chats, setChats] = createSignal<Chat[]>([])
const [messages, setMessages] = createSignal<Message[]>([])
// const subclient = createMemo<Client>(() => createChatClient())
const loadChats = async () => {
try {
const newChats = await apiClient.getChats({ limit: 50, offset: 0 })
setChats(newChats)
} catch (error) {
console.log(error)
}
}
const getMessages = async (chatId: string) => {
if (!chatId) return
try {
const response = await loadMessages({ chat: chatId })
setMessages(response as unknown as Message[])
} catch (error) {
console.error('[loadMessages]', error)
}
}
const sendMessage = async (args) => {
try {
const message = await apiClient.createMessage(args)
setMessages((prev) => [...prev, message])
const currentChat = chats().find((chat) => chat.id === args.chat)
setChats((prev) => [
...prev.filter((c) => c.id !== currentChat.id),
{ ...currentChat, updatedAt: message.createdAt }
])
} catch (error) {
console.error('[post message error]:', error)
}
}
const createChat = async (members: number[], title: string) => {
const chat = await apiClient.createChat({ members, title })
setChatEntities((s) => {
s[chat.id] = chat
setChats((prevChats) => {
return [chat, ...prevChats]
})
return chat
}
const { unsubscribe } = pipe(
() => null, // subclient().subscription(newMessage, {}),
subscribe((result) => {
console.info('[subscription]')
console.debug(result)
// TODO: handle data result
})
)
const actions = {
createChat
createChat,
loadChats,
getMessages,
sendMessage,
unsubscribe // TODO: call unsubscribe some time!
}
const value: InboxContextType = { chatEntities, actions }
const value: InboxContextType = { chats, messages, actions }
return <InboxContext.Provider value={value}>{props.children}</InboxContext.Provider>
}

View File

@ -1,12 +1,16 @@
import { gql } from '@urql/core'
export default gql`
mutation createMessage($chat: String!, $body: String!) {
createMessage(chat: $chat, body: $body) {
mutation createMessage($chat: String!, $body: String!, $replyTo: Int) {
createMessage(chat: $chat, body: $body, replyTo: $replyTo) {
error
author {
slug
message {
id
body
author
createdAt
replyTo
updatedAt
}
}
}

View File

@ -7,6 +7,7 @@ export default gql`
token
user {
_id: slug
id
name
slug
bio

View File

@ -1,4 +1,13 @@
import { ClientOptions, dedupExchange, fetchExchange, Exchange, createClient } from '@urql/core'
import {
ClientOptions,
dedupExchange,
fetchExchange,
Exchange,
subscriptionExchange,
createClient
} from '@urql/core'
import { createClient as createSubClient } from 'graphql-sse'
// import { createClient as createSubClient } from 'graphql-ws'
import { devtoolsExchange } from '@urql/devtools'
import { isDev, apiBaseUrl } from '../utils/config'
// import { cache } from './cache'
@ -28,7 +37,7 @@ export const resetToken = () => {
}
const options: ClientOptions = {
url: apiBaseUrl,
url: apiBaseUrl + '/graphql',
maskTypename: true,
requestPolicy: 'cache-and-network',
fetchOptions: () => {
@ -45,3 +54,25 @@ const options: ClientOptions = {
}
export const privateGraphQLClient = createClient(options)
export const createChatClient = () => {
const subClient = createSubClient({
url: apiBaseUrl + '/messages' // .replace('http', 'ws')
})
const subExchange = subscriptionExchange({
forwardSubscription(operation) {
return {
subscribe: (sink) => {
const dispose = subClient.subscribe(operation, sink)
return {
unsubscribe: dispose
}
}
}
}
})
options.exchanges.unshift(subExchange)
return createClient(options)
}

View File

@ -10,7 +10,7 @@ if (isDev) {
}
const options: ClientOptions = {
url: apiBaseUrl,
url: apiBaseUrl + '/graphql',
maskTypename: true,
requestPolicy: 'cache-and-network',
exchanges

View File

@ -4,6 +4,7 @@ export default gql`
query GetAuthorBySlugQuery($slug: String!) {
getAuthor(slug: $slug) {
_id: slug
id
slug
name
bio

View File

@ -4,6 +4,7 @@ export default gql`
query UserSubscribersQuery($slug: String!) {
userSubcribers(slug: $slug) {
_id: slug
id
slug
name
bio

View File

@ -4,6 +4,7 @@ export default gql`
query UserFollowingQuery($slug: String!) {
userFollowing(slug: $slug) {
_id: slug
id
slug
name
bio

View File

@ -4,6 +4,7 @@ export default gql`
query AuthorsAllQuery {
authorsAll {
_id: slug
id
slug
name
bio

View File

@ -4,6 +4,7 @@ export default gql`
query AuthorLoadByQuery($by: AuthorsBy, $limit: Int, $offset: Int) {
loadAuthorsBy(by: $by, limit: $limit, offset: $offset) {
_id: slug
id
slug
name
bio

View File

@ -7,9 +7,11 @@ export default gql`
messages {
author
body
replyTo
createdAt
id
updatedAt
seen
replyTo
}
}
}

View File

@ -6,6 +6,7 @@ export default gql`
members {
id
name
id
slug
userpic
}

View File

@ -6,11 +6,19 @@ export default gql`
error
chats {
id
title
admins
users
members {
id
slug
name
userpic
}
unread
description
updatedAt
private
messages {
id
body

View File

@ -0,0 +1,15 @@
import { gql } from '@urql/core'
export default gql`
subscription {
newMessage {
id
chatId
author
body
replyTo
createdAt
updatedAt
}
}
`

View File

@ -146,7 +146,7 @@ export type Message = {
chatId: Scalars['String']
createdAt: Scalars['Int']
id: Scalars['Int']
replyTo?: Maybe<Scalars['String']>
replyTo?: Maybe<Scalars['Int']>
seen?: Maybe<Scalars['Boolean']>
updatedAt?: Maybe<Scalars['Int']>
}

View File

@ -184,12 +184,13 @@
"discussion": "дискурс",
"Personal": "Личные",
"Groups": "Группы",
"All": "Все",
"create_chat": "Создать чат",
"create_group": "Создать группу",
"discourse_theme": "Тема дискурса",
"cancel": "Отмена",
"group_chat": "Общий чат",
"Choose who you want to write to": "Выберите кому хотите написать",
"Start conversation": "Начать беседу",
"Profile settings": "Настройки профиля",
"Here you can customize your profile the way you want.": "Здесь можно настроить свой профиль так, как вы хотите.",
"Userpic": "Аватар",
@ -204,6 +205,11 @@
"Date of Birth": "Дата рождения",
"Social networks": "Социальные сети",
"Save settings": "Сохранить настройки",
"Write message": "Написать сообщение",
"Copy": "Скопировать",
"Pin": "Закрепить",
"Forward": "Переслать",
"Select": "Выбрать",
"slug is used by another user": "Имя уже занято другим пользователем",
"It does not look like url": "Это не похоже на ссылку"
}

View File

@ -1,9 +1,10 @@
import { apiClient } from '../utils/apiClient'
import type { MessagesBy } from '../graphql/types.gen'
export const loadRecipients = async (by = {}): Promise<void> => {
return await apiClient.getRecipients(by)
}
export const loadChats = async (): Promise<void> => {
return await apiClient.getChats({ limit: 50, offset: 0 })
export const loadMessages = async (by: MessagesBy): Promise<void> => {
return await apiClient.getChatMessages({ by, limit: 50, offset: 0 })
}

View File

@ -2,6 +2,7 @@ import type { FollowingEntity } from '../../graphql/types.gen'
import { apiClient } from '../../utils/apiClient'
export const follow = async ({ what, slug }: { what: FollowingEntity; slug: string }) => {
console.log('!!! follow:')
await apiClient.follow({ what, slug })
}
export const unfollow = async ({ what, slug }: { what: FollowingEntity; slug: string }) => {

View File

@ -60,6 +60,7 @@ img {
.shoutMediaBody {
display: block;
audio {
display: inline-block;
}
@ -258,6 +259,7 @@ img {
.commentsHeader {
@include font-size(2.4rem);
margin-bottom: 1em;
}
@ -287,6 +289,7 @@ img {
button {
@include font-size(1.5rem);
border-radius: 0.8rem;
margin-right: 1.2rem;
padding: 1.1rem 1.2rem 0.9rem;

View File

@ -5,17 +5,18 @@ main {
position: relative;
}
.messages {
display: none;
top: 74px;
.Inbox {
top: 84px;
height: calc(100% - 74px);
left: 0;
right: 0;
padding-left: 42px;
padding-right: 26px;
background: #fff;
display: flex;
flex: 1;
flex-direction: column;
position: absolute;
.row {
flex: 1;
@ -31,7 +32,7 @@ main {
}
// список диалогов и юзеров
.chat-list {
.chatList {
display: flex;
flex-direction: column;
padding: 10px;
@ -39,7 +40,7 @@ main {
$fade-height: 10px;
.sidebar-header {
.sidebarHeader {
display: flex;
align-items: center;
gap: 10px;
@ -56,7 +57,7 @@ main {
content: '';
position: absolute;
width: 100%;
right: 10px;
right: 0;
z-index: 1;
height: $fade-height;
}
@ -87,14 +88,13 @@ main {
}
}
.chat-list__search,
.interlocutor {
.chat-list__search {
border-bottom: 3px solid #141414;
padding: 1em 0;
}
// табы выбора списка
.chat-list__types {
.chatListTypes {
@include font-size(1.7rem);
margin: 16px 0;
@ -110,51 +110,16 @@ main {
li {
margin-right: 1em;
color: #696969;
}
cursor: pointer;
strong {
border-bottom: 3px solid;
font-weight: normal;
color: #000;
}
}
.interlocutor {
height: 56px;
box-sizing: content-box;
.circlewrap {
height: 56px;
max-width: 56px;
width: 56px;
}
.author {
margin-bottom: 0;
&::before {
left: 40px !important;
height: 8px !important;
width: 8px !important;
&.selected {
span {
border-bottom: 3px solid;
font-weight: normal;
color: #000;
}
}
}
.author__name {
@include font-size(1.7rem);
margin: 0.4em 0 0;
}
.author__details,
.user-status {
margin-left: 6.8rem;
}
.user-status {
@include font-size(1.2rem);
color: #ccc;
}
}
.conversation {
@ -162,36 +127,31 @@ main {
flex-direction: column;
}
.conversation__messages {
flex: 1;
overflow: auto;
position: relative;
}
.message-form {
.messageForm {
background: #fff;
padding: 2px 0 12px 0;
padding: 2px 0 12px;
.wrapper {
border: 2px solid #cccccc;
border: 2px solid #ccc;
border-radius: 16px;
padding: 4px;
display: flex;
flex-direction: row;
align-items: center;
.grow-wrap {
.growWrap {
display: grid;
width: 100%;
&::after {
content: attr(data-replicated-value) ' ';
content: attr(data-replicated-value);
white-space: pre-wrap;
word-wrap: break-word;
visibility: hidden;
transition: height 1.3s ease-in-out;
}
& textarea {
.textInput {
margin-bottom: 0;
font-family: inherit;
border: none;
@ -243,36 +203,40 @@ main {
}
}
.conversation__messages-container {
left: 0;
height: 100%;
.conversationMessages {
flex: 1;
overflow: auto;
position: absolute;
top: 0;
width: 100%;
scroll-behavior: smooth;
}
.conversation__date {
position: relative;
text-align: center;
&::before {
background: #141414;
content: '';
height: 1px;
.messagesContainer {
left: 0;
height: 100%;
overflow: auto;
position: absolute;
top: 0.8em;
top: 0;
width: 100%;
z-index: -1;
scroll-behavior: smooth;
}
.conversation__date {
position: relative;
text-align: center;
time {
background: #fff;
@include font-size(1.5rem);
&::before {
background: #141414;
content: '';
height: 1px;
left: 0;
position: absolute;
top: 0.8em;
width: 100%;
z-index: -1;
}
color: #9fa1a7;
padding: 0 0.5em;
time {
@include font-size(1.5rem);
background: #fff;
color: #9fa1a7;
padding: 0 0.5em;
}
}
}

View File

@ -85,8 +85,8 @@ h2 {
color: #fff;
margin-left: -0.15em;
padding: 0 0.15em;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
&::selection {
background: #fff;
@ -809,5 +809,6 @@ details {
.description {
@include font-size(1.4rem);
color: rgba(0 0 0 / 40%);
}

View File

@ -10,8 +10,8 @@ import type {
QueryLoadMessagesByArgs,
MutationCreateChatArgs,
MutationCreateMessageArgs,
Chat,
QueryLoadRecipientsArgs,
User,
ProfileInput
} from '../graphql/types.gen'
import { publicGraphQLClient } from '../graphql/publicGraphQLClient'
@ -276,7 +276,7 @@ export const apiClient = {
},
// inbox
getChats: async (options: QueryLoadChatsArgs) => {
getChats: async (options: QueryLoadChatsArgs): Promise<Chat[]> => {
const resp = await privateGraphQLClient.query(myChats, options).toPromise()
return resp.data.loadChats.chats
},
@ -288,13 +288,15 @@ export const apiClient = {
createMessage: async (options: MutationCreateMessageArgs) => {
const resp = await privateGraphQLClient.mutation(createMessage, options).toPromise()
return resp.data.createMessage
return resp.data.createMessage.message
},
getChatMessages: async (options: QueryLoadMessagesByArgs) => {
const resp = await privateGraphQLClient.query(chatMessagesLoadBy, options).toPromise()
return resp.data.loadChat
console.log('[getChatMessages]', resp)
return resp.data.loadMessagesBy.messages
},
getRecipients: async (options: QueryLoadRecipientsArgs) => {
const resp = await privateGraphQLClient.query(loadRecipients, options).toPromise()
return resp.data.loadRecipients.members

View File

@ -0,0 +1,13 @@
import { createMemo } from 'solid-js'
import { locale } from '../stores/ui'
// unix timestamp in seconds
const formattedTime = (time: number) =>
createMemo<string>(() => {
return new Date(time).toLocaleTimeString(locale(), {
hour: 'numeric',
minute: 'numeric'
})
})
export default formattedTime

View File

@ -4,7 +4,7 @@ export const groupByName = (arr: Author[]) => {
return arr.reduce(
(acc, tt) => {
let c = (tt.name || '')
.replace(/[^\d A-Za-zА-я]/g, '')
.replaceAll(/[^\d A-Za-zА-я]/g, '')
.split(' ')
.pop()
.slice(0, 1)
@ -24,7 +24,7 @@ export const groupByTitle = (arr: (Shout | Topic)[]) => {
return arr.reduce(
(acc, tt) => {
let c = (tt.title || '')
.replace(/[^\d A-Za-zА-я]/g, '')
.replaceAll(/[^\d A-Za-zА-я]/g, '')
.slice(0, 1)
.toUpperCase()
if (/[^А-я]/.test(c)) c = 'A-Z'

View File

@ -4407,11 +4407,6 @@ comma-separated-tokens@^2.0.0:
resolved "https://registry.yarnpkg.com/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz#4e89c9458acb61bc8fef19f4529973b2392839ee"
integrity sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==
commander@^8.0.0:
version "8.3.0"
resolved "https://registry.yarnpkg.com/commander/-/commander-8.3.0.tgz#4837ea1b2da67b9c616a67afbb0fafee567bca66"
integrity sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==
commander@^9.3.0:
version "9.4.1"
resolved "https://registry.yarnpkg.com/commander/-/commander-9.4.1.tgz#d1dd8f2ce6faf93147295c0df13c7c21141cfbdd"
@ -5119,7 +5114,7 @@ esbuild-windows-arm64@0.15.15:
resolved "https://registry.yarnpkg.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.15.15.tgz#5a277ce10de999d2a6465fc92a8c2a2d207ebd31"
integrity sha512-ttuoCYCIJAFx4UUKKWYnFdrVpoXa3+3WWkXVI6s09U+YjhnyM5h96ewTq/WgQj9LFSIlABQvadHSOQyAVjW5xQ==
esbuild@^0.14.0, esbuild@^0.14.27, esbuild@^0.14.43:
esbuild@^0.14.0, esbuild@^0.14.43:
version "0.14.54"
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.14.54.tgz#8b44dcf2b0f1a66fc22459943dccf477535e9aa2"
integrity sha512-Cy9llcy8DvET5uznocPyqL3BFRrFXSVqbgpMJ9Wz8oVjZlh/zUSNbPRbov0VX7VxN2JH1Oa0uNxZ7eLRb62pJA==
@ -5949,7 +5944,7 @@ glob-parent@^6.0.2:
dependencies:
is-glob "^4.0.3"
glob@^7.1.1, glob@^7.1.3, glob@^7.1.4, glob@^7.1.7:
glob@^7.1.1, glob@^7.1.3, glob@^7.1.4:
version "7.2.3"
resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b"
integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==
@ -6085,6 +6080,11 @@ graphql-request@^5.0.0:
extract-files "^9.0.0"
form-data "^3.0.0"
graphql-sse@^1.3.1:
version "1.3.1"
resolved "https://registry.yarnpkg.com/graphql-sse/-/graphql-sse-1.3.1.tgz#74304c6754702431a62f576a67969bc2ca5c7d7f"
integrity sha512-JIeBJsk1kGQQjfrDu0KWy5UBMvDHwcHYBmKb+4ZPZHaws/j00H7fw5CH5Vb077D7LCta+KNQA0xcGGPmIHzu4A==
graphql-tag@^2.11.0, graphql-tag@^2.12.6:
version "2.12.6"
resolved "https://registry.yarnpkg.com/graphql-tag/-/graphql-tag-2.12.6.tgz#d441a569c1d2537ef10ca3d1633b48725329b5f1"
@ -8977,7 +8977,7 @@ postcss-value-parser@^4.1.0, postcss-value-parser@^4.2.0:
resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514"
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
postcss@^8.3.11, postcss@^8.3.5, postcss@^8.4.13, postcss@^8.4.14, postcss@^8.4.18, postcss@^8.4.19:
postcss@^8.3.11, postcss@^8.4.14, postcss@^8.4.18, postcss@^8.4.19:
version "8.4.19"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.19.tgz#61178e2add236b17351897c8bcc0b4c8ecab56fc"
integrity sha512-h+pbPsyhlYj6N2ozBmHhHrs9DzGmbaarbLvWipMRO7RLS+v4onj26MPFXA5OBYFxyqYhUJK456SwDcY9H2/zsA==
@ -9215,16 +9215,6 @@ punycode@^2.1.0:
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==
purgecss@^4.1.1:
version "4.1.3"
resolved "https://registry.yarnpkg.com/purgecss/-/purgecss-4.1.3.tgz#683f6a133c8c4de7aa82fe2746d1393b214918f7"
integrity sha512-99cKy4s+VZoXnPxaoM23e5ABcP851nC2y2GROkkjS8eJaJtlciGavd7iYAw2V84WeBqggZ12l8ef44G99HmTaw==
dependencies:
commander "^8.0.0"
glob "^7.1.7"
postcss "^8.3.5"
postcss-selector-parser "^6.0.6"
pvtsutils@^1.3.2:
version "1.3.2"
resolved "https://registry.yarnpkg.com/pvtsutils/-/pvtsutils-1.3.2.tgz#9f8570d132cdd3c27ab7d51a2799239bf8d8d5de"
@ -9624,13 +9614,6 @@ rollup-pluginutils@^2.8.2:
dependencies:
estree-walker "^0.6.1"
"rollup@>=2.59.0 <2.78.0":
version "2.77.3"
resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.77.3.tgz#8f00418d3a2740036e15deb653bed1a90ee0cc12"
integrity sha512-/qxNTG7FbmefJWoeeYJFbHehJ2HNWnjkAFRKzWN/45eNBBF/r8lo992CwcJXEzyVxs5FmfId+vTSTQDb+bxA+g==
optionalDependencies:
fsevents "~2.3.2"
rollup@^2.79.1:
version "2.79.1"
resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.79.1.tgz#bedee8faef7c9f93a2647ac0108748f497f081c7"
@ -10910,26 +10893,6 @@ vfile@^5.0.0, vfile@^5.3.2:
unist-util-stringify-position "^3.0.0"
vfile-message "^3.0.0"
vite-plugin-html-purgecss@^0.1.1:
version "0.1.1"
resolved "https://registry.yarnpkg.com/vite-plugin-html-purgecss/-/vite-plugin-html-purgecss-0.1.1.tgz#e392c4c26470c1a80d45e70c5638cd07866e1292"
integrity sha512-/VJnN/CkUoXlgVCvIbFymfsW7hUEO2Dch5uWwiKJFTb4SLLNhTr/sPJfEUl1wTj5y3SwPXgPz002sQgXJj0mCw==
dependencies:
purgecss "^4.1.1"
vite "^2.6.7"
vite@^2.6.7:
version "2.9.15"
resolved "https://registry.yarnpkg.com/vite/-/vite-2.9.15.tgz#2858dd5b2be26aa394a283e62324281892546f0b"
integrity sha512-fzMt2jK4vQ3yK56te3Kqpkaeq9DkcZfBbzHwYpobasvgYmP2SoAr6Aic05CsB4CzCZbsDv4sujX3pkEGhLabVQ==
dependencies:
esbuild "^0.14.27"
postcss "^8.4.13"
resolve "^1.22.0"
rollup ">=2.59.0 <2.78.0"
optionalDependencies:
fsevents "~2.3.2"
vite@^3.2.4, vite@~3.2.4:
version "3.2.4"
resolved "https://registry.yarnpkg.com/vite/-/vite-3.2.4.tgz#d8c7892dd4268064e04fffbe7d866207dd24166e"