Add channel to conversation

This commit is contained in:
2026-09-30 11:08:33 +03:30
parent f71e78f3a7
commit 4c8731122b
6 changed files with 291 additions and 35 deletions
+135 -9
View File
@@ -40,8 +40,11 @@ const activeRoom = ref<ConversationRoomDto | null>(null);
const draft = ref('');
const showNewDm = ref(false);
const showNewGroup = ref(false);
const showNewChannel = ref(false);
const groupName = ref('');
const groupMemberIds = ref<string[]>([]);
const channelName = ref('');
const channelMemberIds = ref<string[]>([]);
const dmPeerId = ref('');
const chatUserId = ref('');
const pendingAttaches = ref<PendingAttach[]>([]);
@@ -96,7 +99,7 @@ const selectedIds = ref<string[]>([]);
const showForward = ref(false);
const forwardBusy = ref(false);
const roomListQuery = ref('');
const roomListTab = ref<'all' | 'dm' | 'group'>('all');
const roomListTab = ref<'all' | 'dm' | 'group' | 'channel'>('all');
const pinnedMessage = ref<ConversationPinnedMessageDto | null>(null);
const chatSearchOpen = ref(false);
const chatSearchQuery = ref('');
@@ -105,6 +108,15 @@ const mentionState = ref<{ start: number; end: number; query: string } | null>(n
const mentionIndex = ref(0);
const canCreateGroup = computed(() => access.value?.canCreateGroup ?? false);
const canCreateChannel = computed(
() => access.value?.canCreateChannel ?? access.value?.canCreateGroup ?? false,
);
const canPostInActive = computed(() => {
const room = activeRoom.value;
if (!room) return false;
if (room.kind !== 'channel') return true;
return room.canPost !== false;
});
async function scrollToBottom() {
await nextTick();
@@ -129,6 +141,7 @@ const filteredRooms = computed(() => {
return sortedRooms.value.filter((room) => {
if (roomListTab.value === 'dm' && room.kind !== 'dm') return false;
if (roomListTab.value === 'group' && room.kind !== 'group') return false;
if (roomListTab.value === 'channel' && room.kind !== 'channel') return false;
if (!q) return true;
const title = roomTitle(room).toLowerCase();
const preview = (room.lastMessagePreview || '').toLowerCase();
@@ -164,11 +177,17 @@ const activeSearchHitId = computed(() => {
});
function roomTitle(room: ConversationRoomDto) {
if (room.kind === 'group') return room.name;
if (room.kind === 'group' || room.kind === 'channel') return room.name;
const peer = peers.value.find((p) => p.id === room.peerUserId);
return peer?.fullName ?? room.name;
}
function roomKindLabel(kind: ConversationRoomDto['kind']) {
if (kind === 'channel') return 'کانال';
if (kind === 'group') return 'گروه';
return 'خصوصی';
}
type TextSeg = { text: string; mention?: boolean; hit?: boolean };
function messageTextSegments(content: string, highlightQuery = ''): TextSeg[] {
@@ -941,6 +960,10 @@ async function openRoom(room: ConversationRoomDto) {
async function submitMessage() {
if (!activeRoom.value) return;
if (!canPostInActive.value) {
error.value = 'فقط مدیران کانال می‌توانند پیام بفرستند.';
return;
}
if (sending.value || recording.value) return;
const text = draft.value.trim();
const attaches = [...pendingAttaches.value];
@@ -1034,6 +1057,27 @@ async function startGroup() {
}
}
async function startChannel() {
if (!channelName.value.trim() || !channelMemberIds.value.length) return;
try {
const { data } = await api.post<ConversationRoomDto>('/conversations/rooms/channel', {
name: channelName.value.trim(),
memberUserIds: channelMemberIds.value,
});
rooms.value = [{ ...data, canPost: true }, ...rooms.value];
showNewChannel.value = false;
channelName.value = '';
channelMemberIds.value = [];
await openRoom({ ...data, canPost: true });
} catch (e) {
if (axios.isAxiosError(e) && e.response?.status === 403) {
error.value = 'اجازه ساخت کانال را ندارید.';
} else {
error.value = 'ساخت کانال ناموفق بود.';
}
}
}
function toggleGroupMember(id: string) {
if (groupMemberIds.value.includes(id)) {
groupMemberIds.value = groupMemberIds.value.filter((x) => x !== id);
@@ -1042,6 +1086,14 @@ function toggleGroupMember(id: string) {
}
}
function toggleChannelMember(id: string) {
if (channelMemberIds.value.includes(id)) {
channelMemberIds.value = channelMemberIds.value.filter((x) => x !== id);
} else {
channelMemberIds.value = [...channelMemberIds.value, id];
}
}
watch(activeRoom, () => {
draft.value = '';
clearPendingAttaches();
@@ -1134,6 +1186,14 @@ onUnmounted(() => {
>
گروه
</button>
<button
v-if="canCreateChannel"
type="button"
class="fab ghost"
@click="showNewChannel = true"
>
کانال
</button>
</div>
<div class="rail-filters">
<label class="rail-search">
@@ -1173,6 +1233,15 @@ onUnmounted(() => {
>
گروه
</button>
<button
type="button"
role="tab"
:aria-selected="roomListTab === 'channel'"
:class="{ active: roomListTab === 'channel' }"
@click="roomListTab = 'channel'"
>
کانال
</button>
</div>
</div>
<ul class="room-list">
@@ -1295,7 +1364,9 @@ onUnmounted(() => {
</span>
<div class="stage-titles">
<h2>{{ roomTitle(activeRoom) }}</h2>
<span class="kind-tag">{{ activeRoom.kind === 'group' ? 'گروه' : 'خصوصی' }}</span>
<span class="kind-tag" :data-kind="activeRoom.kind">{{
roomKindLabel(activeRoom.kind)
}}</span>
</div>
<div class="stage-tools">
<button type="button" class="select-act" title="جستجو" @click="openChatSearch">
@@ -1470,7 +1541,11 @@ onUnmounted(() => {
</li>
</ul>
<form class="composer" @submit.prevent="submitMessage">
<div v-if="!canPostInActive" class="channel-readonly">
<UiIcon name="megaphone" :size="18" />
<span>فقط مدیران کانال می‌توانند پیام بفرستند</span>
</div>
<form v-else class="composer" @submit.prevent="submitMessage">
<div v-if="mentionState && mentionCandidates.length" class="mention-sheet">
<button
v-for="(peer, idx) in mentionCandidates"
@@ -1699,6 +1774,29 @@ onUnmounted(() => {
</div>
</dialog>
<dialog v-if="showNewChannel" class="modal" open @click.self="showNewChannel = false">
<div class="modal-card wide">
<h3>کانال جدید</h3>
<p class="hint">کانال یک‌طرفه است؛ فقط شما و مدیران سازمان می‌توانند پیام بفرستند.</p>
<input v-model="channelName" type="text" placeholder="نام کانال" />
<p class="hint">اعضا (مشاهده‌کنندگان)</p>
<div class="member-grid">
<label v-for="p in peers" :key="p.id" class="member-check">
<input
type="checkbox"
:checked="channelMemberIds.includes(p.id)"
@change="toggleChannelMember(p.id)"
/>
{{ p.fullName }}
</label>
</div>
<div class="modal-actions">
<button type="button" @click="showNewChannel = false">انصراف</button>
<button type="button" class="primary" @click="startChannel">ساخت کانال</button>
</div>
</div>
</dialog>
<dialog v-if="showForward" class="modal" open @click.self="showForward = false">
<div class="modal-card">
<h3>ارسال برای دیگری</h3>
@@ -1826,7 +1924,8 @@ onUnmounted(() => {
}
.rail-actions {
display: flex;
gap: 0.4rem;
gap: 0.35rem;
flex-wrap: wrap;
}
.rail-filters {
display: flex;
@@ -1859,8 +1958,8 @@ onUnmounted(() => {
}
.rail-tabs {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.25rem;
grid-template-columns: repeat(4, 1fr);
gap: 0.2rem;
padding: 0.2rem;
border-radius: 12px;
background: rgba(148, 163, 184, 0.1);
@@ -1869,9 +1968,9 @@ onUnmounted(() => {
border: none;
background: transparent;
border-radius: 9px;
padding: 0.35rem 0.25rem;
padding: 0.35rem 0.15rem;
font: inherit;
font-size: 0.78rem;
font-size: 0.72rem;
font-weight: 600;
color: var(--muted);
cursor: pointer;
@@ -2231,6 +2330,33 @@ onUnmounted(() => {
color: #6d28d9;
width: fit-content;
}
.kind-tag[data-kind='dm'] {
background: rgba(13, 148, 136, 0.12);
color: #0f766e;
}
.kind-tag[data-kind='group'] {
background: rgba(99, 102, 241, 0.12);
color: #4338ca;
}
.kind-tag[data-kind='channel'] {
background: rgba(245, 158, 11, 0.16);
color: #b45309;
font-weight: 700;
}
.channel-readonly {
display: flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
padding: 0.85rem 1rem calc(0.85rem + env(safe-area-inset-bottom, 0px));
border-top: 1px solid var(--line);
background: #fff7ed;
color: #9a3412;
font-size: 0.86rem;
font-weight: 600;
flex-shrink: 0;
text-align: center;
}
.msg-stream {
flex: 1;
list-style: none;