Add, edit, delete, and resend messages.
This commit is contained in:
@@ -37,6 +37,11 @@ const PAGE_SIZE = 50;
|
||||
const IMAGE_MAX_EDGE = 1600;
|
||||
const IMAGE_WEBP_QUALITY = 0.82;
|
||||
const MAX_UPLOAD_BYTES = 40 * 1024 * 1024;
|
||||
const DELETED_CONTENT = 'این پیام حذف شد';
|
||||
|
||||
function isDeletedContent(content: string): boolean {
|
||||
return /^(?:\[?\s*deleted\s*\]?|این پیام حذف شد|پیام حذف شد)$/i.test(content.trim());
|
||||
}
|
||||
|
||||
let socket: WebSocket | null = null;
|
||||
let sessionCache: ConversationSessionDto | null = null;
|
||||
@@ -305,7 +310,7 @@ function normalizeIncoming(
|
||||
base.deleted ||
|
||||
base.is_deleted ||
|
||||
base.isDeleted ||
|
||||
/^(?:\[?\s*deleted\s*\]?|این پیام حذف شد|پیام حذف شد)$/i.test(content),
|
||||
isDeletedContent(content),
|
||||
);
|
||||
const edited = Boolean(base.edited || base.is_edited || base.isEdited || base.edited_at);
|
||||
|
||||
@@ -315,7 +320,7 @@ function normalizeIncoming(
|
||||
senderId: String(
|
||||
base.sender_id ?? base.user_id ?? base.from_user_id ?? raw.sender_id ?? '',
|
||||
),
|
||||
content: deleted ? 'این پیام حذف شد' : content,
|
||||
content: deleted ? DELETED_CONTENT : content,
|
||||
type: deleted ? 'text' : type,
|
||||
createdAt: String(
|
||||
base.created_at ?? base.createdAt ?? raw.created_at ?? new Date().toISOString(),
|
||||
@@ -339,20 +344,33 @@ function mergeMessages(incoming: ChatMessage, list: ChatMessage[]): ChatMessage[
|
||||
...prev,
|
||||
...incoming,
|
||||
optimistic: false,
|
||||
type: inferMediaType(
|
||||
prev.type === 'voice' || incoming.type === 'voice'
|
||||
? 'voice'
|
||||
: prev.type === 'video' || incoming.type === 'video'
|
||||
? 'video'
|
||||
: incoming.type,
|
||||
incoming.content || prev.content,
|
||||
incoming.fileName || prev.fileName,
|
||||
),
|
||||
type:
|
||||
prev.deleted || incoming.deleted
|
||||
? 'text'
|
||||
: inferMediaType(
|
||||
prev.type === 'voice' || incoming.type === 'voice'
|
||||
? 'voice'
|
||||
: prev.type === 'video' || incoming.type === 'video'
|
||||
? 'video'
|
||||
: incoming.type,
|
||||
incoming.content || prev.content,
|
||||
incoming.fileName || prev.fileName,
|
||||
),
|
||||
content:
|
||||
prev.deleted || incoming.deleted
|
||||
? DELETED_CONTENT
|
||||
: incoming.content || prev.content,
|
||||
localPreviewUrl: incoming.localPreviewUrl ?? prev.localPreviewUrl,
|
||||
mediaUrl: incoming.mediaUrl || prev.mediaUrl,
|
||||
fileName: incoming.fileName || prev.fileName,
|
||||
mediaUrl:
|
||||
prev.deleted || incoming.deleted
|
||||
? undefined
|
||||
: incoming.mediaUrl || prev.mediaUrl,
|
||||
fileName:
|
||||
prev.deleted || incoming.deleted
|
||||
? undefined
|
||||
: incoming.fileName || prev.fileName,
|
||||
edited: incoming.edited || prev.edited,
|
||||
deleted: incoming.deleted || prev.deleted,
|
||||
deleted: prev.deleted || incoming.deleted,
|
||||
delivery:
|
||||
incoming.delivery === 'read' || prev.delivery === 'read'
|
||||
? 'read'
|
||||
@@ -520,6 +538,11 @@ function applyEditedMessage(payload: Record<string, unknown>) {
|
||||
if (!id) return;
|
||||
const content = String(base.content ?? base.text ?? base.body ?? '').trim();
|
||||
if (!content) return;
|
||||
// Delete-for-everyone is synced via edit tombstone (native delete is not broadcast by chat WS)
|
||||
if (isDeletedContent(content)) {
|
||||
applyDeletedMessage({ id });
|
||||
return;
|
||||
}
|
||||
messages.value = messages.value.map((m) =>
|
||||
m.id === id
|
||||
? {
|
||||
@@ -546,7 +569,7 @@ function applyDeletedMessage(payload: Record<string, unknown>) {
|
||||
m.id === id
|
||||
? {
|
||||
...m,
|
||||
content: 'این پیام حذف شد',
|
||||
content: DELETED_CONTENT,
|
||||
type: 'text',
|
||||
deleted: true,
|
||||
edited: false,
|
||||
@@ -1044,22 +1067,59 @@ export function useOrgChat() {
|
||||
|
||||
applyDeletedMessage({ id: messageId });
|
||||
|
||||
// Chat WS does not reliably broadcast type:delete — sync via edit (proven channel).
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: 'edit',
|
||||
id: messageId,
|
||||
room_id: roomId,
|
||||
content: DELETED_CONTENT,
|
||||
}),
|
||||
);
|
||||
// Best-effort native delete for backends that support it
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: 'delete',
|
||||
id: messageId,
|
||||
message_id: messageId,
|
||||
room_id: roomId,
|
||||
}),
|
||||
);
|
||||
|
||||
emitRoomLive({
|
||||
roomId,
|
||||
preview: 'این پیام حذف شد',
|
||||
preview: DELETED_CONTENT,
|
||||
at: new Date().toISOString(),
|
||||
fromSelf: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteMessages(roomId: string, messageIds: string[]) {
|
||||
const ids = [...new Set(messageIds.filter(Boolean))];
|
||||
for (const id of ids) {
|
||||
await deleteMessage(roomId, id);
|
||||
}
|
||||
}
|
||||
|
||||
async function forwardMessages(targetRoomId: string, sourceMessages: ChatMessage[]) {
|
||||
const roomId = targetRoomId.trim();
|
||||
if (!roomId) return;
|
||||
await connectSocket();
|
||||
for (const msg of sourceMessages) {
|
||||
if (!msg || msg.deleted || msg.optimistic) continue;
|
||||
if (msg.type === 'text') {
|
||||
const text = msg.content.trim();
|
||||
if (text && !isDeletedContent(text)) await sendText(roomId, text);
|
||||
continue;
|
||||
}
|
||||
const path = String(msg.content || '').trim();
|
||||
if (!path) continue;
|
||||
await sendMediaPath(roomId, path, msg.type, {
|
||||
fileName: msg.fileName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function clearActiveRoom() {
|
||||
activeRoomId.value = '';
|
||||
messages.value = [];
|
||||
@@ -1083,6 +1143,8 @@ export function useOrgChat() {
|
||||
sendFile,
|
||||
editMessage,
|
||||
deleteMessage,
|
||||
deleteMessages,
|
||||
forwardMessages,
|
||||
sendRead,
|
||||
ensureConnected,
|
||||
setConversationsPageOpen,
|
||||
|
||||
@@ -67,6 +67,8 @@ const {
|
||||
sendFile,
|
||||
editMessage,
|
||||
deleteMessage,
|
||||
deleteMessages,
|
||||
forwardMessages,
|
||||
sendRead,
|
||||
ensureConnected,
|
||||
setConversationsPageOpen,
|
||||
@@ -80,6 +82,11 @@ let recordChunks: Blob[] = [];
|
||||
let recordTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let recordStream: MediaStream | null = null;
|
||||
|
||||
const selectMode = ref(false);
|
||||
const selectedIds = ref<string[]>([]);
|
||||
const showForward = ref(false);
|
||||
const forwardBusy = ref(false);
|
||||
|
||||
const canCreateGroup = computed(() => access.value?.canCreateGroup ?? false);
|
||||
|
||||
async function scrollToBottom() {
|
||||
@@ -130,8 +137,10 @@ function closeMobileChat() {
|
||||
activeRoom.value = null;
|
||||
clearPendingAttaches();
|
||||
cancelEdit();
|
||||
exitSelectMode();
|
||||
emojiOpen.value = false;
|
||||
msgMenu.value = null;
|
||||
showForward.value = false;
|
||||
if (recording.value) mediaRecorder?.stop();
|
||||
}
|
||||
|
||||
@@ -142,6 +151,86 @@ const canSend = computed(
|
||||
const showMicInsteadOfSend = computed(
|
||||
() => !draft.value.trim() && !pendingAttaches.value.length && !editingMessageId.value,
|
||||
);
|
||||
const selectedCount = computed(() => selectedIds.value.length);
|
||||
const forwardTargets = computed(() =>
|
||||
sortedRooms.value.filter((r) => r.externalRoomId !== activeRoom.value?.externalRoomId),
|
||||
);
|
||||
|
||||
function isSelected(id: string) {
|
||||
return selectedIds.value.includes(id);
|
||||
}
|
||||
|
||||
function exitSelectMode() {
|
||||
selectMode.value = false;
|
||||
selectedIds.value = [];
|
||||
}
|
||||
|
||||
function enterSelectMode(msg?: ChatMessage) {
|
||||
selectMode.value = true;
|
||||
msgMenu.value = null;
|
||||
emojiOpen.value = false;
|
||||
cancelEdit();
|
||||
if (msg && !msg.deleted) selectedIds.value = [msg.id];
|
||||
}
|
||||
|
||||
function toggleSelect(msg: ChatMessage) {
|
||||
if (msg.deleted || msg.optimistic) return;
|
||||
if (!selectMode.value) {
|
||||
enterSelectMode(msg);
|
||||
return;
|
||||
}
|
||||
if (isSelected(msg.id)) {
|
||||
selectedIds.value = selectedIds.value.filter((id) => id !== msg.id);
|
||||
if (!selectedIds.value.length) exitSelectMode();
|
||||
} else {
|
||||
selectedIds.value = [...selectedIds.value, msg.id];
|
||||
}
|
||||
}
|
||||
|
||||
function selectedMessages(): ChatMessage[] {
|
||||
const set = new Set(selectedIds.value);
|
||||
return messages.value.filter((m) => set.has(m.id) && !m.deleted);
|
||||
}
|
||||
|
||||
async function deleteSelected() {
|
||||
if (!activeRoom.value || !selectedIds.value.length) return;
|
||||
const n = selectedIds.value.length;
|
||||
if (!window.confirm(n > 1 ? `${n} پیام حذف شوند؟` : 'این پیام حذف شود؟')) return;
|
||||
const ids = [...selectedIds.value];
|
||||
try {
|
||||
await deleteMessages(activeRoom.value.externalRoomId, ids);
|
||||
exitSelectMode();
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'حذف پیام ناموفق بود.';
|
||||
}
|
||||
}
|
||||
|
||||
function openForwardPicker(msgs?: ChatMessage[]) {
|
||||
msgMenu.value = null;
|
||||
if (msgs?.length) {
|
||||
selectedIds.value = msgs.map((m) => m.id);
|
||||
selectMode.value = true;
|
||||
}
|
||||
if (!selectedIds.value.length) return;
|
||||
showForward.value = true;
|
||||
}
|
||||
|
||||
async function confirmForward(targetRoomId: string) {
|
||||
if (!targetRoomId || forwardBusy.value) return;
|
||||
const list = selectedMessages();
|
||||
if (!list.length) return;
|
||||
forwardBusy.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await forwardMessages(targetRoomId, list);
|
||||
showForward.value = false;
|
||||
exitSelectMode();
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'ارسال برای دیگری ناموفق بود.';
|
||||
} finally {
|
||||
forwardBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function insertEmoji(mark: string) {
|
||||
const el = draftInputEl.value;
|
||||
@@ -177,7 +266,7 @@ function startEditMessage(msg: ChatMessage) {
|
||||
async function confirmDeleteMessage(msg: ChatMessage) {
|
||||
msgMenu.value = null;
|
||||
if (!activeRoom.value || msg.optimistic || msg.deleted) return;
|
||||
if (!window.confirm('این پیام حذف شود؟')) return;
|
||||
if (!window.confirm('این پیام برای همه حذف شود؟')) return;
|
||||
try {
|
||||
await deleteMessage(activeRoom.value.externalRoomId, msg.id);
|
||||
if (editingMessageId.value === msg.id) cancelEdit();
|
||||
@@ -187,8 +276,11 @@ async function confirmDeleteMessage(msg: ChatMessage) {
|
||||
}
|
||||
|
||||
function openMsgMenu(msg: ChatMessage, event: MouseEvent | TouchEvent) {
|
||||
const mine = msg.senderId === chatUserId.value || msg.optimistic;
|
||||
if (!mine || msg.deleted) return;
|
||||
if (msg.deleted || msg.optimistic) return;
|
||||
if (selectMode.value) {
|
||||
toggleSelect(msg);
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
@@ -213,6 +305,11 @@ function menuMessage(): ChatMessage | null {
|
||||
return messages.value.find((m) => m.id === msgMenu.value!.id) ?? null;
|
||||
}
|
||||
|
||||
function isMineMessage(msg: ChatMessage | null | undefined) {
|
||||
if (!msg) return false;
|
||||
return msg.senderId === chatUserId.value || Boolean(msg.optimistic);
|
||||
}
|
||||
|
||||
let longPressTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
function clearLongPress() {
|
||||
if (longPressTimer) {
|
||||
@@ -563,6 +660,11 @@ function toggleGroupMember(id: string) {
|
||||
watch(activeRoom, () => {
|
||||
draft.value = '';
|
||||
clearPendingAttaches();
|
||||
exitSelectMode();
|
||||
cancelEdit();
|
||||
emojiOpen.value = false;
|
||||
msgMenu.value = null;
|
||||
showForward.value = false;
|
||||
if (recording.value) {
|
||||
mediaRecorder?.stop();
|
||||
}
|
||||
@@ -667,27 +769,71 @@ onUnmounted(() => {
|
||||
|
||||
<template v-else>
|
||||
<div class="stage-top">
|
||||
<button type="button" class="back-btn" aria-label="بازگشت" @click="closeMobileChat">
|
||||
<UiIcon name="arrow-right" :size="20" />
|
||||
</button>
|
||||
<span class="stage-avatar">
|
||||
<UserAvatar :name="roomTitle(activeRoom)" :size="36" />
|
||||
</span>
|
||||
<div class="stage-titles">
|
||||
<h2>{{ roomTitle(activeRoom) }}</h2>
|
||||
<span class="kind-tag">{{ activeRoom.kind === 'group' ? 'گروه' : 'خصوصی' }}</span>
|
||||
</div>
|
||||
<template v-if="selectMode">
|
||||
<button type="button" class="back-btn show" aria-label="انصراف" @click="exitSelectMode">
|
||||
<UiIcon name="x" :size="20" />
|
||||
</button>
|
||||
<div class="stage-titles">
|
||||
<h2>{{ toFaDigits(selectedCount) }} انتخابشده</h2>
|
||||
</div>
|
||||
<div class="select-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="select-act"
|
||||
title="ارسال برای دیگری"
|
||||
:disabled="!selectedCount"
|
||||
@click="openForwardPicker()"
|
||||
>
|
||||
<UiIcon name="send" :size="18" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="select-act danger"
|
||||
title="حذف"
|
||||
:disabled="!selectedCount"
|
||||
@click="deleteSelected"
|
||||
>
|
||||
<UiIcon name="trash" :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button type="button" class="back-btn" aria-label="بازگشت" @click="closeMobileChat">
|
||||
<UiIcon name="arrow-right" :size="20" />
|
||||
</button>
|
||||
<span class="stage-avatar">
|
||||
<UserAvatar :name="roomTitle(activeRoom)" :size="36" />
|
||||
</span>
|
||||
<div class="stage-titles">
|
||||
<h2>{{ roomTitle(activeRoom) }}</h2>
|
||||
<span class="kind-tag">{{ activeRoom.kind === 'group' ? 'گروه' : 'خصوصی' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingMessages" class="muted center pad">در حال دریافت پیامها…</div>
|
||||
<ul v-else ref="msgStreamEl" class="msg-stream">
|
||||
<ul v-else ref="msgStreamEl" class="msg-stream" :class="{ selecting: selectMode }">
|
||||
<li
|
||||
v-for="msg in messages"
|
||||
:key="msg.id"
|
||||
class="msg-row"
|
||||
:class="{ mine: msg.senderId === chatUserId || msg.optimistic }"
|
||||
:class="{
|
||||
mine: msg.senderId === chatUserId || msg.optimistic,
|
||||
selected: isSelected(msg.id),
|
||||
}"
|
||||
@contextmenu="openMsgMenu(msg, $event)"
|
||||
@click="selectMode ? toggleSelect(msg) : undefined"
|
||||
>
|
||||
<button
|
||||
v-if="selectMode"
|
||||
type="button"
|
||||
class="select-check"
|
||||
:class="{ on: isSelected(msg.id) }"
|
||||
:aria-label="isSelected(msg.id) ? 'برداشتن انتخاب' : 'انتخاب'"
|
||||
@click.stop="toggleSelect(msg)"
|
||||
>
|
||||
<UiIcon v-if="isSelected(msg.id)" name="check-plain" :size="14" />
|
||||
</button>
|
||||
<div
|
||||
class="bubble"
|
||||
:class="{
|
||||
@@ -890,7 +1036,21 @@ onUnmounted(() => {
|
||||
:style="{ top: `${msgMenu.y}px`, left: `${msgMenu.x}px` }"
|
||||
>
|
||||
<button
|
||||
v-if="menuMessage()?.type === 'text' && !menuMessage()?.optimistic"
|
||||
type="button"
|
||||
@click="enterSelectMode(menuMessage()!)"
|
||||
>
|
||||
<UiIcon name="check-plain" :size="16" />
|
||||
انتخاب
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="openForwardPicker([menuMessage()!])"
|
||||
>
|
||||
<UiIcon name="send" :size="16" />
|
||||
ارسال برای دیگری
|
||||
</button>
|
||||
<button
|
||||
v-if="isMineMessage(menuMessage()) && menuMessage()?.type === 'text'"
|
||||
type="button"
|
||||
@click="startEditMessage(menuMessage()!)"
|
||||
>
|
||||
@@ -898,12 +1058,13 @@ onUnmounted(() => {
|
||||
ویرایش
|
||||
</button>
|
||||
<button
|
||||
v-if="isMineMessage(menuMessage())"
|
||||
type="button"
|
||||
class="danger"
|
||||
@click="confirmDeleteMessage(menuMessage()!)"
|
||||
>
|
||||
<UiIcon name="trash" :size="16" />
|
||||
حذف
|
||||
حذف برای همه
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -946,6 +1107,30 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog v-if="showForward" class="modal" open @click.self="showForward = false">
|
||||
<div class="modal-card">
|
||||
<h3>ارسال برای دیگری</h3>
|
||||
<p class="hint">{{ toFaDigits(selectedCount) }} پیام انتخاب شده</p>
|
||||
<div v-if="!forwardTargets.length" class="muted">گفتگوی دیگری برای ارسال نیست.</div>
|
||||
<div v-else class="forward-list">
|
||||
<button
|
||||
v-for="room in forwardTargets"
|
||||
:key="room.externalRoomId"
|
||||
type="button"
|
||||
class="forward-item"
|
||||
:disabled="forwardBusy"
|
||||
@click="confirmForward(room.externalRoomId)"
|
||||
>
|
||||
<UserAvatar :name="roomTitle(room)" :size="36" />
|
||||
<span>{{ roomTitle(room) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" :disabled="forwardBusy" @click="showForward = false">انصراف</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog v-if="lightboxUrl" class="lightbox" open @click.self="lightboxUrl = ''">
|
||||
<button type="button" class="lightbox-close" aria-label="بستن" @click="lightboxUrl = ''">
|
||||
<UiIcon name="x" :size="18" />
|
||||
@@ -1163,6 +1348,9 @@ onUnmounted(() => {
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.back-btn.show {
|
||||
display: grid;
|
||||
}
|
||||
.stage-avatar {
|
||||
flex-shrink: 0;
|
||||
border-radius: 999px;
|
||||
@@ -1182,6 +1370,59 @@ onUnmounted(() => {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.select-actions {
|
||||
margin-inline-start: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.select-act {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #0f766e;
|
||||
width: 2.35rem;
|
||||
height: 2.35rem;
|
||||
border-radius: 999px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.select-act.danger {
|
||||
color: #dc2626;
|
||||
}
|
||||
.select-act:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.forward-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
.forward-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 0.55rem 0.7rem;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
text-align: right;
|
||||
}
|
||||
.forward-item:hover,
|
||||
.forward-item:active {
|
||||
background: rgba(13, 148, 136, 0.06);
|
||||
}
|
||||
.forward-item:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.kind-tag {
|
||||
font-size: 0.72rem;
|
||||
padding: 0.1rem 0.45rem;
|
||||
@@ -1207,10 +1448,36 @@ onUnmounted(() => {
|
||||
.msg-row {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-end;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.msg-row.mine {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.msg-row.selected .bubble {
|
||||
outline: 2px solid rgba(13, 148, 136, 0.45);
|
||||
}
|
||||
.msg-stream.selecting .msg-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
.select-check {
|
||||
flex: 0 0 auto;
|
||||
width: 1.35rem;
|
||||
height: 1.35rem;
|
||||
border-radius: 999px;
|
||||
border: 2px solid #94a3b8;
|
||||
background: #fff;
|
||||
color: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.select-check.on {
|
||||
border-color: #0d9488;
|
||||
background: #0d9488;
|
||||
}
|
||||
.bubble {
|
||||
max-width: min(420px, 85%);
|
||||
padding: 0.65rem 0.85rem;
|
||||
|
||||
Reference in New Issue
Block a user