mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Implement profile dialog
This commit is contained in:
@@ -1,6 +1,5 @@
|
|||||||
---
|
---
|
||||||
description: Documentation rules
|
alwaysApply: true
|
||||||
alwaysApply: false
|
|
||||||
---
|
---
|
||||||
|
|
||||||
When documenting this project, follow these rules:
|
When documenting this project, follow these rules:
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ interface RichTextAreaProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
rows?: number;
|
rows?: number;
|
||||||
autoComplete?: string;
|
autoComplete?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RichTextArea({
|
export function RichTextArea({
|
||||||
@@ -21,6 +22,7 @@ export function RichTextArea({
|
|||||||
className,
|
className,
|
||||||
rows = 1,
|
rows = 1,
|
||||||
autoComplete = "off",
|
autoComplete = "off",
|
||||||
|
readOnly = false
|
||||||
}: RichTextAreaProps) {
|
}: RichTextAreaProps) {
|
||||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
const hiddenTextareaRef = useRef<HTMLTextAreaElement | null>(null);
|
const hiddenTextareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
@@ -183,10 +185,10 @@ export function RichTextArea({
|
|||||||
value={text}
|
value={text}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
rows={rows}
|
rows={rows}
|
||||||
autoComplete={autoComplete}
|
autoComplete={readOnly ? "off" : autoComplete}
|
||||||
onChange={handleChange}
|
onChange={readOnly ? undefined : handleChange}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={readOnly ? undefined : handleKeyDown}
|
||||||
/>
|
readOnly={readOnly} />
|
||||||
<textarea
|
<textarea
|
||||||
aria-hidden
|
aria-hidden
|
||||||
readOnly
|
readOnly
|
||||||
|
|||||||
@@ -25,9 +25,31 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-start;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
||||||
|
&.received {
|
||||||
|
.message-profile-pic {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
border: 2px solid $color-dark-outline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.message-inner {
|
.message-inner {
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -38,37 +60,16 @@
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|
||||||
.message-profile-pic {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
margin: 10px;
|
|
||||||
|
|
||||||
img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
border-radius: 50%;
|
|
||||||
object-fit: cover;
|
|
||||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
transform: scale(1.1);
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-username {
|
.message-username {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-bottom: 0.3rem;
|
margin-bottom: 0.3rem;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
transition: color 0.2s ease;
|
|
||||||
margin: 10px;
|
margin: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: $color-dark-primary;
|
transform: scale(1.05);
|
||||||
text-decoration: underline;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,20 +286,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-profile-pic {
|
|
||||||
img {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: 50%;
|
|
||||||
object-fit: cover;
|
|
||||||
border: 2px solid $color-dark-outline;
|
|
||||||
|
|
||||||
&.loading {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-username {
|
.message-username {
|
||||||
&.loading {
|
&.loading {
|
||||||
|
|||||||
@@ -2,250 +2,197 @@
|
|||||||
@use "../../../css/material" as *;
|
@use "../../../css/material" as *;
|
||||||
@use "sass:color";
|
@use "sass:color";
|
||||||
|
|
||||||
// Profile styles
|
// Profile Dialog Styles
|
||||||
#profile-dialog .content {
|
.profile-dialog-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 30px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: opacity 0.3s ease, visibility 0.3s ease;
|
||||||
|
|
||||||
|
&.open {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-dialog {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
max-height: calc(100vh - 60px);
|
||||||
|
background: $color-dark-surface-container;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 24px 38px 3px rgba(0, 0, 0, 0.14),
|
||||||
|
0 9px 46px 8px rgba(0, 0, 0, 0.12),
|
||||||
|
0 11px 15px -7px rgba(0, 0, 0, 0.2);
|
||||||
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 24px;
|
transform: scale(0.9);
|
||||||
min-width: 400px;
|
opacity: 0;
|
||||||
|
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||||
.header-top {
|
|
||||||
display: flex;
|
&.open {
|
||||||
flex-direction: row;
|
transform: scale(1);
|
||||||
align-items: center;
|
opacity: 1;
|
||||||
gap: 16px;
|
|
||||||
position: relative;
|
|
||||||
padding-bottom: 16px;
|
|
||||||
border-bottom: 1px solid $color-dark-outline;
|
|
||||||
|
|
||||||
.profile-picture-container {
|
|
||||||
position: relative;
|
|
||||||
$size: 70px;
|
|
||||||
width: $size;
|
|
||||||
height: $size;
|
|
||||||
flex-shrink: 0;
|
|
||||||
|
|
||||||
#profile-picture {
|
|
||||||
width: $size;
|
|
||||||
height: $size;
|
|
||||||
border-radius: 50%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-overlay {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
right: 0;
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mdui-text-field {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#profile-form {
|
.profile-dialog-content {
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
|
|
||||||
mdui-text-field {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
padding-top: 16px;
|
|
||||||
border-top: 1px solid $color-dark-outline;
|
|
||||||
|
|
||||||
> * {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// User profile dialog content styles
|
|
||||||
#user-profile-dialog .content {
|
|
||||||
display: flex;
|
|
||||||
gap: 1.5rem;
|
|
||||||
|
|
||||||
.profile-picture-section {
|
|
||||||
flex-shrink: 0;
|
|
||||||
|
|
||||||
.profile-picture {
|
|
||||||
width: 80px;
|
|
||||||
height: 80px;
|
|
||||||
border-radius: 50%;
|
|
||||||
object-fit: cover;
|
|
||||||
border: 2px solid $color-dark-outline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-info {
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1rem;
|
align-items: center;
|
||||||
|
|
||||||
|
.profile-picture-section {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
margin: 16px;
|
||||||
|
|
||||||
|
.profile-picture {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
border-radius: 60px;
|
||||||
|
object-fit: cover;
|
||||||
|
border: 3px solid $color-dark-outline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-picture-edit-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
border-radius: 60px;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.username-section {
|
.username-section {
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.username-input {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: $color-dark-on-surface;
|
||||||
|
text-align: center;
|
||||||
|
outline: none;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-status-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
.username {
|
|
||||||
margin: 0;
|
.online-indicator {
|
||||||
color: $color-dark-on-surface;
|
width: 8px;
|
||||||
font-size: 1.1rem;
|
height: 8px;
|
||||||
font-weight: 600;
|
border-radius: 50%;
|
||||||
}
|
background: $color-dark-primary;
|
||||||
|
|
||||||
.online-status {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
|
|
||||||
&.online {
|
|
||||||
color: $success;
|
|
||||||
background-color: rgba(76, 175, 80, 0.1);
|
|
||||||
|
|
||||||
.online-indicator {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background-color: $success;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.offline {
|
&.offline {
|
||||||
color: $color-dark-on-surface-variant;
|
background: $color-dark-on-surface-variant;
|
||||||
background-color: rgba(255, 255, 255, 0.05);
|
|
||||||
|
|
||||||
.offline-indicator {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background-color: $color-dark-on-surface-variant;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
.status-text {
|
||||||
.bio-section {
|
font-size: 0.875rem;
|
||||||
label {
|
|
||||||
display: block;
|
|
||||||
color: $color-dark-on-surface-variant;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 500;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bio-display {
|
|
||||||
color: $color-dark-on-surface;
|
color: $color-dark-on-surface;
|
||||||
font-size: 0.9rem;
|
|
||||||
line-height: 1.4;
|
|
||||||
padding: 0.75rem;
|
|
||||||
background-color: $color-dark-surface;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid $color-dark-outline;
|
|
||||||
min-height: 60px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bio-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-stats {
|
.profile-sections {
|
||||||
|
margin: 16px;
|
||||||
|
border-radius: 24px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
width: calc(100% - (16px * 2));
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
.stat {
|
.section {
|
||||||
|
background: $color-dark-surface-container-high;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
flex-direction: row;
|
||||||
|
gap: 16px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0.5rem 0;
|
|
||||||
|
|
||||||
.stat-label {
|
.content-container {
|
||||||
color: $color-dark-on-surface-variant;
|
display: flex;
|
||||||
font-size: 0.85rem;
|
flex-direction: column;
|
||||||
}
|
gap: 4px;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
.stat-value {
|
.label {
|
||||||
color: $color-dark-on-surface;
|
font-size: small;
|
||||||
font-size: 0.85rem;
|
color: $color-dark-on-surface-variant;
|
||||||
font-weight: 500;
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
color: $color-dark-on-surface;
|
||||||
|
font-size: medium;
|
||||||
|
width: 100%;
|
||||||
|
line-height: 1.4;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: text;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
caret-color: $color-dark-primary;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: $color-dark-on-surface-variant;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.profile-actions {
|
|
||||||
display: flex;
|
.profile-dialog-fab {
|
||||||
gap: 0.75rem;
|
position: absolute;
|
||||||
margin-top: 0.5rem;
|
bottom: 24px;
|
||||||
|
right: 24px;
|
||||||
mdui-button {
|
z-index: 1002;
|
||||||
flex: 1;
|
transform: translateY(100px);
|
||||||
}
|
transition: transform 0.3s ease;
|
||||||
|
|
||||||
|
&.visible {
|
||||||
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cropper Dialog Styles
|
|
||||||
#cropper-dialog {
|
|
||||||
.cropper-dialog-content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
min-width: 500px;
|
|
||||||
max-width: 600px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cropper-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding-bottom: 16px;
|
|
||||||
border-bottom: 1px solid $color-dark-outline;
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
margin: 0;
|
|
||||||
color: $color-dark-on-surface;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cropper-container {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 400px;
|
|
||||||
background: $color-dark-surface-container;
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
#cropper-area {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
min-height: 400px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cropper-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
justify-content: flex-end;
|
|
||||||
padding-top: 16px;
|
|
||||||
border-top: 1px solid $color-dark-outline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
@use "chat-input";
|
@use "chat-input";
|
||||||
@use "message-reactions";
|
@use "message-reactions";
|
||||||
@use "context-menu";
|
@use "context-menu";
|
||||||
@use "profile-dialog";
|
|
||||||
@use "settings-dialog";
|
@use "settings-dialog";
|
||||||
@use "animations";
|
@use "animations";
|
||||||
@use "callWindow";
|
@use "callWindow";
|
||||||
|
@use "profile-dialog";
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
@use "../../../css/colors" as *;
|
||||||
|
@use "../../../css/material" as *;
|
||||||
|
@use "sass:color";
|
||||||
|
|
||||||
|
// Cropper Dialog Styles
|
||||||
|
#cropper-dialog {
|
||||||
|
.cropper-dialog-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
min-width: 500px;
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cropper-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
border-bottom: 1px solid $color-dark-outline;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: $color-dark-on-surface;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cropper-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 400px;
|
||||||
|
background: $color-dark-surface-container;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
#cropper-area {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cropper-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid $color-dark-outline;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,16 @@ export type ChatTabs = "chats" | "channels" | "contacts";
|
|||||||
|
|
||||||
export type CallStatus = "calling" | "connecting" | "active" | "ended";
|
export type CallStatus = "calling" | "connecting" | "active" | "ended";
|
||||||
|
|
||||||
|
export interface ProfileDialogData {
|
||||||
|
userId?: number;
|
||||||
|
username?: string;
|
||||||
|
profilePicture?: string;
|
||||||
|
bio?: string;
|
||||||
|
memberSince?: string;
|
||||||
|
online?: boolean;
|
||||||
|
isOwnProfile: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface ActiveDM {
|
interface ActiveDM {
|
||||||
userId: number;
|
userId: number;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -50,6 +60,7 @@ interface ChatState {
|
|||||||
dmPanel: DMPanel | null;
|
dmPanel: DMPanel | null;
|
||||||
pendingPanel?: MessagePanel | null;
|
pendingPanel?: MessagePanel | null;
|
||||||
call: CallState;
|
call: CallState;
|
||||||
|
profileDialog: ProfileDialogData | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserState {
|
export interface UserState {
|
||||||
@@ -94,6 +105,10 @@ interface AppState {
|
|||||||
setUser: (token: string, user: User) => void;
|
setUser: (token: string, user: User) => void;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
restoreUserFromStorage: () => Promise<void>;
|
restoreUserFromStorage: () => Promise<void>;
|
||||||
|
|
||||||
|
// Profile dialog state
|
||||||
|
setProfileDialog: (data: ProfileDialogData | null) => void;
|
||||||
|
closeProfileDialog: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAppState = create<AppState>((set, get) => ({
|
export const useAppState = create<AppState>((set, get) => ({
|
||||||
@@ -115,6 +130,7 @@ export const useAppState = create<AppState>((set, get) => ({
|
|||||||
publicChatPanel: null,
|
publicChatPanel: null,
|
||||||
dmPanel: null,
|
dmPanel: null,
|
||||||
pendingPanel: null,
|
pendingPanel: null,
|
||||||
|
profileDialog: null,
|
||||||
call: {
|
call: {
|
||||||
isActive: false,
|
isActive: false,
|
||||||
status: "ended",
|
status: "ended",
|
||||||
@@ -577,5 +593,20 @@ export const useAppState = create<AppState>((set, get) => ({
|
|||||||
isMinimized: !state.chat.call.isMinimized
|
isMinimized: !state.chat.call.isMinimized
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
})),
|
||||||
|
|
||||||
|
// Profile dialog state management
|
||||||
|
setProfileDialog: (data: ProfileDialogData | null) => set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
profileDialog: data
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
|
||||||
|
closeProfileDialog: () => set((state) => ({
|
||||||
|
chat: {
|
||||||
|
...state.chat,
|
||||||
|
profileDialog: null
|
||||||
|
}
|
||||||
}))
|
}))
|
||||||
}));
|
}));
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
import { useState, useEffect, useRef, useMemo } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { useAppState } from "@/pages/chat/state";
|
||||||
|
import type { ProfileDialogData } from "@/pages/chat/state";
|
||||||
|
import defaultAvatar from "@/images/default-avatar.png";
|
||||||
|
import { confirm } from "mdui/functions/confirm";
|
||||||
|
import { updateProfile, uploadProfilePicture, fetchUserProfile } from "@/core/api/profileApi";
|
||||||
|
import { RichTextArea } from "@/core/components/RichTextArea";
|
||||||
|
|
||||||
|
export function ProfileDialog() {
|
||||||
|
const { chat, user, closeProfileDialog } = useAppState();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [originalData, setOriginalData] = useState<ProfileDialogData | null>(null);
|
||||||
|
const [currentData, setCurrentData] = useState<ProfileDialogData | null>(null);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const backdropRef = useRef<HTMLDivElement>(null);
|
||||||
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Handle dialog open/close based on state
|
||||||
|
useEffect(() => {
|
||||||
|
if (chat.profileDialog && !isOpen) {
|
||||||
|
// Fetch fresh data when opening dialog
|
||||||
|
fetchFreshProfileData(chat.profileDialog);
|
||||||
|
} else if (!chat.profileDialog && isOpen) {
|
||||||
|
// Start close animation
|
||||||
|
if (backdropRef.current && dialogRef.current) {
|
||||||
|
backdropRef.current.classList.remove('open');
|
||||||
|
dialogRef.current.classList.remove('open');
|
||||||
|
|
||||||
|
// Wait for animation to complete before closing
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
}, 300); // Match CSS transition duration
|
||||||
|
} else {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [chat.profileDialog, isOpen]);
|
||||||
|
|
||||||
|
const fetchFreshProfileData = async (profileData: ProfileDialogData) => {
|
||||||
|
if (!user.authToken) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let freshData = profileData;
|
||||||
|
|
||||||
|
// If it's not the public chat and has a username, fetch fresh data
|
||||||
|
if (profileData.username && profileData.username !== "Общий чат" && profileData.userId) {
|
||||||
|
const userProfile = await fetchUserProfile(user.authToken, profileData.username);
|
||||||
|
if (userProfile) {
|
||||||
|
freshData = {
|
||||||
|
userId: userProfile.id,
|
||||||
|
username: userProfile.username,
|
||||||
|
profilePicture: userProfile.profile_picture,
|
||||||
|
bio: userProfile.bio,
|
||||||
|
memberSince: userProfile.created_at,
|
||||||
|
online: userProfile.online,
|
||||||
|
isOwnProfile: profileData.isOwnProfile
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setOriginalData(freshData);
|
||||||
|
setCurrentData(freshData);
|
||||||
|
setIsOpen(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch fresh profile data:", error);
|
||||||
|
// Fallback to cached data if fetch fails
|
||||||
|
setOriginalData(profileData);
|
||||||
|
setCurrentData(profileData);
|
||||||
|
setIsOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Trigger transition after component mounts
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
// Small delay to ensure DOM is ready for transition
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (backdropRef.current && dialogRef.current) {
|
||||||
|
backdropRef.current.classList.add('open');
|
||||||
|
dialogRef.current.classList.add('open');
|
||||||
|
}
|
||||||
|
}, 10);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// Handle ESC key
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEsc = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape" && isOpen) {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener("keydown", handleEsc);
|
||||||
|
return () => document.removeEventListener("keydown", handleEsc);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const hasChanges = useMemo(() => {
|
||||||
|
if (!originalData || !currentData) return false;
|
||||||
|
|
||||||
|
// Normalize values for comparison (handle empty strings, undefined, null)
|
||||||
|
const normalizeValue = (value: string | undefined | null) => {
|
||||||
|
if (value === null || value === undefined) return "";
|
||||||
|
return value.trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
normalizeValue(originalData.username) !== normalizeValue(currentData.username) ||
|
||||||
|
normalizeValue(originalData.bio) !== normalizeValue(currentData.bio) ||
|
||||||
|
originalData.profilePicture !== currentData.profilePicture
|
||||||
|
);
|
||||||
|
}, [originalData, currentData]);
|
||||||
|
|
||||||
|
const handleClose = async () => {
|
||||||
|
if (hasChanges) {
|
||||||
|
try {
|
||||||
|
await confirm({
|
||||||
|
headline: "Несохраненные изменения",
|
||||||
|
description: "У вас есть несохраненные изменения. Вы уверены, что хотите закрыть?",
|
||||||
|
confirmText: "Закрыть",
|
||||||
|
cancelText: "Отмена"
|
||||||
|
});
|
||||||
|
triggerCloseAnimation();
|
||||||
|
} catch {
|
||||||
|
// User cancelled, do nothing
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
triggerCloseAnimation();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerCloseAnimation = () => {
|
||||||
|
if (backdropRef.current && dialogRef.current) {
|
||||||
|
backdropRef.current.classList.remove('open');
|
||||||
|
dialogRef.current.classList.remove('open');
|
||||||
|
|
||||||
|
// Wait for animation to complete before closing
|
||||||
|
setTimeout(() => {
|
||||||
|
closeProfileDialog();
|
||||||
|
}, 300); // Match CSS transition duration
|
||||||
|
} else {
|
||||||
|
closeProfileDialog();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUsernameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (!currentData) return;
|
||||||
|
setCurrentData({ ...currentData, username: e.target.value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBioChange = (newBio: string) => {
|
||||||
|
if (!currentData) return;
|
||||||
|
setCurrentData({ ...currentData, bio: newBio });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProfilePictureClick = () => {
|
||||||
|
if (currentData?.isOwnProfile) {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file && file.type.startsWith("image/")) {
|
||||||
|
// Open cropper dialog here - for now just update the image
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (event) => {
|
||||||
|
const imageUrl = event.target?.result as string;
|
||||||
|
if (currentData) {
|
||||||
|
setCurrentData({ ...currentData, profilePicture: imageUrl });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!currentData || !user.authToken || !originalData) return;
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
// Update profile data
|
||||||
|
const updateData: any = {};
|
||||||
|
if (originalData.username !== currentData.username) {
|
||||||
|
updateData.nickname = currentData.username;
|
||||||
|
}
|
||||||
|
if (originalData.bio !== currentData.bio) {
|
||||||
|
updateData.description = currentData.bio;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(updateData).length > 0) {
|
||||||
|
await updateProfile(user.authToken, updateData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update profile picture if changed
|
||||||
|
if (originalData.profilePicture !== currentData.profilePicture && currentData.profilePicture) {
|
||||||
|
// Convert data URL to blob if needed
|
||||||
|
if (currentData.profilePicture.startsWith("data:")) {
|
||||||
|
const response = await fetch(currentData.profilePicture);
|
||||||
|
const blob = await response.blob();
|
||||||
|
await uploadProfilePicture(user.authToken, blob);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the original data to match current data
|
||||||
|
setOriginalData(currentData);
|
||||||
|
|
||||||
|
// Close dialog with animation after successful save
|
||||||
|
triggerCloseAnimation();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to save profile:", error);
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleDateString("ru-RU", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric"
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen || !currentData) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
ref={backdropRef}
|
||||||
|
className="profile-dialog-backdrop"
|
||||||
|
onClick={handleBackdropClick}
|
||||||
|
>
|
||||||
|
<div ref={dialogRef} className="profile-dialog">
|
||||||
|
<div className="profile-dialog-content">
|
||||||
|
{/* Profile Picture */}
|
||||||
|
<div className="profile-picture-section">
|
||||||
|
<img
|
||||||
|
className="profile-picture"
|
||||||
|
src={currentData.profilePicture || defaultAvatar}
|
||||||
|
alt="Profile Picture"
|
||||||
|
onError={(e) => {
|
||||||
|
const target = e.target as HTMLImageElement;
|
||||||
|
target.src = defaultAvatar;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{currentData.isOwnProfile && (
|
||||||
|
<div
|
||||||
|
className="profile-picture-edit-overlay"
|
||||||
|
onClick={handleProfilePictureClick}
|
||||||
|
>
|
||||||
|
<mdui-icon name="camera_alt--filled" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Username */}
|
||||||
|
{currentData.username && (
|
||||||
|
<div className="username-section">
|
||||||
|
<input
|
||||||
|
className="username-input"
|
||||||
|
type="text"
|
||||||
|
value={currentData.username}
|
||||||
|
onChange={handleUsernameChange}
|
||||||
|
readOnly={!currentData.isOwnProfile}
|
||||||
|
placeholder="Имя пользователя"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Online Status */}
|
||||||
|
{currentData.online !== undefined && (
|
||||||
|
<div className="online-status-section">
|
||||||
|
<span className={`online-indicator ${currentData.online ? "" : "offline"}`} />
|
||||||
|
<span className="status-text">
|
||||||
|
{currentData.online ? "Онлайн" : "Оффлайн"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="profile-sections">
|
||||||
|
{/* Bio */}
|
||||||
|
{currentData.bio !== undefined && (
|
||||||
|
<div className="section bio">
|
||||||
|
<mdui-icon name="info--filled" />
|
||||||
|
<div className="content-container">
|
||||||
|
<label className="label">О себе:</label>
|
||||||
|
<RichTextArea
|
||||||
|
text={currentData.bio || ""}
|
||||||
|
onTextChange={handleBioChange}
|
||||||
|
placeholder="Нет информации о себе"
|
||||||
|
className="value"
|
||||||
|
rows={1}
|
||||||
|
readOnly={!currentData.isOwnProfile}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Member Since */}
|
||||||
|
{currentData.memberSince && (
|
||||||
|
<div className="section member-since">
|
||||||
|
<mdui-icon name="calendar_month--filled" />
|
||||||
|
<div className="content-container">
|
||||||
|
<span className="label">Участник с:</span>
|
||||||
|
<span className="value">
|
||||||
|
{formatDate(currentData.memberSince)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save FAB */}
|
||||||
|
{currentData.isOwnProfile && (
|
||||||
|
<mdui-fab
|
||||||
|
icon="check"
|
||||||
|
className={`profile-dialog-fab ${hasChanges ? "visible" : ""}`}
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Hidden file input */}
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
style={{ display: "none" }}
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.getElementById("root")!
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,21 +2,32 @@ import { PRODUCT_NAME } from "@/core/config";
|
|||||||
import useProfile from "@/pages/chat/hooks/useProfile";
|
import useProfile from "@/pages/chat/hooks/useProfile";
|
||||||
import defaultAvatar from "@/images/default-avatar.png";
|
import defaultAvatar from "@/images/default-avatar.png";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { ProfileDialog } from "./profile/ProfileDialog";
|
import { useAppState } from "@/pages/chat/state";
|
||||||
import { MinimizedCallBar } from "@/pages/chat/ui/right/calls/MinimizedCallBar";
|
import { MinimizedCallBar } from "@/pages/chat/ui/right/calls/MinimizedCallBar";
|
||||||
|
|
||||||
export function ChatHeader() {
|
export function ChatHeader() {
|
||||||
const { profileData } = useProfile();
|
const { profileData } = useProfile();
|
||||||
const [isProfileOpen, setIsProfileOpen] = useState(false);
|
const { setProfileDialog, user } = useAppState();
|
||||||
|
|
||||||
const [profilePictureUrl, setProfilePictureUrl] = useState(profileData?.profile_picture || defaultAvatar);
|
const [profilePictureUrl, setProfilePictureUrl] = useState(profileData?.profile_picture || defaultAvatar);
|
||||||
|
|
||||||
|
const handleProfileClick = () => {
|
||||||
|
setProfileDialog({
|
||||||
|
userId: user.currentUser?.id,
|
||||||
|
username: profileData?.nickname || "Пользователь",
|
||||||
|
profilePicture: profileData?.profile_picture,
|
||||||
|
bio: profileData?.description,
|
||||||
|
memberSince: user.currentUser?.created_at,
|
||||||
|
online: user.currentUser?.online,
|
||||||
|
isOwnProfile: true
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<header className="chat-header-left">
|
<header className="chat-header-left">
|
||||||
<div className="product-name">{PRODUCT_NAME}</div>
|
<div className="product-name">{PRODUCT_NAME}</div>
|
||||||
<div className="profile">
|
<div className="profile">
|
||||||
<a href="#" id="profile-open" onClick={() => setIsProfileOpen(true)}>
|
<a href="#" id="profile-open" onClick={handleProfileClick}>
|
||||||
<img
|
<img
|
||||||
src={profilePictureUrl}
|
src={profilePictureUrl}
|
||||||
alt=""
|
alt=""
|
||||||
@@ -26,7 +37,6 @@ export function ChatHeader() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<MinimizedCallBar />
|
<MinimizedCallBar />
|
||||||
<ProfileDialog isOpen={isProfileOpen} onOpenChange={setIsProfileOpen} />
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import "./css/cropper-dialog.scss";
|
||||||
|
|
||||||
export function CropperDialog() {
|
export function CropperDialog() {
|
||||||
return (
|
return (
|
||||||
<mdui-dialog id="cropper-dialog" close-on-overlay-click close-on-esc>
|
<mdui-dialog id="cropper-dialog" close-on-overlay-click close-on-esc>
|
||||||
|
|||||||
@@ -1,179 +0,0 @@
|
|||||||
import { useState, useEffect, useRef, type FormEvent } from "react";
|
|
||||||
import defaultAvatar from "@/images/default-avatar.png";
|
|
||||||
import type { TextField } from "mdui/components/text-field";
|
|
||||||
import type { DialogProps } from "@/core/types";
|
|
||||||
import { MaterialDialog } from "@/core/components/Dialog";
|
|
||||||
import useProfile from "@/pages/chat/hooks/useProfile";
|
|
||||||
import { ImageCropper } from "./ImageCropper";
|
|
||||||
import { MaterialTextField } from "@/core/components/TextField";
|
|
||||||
|
|
||||||
export function ProfileDialog({ isOpen, onOpenChange }: DialogProps) {
|
|
||||||
const { profileData, isLoading, isUpdating, updateProfileData, uploadProfilePictureData } = useProfile();
|
|
||||||
|
|
||||||
const [username, setUsername] = useState(profileData?.nickname ?? "");
|
|
||||||
const [description, setDescription] = useState(profileData?.description ?? "");
|
|
||||||
const [selectedImage, setSelectedImage] = useState<File | null>(null);
|
|
||||||
const [showCropper, setShowCropper] = useState(false);
|
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
// Update form fields when profile data changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (profileData) {
|
|
||||||
setUsername(profileData.nickname || "");
|
|
||||||
setDescription(profileData.description || "");
|
|
||||||
}
|
|
||||||
}, [profileData]);
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const success = await updateProfileData({
|
|
||||||
nickname: username.trim() || undefined,
|
|
||||||
description: description.trim() || undefined
|
|
||||||
});
|
|
||||||
|
|
||||||
if (success) {
|
|
||||||
onOpenChange(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (file && file.type.startsWith('image/')) {
|
|
||||||
setSelectedImage(file);
|
|
||||||
setShowCropper(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCropComplete = async (croppedImageData: string) => {
|
|
||||||
try {
|
|
||||||
// Convert data URL to blob
|
|
||||||
const response = await fetch(croppedImageData);
|
|
||||||
const blob = await response.blob();
|
|
||||||
|
|
||||||
const success = await uploadProfilePictureData(blob);
|
|
||||||
if (success) {
|
|
||||||
setShowCropper(false);
|
|
||||||
setSelectedImage(null);
|
|
||||||
if (fileInputRef.current) {
|
|
||||||
fileInputRef.current.value = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error processing cropped image:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCropCancel = () => {
|
|
||||||
setShowCropper(false);
|
|
||||||
setSelectedImage(null);
|
|
||||||
if (fileInputRef.current) {
|
|
||||||
fileInputRef.current.value = '';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUploadClick = () => {
|
|
||||||
fileInputRef.current?.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
const profilePictureUrl = profileData?.profile_picture || defaultAvatar;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<MaterialDialog id="profile-dialog" close-on-overlay-click close-on-esc open={isOpen} onOpenChange={onOpenChange}>
|
|
||||||
<div className="content">
|
|
||||||
<div className="header-top">
|
|
||||||
<div className="profile-picture-container">
|
|
||||||
<img
|
|
||||||
id="profile-picture"
|
|
||||||
src={profilePictureUrl}
|
|
||||||
alt="Ваше фото"
|
|
||||||
onError={(e) => {
|
|
||||||
const target = e.target as HTMLImageElement;
|
|
||||||
target.src = defaultAvatar;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<mdui-button-icon
|
|
||||||
icon="camera_alt--filled"
|
|
||||||
id="upload-pfp-btn"
|
|
||||||
className="upload-overlay"
|
|
||||||
variant="filled"
|
|
||||||
onClick={handleUploadClick}
|
|
||||||
disabled={isUpdating}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
id="pfp-file-input"
|
|
||||||
accept="image/*"
|
|
||||||
style={{ display: "none" }}
|
|
||||||
onChange={handleImageSelect}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<MaterialTextField
|
|
||||||
id="username-field"
|
|
||||||
label="Имя пользователя"
|
|
||||||
variant="outlined"
|
|
||||||
value={username}
|
|
||||||
onChange={(e: FormEvent<HTMLElement & TextField>) => setUsername((e.target as TextField).value)}
|
|
||||||
autocomplete="username"
|
|
||||||
disabled={isLoading || isUpdating} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form id="profile-form" onSubmit={handleSubmit}>
|
|
||||||
<MaterialTextField
|
|
||||||
id="description-field"
|
|
||||||
label="О себе"
|
|
||||||
variant="outlined"
|
|
||||||
value={description}
|
|
||||||
onChange={(e: FormEvent<HTMLElement & TextField>) => setDescription((e.target as TextField).value)}
|
|
||||||
placeholder="Расскажите о себе..."
|
|
||||||
autocomplete="none"
|
|
||||||
disabled={isLoading || isUpdating} />
|
|
||||||
<div className="dialog-actions">
|
|
||||||
<mdui-button
|
|
||||||
type="submit"
|
|
||||||
id="profile-submit"
|
|
||||||
disabled={isLoading || isUpdating}
|
|
||||||
>
|
|
||||||
{isUpdating ? "Сохранение..." : "Сохранить изменения"}
|
|
||||||
</mdui-button>
|
|
||||||
<mdui-button
|
|
||||||
id="profile-dialog-close"
|
|
||||||
variant="outlined"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
disabled={isUpdating}
|
|
||||||
>
|
|
||||||
Закрыть
|
|
||||||
</mdui-button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</MaterialDialog>
|
|
||||||
|
|
||||||
{/* Image Cropper Dialog */}
|
|
||||||
<MaterialDialog
|
|
||||||
id="cropper-dialog"
|
|
||||||
close-on-overlay-click
|
|
||||||
close-on-esc
|
|
||||||
open={showCropper}
|
|
||||||
onOpenChange={setShowCropper}
|
|
||||||
>
|
|
||||||
<div className="cropper-dialog-content">
|
|
||||||
<div className="cropper-header">
|
|
||||||
<h3>Обрезать фото профиля</h3>
|
|
||||||
<mdui-button-icon icon="close" onClick={handleCropCancel} />
|
|
||||||
</div>
|
|
||||||
<div className="cropper-container">
|
|
||||||
<ImageCropper
|
|
||||||
imageFile={selectedImage}
|
|
||||||
onCrop={handleCropComplete}
|
|
||||||
onCancel={handleCropCancel}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</MaterialDialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,8 @@
|
|||||||
import { Message } from "./Message";
|
import { Message } from "./Message";
|
||||||
import { useAppState } from "@/pages/chat/state";
|
import { useAppState } from "@/pages/chat/state";
|
||||||
import type { Message as MessageType } from "@/core/types";
|
import type { Message as MessageType } from "@/core/types";
|
||||||
import type { UserProfile } from "@/core/types";
|
|
||||||
import { UserProfileDialog } from "./UserProfileDialog";
|
|
||||||
import { MessageContextMenu, type ContextMenuState } from "./MessageContextMenu";
|
import { MessageContextMenu, type ContextMenuState } from "./MessageContextMenu";
|
||||||
import { fetchUserProfile } from "@/core/api/profileApi";
|
|
||||||
import { useEffect, useState, type ReactNode } from "react";
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
import { delay } from "@/utils/utils";
|
|
||||||
import { MaterialDialog } from "@/core/components/Dialog";
|
import { MaterialDialog } from "@/core/components/Dialog";
|
||||||
import { request } from "@/core/websocket";
|
import { request } from "@/core/websocket";
|
||||||
import type { AddReactionRequest, AddDmReactionRequest } from "@/core/types";
|
import type { AddReactionRequest, AddDmReactionRequest } from "@/core/types";
|
||||||
@@ -26,9 +22,6 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
|
|||||||
const { user } = useAppState();
|
const { user } = useAppState();
|
||||||
|
|
||||||
// Use prop messages (panels provide their own messages)
|
// Use prop messages (panels provide their own messages)
|
||||||
const [profileDialogOpen, setProfileDialogOpen] = useState(false);
|
|
||||||
const [selectedUserProfile, setSelectedUserProfile] = useState<UserProfile | null>(null);
|
|
||||||
const [isLoadingProfile, setIsLoadingProfile] = useState(false);
|
|
||||||
|
|
||||||
// Context menu state
|
// Context menu state
|
||||||
const [contextMenu, setContextMenu] = useState<ContextMenuState>({
|
const [contextMenu, setContextMenu] = useState<ContextMenuState>({
|
||||||
@@ -48,22 +41,6 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
|
|||||||
}
|
}
|
||||||
}, [deleteDialogOpen]);
|
}, [deleteDialogOpen]);
|
||||||
|
|
||||||
async function handleProfileClick(username: string) {
|
|
||||||
if (!user.authToken) return;
|
|
||||||
|
|
||||||
setIsLoadingProfile(true);
|
|
||||||
try {
|
|
||||||
const profile = await fetchUserProfile(user.authToken, username);
|
|
||||||
if (profile) {
|
|
||||||
setSelectedUserProfile(profile);
|
|
||||||
setProfileDialogOpen(true);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch user profile:", error);
|
|
||||||
} finally {
|
|
||||||
setIsLoadingProfile(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function handleContextMenu(e: React.MouseEvent, message: MessageType) {
|
function handleContextMenu(e: React.MouseEvent, message: MessageType) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -158,27 +135,14 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
|
|||||||
(message.runtimeData?.dmEnvelope?.senderId === user.currentUser?.id) :
|
(message.runtimeData?.dmEnvelope?.senderId === user.currentUser?.id) :
|
||||||
(message.username === user.currentUser?.username)
|
(message.username === user.currentUser?.username)
|
||||||
}
|
}
|
||||||
onProfileClick={handleProfileClick}
|
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
onReactionClick={handleReactionClick}
|
onReactionClick={handleReactionClick}
|
||||||
isLoadingProfile={isLoadingProfile}
|
|
||||||
isDm={isDm}
|
isDm={isDm}
|
||||||
dmRecipientPublicKey={dmRecipientPublicKey} />
|
dmRecipientPublicKey={dmRecipientPublicKey} />
|
||||||
))}
|
))}
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UserProfileDialog
|
|
||||||
isOpen={profileDialogOpen}
|
|
||||||
onOpenChange={async (value) => {
|
|
||||||
setProfileDialogOpen(value);
|
|
||||||
if (!value) {
|
|
||||||
await delay(1000);
|
|
||||||
setSelectedUserProfile(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
userProfile={selectedUserProfile}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MaterialDialog
|
<MaterialDialog
|
||||||
headline="Удалить сообщение?"
|
headline="Удалить сообщение?"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
|
|||||||
import { importAesGcmKey, aesGcmDecrypt } from "@/utils/crypto/symmetric";
|
import { importAesGcmKey, aesGcmDecrypt } from "@/utils/crypto/symmetric";
|
||||||
import { getAuthHeaders } from "@/core/api/authApi";
|
import { getAuthHeaders } from "@/core/api/authApi";
|
||||||
import { useAppState } from "@/pages/chat/state";
|
import { useAppState } from "@/pages/chat/state";
|
||||||
|
import { fetchUserProfile } from "@/core/api/profileApi";
|
||||||
import { ub64 } from "@/utils/utils";
|
import { ub64 } from "@/utils/utils";
|
||||||
import { useImmer } from "use-immer";
|
import { useImmer } from "use-immer";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
@@ -132,10 +133,8 @@ function Reactions({ reactions, onReactionClick, messageId }: MessageReactionsPr
|
|||||||
interface MessageProps {
|
interface MessageProps {
|
||||||
message: MessageType;
|
message: MessageType;
|
||||||
isAuthor: boolean;
|
isAuthor: boolean;
|
||||||
onProfileClick: (username: string) => void;
|
|
||||||
onContextMenu: (e: React.MouseEvent, message: MessageType) => void;
|
onContextMenu: (e: React.MouseEvent, message: MessageType) => void;
|
||||||
onReactionClick?: (messageId: number, emoji: string) => void;
|
onReactionClick?: (messageId: number, emoji: string) => void;
|
||||||
isLoadingProfile?: boolean;
|
|
||||||
isDm?: boolean;
|
isDm?: boolean;
|
||||||
dmRecipientPublicKey?: string;
|
dmRecipientPublicKey?: string;
|
||||||
}
|
}
|
||||||
@@ -147,7 +146,7 @@ interface Rect {
|
|||||||
height: number
|
height: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Message({ message, isAuthor, onProfileClick, onContextMenu, onReactionClick, isLoadingProfile = false, isDm = false, dmRecipientPublicKey }: MessageProps) {
|
export function Message({ message, isAuthor, onContextMenu, onReactionClick, isDm = false, dmRecipientPublicKey }: MessageProps) {
|
||||||
const [formattedMessage, setFormattedMessage] = useState({ __html: "" });
|
const [formattedMessage, setFormattedMessage] = useState({ __html: "" });
|
||||||
const [decryptedFiles, updateDecryptedFiles] = useImmer<Map<string, string>>(new Map());
|
const [decryptedFiles, updateDecryptedFiles] = useImmer<Map<string, string>>(new Map());
|
||||||
const [loadedImages, updateLoadedImages] = useImmer<Set<string>>(new Set());
|
const [loadedImages, updateLoadedImages] = useImmer<Set<string>>(new Set());
|
||||||
@@ -161,7 +160,7 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, onRe
|
|||||||
endRect: Rect;
|
endRect: Rect;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [isAnimatingOpen, setIsAnimatingOpen] = useState(false);
|
const [isAnimatingOpen, setIsAnimatingOpen] = useState(false);
|
||||||
const { user } = useAppState();
|
const { user, setProfileDialog } = useAppState();
|
||||||
const imageRefs = useRef<Map<string, HTMLImageElement>>(new Map());
|
const imageRefs = useRef<Map<string, HTMLImageElement>>(new Map());
|
||||||
const dmEnvelope = message.runtimeData?.dmEnvelope;
|
const dmEnvelope = message.runtimeData?.dmEnvelope;
|
||||||
|
|
||||||
@@ -389,6 +388,22 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, onRe
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function handleProfileClick() {
|
||||||
|
if (!user.authToken || !message.username) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userProfile = await fetchUserProfile(user.authToken, message.username);
|
||||||
|
if (userProfile) {
|
||||||
|
setProfileDialog({
|
||||||
|
...userProfile,
|
||||||
|
isOwnProfile: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch user profile:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleContextMenu(e: React.MouseEvent) {
|
function handleContextMenu(e: React.MouseEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -402,28 +417,24 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, onRe
|
|||||||
data-id={message.id}
|
data-id={message.id}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
>
|
>
|
||||||
|
{!isAuthor && !isDm && (
|
||||||
|
<div className="message-profile-pic" onClick={handleProfileClick}>
|
||||||
|
<img
|
||||||
|
src={message.profile_picture || defaultAvatar}
|
||||||
|
alt={message.username}
|
||||||
|
onError={(e) => {
|
||||||
|
const target = e.target as HTMLImageElement;
|
||||||
|
target.src = defaultAvatar;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="message-inner">
|
<div className="message-inner">
|
||||||
{!isAuthor && !isDm && (
|
|
||||||
<div className="message-profile-pic">
|
|
||||||
<img
|
|
||||||
src={message.profile_picture || defaultAvatar}
|
|
||||||
alt={message.username}
|
|
||||||
onClick={() => !isLoadingProfile && onProfileClick(message.username)}
|
|
||||||
style={{ cursor: isLoadingProfile ? "default" : "pointer" }}
|
|
||||||
className={isLoadingProfile ? "loading" : ""}
|
|
||||||
onError={(e) => {
|
|
||||||
const target = e.target as HTMLImageElement;
|
|
||||||
target.src = defaultAvatar;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isAuthor && !isDm && (
|
{!isAuthor && !isDm && (
|
||||||
<div
|
<div
|
||||||
className={`message-username ${isLoadingProfile ? "loading" : ""}`}
|
className="message-username"
|
||||||
onClick={() => !isLoadingProfile && onProfileClick(message.username)}
|
onClick={handleProfileClick}>
|
||||||
style={{ cursor: isLoadingProfile ? "default" : "pointer" }}>
|
|
||||||
{message.username}
|
{message.username}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useAppState } from "@/pages/chat/state";
|
|||||||
import { MessagePanel, type MessagePanelState } from "./panels/MessagePanel";
|
import { MessagePanel, type MessagePanelState } from "./panels/MessagePanel";
|
||||||
import { ChatMessages } from "./ChatMessages";
|
import { ChatMessages } from "./ChatMessages";
|
||||||
import { ChatInputWrapper } from "./ChatInputWrapper";
|
import { ChatInputWrapper } from "./ChatInputWrapper";
|
||||||
|
import { ProfileDialog } from "../ProfileDialog";
|
||||||
import { setGlobalMessageHandler } from "@/core/websocket";
|
import { setGlobalMessageHandler } from "@/core/websocket";
|
||||||
import type { Message, WebSocketMessage } from "@/core/types";
|
import type { Message, WebSocketMessage } from "@/core/types";
|
||||||
import defaultAvatar from "@/images/default-avatar.png";
|
import defaultAvatar from "@/images/default-avatar.png";
|
||||||
@@ -15,7 +16,7 @@ interface MessagePanelRendererProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
|
export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
|
||||||
const { applyPendingPanel, chat } = useAppState();
|
const { applyPendingPanel, chat, setProfileDialog } = useAppState();
|
||||||
const messagePanelRef = useRef<HTMLDivElement>(null);
|
const messagePanelRef = useRef<HTMLDivElement>(null);
|
||||||
const [panelState, setPanelState] = useState<MessagePanelState | null>(null);
|
const [panelState, setPanelState] = useState<MessagePanelState | null>(null);
|
||||||
const [switchIn, setSwitchIn] = useState(false);
|
const [switchIn, setSwitchIn] = useState(false);
|
||||||
@@ -170,6 +171,19 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function handleProfileClick() {
|
||||||
|
if (!panel) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const profileData = await panel.getProfile();
|
||||||
|
if (profileData) {
|
||||||
|
setProfileDialog(profileData);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to get profile:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`chat-container ${switchIn ? "chat-switch-in" : ""} ${switchOut ? "chat-switch-out" : ""}`}>
|
<div className={`chat-container ${switchIn ? "chat-switch-in" : ""} ${switchOut ? "chat-switch-out" : ""}`}>
|
||||||
<div
|
<div
|
||||||
@@ -213,7 +227,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
|
|||||||
src={panelState?.profilePicture || defaultAvatar}
|
src={panelState?.profilePicture || defaultAvatar}
|
||||||
alt="Avatar"
|
alt="Avatar"
|
||||||
className="chat-header-avatar"
|
className="chat-header-avatar"
|
||||||
onClick={panel?.handleProfileClick}
|
onClick={handleProfileClick}
|
||||||
style={{ cursor: panel ? "pointer" : "default" }}
|
style={{ cursor: panel ? "pointer" : "default" }}
|
||||||
/>
|
/>
|
||||||
<div className="chat-header-info">
|
<div className="chat-header-info">
|
||||||
@@ -344,6 +358,9 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Profile Dialog */}
|
||||||
|
<ProfileDialog />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
import type { DialogProps } from "@/core/types";
|
|
||||||
import type { UserProfile } from "@/core/types";
|
|
||||||
import { MaterialDialog } from "@/core/components/Dialog";
|
|
||||||
import { formatTime } from "@/utils/utils";
|
|
||||||
import defaultAvatar from "@/images/default-avatar.png";
|
|
||||||
|
|
||||||
interface UserProfileDialogProps extends DialogProps {
|
|
||||||
userProfile: UserProfile | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UserProfileDialog({ isOpen, onOpenChange, userProfile }: UserProfileDialogProps) {
|
|
||||||
const content = userProfile ? (
|
|
||||||
<div className="content">
|
|
||||||
<div className="profile-picture-section">
|
|
||||||
<img
|
|
||||||
className="profile-picture"
|
|
||||||
alt="Profile Picture"
|
|
||||||
src={userProfile.profile_picture || defaultAvatar}
|
|
||||||
onError={(e) => {
|
|
||||||
const target = e.target as HTMLImageElement;
|
|
||||||
target.src = defaultAvatar;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="profile-info">
|
|
||||||
<div className="username-section">
|
|
||||||
<h4 className="username">{userProfile.username}</h4>
|
|
||||||
<div className={`online-status ${userProfile.online ? "online" : "offline"}`}>
|
|
||||||
{userProfile.online ? (
|
|
||||||
<>
|
|
||||||
<span className="online-indicator"></span> Онлайн
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<span className="offline-indicator"></span> Последний заход {formatTime(userProfile.last_seen)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bio-section">
|
|
||||||
<label>О себе:</label>
|
|
||||||
<div className="bio-display">
|
|
||||||
{userProfile.bio || "No bio available."}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="profile-stats">
|
|
||||||
<div className="stat">
|
|
||||||
<span className="stat-label">Зарегистрирован:</span>
|
|
||||||
<span className="stat-value member-since">{formatTime(userProfile.created_at)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="stat">
|
|
||||||
<span className="stat-label">Last seen:</span>
|
|
||||||
<span className="stat-value last-seen">{formatTime(userProfile.last_seen)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="profile-actions">
|
|
||||||
<mdui-button id="dm-button" variant="filled">
|
|
||||||
<mdui-icon slot="icon" name="chat--filled"></mdui-icon>
|
|
||||||
Send Message
|
|
||||||
</mdui-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MaterialDialog open={isOpen} onOpenChange={onOpenChange} close-on-overlay-click close-on-esc id="user-profile-dialog">
|
|
||||||
{content}
|
|
||||||
</MaterialDialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -7,8 +7,9 @@ import {
|
|||||||
editDmEnvelope,
|
editDmEnvelope,
|
||||||
deleteDmEnvelope
|
deleteDmEnvelope
|
||||||
} from "@/core/api/dmApi";
|
} from "@/core/api/dmApi";
|
||||||
|
import { fetchUserProfile } from "@/core/api/profileApi";
|
||||||
import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "@/core/types";
|
import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "@/core/types";
|
||||||
import type { UserState } from "@/pages/chat/state";
|
import type { UserState, ProfileDialogData } from "@/pages/chat/state";
|
||||||
import { formatDMUsername } from "@/pages/chat/hooks/useDM";
|
import { formatDMUsername } from "@/pages/chat/hooks/useDM";
|
||||||
|
|
||||||
export interface DMPanelData {
|
export interface DMPanelData {
|
||||||
@@ -322,7 +323,27 @@ export class DMPanel extends MessagePanel {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
handleProfileClick(): void {}
|
async getProfile(): Promise<ProfileDialogData | null> {
|
||||||
|
if (!this.dmData || !this.currentUser.authToken) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userProfile = await fetchUserProfile(this.currentUser.authToken, this.dmData.username);
|
||||||
|
if (!userProfile) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: userProfile.id,
|
||||||
|
username: userProfile.username,
|
||||||
|
profilePicture: userProfile.profile_picture,
|
||||||
|
bio: userProfile.bio,
|
||||||
|
memberSince: userProfile.created_at,
|
||||||
|
online: userProfile.online,
|
||||||
|
isOwnProfile: false
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch user profile:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
updateMessageReactions(dmEnvelopeId: number, reactions: any[]): void {
|
updateMessageReactions(dmEnvelopeId: number, reactions: any[]): void {
|
||||||
const messages = this.getMessages();
|
const messages = this.getMessages();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Message, WebSocketMessage } from "@/core/types";
|
import type { Message, WebSocketMessage } from "@/core/types";
|
||||||
import type { UserState } from "@/pages/chat/state";
|
import type { UserState, ProfileDialogData } from "@/pages/chat/state";
|
||||||
|
|
||||||
export interface MessagePanelState {
|
export interface MessagePanelState {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -47,6 +47,7 @@ export abstract class MessagePanel {
|
|||||||
protected abstract sendMessage(content: string, replyToId?: number, files?: File[]): Promise<void>;
|
protected abstract sendMessage(content: string, replyToId?: number, files?: File[]): Promise<void>;
|
||||||
abstract isDm(): boolean;
|
abstract isDm(): boolean;
|
||||||
abstract handleWebSocketMessage(response: WebSocketMessage<any>): Promise<void>;
|
abstract handleWebSocketMessage(response: WebSocketMessage<any>): Promise<void>;
|
||||||
|
abstract getProfile(): Promise<ProfileDialogData | null>;
|
||||||
|
|
||||||
// Common methods
|
// Common methods
|
||||||
protected updateState(updates: Partial<MessagePanelState>): void {
|
protected updateState(updates: Partial<MessagePanelState>): void {
|
||||||
@@ -349,5 +350,4 @@ export abstract class MessagePanel {
|
|||||||
|
|
||||||
abstract handleEditMessage(messageId: number, content: string): Promise<void>;
|
abstract handleEditMessage(messageId: number, content: string): Promise<void>;
|
||||||
abstract handleDeleteMessage(messageId: number): Promise<void>;
|
abstract handleDeleteMessage(messageId: number): Promise<void>;
|
||||||
abstract handleProfileClick(): void;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { API_BASE_URL } from "@/core/config";
|
|||||||
import { getAuthHeaders } from "@/core/api/authApi";
|
import { getAuthHeaders } from "@/core/api/authApi";
|
||||||
import { request } from "@/core/websocket";
|
import { request } from "@/core/websocket";
|
||||||
import type { ChatWebSocketMessage, Message, SendMessageRequest, ReactionUpdateWebSocketMessage } from "@/core/types";
|
import type { ChatWebSocketMessage, Message, SendMessageRequest, ReactionUpdateWebSocketMessage } from "@/core/types";
|
||||||
import type { UserState } from "@/pages/chat/state";
|
import type { UserState, ProfileDialogData } from "@/pages/chat/state";
|
||||||
|
|
||||||
export class PublicChatPanel extends MessagePanel {
|
export class PublicChatPanel extends MessagePanel {
|
||||||
private messagesLoaded: boolean = false;
|
private messagesLoaded: boolean = false;
|
||||||
@@ -197,5 +197,11 @@ export class PublicChatPanel extends MessagePanel {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
handleProfileClick(): void {}
|
async getProfile(): Promise<ProfileDialogData | null> {
|
||||||
|
return {
|
||||||
|
username: "Общий чат",
|
||||||
|
bio: "Общаемся со всеми пользователями FromChat!",
|
||||||
|
isOwnProfile: false
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user