@terrafirmagreg-team/chat 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +39 -3
- package/src/lib/identity.ts +28 -0
- package/src/lib/index.ts +12 -0
- package/src/locales/en_us.json +16 -0
- package/src/locales/pl_pl.json +16 -0
- package/src/locales/ru_ru.json +16 -0
- package/src/locales/uk_ua.json +16 -0
- package/src/locales/zh_cn.json +16 -0
- package/src/locales/zh_tw.json +16 -0
- package/src/locales.ts +5 -0
- package/src/styles.css +179 -0
- package/src/types.ts +31 -0
- package/src/ui/box.tsx +228 -0
- package/src/ui/index.ts +10 -0
- package/src/ui/message.tsx +58 -0
- package/README.md +0 -3
package/package.json
CHANGED
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@terrafirmagreg-team/chat",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": "./src/ui/index.ts",
|
|
7
|
+
"./lib": "./src/lib/index.ts",
|
|
8
|
+
"./styles.css": "./src/styles.css",
|
|
9
|
+
"./*": [
|
|
10
|
+
"./src/*.ts",
|
|
11
|
+
"./src/*.tsx",
|
|
12
|
+
"./src/*/index.ts",
|
|
13
|
+
"./src/*/index.tsx"
|
|
14
|
+
]
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"src",
|
|
18
|
+
"styles",
|
|
19
|
+
"styles.css"
|
|
20
|
+
],
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@terrafirmagreg-team/ui": "0.1.0",
|
|
23
|
+
"lucide-react": "^1.63.0",
|
|
24
|
+
"@terrafirmagreg-team/translate": "0.1.0"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"react": ">=19"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/node": "^26.6.5",
|
|
31
|
+
"@types/react": "^19.3.0",
|
|
32
|
+
"@types/react-dom": "^19.3.0"
|
|
33
|
+
},
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "https://github.com/TerraFirmaGreg-Team/Web-Foundation.git",
|
|
37
|
+
"directory": "packages/chat"
|
|
38
|
+
},
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
}
|
|
6
42
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type {ChatMessage} from '@terrafirmagreg-team/chat/types';
|
|
2
|
+
|
|
3
|
+
export function getStoredName(key: string): string {
|
|
4
|
+
try {
|
|
5
|
+
return window.localStorage?.getItem(key) ?? '';
|
|
6
|
+
} catch {
|
|
7
|
+
return '';
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function setStoredName(key: string, name: string): void {
|
|
12
|
+
try {
|
|
13
|
+
window.localStorage?.setItem(key, name);
|
|
14
|
+
} catch {
|
|
15
|
+
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
export function isOwnChatMessage(
|
|
21
|
+
message: ChatMessage,
|
|
22
|
+
names: {chatName?: string; loginName?: string},
|
|
23
|
+
): boolean {
|
|
24
|
+
if (message.type !== 'chat' || message.source !== 'web') return false;
|
|
25
|
+
const chat = names.chatName?.trim();
|
|
26
|
+
const login = names.loginName?.trim();
|
|
27
|
+
return (!!chat && message.playerName === chat) || (!!login && message.playerName === login);
|
|
28
|
+
}
|
package/src/lib/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
ChatBoxSettings,
|
|
3
|
+
ChatMessage,
|
|
4
|
+
ChatMessageType,
|
|
5
|
+
ChatSendingSettings,
|
|
6
|
+
} from '@terrafirmagreg-team/chat/types';
|
|
7
|
+
export {
|
|
8
|
+
getStoredName,
|
|
9
|
+
isOwnChatMessage,
|
|
10
|
+
setStoredName,
|
|
11
|
+
} from './identity';
|
|
12
|
+
export { type ChatTranslate } from '@terrafirmagreg-team/chat/types';
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"chat": {
|
|
3
|
+
"title": "Chat",
|
|
4
|
+
"noMessages": "No messages yet",
|
|
5
|
+
"placeholder": "Message",
|
|
6
|
+
"namePlaceholder": "Name",
|
|
7
|
+
"nameLabel": "Name",
|
|
8
|
+
"send": "Send",
|
|
9
|
+
"login": "Log in",
|
|
10
|
+
"playerJoin": "{playername} joined the game",
|
|
11
|
+
"playerQuit": "{playername} left the game",
|
|
12
|
+
"anonymousJoin": "A player joined the game",
|
|
13
|
+
"anonymousQuit": "A player left the game",
|
|
14
|
+
"errorUnknown": "Unknown error"
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"chat": {
|
|
3
|
+
"title": "Czat",
|
|
4
|
+
"noMessages": "Brak wiadomości",
|
|
5
|
+
"placeholder": "Wiadomość",
|
|
6
|
+
"namePlaceholder": "Nazwa",
|
|
7
|
+
"nameLabel": "Nazwa",
|
|
8
|
+
"send": "Wyślij",
|
|
9
|
+
"login": "Zaloguj się",
|
|
10
|
+
"playerJoin": "{playername} dołączył do gry",
|
|
11
|
+
"playerQuit": "{playername} opuścił grę",
|
|
12
|
+
"anonymousJoin": "Gracz dołączył do gry",
|
|
13
|
+
"anonymousQuit": "Gracz opuścił grę",
|
|
14
|
+
"errorUnknown": "Nieznany błąd"
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"chat": {
|
|
3
|
+
"title": "Чат",
|
|
4
|
+
"noMessages": "Пока нет сообщений",
|
|
5
|
+
"placeholder": "Сообщение",
|
|
6
|
+
"namePlaceholder": "Имя",
|
|
7
|
+
"nameLabel": "Имя",
|
|
8
|
+
"send": "Отправить",
|
|
9
|
+
"login": "Войти",
|
|
10
|
+
"playerJoin": "{playername} зашёл в игру",
|
|
11
|
+
"playerQuit": "{playername} вышел из игры",
|
|
12
|
+
"anonymousJoin": "Игрок зашёл в игру",
|
|
13
|
+
"anonymousQuit": "Игрок вышел из игры",
|
|
14
|
+
"errorUnknown": "Неизвестная ошибка"
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"chat": {
|
|
3
|
+
"title": "Чат",
|
|
4
|
+
"noMessages": "Поки немає повідомлень",
|
|
5
|
+
"placeholder": "Повідомлення",
|
|
6
|
+
"namePlaceholder": "Ім'я",
|
|
7
|
+
"nameLabel": "Ім'я",
|
|
8
|
+
"send": "Надіслати",
|
|
9
|
+
"login": "Увійти",
|
|
10
|
+
"playerJoin": "{playername} зайшов у гру",
|
|
11
|
+
"playerQuit": "{playername} вийшов з гри",
|
|
12
|
+
"anonymousJoin": "Гравець зайшов у гру",
|
|
13
|
+
"anonymousQuit": "Гравець вийшов з гри",
|
|
14
|
+
"errorUnknown": "Невідома помилка"
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"chat": {
|
|
3
|
+
"title": "",
|
|
4
|
+
"noMessages": "",
|
|
5
|
+
"placeholder": "",
|
|
6
|
+
"namePlaceholder": "",
|
|
7
|
+
"nameLabel": "",
|
|
8
|
+
"send": "",
|
|
9
|
+
"login": "",
|
|
10
|
+
"playerJoin": "{playername} ",
|
|
11
|
+
"playerQuit": "{playername} ",
|
|
12
|
+
"anonymousJoin": "",
|
|
13
|
+
"anonymousQuit": "",
|
|
14
|
+
"errorUnknown": ""
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"chat": {
|
|
3
|
+
"title": "",
|
|
4
|
+
"noMessages": "",
|
|
5
|
+
"placeholder": "",
|
|
6
|
+
"namePlaceholder": "",
|
|
7
|
+
"nameLabel": "",
|
|
8
|
+
"send": "",
|
|
9
|
+
"login": "",
|
|
10
|
+
"playerJoin": "{playername} ",
|
|
11
|
+
"playerQuit": "{playername} ",
|
|
12
|
+
"anonymousJoin": "",
|
|
13
|
+
"anonymousQuit": "",
|
|
14
|
+
"errorUnknown": ""
|
|
15
|
+
}
|
|
16
|
+
}
|
package/src/locales.ts
ADDED
package/src/styles.css
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
.chat {
|
|
2
|
+
flex: 1 1 auto;
|
|
3
|
+
min-height: 0;
|
|
4
|
+
display: flex;
|
|
5
|
+
flex-direction: column;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.chat__header {
|
|
9
|
+
display: flex;
|
|
10
|
+
align-items: center;
|
|
11
|
+
gap: 0.8rem;
|
|
12
|
+
flex-shrink: 0;
|
|
13
|
+
min-height: 5.6rem;
|
|
14
|
+
padding: 0 1.2rem 0 1.6rem;
|
|
15
|
+
border-bottom: 1px solid var(--border);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.chat__title {
|
|
19
|
+
flex: 1 1 auto;
|
|
20
|
+
min-width: 0;
|
|
21
|
+
font-size: 1.5rem;
|
|
22
|
+
font-weight: 600;
|
|
23
|
+
color: var(--text-emphasis);
|
|
24
|
+
overflow: hidden;
|
|
25
|
+
text-overflow: ellipsis;
|
|
26
|
+
white-space: nowrap;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.chat__close {
|
|
30
|
+
display: none;
|
|
31
|
+
flex: 0 0 auto;
|
|
32
|
+
width: 3.2rem;
|
|
33
|
+
height: 3.2rem;
|
|
34
|
+
align-items: center;
|
|
35
|
+
justify-content: center;
|
|
36
|
+
border: none;
|
|
37
|
+
border-radius: 0.8rem;
|
|
38
|
+
background: transparent;
|
|
39
|
+
color: var(--muted-foreground);
|
|
40
|
+
cursor: pointer;
|
|
41
|
+
transition: background 0.15s, color 0.15s;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.chat__close:hover {
|
|
45
|
+
background: var(--accent);
|
|
46
|
+
color: var(--accent-foreground);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.chat__close .svg-icon {
|
|
50
|
+
width: 1.6rem;
|
|
51
|
+
height: 1.6rem;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
@media (width < 1024px) {
|
|
55
|
+
.chat__close {
|
|
56
|
+
display: inline-flex;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.chat__offline {
|
|
61
|
+
flex: 1;
|
|
62
|
+
display: flex;
|
|
63
|
+
align-items: center;
|
|
64
|
+
justify-content: center;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.chat__offline-chip {
|
|
68
|
+
display: inline-flex;
|
|
69
|
+
align-items: center;
|
|
70
|
+
gap: 0.6rem;
|
|
71
|
+
height: 2.8rem;
|
|
72
|
+
padding: 0 1.2rem;
|
|
73
|
+
border-radius: 999px;
|
|
74
|
+
background: color-mix(in srgb, var(--color-secondary) 50%, transparent);
|
|
75
|
+
border: 1px solid var(--border);
|
|
76
|
+
color: var(--muted-foreground);
|
|
77
|
+
font-size: 1.2rem;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.chat__scroll {
|
|
81
|
+
flex: 1 1 auto;
|
|
82
|
+
min-height: 0;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.chat__content {
|
|
86
|
+
padding: 0.8rem 1.2rem 1.2rem;
|
|
87
|
+
gap: 0.8rem;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.chat__face {
|
|
91
|
+
border-radius: 999px;
|
|
92
|
+
display: block;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.chat__meta {
|
|
96
|
+
gap: 0.6rem;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.chat__meta > *:not(:first-child)::before {
|
|
100
|
+
content: '·\00a0';
|
|
101
|
+
opacity: 0.6;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.chat__event {
|
|
105
|
+
text-align: center;
|
|
106
|
+
font-size: 1.2rem;
|
|
107
|
+
font-style: italic;
|
|
108
|
+
color: var(--muted-foreground);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.chat__name {
|
|
112
|
+
flex: 1;
|
|
113
|
+
min-width: 0;
|
|
114
|
+
border: none;
|
|
115
|
+
background: transparent;
|
|
116
|
+
padding: 0;
|
|
117
|
+
color: var(--foreground);
|
|
118
|
+
font-size: 1.3rem;
|
|
119
|
+
outline: none;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.chat__name::placeholder {
|
|
123
|
+
color: var(--muted-foreground);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.chat__form {
|
|
127
|
+
flex: 0 0 auto;
|
|
128
|
+
display: flex;
|
|
129
|
+
flex-direction: column;
|
|
130
|
+
gap: 0.4rem;
|
|
131
|
+
margin: 0 1.2rem 1.2rem;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.chat__error {
|
|
135
|
+
display: flex;
|
|
136
|
+
align-items: center;
|
|
137
|
+
gap: 1.2rem;
|
|
138
|
+
border-radius: 0.8rem;
|
|
139
|
+
background: color-mix(in srgb, var(--destructive) 5%, transparent);
|
|
140
|
+
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--destructive) 15%, transparent);
|
|
141
|
+
padding: 0.8rem 1.2rem;
|
|
142
|
+
font-size: 1.3rem;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.chat__error-dot {
|
|
146
|
+
width: 0.6rem;
|
|
147
|
+
height: 0.6rem;
|
|
148
|
+
border-radius: 999px;
|
|
149
|
+
background: var(--destructive);
|
|
150
|
+
flex: 0 0 auto;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.chat__login {
|
|
154
|
+
display: flex;
|
|
155
|
+
align-items: center;
|
|
156
|
+
justify-content: center;
|
|
157
|
+
gap: 0.8rem;
|
|
158
|
+
flex-shrink: 0;
|
|
159
|
+
margin: 0 1.2rem 1.2rem;
|
|
160
|
+
height: 4rem;
|
|
161
|
+
border: 1px solid var(--border);
|
|
162
|
+
border-radius: 0.8rem;
|
|
163
|
+
background: var(--popover);
|
|
164
|
+
color: var(--muted-foreground);
|
|
165
|
+
font-size: 1.4rem;
|
|
166
|
+
cursor: pointer;
|
|
167
|
+
transition: background 0.15s, color 0.15s;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.chat__login:hover {
|
|
171
|
+
color: var(--accent-foreground);
|
|
172
|
+
background: var(--accent);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
@media (max-width: 400px), (max-height: 480px) {
|
|
176
|
+
.chat {
|
|
177
|
+
max-width: calc(100% - 7rem);
|
|
178
|
+
}
|
|
179
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export type ChatMessageType = 'chat' | 'playerjoin' | 'playerleave';
|
|
2
|
+
|
|
3
|
+
export interface ChatMessage {
|
|
4
|
+
type: ChatMessageType;
|
|
5
|
+
|
|
6
|
+
message?: string;
|
|
7
|
+
|
|
8
|
+
playerName?: string;
|
|
9
|
+
|
|
10
|
+
playerAccount?: string;
|
|
11
|
+
|
|
12
|
+
channel?: string;
|
|
13
|
+
|
|
14
|
+
source?: string;
|
|
15
|
+
timestamp?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ChatBoxSettings {
|
|
19
|
+
|
|
20
|
+
messageHistory?: number;
|
|
21
|
+
|
|
22
|
+
showPlayerFaces?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ChatSendingSettings {
|
|
26
|
+
maxLength?: number;
|
|
27
|
+
|
|
28
|
+
loginRequired?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type ChatTranslate = (key: string, params?: Record<string, unknown>) => string;
|
package/src/ui/box.tsx
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import {useRef, useState, type FormEvent, type ReactNode} from 'react';
|
|
3
|
+
import {ArrowUp, LogIn, User, WifiOff, X} from 'lucide-react';
|
|
4
|
+
import {
|
|
5
|
+
Empty,
|
|
6
|
+
EmptyDescription,
|
|
7
|
+
EmptyHeader,
|
|
8
|
+
EmptyTitle,
|
|
9
|
+
} from '@terrafirmagreg-team/ui/components/ui/empty';
|
|
10
|
+
import {
|
|
11
|
+
InputGroup,
|
|
12
|
+
InputGroupAddon,
|
|
13
|
+
InputGroupButton,
|
|
14
|
+
InputGroupTextarea,
|
|
15
|
+
} from '@terrafirmagreg-team/ui/components/ui/input-group';
|
|
16
|
+
import {
|
|
17
|
+
MessageScroller,
|
|
18
|
+
MessageScrollerButton,
|
|
19
|
+
MessageScrollerContent,
|
|
20
|
+
MessageScrollerItem,
|
|
21
|
+
MessageScrollerProvider,
|
|
22
|
+
MessageScrollerViewport,
|
|
23
|
+
} from '@terrafirmagreg-team/ui/components/ui/message-scroller';
|
|
24
|
+
import {
|
|
25
|
+
getStoredName,
|
|
26
|
+
isOwnChatMessage,
|
|
27
|
+
setStoredName,
|
|
28
|
+
} from '@terrafirmagreg-team/chat/lib';
|
|
29
|
+
import type { ChatMessage, ChatTranslate } from '@terrafirmagreg-team/chat/types';
|
|
30
|
+
import { Hint } from '@terrafirmagreg-team/ui/components/blocks/tooltip/hint';
|
|
31
|
+
import {ChatMessageRow} from './message';
|
|
32
|
+
|
|
33
|
+
export function ChatBox({
|
|
34
|
+
messages,
|
|
35
|
+
t,
|
|
36
|
+
offline = false,
|
|
37
|
+
loggedIn = false,
|
|
38
|
+
loginRequired = false,
|
|
39
|
+
maxLength,
|
|
40
|
+
messageHistory,
|
|
41
|
+
showPlayerFaces = false,
|
|
42
|
+
anonymousNameKey = 'plugin.chatname',
|
|
43
|
+
loginNameKey = 'plugin.loginname',
|
|
44
|
+
send,
|
|
45
|
+
onClose,
|
|
46
|
+
onLogin,
|
|
47
|
+
renderAvatar,
|
|
48
|
+
unknownError = 'chat.errorUnknown',
|
|
49
|
+
}: {
|
|
50
|
+
messages: readonly ChatMessage[];
|
|
51
|
+
t: ChatTranslate;
|
|
52
|
+
|
|
53
|
+
offline?: boolean;
|
|
54
|
+
loggedIn?: boolean;
|
|
55
|
+
loginRequired?: boolean;
|
|
56
|
+
maxLength?: number;
|
|
57
|
+
|
|
58
|
+
messageHistory?: number;
|
|
59
|
+
showPlayerFaces?: boolean;
|
|
60
|
+
|
|
61
|
+
anonymousNameKey?: string;
|
|
62
|
+
loginNameKey?: string;
|
|
63
|
+
|
|
64
|
+
send?: (message: string, name?: string) => Promise<void>;
|
|
65
|
+
onClose?: () => void;
|
|
66
|
+
onLogin?: () => void;
|
|
67
|
+
|
|
68
|
+
renderAvatar?: (playerAccount: string) => ReactNode;
|
|
69
|
+
|
|
70
|
+
unknownError?: string;
|
|
71
|
+
}) {
|
|
72
|
+
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
73
|
+
const [enteredMessage, setEnteredMessage] = useState('');
|
|
74
|
+
const [sendingMessage, setSendingMessage] = useState(false);
|
|
75
|
+
const [sendingError, setSendingError] = useState<string | null>(null);
|
|
76
|
+
|
|
77
|
+
const [chatName, setChatName] = useState(() => getStoredName(anonymousNameKey));
|
|
78
|
+
const rememberChatName = (name: string) => {
|
|
79
|
+
setChatName(name);
|
|
80
|
+
setStoredName(anonymousNameKey, name);
|
|
81
|
+
};
|
|
82
|
+
const loginName = getStoredName(loginNameKey);
|
|
83
|
+
|
|
84
|
+
const shown = messageHistory
|
|
85
|
+
? messages.slice(0, messageHistory).reverse()
|
|
86
|
+
: [...messages].reverse();
|
|
87
|
+
|
|
88
|
+
const sendingEnabled = !loginRequired || loggedIn;
|
|
89
|
+
const nameMissing = !loggedIn && !chatName.trim();
|
|
90
|
+
|
|
91
|
+
const sendMessage = async (e?: FormEvent) => {
|
|
92
|
+
e?.preventDefault();
|
|
93
|
+
const message = enteredMessage.trim().substring(0, maxLength);
|
|
94
|
+
if (!message || nameMissing) return;
|
|
95
|
+
|
|
96
|
+
if (!send) return;
|
|
97
|
+
|
|
98
|
+
setSendingMessage(true);
|
|
99
|
+
setSendingError(null);
|
|
100
|
+
try {
|
|
101
|
+
await send(message, loggedIn ? undefined : chatName.trim() || undefined);
|
|
102
|
+
setEnteredMessage('');
|
|
103
|
+
} catch (err) {
|
|
104
|
+
setSendingError(err instanceof Error ? err.message : t(unknownError));
|
|
105
|
+
} finally {
|
|
106
|
+
setSendingMessage(false);
|
|
107
|
+
requestAnimationFrame(() => inputRef.current?.focus());
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<section className="chat">
|
|
113
|
+
<header className="chat__header">
|
|
114
|
+
<p className="chat__title">{t('chat.title')}</p>
|
|
115
|
+
{onClose && (
|
|
116
|
+
<Hint label={t('common.close')}>
|
|
117
|
+
<button
|
|
118
|
+
type="button"
|
|
119
|
+
className="chat__close"
|
|
120
|
+
aria-label={t('common.close')}
|
|
121
|
+
onClick={onClose}>
|
|
122
|
+
<X className="size-4" aria-hidden="true" />
|
|
123
|
+
</button>
|
|
124
|
+
</Hint>
|
|
125
|
+
)}
|
|
126
|
+
</header>
|
|
127
|
+
{offline ? (
|
|
128
|
+
<div className="chat__offline" role="status">
|
|
129
|
+
<span className="chat__offline-chip">
|
|
130
|
+
<WifiOff className="size-3.5" aria-hidden="true" />
|
|
131
|
+
{t('servers.offline')}
|
|
132
|
+
</span>
|
|
133
|
+
</div>
|
|
134
|
+
) : (
|
|
135
|
+
<MessageScrollerProvider autoScroll defaultScrollPosition="end">
|
|
136
|
+
<MessageScroller className="chat__scroll">
|
|
137
|
+
<MessageScrollerViewport role="log" aria-live="polite" aria-relevant="additions">
|
|
138
|
+
<MessageScrollerContent className="chat__content">
|
|
139
|
+
{shown.map(message => (
|
|
140
|
+
<MessageScrollerItem key={message.timestamp + (message.playerName ?? '')}>
|
|
141
|
+
<ChatMessageRow
|
|
142
|
+
message={message}
|
|
143
|
+
own={isOwnChatMessage(message, {chatName, loginName})}
|
|
144
|
+
avatar={
|
|
145
|
+
showPlayerFaces && message.playerAccount
|
|
146
|
+
? renderAvatar?.(message.playerAccount)
|
|
147
|
+
: undefined
|
|
148
|
+
}
|
|
149
|
+
t={t}
|
|
150
|
+
/>
|
|
151
|
+
</MessageScrollerItem>
|
|
152
|
+
))}
|
|
153
|
+
{!shown.length && (
|
|
154
|
+
<Empty>
|
|
155
|
+
<EmptyHeader>
|
|
156
|
+
<EmptyTitle>{t('chat.title')}</EmptyTitle>
|
|
157
|
+
<EmptyDescription>{t('chat.noMessages')}</EmptyDescription>
|
|
158
|
+
</EmptyHeader>
|
|
159
|
+
</Empty>
|
|
160
|
+
)}
|
|
161
|
+
</MessageScrollerContent>
|
|
162
|
+
</MessageScrollerViewport>
|
|
163
|
+
<MessageScrollerButton />
|
|
164
|
+
</MessageScroller>
|
|
165
|
+
</MessageScrollerProvider>
|
|
166
|
+
)}
|
|
167
|
+
{!offline && send && sendingEnabled && (
|
|
168
|
+
<form className="chat__form" onSubmit={sendMessage}>
|
|
169
|
+
{sendingError && (
|
|
170
|
+
<div className="chat__error" role="alert">
|
|
171
|
+
<span className="chat__error-dot" aria-hidden="true" />
|
|
172
|
+
<span className="flex-1 min-w-0">{sendingError}</span>
|
|
173
|
+
</div>
|
|
174
|
+
)}
|
|
175
|
+
<InputGroup>
|
|
176
|
+
{!loggedIn && (
|
|
177
|
+
<InputGroupAddon align="block-start" className="border-b">
|
|
178
|
+
<User aria-hidden="true" />
|
|
179
|
+
<input
|
|
180
|
+
className="chat__name"
|
|
181
|
+
type="text"
|
|
182
|
+
value={chatName}
|
|
183
|
+
maxLength={24}
|
|
184
|
+
placeholder={t('chat.namePlaceholder')}
|
|
185
|
+
aria-label={t('chat.nameLabel')}
|
|
186
|
+
onChange={e => rememberChatName(e.target.value)}
|
|
187
|
+
/>
|
|
188
|
+
</InputGroupAddon>
|
|
189
|
+
)}
|
|
190
|
+
<InputGroupTextarea
|
|
191
|
+
ref={inputRef}
|
|
192
|
+
rows={2}
|
|
193
|
+
value={enteredMessage}
|
|
194
|
+
maxLength={maxLength}
|
|
195
|
+
placeholder={nameMissing ? t('chat.namePlaceholder') : t('chat.placeholder')}
|
|
196
|
+
disabled={sendingMessage || nameMissing}
|
|
197
|
+
aria-label={t('chat.placeholder')}
|
|
198
|
+
onChange={e => setEnteredMessage(e.target.value)}
|
|
199
|
+
onKeyDown={e => {
|
|
200
|
+
if (e.nativeEvent.isComposing || e.keyCode === 229) return;
|
|
201
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
202
|
+
e.preventDefault();
|
|
203
|
+
void sendMessage();
|
|
204
|
+
}
|
|
205
|
+
}}
|
|
206
|
+
/>
|
|
207
|
+
<InputGroupAddon align="block-end" className="justify-end">
|
|
208
|
+
<InputGroupButton
|
|
209
|
+
type="submit"
|
|
210
|
+
variant="default"
|
|
211
|
+
size="icon-sm"
|
|
212
|
+
disabled={!enteredMessage.trim() || sendingMessage || nameMissing}
|
|
213
|
+
aria-label={t('chat.send')}>
|
|
214
|
+
<ArrowUp strokeWidth={2.25} aria-hidden="true" />
|
|
215
|
+
</InputGroupButton>
|
|
216
|
+
</InputGroupAddon>
|
|
217
|
+
</InputGroup>
|
|
218
|
+
</form>
|
|
219
|
+
)}
|
|
220
|
+
{send && loginRequired && !loggedIn && onLogin && (
|
|
221
|
+
<button type="button" className="chat__login" onClick={onLogin}>
|
|
222
|
+
<LogIn className="size-4" aria-hidden="true" />
|
|
223
|
+
{t('chat.login')}
|
|
224
|
+
</button>
|
|
225
|
+
)}
|
|
226
|
+
</section>
|
|
227
|
+
);
|
|
228
|
+
}
|
package/src/ui/index.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export {ChatBox} from './box';
|
|
2
|
+
export {ChatMessageRow} from './message';
|
|
3
|
+
export type {
|
|
4
|
+
ChatBoxSettings,
|
|
5
|
+
ChatMessage,
|
|
6
|
+
ChatMessageType,
|
|
7
|
+
ChatSendingSettings,
|
|
8
|
+
ChatTranslate,
|
|
9
|
+
} from '@terrafirmagreg-team/chat/types';
|
|
10
|
+
export {getStoredName, isOwnChatMessage, setStoredName} from '@terrafirmagreg-team/chat/lib';
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import type {ReactNode} from 'react';
|
|
3
|
+
import {Bubble, BubbleContent} from '@terrafirmagreg-team/ui/components/ui/bubble';
|
|
4
|
+
import {Message, MessageAvatar, MessageContent, MessageHeader} from '@terrafirmagreg-team/ui/components/ui/message';
|
|
5
|
+
import type {ChatMessage, ChatTranslate} from '@terrafirmagreg-team/chat/types';
|
|
6
|
+
|
|
7
|
+
export function ChatMessageRow({
|
|
8
|
+
message,
|
|
9
|
+
own = false,
|
|
10
|
+
avatar,
|
|
11
|
+
t,
|
|
12
|
+
}: {
|
|
13
|
+
message: ChatMessage;
|
|
14
|
+
own?: boolean;
|
|
15
|
+
avatar?: ReactNode;
|
|
16
|
+
t: ChatTranslate;
|
|
17
|
+
}) {
|
|
18
|
+
const channel = message.type === 'chat' ? message.channel : undefined;
|
|
19
|
+
|
|
20
|
+
const content = (() => {
|
|
21
|
+
switch (message.type) {
|
|
22
|
+
case 'chat':
|
|
23
|
+
return message.message;
|
|
24
|
+
case 'playerjoin':
|
|
25
|
+
return message.playerName
|
|
26
|
+
? t('chat.playerJoin', {playername: message.playerName})
|
|
27
|
+
: t('chat.anonymousJoin');
|
|
28
|
+
case 'playerleave':
|
|
29
|
+
return message.playerName
|
|
30
|
+
? t('chat.playerQuit', {playername: message.playerName})
|
|
31
|
+
: t('chat.anonymousQuit');
|
|
32
|
+
}
|
|
33
|
+
})();
|
|
34
|
+
|
|
35
|
+
if (!content) return null;
|
|
36
|
+
|
|
37
|
+
if (message.type !== 'chat') {
|
|
38
|
+
return <p className="chat__event">{content}</p>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<Message align={own ? 'end' : 'start'}>
|
|
43
|
+
{!own && avatar && <MessageAvatar>{avatar}</MessageAvatar>}
|
|
44
|
+
<MessageContent>
|
|
45
|
+
{!own && (
|
|
46
|
+
<MessageHeader className="chat__meta">
|
|
47
|
+
{}
|
|
48
|
+
<span>{message.playerName || channel || '…'}</span>
|
|
49
|
+
{!!message.playerName && channel && <span>{channel}</span>}
|
|
50
|
+
</MessageHeader>
|
|
51
|
+
)}
|
|
52
|
+
<Bubble variant={own ? 'default' : 'muted'} align={own ? 'end' : 'start'}>
|
|
53
|
+
<BubbleContent>{content}</BubbleContent>
|
|
54
|
+
</Bubble>
|
|
55
|
+
</MessageContent>
|
|
56
|
+
</Message>
|
|
57
|
+
);
|
|
58
|
+
}
|
package/README.md
DELETED