feat/enh: add/remove users from group channel

This commit is contained in:
Timothy Jaeryang Baek 2025-11-30 10:33:50 -05:00
parent 781aeebd2a
commit 3f1d9ccbf8
11 changed files with 625 additions and 103 deletions

View file

@ -5,7 +5,6 @@ from typing import Optional
from open_webui.internal.db import Base, get_db from open_webui.internal.db import Base, get_db
from open_webui.models.groups import Groups from open_webui.models.groups import Groups
from open_webui.utils.access_control import has_access
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON, case from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON, case
@ -175,7 +174,9 @@ class ChannelWebhookModel(BaseModel):
class ChannelResponse(ChannelModel): class ChannelResponse(ChannelModel):
is_manager: bool = False
write_access: bool = False write_access: bool = False
user_count: Optional[int] = None user_count: Optional[int] = None
@ -196,32 +197,42 @@ class CreateChannelForm(ChannelForm):
class ChannelTable: class ChannelTable:
def _create_memberships_by_user_ids_and_group_ids( def _collect_unique_user_ids(
self, self,
channel_id: str,
invited_by: str, invited_by: str,
user_ids: Optional[list[str]] = None, user_ids: Optional[list[str]] = None,
group_ids: Optional[list[str]] = None, group_ids: Optional[list[str]] = None,
) -> list[ChannelMemberModel]: ) -> set[str]:
# For group and direct message channels, automatically add the specified users as members """
user_ids = user_ids or [] Collect unique user ids from:
if invited_by not in user_ids: - invited_by
user_ids.append(invited_by) # Ensure the creator is also a member - user_ids
- each group in group_ids
Returns a set for efficient SQL diffing.
"""
users = set(user_ids or [])
users.add(invited_by)
# Add users from specified groups for group_id in group_ids or []:
group_ids = group_ids or [] users.update(Groups.get_group_user_ids_by_id(group_id))
for group_id in group_ids:
group_user_ids = Groups.get_group_user_ids_by_id(group_id)
for uid in group_user_ids:
if uid not in user_ids:
user_ids.append(uid)
# Ensure uniqueness return users
user_ids = list(set(user_ids))
def _create_membership_models(
self,
channel_id: str,
invited_by: str,
user_ids: set[str],
) -> list[ChannelMember]:
"""
Takes a set of NEW user IDs (already filtered to exclude existing members).
Returns ORM ChannelMember objects to be added.
"""
now = int(time.time_ns())
memberships = [] memberships = []
for uid in user_ids: for uid in user_ids:
channel_member = ChannelMemberModel( model = ChannelMemberModel(
**{ **{
"id": str(uuid.uuid4()), "id": str(uuid.uuid4()),
"channel_id": channel_id, "channel_id": channel_id,
@ -230,17 +241,16 @@ class ChannelTable:
"is_active": True, "is_active": True,
"is_channel_muted": False, "is_channel_muted": False,
"is_channel_pinned": False, "is_channel_pinned": False,
"invited_at": int(time.time_ns()), "invited_at": now,
"invited_by": invited_by, "invited_by": invited_by,
"joined_at": int(time.time_ns()), "joined_at": now,
"left_at": None, "left_at": None,
"last_read_at": int(time.time_ns()), "last_read_at": now,
"created_at": int(time.time_ns()), "created_at": now,
"updated_at": int(time.time_ns()), "updated_at": now,
} }
) )
memberships.append(ChannelMember(**model.model_dump()))
memberships.append(ChannelMember(**channel_member.model_dump()))
return memberships return memberships
@ -262,14 +272,18 @@ class ChannelTable:
new_channel = Channel(**channel.model_dump()) new_channel = Channel(**channel.model_dump())
if form_data.type in ["group", "dm"]: if form_data.type in ["group", "dm"]:
memberships = self._create_memberships_by_user_ids_and_group_ids( users = self._collect_unique_user_ids(
channel.id, invited_by=user_id,
user_id, user_ids=form_data.user_ids,
form_data.user_ids, group_ids=form_data.group_ids,
form_data.group_ids, )
memberships = self._create_membership_models(
channel_id=new_channel.id,
invited_by=user_id,
user_ids=users,
) )
db.add_all(memberships)
db.add_all(memberships)
db.add(new_channel) db.add(new_channel)
db.commit() db.commit()
return channel return channel
@ -279,24 +293,71 @@ class ChannelTable:
channels = db.query(Channel).all() channels = db.query(Channel).all()
return [ChannelModel.model_validate(channel) for channel in channels] return [ChannelModel.model_validate(channel) for channel in channels]
def get_channels_by_user_id( def _has_permission(self, query, filter: dict, permission: str = "read"):
self, user_id: str, permission: str = "read" group_ids = filter.get("group_ids", [])
) -> list[ChannelModel]: user_id = filter.get("user_id")
channels = self.get_channels()
channel_list = [] json_group_ids = Channel.access_control[permission]["group_ids"]
for channel in channels:
if channel.type == "dm":
membership = self.get_member_by_channel_and_user_id(channel.id, user_id)
if membership and membership.is_active:
channel_list.append(channel)
else:
if channel.user_id == user_id or has_access(
user_id, permission, channel.access_control
):
channel_list.append(channel)
return channel_list conditions = []
if group_ids or user_id:
conditions.append(Channel.access_control.is_(None))
if user_id:
conditions.append(Channel.user_id == user_id)
if group_ids:
group_conditions = []
for gid in group_ids:
# CASE: gid IN JSON array
# SQLite → json_extract(access_control, '$.write.group_ids') LIKE '%gid%'
# Postgres → access_control->'write'->'group_ids' @> '[gid]'
group_conditions.append(json_group_ids.contains([gid]))
conditions.append(or_(*group_conditions))
if conditions:
query = query.filter(or_(*conditions))
return query
def get_channels_by_user_id(self, user_id: str) -> list[ChannelModel]:
with get_db() as db:
user_group_ids = [
group.id for group in Groups.get_groups_by_member_id(user_id)
]
membership_channels = (
db.query(Channel)
.join(ChannelMember, Channel.id == ChannelMember.channel_id)
.filter(
Channel.deleted_at.is_(None),
Channel.archived_at.is_(None),
Channel.type.in_(["group", "dm"]),
ChannelMember.user_id == user_id,
ChannelMember.is_active.is_(True),
)
.all()
)
query = db.query(Channel).filter(
Channel.deleted_at.is_(None),
Channel.archived_at.is_(None),
or_(
Channel.type.is_(None), # True NULL/None
Channel.type == "", # Empty string
and_(Channel.type != "group", Channel.type != "dm"),
),
)
query = self._has_permission(
query, {"user_id": user_id, "group_ids": user_group_ids}
)
standard_channels = query.all()
all_channels = membership_channels + standard_channels
return [ChannelModel.model_validate(c) for c in all_channels]
def get_dm_channel_by_user_ids(self, user_ids: list[str]) -> Optional[ChannelModel]: def get_dm_channel_by_user_ids(self, user_ids: list[str]) -> Optional[ChannelModel]:
with get_db() as db: with get_db() as db:
@ -331,6 +392,78 @@ class ChannelTable:
return ChannelModel.model_validate(channel) if channel else None return ChannelModel.model_validate(channel) if channel else None
def add_members_to_channel(
self,
channel_id: str,
invited_by: str,
user_ids: Optional[list[str]] = None,
group_ids: Optional[list[str]] = None,
) -> list[ChannelMemberModel]:
with get_db() as db:
# 1. Collect all user_ids including groups + inviter
requested_users = self._collect_unique_user_ids(
invited_by, user_ids, group_ids
)
existing_users = {
row.user_id
for row in db.query(ChannelMember.user_id)
.filter(ChannelMember.channel_id == channel_id)
.all()
}
new_user_ids = requested_users - existing_users
if not new_user_ids:
return [] # Nothing to add
new_memberships = self._create_membership_models(
channel_id, invited_by, new_user_ids
)
db.add_all(new_memberships)
db.commit()
return [
ChannelMemberModel.model_validate(membership)
for membership in new_memberships
]
def remove_members_from_channel(
self,
channel_id: str,
user_ids: list[str],
) -> int:
with get_db() as db:
result = (
db.query(ChannelMember)
.filter(
ChannelMember.channel_id == channel_id,
ChannelMember.user_id.in_(user_ids),
)
.delete(synchronize_session=False)
)
db.commit()
return result # number of rows deleted
def is_user_channel_manager(self, channel_id: str, user_id: str) -> bool:
with get_db() as db:
# Check if the user is the creator of the channel
# or has a 'manager' role in ChannelMember
channel = db.query(Channel).filter(Channel.id == channel_id).first()
if channel and channel.user_id == user_id:
return True
membership = (
db.query(ChannelMember)
.filter(
ChannelMember.channel_id == channel_id,
ChannelMember.user_id == user_id,
ChannelMember.role == "manager",
)
.first()
)
return membership is not None
def join_channel( def join_channel(
self, channel_id: str, user_id: str self, channel_id: str, user_id: str
) -> Optional[ChannelMemberModel]: ) -> Optional[ChannelMemberModel]:

View file

@ -7,6 +7,9 @@ from open_webui.internal.db import Base, JSONField, get_db
from open_webui.env import DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL from open_webui.env import DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL
from open_webui.models.chats import Chats from open_webui.models.chats import Chats
from open_webui.models.groups import Groups, GroupMember from open_webui.models.groups import Groups, GroupMember
from open_webui.models.channels import ChannelMember
from open_webui.utils.misc import throttle from open_webui.utils.misc import throttle
@ -311,6 +314,17 @@ class UsersTable:
) )
) )
channel_id = filter.get("channel_id")
if channel_id:
query = query.filter(
exists(
select(ChannelMember.id).where(
ChannelMember.user_id == User.id,
ChannelMember.channel_id == channel_id,
)
)
)
user_ids = filter.get("user_ids") user_ids = filter.get("user_ids")
group_ids = filter.get("group_ids") group_ids = filter.get("group_ids")

View file

@ -261,6 +261,7 @@ async def get_channel_by_id(id: str, user=Depends(get_verified_user)):
**channel.model_dump(), **channel.model_dump(),
"user_ids": user_ids, "user_ids": user_ids,
"users": users, "users": users,
"is_manager": Channels.is_user_channel_manager(channel.id, user.id),
"write_access": True, "write_access": True,
"user_count": len(user_ids), "user_count": len(user_ids),
"last_read_at": channel_member.last_read_at if channel_member else None, "last_read_at": channel_member.last_read_at if channel_member else None,
@ -291,6 +292,7 @@ async def get_channel_by_id(id: str, user=Depends(get_verified_user)):
**channel.model_dump(), **channel.model_dump(),
"user_ids": user_ids, "user_ids": user_ids,
"users": users, "users": users,
"is_manager": Channels.is_user_channel_manager(channel.id, user.id),
"write_access": write_access or user.role == "admin", "write_access": write_access or user.role == "admin",
"user_count": user_count, "user_count": user_count,
"last_read_at": channel_member.last_read_at if channel_member else None, "last_read_at": channel_member.last_read_at if channel_member else None,
@ -334,6 +336,7 @@ async def get_channel_members_by_id(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
) )
if channel.type == "dm":
user_ids = [ user_ids = [
member.user_id for member in Channels.get_members_by_channel_id(channel.id) member.user_id for member in Channels.get_members_by_channel_id(channel.id)
] ]
@ -349,11 +352,8 @@ async def get_channel_members_by_id(
], ],
"total": total, "total": total,
} }
else: else:
filter = { filter = {}
"roles": ["!pending"],
}
if query: if query:
filter["query"] = query filter["query"] = query
@ -362,7 +362,13 @@ async def get_channel_members_by_id(
if direction: if direction:
filter["direction"] = direction filter["direction"] = direction
permitted_ids = get_permitted_group_and_user_ids("read", channel.access_control) if channel.type == "group":
filter["channel_id"] = channel.id
else:
filter["roles"] = ["!pending"]
permitted_ids = get_permitted_group_and_user_ids(
"read", channel.access_control
)
if permitted_ids: if permitted_ids:
filter["user_ids"] = permitted_ids.get("user_ids") filter["user_ids"] = permitted_ids.get("user_ids")
filter["group_ids"] = permitted_ids.get("group_ids") filter["group_ids"] = permitted_ids.get("group_ids")
@ -413,6 +419,101 @@ async def update_is_active_member_by_id_and_user_id(
return True return True
#################################################
# AddMembersById
#################################################
class UpdateMembersForm(BaseModel):
user_ids: list[str] = []
group_ids: list[str] = []
@router.post("/{id}/update/members/add")
async def add_members_by_id(
request: Request,
id: str,
form_data: UpdateMembersForm,
user=Depends(get_verified_user),
):
if user.role != "admin" and not has_permission(
user.id, "features.channels", request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
channel = Channels.get_channel_by_id(id)
if not channel:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
if channel.user_id != user.id and user.role != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
try:
memberships = Channels.add_members_to_channel(
channel.id, user.id, form_data.user_ids, form_data.group_ids
)
return memberships
except Exception as e:
log.exception(e)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
#################################################
#
#################################################
class RemoveMembersForm(BaseModel):
user_ids: list[str] = []
@router.post("/{id}/update/members/remove")
async def remove_members_by_id(
request: Request,
id: str,
form_data: RemoveMembersForm,
user=Depends(get_verified_user),
):
if user.role != "admin" and not has_permission(
user.id, "features.channels", request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
channel = Channels.get_channel_by_id(id)
if not channel:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
if channel.user_id != user.id and user.role != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
try:
deleted = Channels.remove_members_from_channel(channel.id, form_data.user_ids)
return deleted
except Exception as e:
log.exception(e)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
############################ ############################
# UpdateChannelById # UpdateChannelById
############################ ############################

View file

@ -309,11 +309,13 @@ async def user_join(sid, data):
) )
await sio.enter_room(sid, f"user:{user.id}") await sio.enter_room(sid, f"user:{user.id}")
# Join all the channels # Join all the channels
channels = Channels.get_channels_by_user_id(user.id) channels = Channels.get_channels_by_user_id(user.id)
log.debug(f"{channels=}") log.debug(f"{channels=}")
for channel in channels: for channel in channels:
await sio.enter_room(sid, f"channel:{channel.id}") await sio.enter_room(sid, f"channel:{channel.id}")
return {"id": user.id, "name": user.name} return {"id": user.id, "name": user.name}

View file

@ -194,6 +194,88 @@ export const updateChannelMemberActiveStatusById = async (
return res; return res;
}; };
type UpdateMembersForm = {
user_ids?: string[];
group_ids?: string[];
};
export const addMembersById = async (
token: string = '',
channel_id: string,
formData: UpdateMembersForm
) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/channels/${channel_id}/update/members/add`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({ ...formData })
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
type RemoveMembersForm = {
user_ids?: string[];
group_ids?: string[];
};
export const removeMembersById = async (
token: string = '',
channel_id: string,
formData: RemoveMembersForm
) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/channels/${channel_id}/update/members/remove`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({ ...formData })
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const updateChannelById = async ( export const updateChannelById = async (
token: string = '', token: string = '',
channel_id: string, channel_id: string,

View file

@ -309,6 +309,11 @@
return message; return message;
}); });
}} }}
onUpdate={async () => {
channel = await getChannelById(localStorage.token, id).catch((error) => {
return null;
});
}}
/> />
{#if channel && messages !== null} {#if channel && messages !== null}

View file

@ -3,22 +3,41 @@
import { getContext, onMount } from 'svelte'; import { getContext, onMount } from 'svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
import { removeMembersById } from '$lib/apis/channels';
import Spinner from '$lib/components/common/Spinner.svelte'; import Spinner from '$lib/components/common/Spinner.svelte';
import Modal from '$lib/components/common/Modal.svelte'; import Modal from '$lib/components/common/Modal.svelte';
import UserPlusSolid from '$lib/components/icons/UserPlusSolid.svelte';
import WrenchSolid from '$lib/components/icons/WrenchSolid.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import XMark from '$lib/components/icons/XMark.svelte'; import XMark from '$lib/components/icons/XMark.svelte';
import Hashtag from '../icons/Hashtag.svelte'; import Hashtag from '../icons/Hashtag.svelte';
import Lock from '../icons/Lock.svelte'; import Lock from '../icons/Lock.svelte';
import UserList from './ChannelInfoModal/UserList.svelte'; import UserList from './ChannelInfoModal/UserList.svelte';
import AddMembersModal from './ChannelInfoModal/AddMembersModal.svelte';
export let show = false; export let show = false;
export let channel = null; export let channel = null;
export let onUpdate = () => {};
let showAddMembersModal = false;
const submitHandler = async () => {}; const submitHandler = async () => {};
const removeMemberHandler = async (userId) => {
const res = await removeMembersById(localStorage.token, channel.id, {
user_ids: [userId]
}).catch((error) => {
toast.error(`${error}`);
return null;
});
if (res) {
toast.success($i18n.t('Member removed successfully'));
onUpdate();
} else {
toast.error($i18n.t('Failed to remove member'));
}
};
const init = () => {}; const init = () => {};
$: if (show) { $: if (show) {
@ -31,15 +50,14 @@
</script> </script>
{#if channel} {#if channel}
<AddMembersModal bind:show={showAddMembersModal} {channel} {onUpdate} />
<Modal size="sm" bind:show> <Modal size="sm" bind:show>
<div> <div>
<div class=" flex justify-between dark:text-gray-100 px-5 pt-4 mb-1.5"> <div class=" flex justify-between dark:text-gray-100 px-5 pt-4 mb-1.5">
<div class="self-center text-base"> <div class="self-center text-base">
<div class="flex items-center gap-0.5 shrink-0"> <div class="flex items-center gap-0.5 shrink-0">
{#if channel?.type === 'dm'} {#if channel?.type === 'dm'}
<div <div class=" text-left self-center overflow-hidden w-full line-clamp-1 flex-1">
class=" text-left self-center overflow-hidden w-full line-clamp-1 capitalize flex-1"
>
{$i18n.t('Direct Message')} {$i18n.t('Direct Message')}
</div> </div>
{:else} {:else}
@ -51,9 +69,7 @@
{/if} {/if}
</div> </div>
<div <div class=" text-left self-center overflow-hidden w-full line-clamp-1 flex-1">
class=" text-left self-center overflow-hidden w-full line-clamp-1 capitalize flex-1"
>
{channel.name} {channel.name}
</div> </div>
{/if} {/if}
@ -69,7 +85,7 @@
</button> </button>
</div> </div>
<div class="flex flex-col md:flex-row w-full px-4 pb-4 md:space-x-4 dark:text-gray-200"> <div class="flex flex-col md:flex-row w-full px-3 pb-4 md:space-x-4 dark:text-gray-200">
<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6"> <div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
<form <form
class="flex flex-col w-full" class="flex flex-col w-full"
@ -79,7 +95,21 @@
}} }}
> >
<div class="flex flex-col w-full h-full pb-2"> <div class="flex flex-col w-full h-full pb-2">
<UserList {channel} search={channel?.type !== 'dm'} sort={channel?.type !== 'dm'} /> <UserList
{channel}
onAdd={channel?.type === 'group' && channel?.is_manager
? () => {
showAddMembersModal = true;
}
: null}
onRemove={channel?.type === 'group' && channel?.is_manager
? (userId) => {
removeMemberHandler(userId);
}
: null}
search={channel?.type !== 'dm'}
sort={channel?.type !== 'dm'}
/>
</div> </div>
</form> </form>
</div> </div>

View file

@ -0,0 +1,96 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
import { addMembersById } from '$lib/apis/channels';
import Modal from '$lib/components/common/Modal.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import MemberSelector from '$lib/components/workspace/common/MemberSelector.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
export let show = false;
export let channel = null;
export let onUpdate = () => {};
let groupIds = [];
let userIds = [];
let loading = false;
const submitHandler = async () => {
const res = await addMembersById(localStorage.token, channel.id, {
user_ids: userIds,
group_ids: groupIds
}).catch((error) => {
toast.error(`${error}`);
return null;
});
if (res) {
toast.success($i18n.t('Members added successfully'));
onUpdate();
show = false;
} else {
toast.error($i18n.t('Failed to add members'));
}
};
</script>
{#if channel}
<Modal size="sm" bind:show>
<div>
<div class=" flex justify-between dark:text-gray-100 px-5 pt-4 mb-1.5">
<div class="self-center text-base">
<div class="flex items-center gap-0.5 shrink-0">
{$i18n.t('Add Members')}
</div>
</div>
<button
class="self-center"
on:click={() => {
show = false;
}}
>
<XMark className={'size-5'} />
</button>
</div>
<div class="flex flex-col md:flex-row w-full px-3 pb-4 md:space-x-4 dark:text-gray-200">
<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
<form
class="flex flex-col w-full"
on:submit={(e) => {
e.preventDefault();
submitHandler();
}}
>
<div class="flex flex-col w-full h-full pb-2">
<MemberSelector bind:userIds bind:groupIds includeGroups={true} />
</div>
<div class="flex justify-end pt-3 text-sm font-medium gap-1.5">
<button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-950 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center {loading
? ' cursor-not-allowed'
: ''}"
type="submit"
disabled={loading}
>
{$i18n.t('Add')}
{#if loading}
<div class="ml-2 self-center">
<Spinner />
</div>
{/if}
</button>
</div>
</form>
</div>
</div>
</div>
</Modal>
{/if}

View file

@ -1,6 +1,6 @@
<script> <script>
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants'; import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import { WEBUI_NAME, config, user, showSidebar } from '$lib/stores'; import { WEBUI_NAME, config, user as _user, showSidebar } from '$lib/stores';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { onMount, getContext } from 'svelte'; import { onMount, getContext } from 'svelte';
@ -14,29 +14,21 @@
import { getChannelMembersById } from '$lib/apis/channels'; import { getChannelMembersById } from '$lib/apis/channels';
import Pagination from '$lib/components/common/Pagination.svelte'; import Pagination from '$lib/components/common/Pagination.svelte';
import ChatBubbles from '$lib/components/icons/ChatBubbles.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte';
import EditUserModal from '$lib/components/admin/Users/UserList/EditUserModal.svelte';
import UserChatsModal from '$lib/components/admin/Users/UserList/UserChatsModal.svelte';
import AddUserModal from '$lib/components/admin/Users/UserList/AddUserModal.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import RoleUpdateConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import Badge from '$lib/components/common/Badge.svelte'; import Badge from '$lib/components/common/Badge.svelte';
import Plus from '$lib/components/icons/Plus.svelte'; import Plus from '$lib/components/icons/Plus.svelte';
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import About from '$lib/components/chat/Settings/About.svelte';
import Banner from '$lib/components/common/Banner.svelte';
import Markdown from '$lib/components/chat/Messages/Markdown.svelte';
import Spinner from '$lib/components/common/Spinner.svelte'; import Spinner from '$lib/components/common/Spinner.svelte';
import ProfilePreview from '../Messages/Message/ProfilePreview.svelte'; import ProfilePreview from '../Messages/Message/ProfilePreview.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
export let channel = null; export let channel = null;
export let onAdd = null;
export let onRemove = null;
export let search = true; export let search = true;
export let sort = true; export let sort = true;
@ -85,7 +77,13 @@
} }
}; };
$: if (page !== null && query !== null && orderBy !== null && direction !== null) { $: if (
channel !== null &&
page !== null &&
query !== null &&
orderBy !== null &&
direction !== null
) {
getUserList(); getUserList();
} }
</script> </script>
@ -96,10 +94,33 @@
<Spinner className="size-5" /> <Spinner className="size-5" />
</div> </div>
{:else} {:else}
<div class="flex items-center justify-between px-2 mb-1">
<div class="flex gap-1 items-center">
<span class="text-sm">
{$i18n.t('Members')}
</span>
<span class="text-sm text-gray-500">{total}</span>
</div>
{#if onAdd}
<div class="">
<button
type="button"
class=" px-3 py-1.5 gap-1 rounded-xl bg-black dark:text-white dark:bg-gray-850/50 text-black transition font-medium text-xs flex items-center justify-center"
on:click={onAdd}
>
<Plus className="size-3.5 " />
<span>{$i18n.t('Add Member')}</span>
</button>
</div>
{/if}
</div>
<!-- <hr class="my-1 border-gray-100/5- dark:border-gray-850/50" /> -->
{#if search} {#if search}
<div class="flex gap-1 px-0.5"> <div class="flex gap-1 px-1 mb-1">
<div class=" flex w-full space-x-2"> <div class=" flex w-full space-x-2">
<div class="flex flex-1"> <div class="flex flex-1 items-center">
<div class=" self-center ml-1 mr-3"> <div class=" self-center ml-1 mr-3">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@ -127,7 +148,7 @@
{#if users.length > 0} {#if users.length > 0}
<div class="scrollbar-hidden relative whitespace-nowrap w-full max-w-full"> <div class="scrollbar-hidden relative whitespace-nowrap w-full max-w-full">
<div class=" text-sm text-left text-gray-500 dark:text-gray-400 w-full max-w-full"> <div class=" text-sm text-left text-gray-500 dark:text-gray-400 w-full max-w-full">
<div <!-- <div
class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200 w-full mb-0.5" class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200 w-full mb-0.5"
> >
<div <div
@ -181,11 +202,11 @@
</div> </div>
</button> </button>
</div> </div>
</div> </div> -->
<div class="w-full"> <div class="w-full">
{#each users as user, userIdx (user.id)} {#each users as user, userIdx (user.id)}
<div class=" dark:border-gray-850 text-xs flex items-center justify-between"> <div class=" dark:border-gray-850 text-xs flex items-center justify-between">
<div class="px-3 py-1.5 font-medium text-gray-900 dark:text-white flex-1"> <div class="px-2 py-1.5 font-medium text-gray-900 dark:text-white flex-1">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<ProfilePreview {user} side="right" align="center" sideOffset={6}> <ProfilePreview {user} side="right" align="center" sideOffset={6}>
<img <img
@ -212,8 +233,8 @@
</div> </div>
</div> </div>
<div class="px-3 py-1"> <div class="px-2 py-1 flex items-center gap-1 translate-y-0.5">
<div class=" translate-y-0.5"> <div class=" ">
<Badge <Badge
type={user.role === 'admin' type={user.role === 'admin'
? 'info' ? 'info'
@ -223,6 +244,21 @@
content={$i18n.t(user.role)} content={$i18n.t(user.role)}
/> />
</div> </div>
{#if onRemove}
<div>
<button
class=" rounded-full p-1 hover:bg-gray-100 dark:hover:bg-gray-850 transition disabled:opacity-50 disabled:cursor-not-allowed"
type="button"
disabled={user.id === $_user?.id}
on:click={() => {
onRemove(user.id);
}}
>
<XMark />
</button>
</div>
{/if}
</div> </div>
</div> </div>
{/each} {/each}

View file

@ -29,10 +29,11 @@
export let channel; export let channel;
export let onPin = (messageId, pinned) => {}; export let onPin = (messageId, pinned) => {};
export let onUpdate = () => {};
</script> </script>
<PinnedMessagesModal bind:show={showChannelPinnedMessagesModal} {channel} {onPin} /> <PinnedMessagesModal bind:show={showChannelPinnedMessagesModal} {channel} {onPin} />
<ChannelInfoModal bind:show={showChannelInfoModal} {channel} /> <ChannelInfoModal bind:show={showChannelInfoModal} {channel} {onUpdate} />
<nav class="sticky top-0 z-30 w-full px-1.5 py-1 -mb-8 flex items-center drag-region flex flex-col"> <nav class="sticky top-0 z-30 w-full px-1.5 py-1 -mb-8 flex items-center drag-region flex flex-col">
<div <div
id="navbar-bg-gradient-to-b" id="navbar-bg-gradient-to-b"

View file

@ -18,8 +18,6 @@
import Checkbox from '$lib/components/common/Checkbox.svelte'; import Checkbox from '$lib/components/common/Checkbox.svelte';
import { getGroups } from '$lib/apis/groups'; import { getGroups } from '$lib/apis/groups';
export let onChange: Function = () => {};
export let includeGroups = true; export let includeGroups = true;
export let pagination = false; export let pagination = false;
@ -33,6 +31,7 @@
? groups.filter((group) => group.name.toLowerCase().includes(query.toLowerCase())) ? groups.filter((group) => group.name.toLowerCase().includes(query.toLowerCase()))
: []; : [];
let selectedGroup = {};
let selectedUsers = {}; let selectedUsers = {};
let page = 1; let page = 1;
@ -43,15 +42,6 @@
let orderBy = 'name'; // default sort key let orderBy = 'name'; // default sort key
let direction = 'asc'; // default sort order let direction = 'asc'; // default sort order
const setSortKey = (key) => {
if (orderBy === key) {
direction = direction === 'asc' ? 'desc' : 'asc';
} else {
orderBy = key;
direction = 'asc';
}
};
const getUserList = async () => { const getUserList = async () => {
try { try {
const res = await searchUsers(localStorage.token, query, orderBy, direction, page).catch( const res = await searchUsers(localStorage.token, query, orderBy, direction, page).catch(
@ -96,6 +86,38 @@
<Spinner className="size-5" /> <Spinner className="size-5" />
</div> </div>
{:else} {:else}
{#if groupIds.length > 0}
<div class="mx-1 mb-1.5">
<div class="text-xs text-gray-500 mx-0.5 mb-1">
{groupIds.length}
{$i18n.t('groups')}
</div>
<div class="flex gap-1 flex-wrap">
{#each groupIds as id}
{#if selectedGroup[id]}
<button
type="button"
class="inline-flex items-center space-x-1 px-2 py-1 bg-gray-100/50 dark:bg-gray-850 rounded-lg text-xs"
on:click={() => {
groupIds = groupIds.filter((gid) => gid !== id);
delete selectedGroup[id];
}}
>
<div>
{selectedGroup[id].name}
<span class="text-xs text-gray-500">{selectedGroup[id].member_count}</span>
</div>
<div>
<XMark className="size-3" />
</div>
</button>
{/if}
{/each}
</div>
</div>
{/if}
{#if userIds.length > 0} {#if userIds.length > 0}
<div class="mx-1 mb-1.5"> <div class="mx-1 mb-1.5">
<div class="text-xs text-gray-500 mx-0.5 mb-1"> <div class="text-xs text-gray-500 mx-0.5 mb-1">
@ -127,7 +149,7 @@
</div> </div>
{/if} {/if}
<div class="flex gap-1 -mx-0.5 my-1.5"> <div class="flex gap-1 mb-1">
<div class=" flex w-full space-x-2"> <div class=" flex w-full space-x-2">
<div class="flex flex-1"> <div class="flex flex-1">
<div class=" self-center ml-1 mr-3"> <div class=" self-center ml-1 mr-3">
@ -170,10 +192,11 @@
on:click={() => { on:click={() => {
if ((groupIds ?? []).includes(group.id)) { if ((groupIds ?? []).includes(group.id)) {
groupIds = groupIds.filter((id) => id !== group.id); groupIds = groupIds.filter((id) => id !== group.id);
delete selectedGroup[group.id];
} else { } else {
groupIds = [...groupIds, group.id]; groupIds = [...groupIds, group.id];
selectedGroup[group.id] = group;
} }
onChange(groupIds);
}} }}
> >
<div class="px-3 py-1.5 font-medium text-gray-900 dark:text-white flex-1"> <div class="px-3 py-1.5 font-medium text-gray-900 dark:text-white flex-1">
@ -216,7 +239,6 @@
userIds = [...userIds, user.id]; userIds = [...userIds, user.id];
selectedUsers[user.id] = user; selectedUsers[user.id] = user;
} }
onChange(userIds);
}} }}
> >
<div class="px-3 py-1.5 font-medium text-gray-900 dark:text-white flex-1"> <div class="px-3 py-1.5 font-medium text-gray-900 dark:text-white flex-1">