Aligning the chat bar with Telegram
This commit is contained in:
@@ -22,6 +22,8 @@ export type ChatMessage = {
|
||||
fileName?: string;
|
||||
uploadProgress?: number;
|
||||
localPreviewUrl?: string;
|
||||
edited?: boolean;
|
||||
deleted?: boolean;
|
||||
};
|
||||
|
||||
export type RoomLiveEvent = {
|
||||
@@ -299,6 +301,13 @@ function normalizeIncoming(
|
||||
const type = inferMediaType(declared, content, fileName);
|
||||
const isMedia = type !== 'text';
|
||||
const mediaUrl = isMedia ? buildChatFileUrl(content, downloadUrl) : undefined;
|
||||
const deleted = Boolean(
|
||||
base.deleted ||
|
||||
base.is_deleted ||
|
||||
base.isDeleted ||
|
||||
/^(?:\[?\s*deleted\s*\]?|این پیام حذف شد|پیام حذف شد)$/i.test(content),
|
||||
);
|
||||
const edited = Boolean(base.edited || base.is_edited || base.isEdited || base.edited_at);
|
||||
|
||||
return {
|
||||
id: id || `tmp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
@@ -306,16 +315,18 @@ function normalizeIncoming(
|
||||
senderId: String(
|
||||
base.sender_id ?? base.user_id ?? base.from_user_id ?? raw.sender_id ?? '',
|
||||
),
|
||||
content,
|
||||
type,
|
||||
content: deleted ? 'این پیام حذف شد' : content,
|
||||
type: deleted ? 'text' : type,
|
||||
createdAt: String(
|
||||
base.created_at ?? base.createdAt ?? raw.created_at ?? new Date().toISOString(),
|
||||
),
|
||||
reply: base.reply ? String(base.reply) : null,
|
||||
readAt: readAt ? String(readAt) : null,
|
||||
delivery: readAt ? 'read' : 'sent',
|
||||
mediaUrl,
|
||||
fileName,
|
||||
mediaUrl: deleted ? undefined : mediaUrl,
|
||||
fileName: deleted ? undefined : fileName,
|
||||
edited: deleted ? false : edited,
|
||||
deleted,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -340,6 +351,8 @@ function mergeMessages(incoming: ChatMessage, list: ChatMessage[]): ChatMessage[
|
||||
localPreviewUrl: incoming.localPreviewUrl ?? prev.localPreviewUrl,
|
||||
mediaUrl: incoming.mediaUrl || prev.mediaUrl,
|
||||
fileName: incoming.fileName || prev.fileName,
|
||||
edited: incoming.edited || prev.edited,
|
||||
deleted: incoming.deleted || prev.deleted,
|
||||
delivery:
|
||||
incoming.delivery === 'read' || prev.delivery === 'read'
|
||||
? 'read'
|
||||
@@ -379,6 +392,8 @@ function mergeMessages(incoming: ChatMessage, list: ChatMessage[]): ChatMessage[
|
||||
uploadProgress: undefined,
|
||||
mediaUrl: incoming.mediaUrl || prev.mediaUrl,
|
||||
fileName: incoming.fileName || prev.fileName,
|
||||
edited: incoming.edited || prev.edited,
|
||||
deleted: incoming.deleted || prev.deleted,
|
||||
delivery: incoming.readAt ? 'read' : 'sent',
|
||||
};
|
||||
if (prev.localPreviewUrl?.startsWith('blob:')) {
|
||||
@@ -496,6 +511,53 @@ function handleIncomingMessage(msg: ChatMessage) {
|
||||
});
|
||||
}
|
||||
|
||||
function applyEditedMessage(payload: Record<string, unknown>) {
|
||||
const base =
|
||||
payload.message && typeof payload.message === 'object'
|
||||
? (payload.message as Record<string, unknown>)
|
||||
: payload;
|
||||
const id = String(base.id ?? base.message_id ?? payload.message_id ?? '').trim();
|
||||
if (!id) return;
|
||||
const content = String(base.content ?? base.text ?? base.body ?? '').trim();
|
||||
if (!content) return;
|
||||
messages.value = messages.value.map((m) =>
|
||||
m.id === id
|
||||
? {
|
||||
...m,
|
||||
content,
|
||||
type: 'text',
|
||||
edited: true,
|
||||
deleted: false,
|
||||
mediaUrl: undefined,
|
||||
fileName: undefined,
|
||||
}
|
||||
: m,
|
||||
);
|
||||
}
|
||||
|
||||
function applyDeletedMessage(payload: Record<string, unknown>) {
|
||||
const base =
|
||||
payload.message && typeof payload.message === 'object'
|
||||
? (payload.message as Record<string, unknown>)
|
||||
: payload;
|
||||
const id = String(base.id ?? base.message_id ?? payload.message_id ?? '').trim();
|
||||
if (!id) return;
|
||||
messages.value = messages.value.map((m) =>
|
||||
m.id === id
|
||||
? {
|
||||
...m,
|
||||
content: 'این پیام حذف شد',
|
||||
type: 'text',
|
||||
deleted: true,
|
||||
edited: false,
|
||||
mediaUrl: undefined,
|
||||
fileName: undefined,
|
||||
localPreviewUrl: undefined,
|
||||
}
|
||||
: m,
|
||||
);
|
||||
}
|
||||
|
||||
function onSocketMessage(event: MessageEvent) {
|
||||
const raw = String(event.data ?? '').trim();
|
||||
if (!raw || raw === 'pong' || raw === 'ping') return;
|
||||
@@ -507,6 +569,28 @@ function onSocketMessage(event: MessageEvent) {
|
||||
applySeenPayload(parsed);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
type === 'edit' ||
|
||||
type === 'edited' ||
|
||||
type === 'update' ||
|
||||
type === 'message_edit' ||
|
||||
type === 'message:edit' ||
|
||||
type === 'message_edited'
|
||||
) {
|
||||
applyEditedMessage(parsed);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
type === 'delete' ||
|
||||
type === 'deleted' ||
|
||||
type === 'remove' ||
|
||||
type === 'message_delete' ||
|
||||
type === 'message:delete' ||
|
||||
type === 'message_deleted'
|
||||
) {
|
||||
applyDeletedMessage(parsed);
|
||||
return;
|
||||
}
|
||||
const msg = normalizeIncoming(parsed, activeRoomId.value);
|
||||
if (!msg) return;
|
||||
handleIncomingMessage(msg);
|
||||
@@ -916,6 +1000,66 @@ export function useOrgChat() {
|
||||
}
|
||||
}
|
||||
|
||||
async function editMessage(roomId: string, messageId: string, text: string) {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || !messageId) return;
|
||||
await connectSocket();
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) throw new Error('ws not open');
|
||||
|
||||
messages.value = messages.value.map((m) =>
|
||||
m.id === messageId
|
||||
? {
|
||||
...m,
|
||||
content: trimmed,
|
||||
type: 'text',
|
||||
edited: true,
|
||||
deleted: false,
|
||||
mediaUrl: undefined,
|
||||
fileName: undefined,
|
||||
}
|
||||
: m,
|
||||
);
|
||||
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: 'edit',
|
||||
id: messageId,
|
||||
room_id: roomId,
|
||||
content: trimmed,
|
||||
}),
|
||||
);
|
||||
|
||||
emitRoomLive({
|
||||
roomId,
|
||||
preview: trimmed,
|
||||
at: new Date().toISOString(),
|
||||
fromSelf: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteMessage(roomId: string, messageId: string) {
|
||||
if (!messageId) return;
|
||||
await connectSocket();
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) throw new Error('ws not open');
|
||||
|
||||
applyDeletedMessage({ id: messageId });
|
||||
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: 'delete',
|
||||
id: messageId,
|
||||
room_id: roomId,
|
||||
}),
|
||||
);
|
||||
|
||||
emitRoomLive({
|
||||
roomId,
|
||||
preview: 'این پیام حذف شد',
|
||||
at: new Date().toISOString(),
|
||||
fromSelf: true,
|
||||
});
|
||||
}
|
||||
|
||||
function clearActiveRoom() {
|
||||
activeRoomId.value = '';
|
||||
messages.value = [];
|
||||
@@ -937,6 +1081,8 @@ export function useOrgChat() {
|
||||
loadMessages,
|
||||
sendText,
|
||||
sendFile,
|
||||
editMessage,
|
||||
deleteMessage,
|
||||
sendRead,
|
||||
ensureConnected,
|
||||
setConversationsPageOpen,
|
||||
|
||||
@@ -46,6 +46,17 @@ const recording = ref(false);
|
||||
const recordSecs = ref(0);
|
||||
const lightboxUrl = ref('');
|
||||
const fileInputEl = ref<HTMLInputElement | null>(null);
|
||||
const draftInputEl = ref<HTMLInputElement | null>(null);
|
||||
const emojiOpen = ref(false);
|
||||
const editingMessageId = ref<string | null>(null);
|
||||
const msgMenu = ref<{ id: string; x: number; y: number } | null>(null);
|
||||
|
||||
const CHAT_EMOJIS = [
|
||||
'😀', '😁', '😂', '🤣', '😊', '😍', '😘', '😎', '🤔', '😅',
|
||||
'😢', '😭', '😡', '👍', '👎', '👏', '🙏', '🔥', '❤️', '💔',
|
||||
'✨', '🎉', '🌸', '🌹', '⭐', '✅', '❌', '🤝', '👋', '💪',
|
||||
'🍀', '☕', '🎂', '🏆', '📌', '💡', '📎', '🕐', '😴', '🤩',
|
||||
];
|
||||
|
||||
const {
|
||||
messages,
|
||||
@@ -54,6 +65,8 @@ const {
|
||||
loadMessages,
|
||||
sendText,
|
||||
sendFile,
|
||||
editMessage,
|
||||
deleteMessage,
|
||||
sendRead,
|
||||
ensureConnected,
|
||||
setConversationsPageOpen,
|
||||
@@ -116,10 +129,107 @@ function closeMobileChat() {
|
||||
clearActiveRoom();
|
||||
activeRoom.value = null;
|
||||
clearPendingAttaches();
|
||||
cancelEdit();
|
||||
emojiOpen.value = false;
|
||||
msgMenu.value = null;
|
||||
if (recording.value) mediaRecorder?.stop();
|
||||
}
|
||||
|
||||
const mobileChatOpen = computed(() => Boolean(activeRoom.value));
|
||||
const canSend = computed(
|
||||
() => Boolean(draft.value.trim() || pendingAttaches.value.length || editingMessageId.value),
|
||||
);
|
||||
const showMicInsteadOfSend = computed(
|
||||
() => !draft.value.trim() && !pendingAttaches.value.length && !editingMessageId.value,
|
||||
);
|
||||
|
||||
function insertEmoji(mark: string) {
|
||||
const el = draftInputEl.value;
|
||||
const cur = draft.value;
|
||||
if (el && typeof el.selectionStart === 'number') {
|
||||
const start = el.selectionStart;
|
||||
const end = el.selectionEnd ?? start;
|
||||
draft.value = cur.slice(0, start) + mark + cur.slice(end);
|
||||
nextTick(() => {
|
||||
el.focus();
|
||||
const pos = start + mark.length;
|
||||
el.setSelectionRange(pos, pos);
|
||||
});
|
||||
} else {
|
||||
draft.value = cur + mark;
|
||||
}
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingMessageId.value = null;
|
||||
}
|
||||
|
||||
function startEditMessage(msg: ChatMessage) {
|
||||
if (msg.deleted || msg.type !== 'text' || msg.optimistic) return;
|
||||
editingMessageId.value = msg.id;
|
||||
draft.value = msg.content;
|
||||
emojiOpen.value = false;
|
||||
msgMenu.value = null;
|
||||
clearPendingAttaches();
|
||||
nextTick(() => draftInputEl.value?.focus());
|
||||
}
|
||||
|
||||
async function confirmDeleteMessage(msg: ChatMessage) {
|
||||
msgMenu.value = null;
|
||||
if (!activeRoom.value || msg.optimistic || msg.deleted) return;
|
||||
if (!window.confirm('این پیام حذف شود؟')) return;
|
||||
try {
|
||||
await deleteMessage(activeRoom.value.externalRoomId, msg.id);
|
||||
if (editingMessageId.value === msg.id) cancelEdit();
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'حذف پیام ناموفق بود.';
|
||||
}
|
||||
}
|
||||
|
||||
function openMsgMenu(msg: ChatMessage, event: MouseEvent | TouchEvent) {
|
||||
const mine = msg.senderId === chatUserId.value || msg.optimistic;
|
||||
if (!mine || msg.deleted) return;
|
||||
event.preventDefault();
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
if ('changedTouches' in event && event.changedTouches[0]) {
|
||||
x = event.changedTouches[0].clientX;
|
||||
y = event.changedTouches[0].clientY;
|
||||
} else if ('touches' in event && event.touches[0]) {
|
||||
x = event.touches[0].clientX;
|
||||
y = event.touches[0].clientY;
|
||||
} else {
|
||||
x = (event as MouseEvent).clientX;
|
||||
y = (event as MouseEvent).clientY;
|
||||
}
|
||||
const pad = 12;
|
||||
x = Math.min(Math.max(x, pad + 80), window.innerWidth - pad - 80);
|
||||
y = Math.min(Math.max(y, pad + 40), window.innerHeight - pad);
|
||||
msgMenu.value = { id: msg.id, x, y };
|
||||
}
|
||||
|
||||
function menuMessage(): ChatMessage | null {
|
||||
if (!msgMenu.value) return null;
|
||||
return messages.value.find((m) => m.id === msgMenu.value!.id) ?? null;
|
||||
}
|
||||
|
||||
let longPressTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
function clearLongPress() {
|
||||
if (longPressTimer) {
|
||||
clearTimeout(longPressTimer);
|
||||
longPressTimer = null;
|
||||
}
|
||||
}
|
||||
function onBubbleTouchStart(msg: ChatMessage, event: TouchEvent) {
|
||||
clearLongPress();
|
||||
longPressTimer = setTimeout(() => {
|
||||
longPressTimer = null;
|
||||
openMsgMenu(msg, event);
|
||||
}, 480);
|
||||
}
|
||||
function onBubbleTouchEnd() {
|
||||
clearLongPress();
|
||||
}
|
||||
|
||||
function classifyPending(file: File): PendingAttach['kind'] {
|
||||
return classifyChatFile(file);
|
||||
@@ -354,6 +464,25 @@ async function submitMessage() {
|
||||
if (sending.value || recording.value) return;
|
||||
const text = draft.value.trim();
|
||||
const attaches = [...pendingAttaches.value];
|
||||
const editingId = editingMessageId.value;
|
||||
|
||||
if (editingId) {
|
||||
if (!text) return;
|
||||
sending.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await editMessage(activeRoom.value.externalRoomId, editingId, text);
|
||||
draft.value = '';
|
||||
editingMessageId.value = null;
|
||||
emojiOpen.value = false;
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'ویرایش پیام ناموفق بود.';
|
||||
} finally {
|
||||
sending.value = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!text && !attaches.length) return;
|
||||
|
||||
sending.value = true;
|
||||
@@ -361,6 +490,7 @@ async function submitMessage() {
|
||||
const roomId = activeRoom.value.externalRoomId;
|
||||
draft.value = '';
|
||||
pendingAttaches.value = [];
|
||||
emojiOpen.value = false;
|
||||
|
||||
try {
|
||||
if (text) {
|
||||
@@ -556,9 +686,23 @@ onUnmounted(() => {
|
||||
:key="msg.id"
|
||||
class="msg-row"
|
||||
:class="{ mine: msg.senderId === chatUserId || msg.optimistic }"
|
||||
@contextmenu="openMsgMenu(msg, $event)"
|
||||
>
|
||||
<div class="bubble" :class="{ media: msg.type !== 'text' }">
|
||||
<template v-if="msg.type === 'image'">
|
||||
<div
|
||||
class="bubble"
|
||||
:class="{
|
||||
media: msg.type !== 'text' && !msg.deleted,
|
||||
deleted: msg.deleted,
|
||||
}"
|
||||
@touchstart.passive="onBubbleTouchStart(msg, $event)"
|
||||
@touchend="onBubbleTouchEnd"
|
||||
@touchmove="onBubbleTouchEnd"
|
||||
@touchcancel="onBubbleTouchEnd"
|
||||
>
|
||||
<template v-if="msg.deleted">
|
||||
<p class="deleted-text">این پیام حذف شد</p>
|
||||
</template>
|
||||
<template v-else-if="msg.type === 'image'">
|
||||
<button
|
||||
type="button"
|
||||
class="media-image"
|
||||
@@ -617,6 +761,7 @@ onUnmounted(() => {
|
||||
</template>
|
||||
<p v-else>{{ msg.content }}</p>
|
||||
<div class="bubble-meta">
|
||||
<span v-if="msg.edited && !msg.deleted" class="edited-mark">ویرایششده</span>
|
||||
<time>{{ formatListTime(msg.createdAt) }}</time>
|
||||
<span
|
||||
v-if="msg.senderId === chatUserId || msg.optimistic"
|
||||
@@ -636,6 +781,22 @@ onUnmounted(() => {
|
||||
</ul>
|
||||
|
||||
<form class="composer" @submit.prevent="submitMessage">
|
||||
<div v-if="emojiOpen" class="emoji-panel">
|
||||
<button
|
||||
v-for="mark in CHAT_EMOJIS"
|
||||
:key="mark"
|
||||
type="button"
|
||||
class="emoji-btn"
|
||||
@click="insertEmoji(mark)"
|
||||
>
|
||||
{{ mark }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="editingMessageId" class="edit-banner">
|
||||
<UiIcon name="edit" :size="16" />
|
||||
<span>ویرایش پیام</span>
|
||||
<button type="button" class="edit-cancel" @click="cancelEdit(); draft = ''">انصراف</button>
|
||||
</div>
|
||||
<div v-if="pendingAttaches.length" class="attach-preview">
|
||||
<div v-for="item in pendingAttaches" :key="item.id" class="attach-chip" :class="item.kind">
|
||||
<img v-if="item.kind === 'image'" :src="item.previewUrl" :alt="item.name" />
|
||||
@@ -665,40 +826,86 @@ onUnmounted(() => {
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="tool-btn"
|
||||
title="پیوست فایل / عکس / فیلم"
|
||||
:disabled="sending || recording"
|
||||
class="tool-btn attach-btn"
|
||||
title="پیوست"
|
||||
:disabled="sending || recording || Boolean(editingMessageId)"
|
||||
@click="fileInputEl?.click()"
|
||||
>
|
||||
<UiIcon name="document" :size="18" />
|
||||
<UiIcon name="plus" :size="20" />
|
||||
</button>
|
||||
<div class="input-shell">
|
||||
<button
|
||||
type="button"
|
||||
class="emoji-toggle"
|
||||
title="ایموجی"
|
||||
:class="{ on: emojiOpen }"
|
||||
:disabled="sending || recording"
|
||||
@click="emojiOpen = !emojiOpen"
|
||||
>
|
||||
😊
|
||||
</button>
|
||||
<input
|
||||
ref="draftInputEl"
|
||||
v-model="draft"
|
||||
type="text"
|
||||
class="composer-input"
|
||||
:placeholder="editingMessageId ? 'ویرایش پیام…' : 'پیام بنویسید…'"
|
||||
autocomplete="off"
|
||||
enterkeyhint="send"
|
||||
:disabled="sending || recording"
|
||||
@focus="emojiOpen = false"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
v-if="showMicInsteadOfSend"
|
||||
type="button"
|
||||
class="tool-btn"
|
||||
class="tool-btn mic-btn"
|
||||
:class="{ on: recording }"
|
||||
title="ضبط پیام صوتی"
|
||||
:disabled="sending"
|
||||
@click="toggleVoiceRecord"
|
||||
>
|
||||
<UiIcon :name="recording ? 'stop' : 'mic'" :size="18" />
|
||||
<UiIcon :name="recording ? 'stop' : 'mic'" :size="20" />
|
||||
</button>
|
||||
<input
|
||||
v-model="draft"
|
||||
type="text"
|
||||
class="composer-input"
|
||||
placeholder="پیام…"
|
||||
autocomplete="off"
|
||||
:disabled="sending || recording"
|
||||
/>
|
||||
<button
|
||||
v-else
|
||||
type="submit"
|
||||
class="send-btn"
|
||||
:disabled="sending || recording || (!draft.trim() && !pendingAttaches.length)"
|
||||
:disabled="sending || recording || !canSend"
|
||||
>
|
||||
<UiIcon name="send" :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div
|
||||
v-if="msgMenu"
|
||||
class="msg-menu-backdrop"
|
||||
@click="msgMenu = null"
|
||||
@touchstart.passive="msgMenu = null"
|
||||
/>
|
||||
<div
|
||||
v-if="msgMenu && menuMessage()"
|
||||
class="msg-menu"
|
||||
:style="{ top: `${msgMenu.y}px`, left: `${msgMenu.x}px` }"
|
||||
>
|
||||
<button
|
||||
v-if="menuMessage()?.type === 'text' && !menuMessage()?.optimistic"
|
||||
type="button"
|
||||
@click="startEditMessage(menuMessage()!)"
|
||||
>
|
||||
<UiIcon name="edit" :size="16" />
|
||||
ویرایش
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="danger"
|
||||
@click="confirmDeleteMessage(menuMessage()!)"
|
||||
>
|
||||
<UiIcon name="trash" :size="16" />
|
||||
حذف
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</div>
|
||||
@@ -1118,42 +1325,121 @@ onUnmounted(() => {
|
||||
.composer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
padding: 0.65rem 0.75rem calc(0.65rem + env(safe-area-inset-bottom, 0px));
|
||||
gap: 0.4rem;
|
||||
padding: 0.55rem 0.65rem calc(0.55rem + env(safe-area-inset-bottom, 0px));
|
||||
border-top: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
background: #f0f2f5;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.composer-row {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
align-items: flex-end;
|
||||
min-width: 0;
|
||||
}
|
||||
.input-shell {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 22px;
|
||||
padding: 0.15rem 0.35rem 0.15rem 0.55rem;
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
.composer-input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 0.65rem 0.85rem;
|
||||
border: none;
|
||||
outline: none;
|
||||
border-radius: 18px;
|
||||
padding: 0.55rem 0.35rem;
|
||||
font: inherit;
|
||||
font-size: 0.95rem;
|
||||
background: transparent;
|
||||
}
|
||||
.emoji-toggle {
|
||||
flex: 0 0 auto;
|
||||
border: none;
|
||||
background: transparent;
|
||||
width: 2.1rem;
|
||||
height: 2.1rem;
|
||||
border-radius: 999px;
|
||||
font-size: 1.15rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.emoji-toggle.on {
|
||||
background: rgba(13, 148, 136, 0.12);
|
||||
}
|
||||
.emoji-panel {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, minmax(0, 1fr));
|
||||
gap: 0.2rem;
|
||||
max-height: 9.5rem;
|
||||
overflow-y: auto;
|
||||
padding: 0.45rem;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
.emoji-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 10px;
|
||||
font-size: 1.35rem;
|
||||
line-height: 1.4;
|
||||
cursor: pointer;
|
||||
padding: 0.2rem;
|
||||
}
|
||||
.emoji-btn:hover,
|
||||
.emoji-btn:active {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
.edit-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.45rem 0.65rem;
|
||||
border-radius: 12px;
|
||||
background: rgba(13, 148, 136, 0.1);
|
||||
color: #0f766e;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.edit-cancel {
|
||||
margin-inline-start: auto;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #0f766e;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
.tool-btn,
|
||||
.send-btn {
|
||||
flex: 0 0 auto;
|
||||
border-radius: 12px;
|
||||
width: 2.6rem;
|
||||
height: 2.6rem;
|
||||
min-width: 2.6rem;
|
||||
border-radius: 999px;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
min-width: 2.75rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tool-btn {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface, #fff);
|
||||
color: var(--text);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
}
|
||||
.tool-btn.attach-btn,
|
||||
.tool-btn.mic-btn {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
color: #334155;
|
||||
}
|
||||
.tool-btn.on {
|
||||
border-color: #ef4444;
|
||||
@@ -1169,6 +1455,60 @@ onUnmounted(() => {
|
||||
border: none;
|
||||
background: #0d9488;
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 12px rgba(13, 148, 136, 0.28);
|
||||
}
|
||||
.bubble.deleted {
|
||||
opacity: 0.72;
|
||||
font-style: italic;
|
||||
}
|
||||
.deleted-text {
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.edited-mark {
|
||||
font-size: 0.68rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.msg-menu-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
background: transparent;
|
||||
}
|
||||
.msg-menu {
|
||||
position: fixed;
|
||||
z-index: 50;
|
||||
transform: translate(-50%, -110%);
|
||||
min-width: 10rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
padding: 0.35rem;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.16);
|
||||
}
|
||||
.msg-menu button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0.55rem 0.7rem;
|
||||
border-radius: 10px;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
color: #0f172a;
|
||||
text-align: right;
|
||||
}
|
||||
.msg-menu button:hover,
|
||||
.msg-menu button:active {
|
||||
background: #f8fafc;
|
||||
}
|
||||
.msg-menu button.danger {
|
||||
color: #dc2626;
|
||||
}
|
||||
.attach-preview {
|
||||
display: flex;
|
||||
@@ -1484,19 +1824,27 @@ onUnmounted(() => {
|
||||
max-width: min(300px, 82%);
|
||||
}
|
||||
.composer {
|
||||
padding: 0.5rem 0.55rem calc(0.45rem + env(safe-area-inset-bottom, 0px));
|
||||
box-shadow: 0 -6px 18px rgba(15, 23, 42, 0.04);
|
||||
padding: 0.4rem 0.45rem calc(0.4rem + env(safe-area-inset-bottom, 0px));
|
||||
background: #f0f2f5;
|
||||
box-shadow: 0 -8px 24px rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
.tool-btn,
|
||||
.send-btn {
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
min-width: 2.75rem;
|
||||
width: 2.85rem;
|
||||
height: 2.85rem;
|
||||
min-width: 2.85rem;
|
||||
}
|
||||
.input-shell {
|
||||
min-height: 2.85rem;
|
||||
}
|
||||
.composer-input {
|
||||
padding: 0.7rem 0.8rem;
|
||||
padding: 0.6rem 0.3rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.emoji-panel {
|
||||
grid-template-columns: repeat(8, minmax(0, 1fr));
|
||||
max-height: 11rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user