diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index 54ca0218d7..a3a9050f78 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -1455,6 +1455,10 @@ USER_PERMISSIONS_FEATURES_NOTES = ( os.environ.get("USER_PERMISSIONS_FEATURES_NOTES", "True").lower() == "true" ) +USER_PERMISSIONS_FEATURES_CHANNELS = ( + os.environ.get("USER_PERMISSIONS_FEATURES_CHANNELS", "True").lower() == "true" +) + USER_PERMISSIONS_FEATURES_API_KEYS = ( os.environ.get("USER_PERMISSIONS_FEATURES_API_KEYS", "False").lower() == "true" ) @@ -1509,8 +1513,9 @@ DEFAULT_USER_PERMISSIONS = { "features": { # General features "api_keys": USER_PERMISSIONS_FEATURES_API_KEYS, - "folders": USER_PERMISSIONS_FEATURES_FOLDERS, "notes": USER_PERMISSIONS_FEATURES_NOTES, + "folders": USER_PERMISSIONS_FEATURES_FOLDERS, + "channels": USER_PERMISSIONS_FEATURES_CHANNELS, "direct_tool_servers": USER_PERMISSIONS_FEATURES_DIRECT_TOOL_SERVERS, # Chat features "web_search": USER_PERMISSIONS_FEATURES_WEB_SEARCH, diff --git a/backend/open_webui/migrations/versions/90ef40d4714e_update_channel_and_channel_members_table.py b/backend/open_webui/migrations/versions/90ef40d4714e_update_channel_and_channel_members_table.py new file mode 100644 index 0000000000..8c52a4b22a --- /dev/null +++ b/backend/open_webui/migrations/versions/90ef40d4714e_update_channel_and_channel_members_table.py @@ -0,0 +1,81 @@ +"""Update channel and channel members table + +Revision ID: 90ef40d4714e +Revises: b10670c03dd5 +Create Date: 2025-11-30 06:33:38.790341 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import open_webui.internal.db + + +# revision identifiers, used by Alembic. +revision: str = "90ef40d4714e" +down_revision: Union[str, None] = "b10670c03dd5" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Update 'channel' table + op.add_column("channel", sa.Column("is_private", sa.Boolean(), nullable=True)) + + op.add_column("channel", sa.Column("archived_at", sa.BigInteger(), nullable=True)) + op.add_column("channel", sa.Column("archived_by", sa.Text(), nullable=True)) + + op.add_column("channel", sa.Column("deleted_at", sa.BigInteger(), nullable=True)) + op.add_column("channel", sa.Column("deleted_by", sa.Text(), nullable=True)) + + op.add_column("channel", sa.Column("updated_by", sa.Text(), nullable=True)) + + # Update 'channel_member' table + op.add_column("channel_member", sa.Column("role", sa.Text(), nullable=True)) + op.add_column("channel_member", sa.Column("invited_by", sa.Text(), nullable=True)) + op.add_column( + "channel_member", sa.Column("invited_at", sa.BigInteger(), nullable=True) + ) + + # Create 'channel_webhook' table + op.create_table( + "channel_webhook", + sa.Column("id", sa.Text(), primary_key=True, unique=True, nullable=False), + sa.Column("user_id", sa.Text(), nullable=False), + sa.Column( + "channel_id", + sa.Text(), + sa.ForeignKey("channel.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("name", sa.Text(), nullable=False), + sa.Column("profile_image_url", sa.Text(), nullable=True), + sa.Column("token", sa.Text(), nullable=False), + sa.Column("last_used_at", sa.BigInteger(), nullable=True), + sa.Column("created_at", sa.BigInteger(), nullable=False), + sa.Column("updated_at", sa.BigInteger(), nullable=False), + ) + + pass + + +def downgrade() -> None: + # Downgrade 'channel' table + op.drop_column("channel", "is_private") + op.drop_column("channel", "archived_at") + op.drop_column("channel", "archived_by") + op.drop_column("channel", "deleted_at") + op.drop_column("channel", "deleted_by") + op.drop_column("channel", "updated_by") + + # Downgrade 'channel_member' table + op.drop_column("channel_member", "role") + op.drop_column("channel_member", "invited_by") + op.drop_column("channel_member", "invited_at") + + # Drop 'channel_webhook' table + op.drop_table("channel_webhook") + + pass diff --git a/backend/open_webui/models/channels.py b/backend/open_webui/models/channels.py index 5d452b0216..754f6e3dfa 100644 --- a/backend/open_webui/models/channels.py +++ b/backend/open_webui/models/channels.py @@ -4,10 +4,13 @@ import uuid from typing import Optional from open_webui.internal.db import Base, get_db -from open_webui.utils.access_control import has_access +from open_webui.models.groups import Groups from pydantic import BaseModel, ConfigDict -from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON, case +from sqlalchemy.dialects.postgresql import JSONB + + +from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON, case, cast from sqlalchemy import or_, func, select, and_, text from sqlalchemy.sql import exists @@ -26,12 +29,23 @@ class Channel(Base): name = Column(Text) description = Column(Text, nullable=True) + # Used to indicate if the channel is private (for 'group' type channels) + is_private = Column(Boolean, nullable=True) + data = Column(JSON, nullable=True) meta = Column(JSON, nullable=True) access_control = Column(JSON, nullable=True) created_at = Column(BigInteger) + updated_at = Column(BigInteger) + updated_by = Column(Text, nullable=True) + + archived_at = Column(BigInteger, nullable=True) + archived_by = Column(Text, nullable=True) + + deleted_at = Column(BigInteger, nullable=True) + deleted_by = Column(Text, nullable=True) class ChannelModel(BaseModel): @@ -39,17 +53,28 @@ class ChannelModel(BaseModel): id: str user_id: str + type: Optional[str] = None name: str description: Optional[str] = None + is_private: Optional[bool] = None + data: Optional[dict] = None meta: Optional[dict] = None access_control: Optional[dict] = None created_at: int # timestamp in epoch (time_ns) + updated_at: int # timestamp in epoch (time_ns) + updated_by: Optional[str] = None + + archived_at: Optional[int] = None # timestamp in epoch (time_ns) + archived_by: Optional[str] = None + + deleted_at: Optional[int] = None # timestamp in epoch (time_ns) + deleted_by: Optional[str] = None class ChannelMember(Base): @@ -59,7 +84,9 @@ class ChannelMember(Base): channel_id = Column(Text, nullable=False) user_id = Column(Text, nullable=False) + role = Column(Text, nullable=True) status = Column(Text, nullable=True) + is_active = Column(Boolean, nullable=False, default=True) is_channel_muted = Column(Boolean, nullable=False, default=False) @@ -68,6 +95,9 @@ class ChannelMember(Base): data = Column(JSON, nullable=True) meta = Column(JSON, nullable=True) + invited_at = Column(BigInteger, nullable=True) + invited_by = Column(Text, nullable=True) + joined_at = Column(BigInteger) left_at = Column(BigInteger, nullable=True) @@ -84,7 +114,9 @@ class ChannelMemberModel(BaseModel): channel_id: str user_id: str + role: Optional[str] = None status: Optional[str] = None + is_active: bool = True is_channel_muted: bool = False @@ -93,6 +125,9 @@ class ChannelMemberModel(BaseModel): data: Optional[dict] = None meta: Optional[dict] = None + invited_at: Optional[int] = None # timestamp in epoch (time_ns) + invited_by: Optional[str] = None + joined_at: Optional[int] = None # timestamp in epoch (time_ns) left_at: Optional[int] = None # timestamp in epoch (time_ns) @@ -102,29 +137,128 @@ class ChannelMemberModel(BaseModel): updated_at: Optional[int] = None # timestamp in epoch (time_ns) +class ChannelWebhook(Base): + __tablename__ = "channel_webhook" + + id = Column(Text, primary_key=True, unique=True) + channel_id = Column(Text, nullable=False) + user_id = Column(Text, nullable=False) + + name = Column(Text, nullable=False) + profile_image_url = Column(Text, nullable=True) + + token = Column(Text, nullable=False) + last_used_at = Column(BigInteger, nullable=True) + + created_at = Column(BigInteger, nullable=False) + updated_at = Column(BigInteger, nullable=False) + + +class ChannelWebhookModel(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + channel_id: str + user_id: str + + name: str + profile_image_url: Optional[str] = None + + token: str + last_used_at: Optional[int] = None # timestamp in epoch (time_ns) + + created_at: int # timestamp in epoch (time_ns) + updated_at: int # timestamp in epoch (time_ns) + + #################### # Forms #################### class ChannelResponse(ChannelModel): + is_manager: bool = False write_access: bool = False + user_count: Optional[int] = None class ChannelForm(BaseModel): - type: Optional[str] = None - name: str + name: str = "" description: Optional[str] = None + is_private: Optional[bool] = None data: Optional[dict] = None meta: Optional[dict] = None access_control: Optional[dict] = None + group_ids: Optional[list[str]] = None user_ids: Optional[list[str]] = None +class CreateChannelForm(ChannelForm): + type: Optional[str] = None + + class ChannelTable: + + def _collect_unique_user_ids( + self, + invited_by: str, + user_ids: Optional[list[str]] = None, + group_ids: Optional[list[str]] = None, + ) -> set[str]: + """ + Collect unique user ids from: + - invited_by + - user_ids + - each group in group_ids + Returns a set for efficient SQL diffing. + """ + users = set(user_ids or []) + users.add(invited_by) + + for group_id in group_ids or []: + users.update(Groups.get_group_user_ids_by_id(group_id)) + + return users + + 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 = [] + + for uid in user_ids: + model = ChannelMemberModel( + **{ + "id": str(uuid.uuid4()), + "channel_id": channel_id, + "user_id": uid, + "status": "joined", + "is_active": True, + "is_channel_muted": False, + "is_channel_pinned": False, + "invited_at": now, + "invited_by": invited_by, + "joined_at": now, + "left_at": None, + "last_read_at": now, + "created_at": now, + "updated_at": now, + } + ) + memberships.append(ChannelMember(**model.model_dump())) + + return memberships + def insert_new_channel( - self, form_data: ChannelForm, user_id: str + self, form_data: CreateChannelForm, user_id: str ) -> Optional[ChannelModel]: with get_db() as db: channel = ChannelModel( @@ -140,32 +274,19 @@ class ChannelTable: ) new_channel = Channel(**channel.model_dump()) - if form_data.type == "dm": - # For direct message channels, automatically add the specified users as members - user_ids = form_data.user_ids or [] - if user_id not in user_ids: - user_ids.append(user_id) # Ensure the creator is also a member - - for uid in user_ids: - channel_member = ChannelMemberModel( - **{ - "id": str(uuid.uuid4()), - "channel_id": channel.id, - "user_id": uid, - "status": "joined", - "is_active": True, - "is_channel_muted": False, - "is_channel_pinned": False, - "joined_at": int(time.time_ns()), - "left_at": None, - "last_read_at": int(time.time_ns()), - "created_at": int(time.time_ns()), - "updated_at": int(time.time_ns()), - } - ) - new_membership = ChannelMember(**channel_member.model_dump()) - db.add(new_membership) + if form_data.type in ["group", "dm"]: + users = self._collect_unique_user_ids( + invited_by=user_id, + user_ids=form_data.user_ids, + 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(new_channel) db.commit() return channel @@ -175,24 +296,84 @@ class ChannelTable: channels = db.query(Channel).all() return [ChannelModel.model_validate(channel) for channel in channels] - def get_channels_by_user_id( - self, user_id: str, permission: str = "read" - ) -> list[ChannelModel]: - channels = self.get_channels() + def _has_permission(self, db, query, filter: dict, permission: str = "read"): + group_ids = filter.get("group_ids", []) + user_id = filter.get("user_id") - channel_list = [] - 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) + dialect_name = db.bind.dialect.name - return channel_list + # Public access + conditions = [] + if group_ids or user_id: + conditions.extend( + [ + Channel.access_control.is_(None), + cast(Channel.access_control, String) == "null", + ] + ) + + # User-level permission + if user_id: + conditions.append(Channel.user_id == user_id) + + # Group-level permission + if group_ids: + group_conditions = [] + for gid in group_ids: + if dialect_name == "sqlite": + group_conditions.append( + Channel.access_control[permission]["group_ids"].contains([gid]) + ) + elif dialect_name == "postgresql": + group_conditions.append( + cast( + Channel.access_control[permission]["group_ids"], + JSONB, + ).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( + db, 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]: with get_db() as db: @@ -227,6 +408,78 @@ class ChannelTable: 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( self, channel_id: str, user_id: str ) -> Optional[ChannelMemberModel]: @@ -398,8 +651,12 @@ class ChannelTable: return None channel.name = form_data.name + channel.description = form_data.description + channel.is_private = form_data.is_private + channel.data = form_data.data channel.meta = form_data.meta + channel.access_control = form_data.access_control channel.updated_at = int(time.time_ns()) diff --git a/backend/open_webui/models/groups.py b/backend/open_webui/models/groups.py index e5c0612639..a7900e2c78 100644 --- a/backend/open_webui/models/groups.py +++ b/backend/open_webui/models/groups.py @@ -11,7 +11,18 @@ from open_webui.models.files import FileMetadataResponse from pydantic import BaseModel, ConfigDict -from sqlalchemy import BigInteger, Column, String, Text, JSON, func, ForeignKey +from sqlalchemy import ( + BigInteger, + Column, + String, + Text, + JSON, + and_, + func, + ForeignKey, + cast, + or_, +) log = logging.getLogger(__name__) @@ -41,7 +52,6 @@ class Group(Base): class GroupModel(BaseModel): - model_config = ConfigDict(from_attributes=True) id: str user_id: str @@ -56,6 +66,8 @@ class GroupModel(BaseModel): created_at: int # timestamp in epoch updated_at: int # timestamp in epoch + model_config = ConfigDict(from_attributes=True) + class GroupMember(Base): __tablename__ = "group_member" @@ -84,17 +96,8 @@ class GroupMemberModel(BaseModel): #################### -class GroupResponse(BaseModel): - id: str - user_id: str - name: str - description: str - permissions: Optional[dict] = None - data: Optional[dict] = None - meta: Optional[dict] = None +class GroupResponse(GroupModel): member_count: Optional[int] = None - created_at: int # timestamp in epoch - updated_at: int # timestamp in epoch class GroupForm(BaseModel): @@ -112,6 +115,11 @@ class GroupUpdateForm(GroupForm): pass +class GroupListResponse(BaseModel): + items: list[GroupResponse] = [] + total: int = 0 + + class GroupTable: def insert_new_group( self, user_id: str, form_data: GroupForm @@ -140,13 +148,87 @@ class GroupTable: except Exception: return None - def get_groups(self) -> list[GroupModel]: + def get_all_groups(self) -> list[GroupModel]: with get_db() as db: + groups = db.query(Group).order_by(Group.updated_at.desc()).all() + return [GroupModel.model_validate(group) for group in groups] + + def get_groups(self, filter) -> list[GroupResponse]: + with get_db() as db: + query = db.query(Group) + + if filter: + if "query" in filter: + query = query.filter(Group.name.ilike(f"%{filter['query']}%")) + if "member_id" in filter: + query = query.join( + GroupMember, GroupMember.group_id == Group.id + ).filter(GroupMember.user_id == filter["member_id"]) + + if "share" in filter: + share_value = filter["share"] + json_share = Group.data["config"]["share"].as_boolean() + + if share_value: + query = query.filter( + or_( + Group.data.is_(None), + json_share.is_(None), + json_share == True, + ) + ) + else: + query = query.filter( + and_(Group.data.isnot(None), json_share == False) + ) + groups = query.order_by(Group.updated_at.desc()).all() return [ - GroupModel.model_validate(group) - for group in db.query(Group).order_by(Group.updated_at.desc()).all() + GroupResponse.model_validate( + { + **GroupModel.model_validate(group).model_dump(), + "member_count": self.get_group_member_count_by_id(group.id), + } + ) + for group in groups ] + def search_groups( + self, filter: Optional[dict] = None, skip: int = 0, limit: int = 30 + ) -> GroupListResponse: + with get_db() as db: + query = db.query(Group) + + if filter: + if "query" in filter: + query = query.filter(Group.name.ilike(f"%{filter['query']}%")) + if "member_id" in filter: + query = query.join( + GroupMember, GroupMember.group_id == Group.id + ).filter(GroupMember.user_id == filter["member_id"]) + + if "share" in filter: + # 'share' is stored in data JSON, support both sqlite and postgres + share_value = filter["share"] + print("Filtering by share:", share_value) + query = query.filter( + Group.data.op("->>")("share") == str(share_value) + ) + + total = query.count() + query = query.order_by(Group.updated_at.desc()) + groups = query.offset(skip).limit(limit).all() + + return { + "items": [ + GroupResponse.model_validate( + **GroupModel.model_validate(group).model_dump(), + member_count=self.get_group_member_count_by_id(group.id), + ) + for group in groups + ], + "total": total, + } + def get_groups_by_member_id(self, user_id: str) -> list[GroupModel]: with get_db() as db: return [ @@ -293,7 +375,7 @@ class GroupTable: ) -> list[GroupModel]: # check for existing groups - existing_groups = self.get_groups() + existing_groups = self.get_all_groups() existing_group_names = {group.name for group in existing_groups} new_groups = [] diff --git a/backend/open_webui/models/messages.py b/backend/open_webui/models/messages.py index 2351c4c54c..98be21463d 100644 --- a/backend/open_webui/models/messages.py +++ b/backend/open_webui/models/messages.py @@ -5,7 +5,7 @@ from typing import Optional from open_webui.internal.db import Base, get_db from open_webui.models.tags import TagModel, Tag, Tags -from open_webui.models.users import Users, UserNameResponse +from open_webui.models.users import Users, User, UserNameResponse from open_webui.models.channels import Channels, ChannelMember @@ -100,7 +100,7 @@ class MessageForm(BaseModel): class Reactions(BaseModel): name: str - user_ids: list[str] + users: list[dict] count: int @@ -373,6 +373,15 @@ class MessageTable: self, id: str, user_id: str, name: str ) -> Optional[MessageReactionModel]: with get_db() as db: + # check for existing reaction + existing_reaction = ( + db.query(MessageReaction) + .filter_by(message_id=id, user_id=user_id, name=name) + .first() + ) + if existing_reaction: + return MessageReactionModel.model_validate(existing_reaction) + reaction_id = str(uuid.uuid4()) reaction = MessageReactionModel( id=reaction_id, @@ -389,17 +398,30 @@ class MessageTable: def get_reactions_by_message_id(self, id: str) -> list[Reactions]: with get_db() as db: - all_reactions = db.query(MessageReaction).filter_by(message_id=id).all() + # JOIN User so all user info is fetched in one query + results = ( + db.query(MessageReaction, User) + .join(User, MessageReaction.user_id == User.id) + .filter(MessageReaction.message_id == id) + .all() + ) reactions = {} - for reaction in all_reactions: + + for reaction, user in results: if reaction.name not in reactions: reactions[reaction.name] = { "name": reaction.name, - "user_ids": [], + "users": [], "count": 0, } - reactions[reaction.name]["user_ids"].append(reaction.user_id) + + reactions[reaction.name]["users"].append( + { + "id": user.id, + "name": user.name, + } + ) reactions[reaction.name]["count"] += 1 return [Reactions(**reaction) for reaction in reactions.values()] diff --git a/backend/open_webui/models/models.py b/backend/open_webui/models/models.py index 8ddcf59d39..1c44d311ba 100755 --- a/backend/open_webui/models/models.py +++ b/backend/open_webui/models/models.py @@ -13,6 +13,8 @@ from pydantic import BaseModel, ConfigDict from sqlalchemy import String, cast, or_, and_, func from sqlalchemy.dialects import postgresql, sqlite + +from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy import BigInteger, Column, Text, JSON, Boolean @@ -220,30 +222,44 @@ class ModelsTable: or has_access(user_id, permission, model.access_control, user_group_ids) ] - def _has_write_permission(self, query, filter: dict): - if filter.get("group_ids") or filter.get("user_id"): - conditions = [] + def _has_permission(self, db, query, filter: dict, permission: str = "read"): + group_ids = filter.get("group_ids", []) + user_id = filter.get("user_id") - # --- ANY group_ids match ("write".group_ids) --- - if filter.get("group_ids"): - group_ids = filter["group_ids"] - like_clauses = [] + dialect_name = db.bind.dialect.name - for gid in group_ids: - like_clauses.append( - cast(Model.access_control, String).like( - f'%"write"%"group_ids"%"{gid}"%' - ) + # Public access + conditions = [] + if group_ids or user_id: + conditions.extend( + [ + Model.access_control.is_(None), + cast(Model.access_control, String) == "null", + ] + ) + + # User-level permission + if user_id: + conditions.append(Model.user_id == user_id) + + # Group-level permission + if group_ids: + group_conditions = [] + for gid in group_ids: + if dialect_name == "sqlite": + group_conditions.append( + Model.access_control[permission]["group_ids"].contains([gid]) ) + elif dialect_name == "postgresql": + group_conditions.append( + cast( + Model.access_control[permission]["group_ids"], + JSONB, + ).contains([gid]) + ) + conditions.append(or_(*group_conditions)) - # ANY → OR - conditions.append(or_(*like_clauses)) - - # --- user_id match (owner) --- - if filter.get("user_id"): - conditions.append(Model.user_id == filter["user_id"]) - - # Apply OR across the two groups of conditions + if conditions: query = query.filter(or_(*conditions)) return query @@ -266,15 +282,20 @@ class ModelsTable: ) ) - # Apply access control filtering - query = self._has_write_permission(query, filter) - view_option = filter.get("view_option") if view_option == "created": query = query.filter(Model.user_id == user_id) elif view_option == "shared": query = query.filter(Model.user_id != user_id) + # Apply access control filtering + query = self._has_permission( + db, + query, + filter, + permission="write", + ) + tag = filter.get("tag") if tag: # TODO: This is a simple implementation and should be improved for performance diff --git a/backend/open_webui/models/users.py b/backend/open_webui/models/users.py index ede5f5e761..ba56b74ece 100644 --- a/backend/open_webui/models/users.py +++ b/backend/open_webui/models/users.py @@ -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.models.chats import Chats from open_webui.models.groups import Groups, GroupMember +from open_webui.models.channels import ChannelMember + + from open_webui.utils.misc import throttle @@ -104,6 +107,12 @@ class UserModel(BaseModel): model_config = ConfigDict(from_attributes=True) +class UserStatusModel(UserModel): + is_active: bool = False + + model_config = ConfigDict(from_attributes=True) + + class ApiKey(Base): __tablename__ = "api_key" @@ -161,7 +170,13 @@ class UserGroupIdsListResponse(BaseModel): total: int -class UserInfoResponse(BaseModel): +class UserStatus(BaseModel): + status_emoji: Optional[str] = None + status_message: Optional[str] = None + status_expires_at: Optional[int] = None + + +class UserInfoResponse(UserStatus): id: str name: str email: str @@ -176,7 +191,7 @@ class UserIdNameResponse(BaseModel): class UserIdNameStatusResponse(BaseModel): id: str name: str - is_active: bool = False + is_active: Optional[bool] = None class UserInfoListResponse(BaseModel): @@ -311,6 +326,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") group_ids = filter.get("group_ids") @@ -417,7 +443,7 @@ class UsersTable: "total": total, } - def get_users_by_user_ids(self, user_ids: list[str]) -> list[UserModel]: + def get_users_by_user_ids(self, user_ids: list[str]) -> list[UserStatusModel]: with get_db() as db: users = db.query(User).filter(User.id.in_(user_ids)).all() return [UserModel.model_validate(user) for user in users] @@ -473,6 +499,21 @@ class UsersTable: except Exception: return None + def update_user_status_by_id( + self, id: str, form_data: UserStatus + ) -> Optional[UserModel]: + try: + with get_db() as db: + db.query(User).filter_by(id=id).update( + {**form_data.model_dump(exclude_none=True)} + ) + db.commit() + + user = db.query(User).filter_by(id=id).first() + return UserModel.model_validate(user) + except Exception: + return None + def update_user_profile_image_url_by_id( self, id: str, profile_image_url: str ) -> Optional[UserModel]: diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py index 1b79d84cfd..42302043ed 100644 --- a/backend/open_webui/routers/auths.py +++ b/backend/open_webui/routers/auths.py @@ -17,7 +17,12 @@ from open_webui.models.auths import ( SignupForm, UpdatePasswordForm, ) -from open_webui.models.users import UserProfileImageResponse, Users, UpdateProfileForm +from open_webui.models.users import ( + UserProfileImageResponse, + Users, + UpdateProfileForm, + UserStatus, +) from open_webui.models.groups import Groups from open_webui.models.oauth_sessions import OAuthSessions @@ -82,7 +87,7 @@ class SessionUserResponse(Token, UserProfileImageResponse): permissions: Optional[dict] = None -class SessionUserInfoResponse(SessionUserResponse): +class SessionUserInfoResponse(SessionUserResponse, UserStatus): bio: Optional[str] = None gender: Optional[str] = None date_of_birth: Optional[datetime.date] = None @@ -139,6 +144,9 @@ async def get_session_user( "bio": user.bio, "gender": user.gender, "date_of_birth": user.date_of_birth, + "status_emoji": user.status_emoji, + "status_message": user.status_message, + "status_expires_at": user.status_expires_at, "permissions": user_permissions, } diff --git a/backend/open_webui/routers/channels.py b/backend/open_webui/routers/channels.py index d492176a00..0dff67da3e 100644 --- a/backend/open_webui/routers/channels.py +++ b/backend/open_webui/routers/channels.py @@ -8,6 +8,8 @@ from pydantic import BaseModel from open_webui.socket.main import ( + emit_to_users, + enter_room_for_users, sio, get_user_ids_from_room, ) @@ -26,6 +28,7 @@ from open_webui.models.channels import ( ChannelModel, ChannelForm, ChannelResponse, + CreateChannelForm, ) from open_webui.models.messages import ( Messages, @@ -53,6 +56,7 @@ from open_webui.utils.access_control import ( has_access, get_users_with_access, get_permitted_group_and_user_ids, + has_permission, ) from open_webui.utils.webhook import post_webhook from open_webui.utils.channels import extract_mentions, replace_mentions @@ -76,18 +80,28 @@ class ChannelListItemResponse(ChannelModel): @router.get("/", response_model=list[ChannelListItemResponse]) -async def get_channels(user=Depends(get_verified_user)): +async def get_channels(request: Request, 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, + ) channels = Channels.get_channels_by_user_id(user.id) - channel_list = [] for channel in channels: last_message = Messages.get_last_message_by_channel_id(channel.id) last_message_at = last_message.created_at if last_message else None channel_member = Channels.get_member_by_channel_and_user_id(channel.id, user.id) - unread_count = Messages.get_unread_message_count( - channel.id, user.id, channel_member.last_read_at if channel_member else None + unread_count = ( + Messages.get_unread_message_count( + channel.id, user.id, channel_member.last_read_at + ) + if channel_member + else 0 ) user_ids = None @@ -124,24 +138,141 @@ async def get_all_channels(user=Depends(get_verified_user)): return Channels.get_channels_by_user_id(user.id) +############################ +# GetDMChannelByUserId +############################ + + +@router.get("/users/{user_id}", response_model=Optional[ChannelModel]) +async def get_dm_channel_by_user_id( + request: Request, user_id: str, 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, + ) + + try: + existing_channel = Channels.get_dm_channel_by_user_ids([user.id, user_id]) + if existing_channel: + participant_ids = [ + member.user_id + for member in Channels.get_members_by_channel_id(existing_channel.id) + ] + + await emit_to_users( + "events:channel", + {"data": {"type": "channel:created"}}, + participant_ids, + ) + await enter_room_for_users( + f"channel:{existing_channel.id}", participant_ids + ) + + Channels.update_member_active_status(existing_channel.id, user.id, True) + return ChannelModel(**existing_channel.model_dump()) + + channel = Channels.insert_new_channel( + CreateChannelForm( + type="dm", + name="", + user_ids=[user_id], + ), + user.id, + ) + + if channel: + participant_ids = [ + member.user_id + for member in Channels.get_members_by_channel_id(channel.id) + ] + + await emit_to_users( + "events:channel", + {"data": {"type": "channel:created"}}, + participant_ids, + ) + await enter_room_for_users(f"channel:{channel.id}", participant_ids) + + return ChannelModel(**channel.model_dump()) + else: + raise Exception("Error creating channel") + except Exception as e: + log.exception(e) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() + ) + + ############################ # CreateNewChannel ############################ @router.post("/create", response_model=Optional[ChannelModel]) -async def create_new_channel(form_data: ChannelForm, user=Depends(get_admin_user)): +async def create_new_channel( + request: Request, form_data: CreateChannelForm, 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, + ) + + if form_data.type not in ["group", "dm"] and user.role != "admin": + # Only admins can create standard channels (joined by default) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + try: if form_data.type == "dm": existing_channel = Channels.get_dm_channel_by_user_ids( [user.id, *form_data.user_ids] ) if existing_channel: + participant_ids = [ + member.user_id + for member in Channels.get_members_by_channel_id( + existing_channel.id + ) + ] + await emit_to_users( + "events:channel", + {"data": {"type": "channel:created"}}, + participant_ids, + ) + await enter_room_for_users( + f"channel:{existing_channel.id}", participant_ids + ) + Channels.update_member_active_status(existing_channel.id, user.id, True) return ChannelModel(**existing_channel.model_dump()) channel = Channels.insert_new_channel(form_data, user.id) - return ChannelModel(**channel.model_dump()) + + if channel: + participant_ids = [ + member.user_id + for member in Channels.get_members_by_channel_id(channel.id) + ] + + await emit_to_users( + "events:channel", + {"data": {"type": "channel:created"}}, + participant_ids, + ) + await enter_room_for_users(f"channel:{channel.id}", participant_ids) + + return ChannelModel(**channel.model_dump()) + else: + raise Exception("Error creating channel") except Exception as e: log.exception(e) raise HTTPException( @@ -155,8 +286,8 @@ async def create_new_channel(form_data: ChannelForm, user=Depends(get_admin_user class ChannelFullResponse(ChannelResponse): - user_ids: Optional[list[str]] = None # 'dm' channels only - users: Optional[list[UserIdNameResponse]] = None # 'dm' channels only + user_ids: Optional[list[str]] = None # 'group'/'dm' channels only + users: Optional[list[UserIdNameStatusResponse]] = None # 'group'/'dm' channels only last_read_at: Optional[int] = None # timestamp in epoch (time_ns) unread_count: int = 0 @@ -173,7 +304,7 @@ async def get_channel_by_id(id: str, user=Depends(get_verified_user)): user_ids = None users = None - if channel.type == "dm": + if channel.type in ["group", "dm"]: if not Channels.is_user_channel_member(channel.id, user.id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() @@ -182,8 +313,11 @@ async def get_channel_by_id(id: str, user=Depends(get_verified_user)): user_ids = [ member.user_id for member in Channels.get_members_by_channel_id(channel.id) ] + users = [ - UserIdNameResponse(**user.model_dump()) + UserIdNameStatusResponse( + **{**user.model_dump(), "is_active": Users.is_user_active(user.id)} + ) for user in Users.get_users_by_user_ids(user_ids) ] @@ -197,13 +331,13 @@ async def get_channel_by_id(id: str, user=Depends(get_verified_user)): **channel.model_dump(), "user_ids": user_ids, "users": users, + "is_manager": Channels.is_user_channel_manager(channel.id, user.id), "write_access": True, "user_count": len(user_ids), "last_read_at": channel_member.last_read_at if channel_member else None, "unread_count": unread_count, } ) - else: if user.role != "admin" and not has_access( user.id, type="read", access_control=channel.access_control @@ -228,6 +362,7 @@ async def get_channel_by_id(id: str, user=Depends(get_verified_user)): **channel.model_dump(), "user_ids": user_ids, "users": users, + "is_manager": Channels.is_user_channel_manager(channel.id, user.id), "write_access": write_access or user.role == "admin", "user_count": user_count, "last_read_at": channel_member.last_read_at if channel_member else None, @@ -265,17 +400,17 @@ async def get_channel_members_by_id( page = max(1, page) skip = (page - 1) * limit - if channel.type == "dm": + if channel.type in ["group", "dm"]: if not Channels.is_user_channel_member(channel.id, user.id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() ) + if channel.type == "dm": user_ids = [ member.user_id for member in Channels.get_members_by_channel_id(channel.id) ] users = Users.get_users_by_user_ids(user_ids) - total = len(users) return { @@ -287,11 +422,8 @@ async def get_channel_members_by_id( ], "total": total, } - else: - filter = { - "roles": ["!pending"], - } + filter = {} if query: filter["query"] = query @@ -300,10 +432,16 @@ async def get_channel_members_by_id( if direction: filter["direction"] = direction - permitted_ids = get_permitted_group_and_user_ids("read", channel.access_control) - if permitted_ids: - filter["user_ids"] = permitted_ids.get("user_ids") - filter["group_ids"] = permitted_ids.get("group_ids") + 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: + filter["user_ids"] = permitted_ids.get("user_ids") + filter["group_ids"] = permitted_ids.get("group_ids") result = Users.get_users(filter=filter, skip=skip, limit=limit) @@ -351,6 +489,101 @@ async def update_is_active_member_by_id_and_user_id( 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 ############################ @@ -358,14 +591,27 @@ async def update_is_active_member_by_id_and_user_id( @router.post("/{id}/update", response_model=Optional[ChannelModel]) async def update_channel_by_id( - id: str, form_data: ChannelForm, user=Depends(get_admin_user) + request: Request, id: str, form_data: ChannelForm, 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: channel = Channels.update_channel_by_id(id, form_data) return ChannelModel(**channel.model_dump()) @@ -382,13 +628,28 @@ async def update_channel_by_id( @router.delete("/{id}/delete", response_model=bool) -async def delete_channel_by_id(id: str, user=Depends(get_admin_user)): +async def delete_channel_by_id( + request: Request, id: str, 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: Channels.delete_channel_by_id(id) return True @@ -418,7 +679,7 @@ async def get_channel_messages( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND ) - if channel.type == "dm": + if channel.type in ["group", "dm"]: if not Channels.is_user_channel_member(channel.id, user.id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() @@ -481,7 +742,7 @@ async def get_pinned_channel_messages( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND ) - if channel.type == "dm": + if channel.type in ["group", "dm"]: if not Channels.is_user_channel_member(channel.id, user.id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() @@ -735,7 +996,7 @@ async def new_message_handler( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND ) - if channel.type == "dm": + if channel.type in ["group", "dm"]: if not Channels.is_user_channel_member(channel.id, user.id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() @@ -751,7 +1012,7 @@ async def new_message_handler( try: message = Messages.insert_new_message(form_data, channel.id, user.id) if message: - if channel.type == "dm": + if channel.type in ["group", "dm"]: members = Channels.get_members_by_channel_id(channel.id) for member in members: if not member.is_active: @@ -857,7 +1118,7 @@ async def get_channel_message( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND ) - if channel.type == "dm": + if channel.type in ["group", "dm"]: if not Channels.is_user_channel_member(channel.id, user.id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() @@ -912,7 +1173,7 @@ async def pin_channel_message( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND ) - if channel.type == "dm": + if channel.type in ["group", "dm"]: if not Channels.is_user_channel_member(channel.id, user.id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() @@ -975,7 +1236,7 @@ async def get_channel_thread_messages( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND ) - if channel.type == "dm": + if channel.type in ["group", "dm"]: if not Channels.is_user_channel_member(channel.id, user.id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() @@ -1040,7 +1301,7 @@ async def update_message_by_id( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() ) - if channel.type == "dm": + if channel.type in ["group", "dm"]: if not Channels.is_user_channel_member(channel.id, user.id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() @@ -1104,7 +1365,7 @@ async def add_reaction_to_message( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND ) - if channel.type == "dm": + if channel.type in ["group", "dm"]: if not Channels.is_user_channel_member(channel.id, user.id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() @@ -1173,7 +1434,7 @@ async def remove_reaction_by_id_and_user_id_and_name( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND ) - if channel.type == "dm": + if channel.type in ["group", "dm"]: if not Channels.is_user_channel_member(channel.id, user.id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() @@ -1256,7 +1517,7 @@ async def delete_message_by_id( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() ) - if channel.type == "dm": + if channel.type in ["group", "dm"]: if not Channels.is_user_channel_member(channel.id, user.id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() diff --git a/backend/open_webui/routers/groups.py b/backend/open_webui/routers/groups.py index b68db3a15e..05d52c5c7b 100755 --- a/backend/open_webui/routers/groups.py +++ b/backend/open_webui/routers/groups.py @@ -32,31 +32,17 @@ router = APIRouter() @router.get("/", response_model=list[GroupResponse]) async def get_groups(share: Optional[bool] = None, user=Depends(get_verified_user)): - if user.role == "admin": - groups = Groups.get_groups() - else: - groups = Groups.get_groups_by_member_id(user.id) - group_list = [] + filter = {} + if user.role != "admin": + filter["member_id"] = user.id - for group in groups: - if share is not None: - # Check if the group has data and a config with share key - if ( - group.data - and "share" in group.data.get("config", {}) - and group.data["config"]["share"] != share - ): - continue + if share is not None: + filter["share"] = share - group_list.append( - GroupResponse( - **group.model_dump(), - member_count=Groups.get_group_member_count_by_id(group.id), - ) - ) + groups = Groups.get_groups(filter=filter) - return group_list + return groups ############################ diff --git a/backend/open_webui/routers/knowledge.py b/backend/open_webui/routers/knowledge.py index 654f11588a..46baa0eaea 100644 --- a/backend/open_webui/routers/knowledge.py +++ b/backend/open_webui/routers/knowledge.py @@ -550,7 +550,11 @@ def remove_file_from_knowledge_by_id( try: VECTOR_DB_CLIENT.delete( collection_name=knowledge.id, filter={"file_id": form_data.file_id} - ) + ) # Remove by file_id first + + VECTOR_DB_CLIENT.delete( + collection_name=knowledge.id, filter={"hash": file.hash} + ) # Remove by hash as well in case of duplicates except Exception as e: log.debug("This was most likely caused by bypassing embedding processing") log.debug(e) @@ -579,7 +583,6 @@ def remove_file_from_knowledge_by_id( data["file_ids"] = file_ids knowledge = Knowledges.update_knowledge_data_by_id(id=id, data=data) - if knowledge: files = Files.get_file_metadatas_by_ids(file_ids) diff --git a/backend/open_webui/routers/scim.py b/backend/open_webui/routers/scim.py index b5d0e029ec..c2ee4d1c35 100644 --- a/backend/open_webui/routers/scim.py +++ b/backend/open_webui/routers/scim.py @@ -719,7 +719,7 @@ async def get_groups( ): """List SCIM Groups""" # Get all groups - groups_list = Groups.get_groups() + groups_list = Groups.get_all_groups() # Apply pagination total = len(groups_list) diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index 7c4b801f4d..3c1bbb72a8 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -19,8 +19,9 @@ from open_webui.models.users import ( UserGroupIdsModel, UserGroupIdsListResponse, UserInfoListResponse, - UserIdNameListResponse, + UserInfoListResponse, UserRoleUpdateForm, + UserStatus, Users, UserSettings, UserUpdateForm, @@ -102,20 +103,31 @@ async def get_all_users( return Users.get_users() -@router.get("/search", response_model=UserIdNameListResponse) +@router.get("/search", response_model=UserInfoListResponse) async def search_users( query: Optional[str] = None, + order_by: Optional[str] = None, + direction: Optional[str] = None, + page: Optional[int] = 1, user=Depends(get_verified_user), ): limit = PAGE_ITEM_COUNT - page = 1 # Always return the first page for search + page = max(1, page) skip = (page - 1) * limit filter = {} if query: filter["query"] = query + filter = {} + if query: + filter["query"] = query + if order_by: + filter["order_by"] = order_by + if direction: + filter["direction"] = direction + return Users.get_users(filter=filter, skip=skip, limit=limit) @@ -196,8 +208,9 @@ class ChatPermissions(BaseModel): class FeaturesPermissions(BaseModel): api_keys: bool = False - folders: bool = True notes: bool = True + channels: bool = True + folders: bool = True direct_tool_servers: bool = False web_search: bool = True @@ -287,6 +300,43 @@ async def update_user_settings_by_session_user( ) +############################ +# GetUserStatusBySessionUser +############################ + + +@router.get("/user/status") +async def get_user_status_by_session_user(user=Depends(get_verified_user)): + user = Users.get_user_by_id(user.id) + if user: + return user + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.USER_NOT_FOUND, + ) + + +############################ +# UpdateUserStatusBySessionUser +############################ + + +@router.post("/user/status/update") +async def update_user_status_by_session_user( + form_data: UserStatus, user=Depends(get_verified_user) +): + user = Users.get_user_by_id(user.id) + if user: + user = Users.update_user_status_by_id(user.id, form_data) + return user + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.USER_NOT_FOUND, + ) + + ############################ # GetUserInfoBySessionUser ############################ @@ -338,9 +388,10 @@ async def update_user_info_by_session_user( ############################ -class UserActiveResponse(BaseModel): +class UserActiveResponse(UserStatus): name: str profile_image_url: Optional[str] = None + is_active: bool model_config = ConfigDict(extra="allow") @@ -365,8 +416,7 @@ async def get_user_by_id(user_id: str, user=Depends(get_verified_user)): if user: return UserActiveResponse( **{ - "id": user.id, - "name": user.name, + **user.model_dump(), "is_active": Users.is_user_active(user_id), } ) diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py index 84705648d9..638a89715a 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py @@ -253,6 +253,38 @@ def get_user_ids_from_room(room): return active_user_ids +async def emit_to_users(event: str, data: dict, user_ids: list[str]): + """ + Send a message to specific users using their user:{id} rooms. + + Args: + event (str): The event name to emit. + data (dict): The payload/data to send. + user_ids (list[str]): The target users' IDs. + """ + try: + for user_id in user_ids: + await sio.emit(event, data, room=f"user:{user_id}") + except Exception as e: + log.debug(f"Failed to emit event {event} to users {user_ids}: {e}") + + +async def enter_room_for_users(room: str, user_ids: list[str]): + """ + Make all sessions of a user join a specific room. + Args: + room (str): The room to join. + user_ids (list[str]): The target user's IDs. + """ + try: + for user_id in user_ids: + session_ids = get_session_ids_from_room(f"user:{user_id}") + for sid in session_ids: + await sio.enter_room(sid, room) + except Exception as e: + log.debug(f"Failed to make users {user_ids} join room {room}: {e}") + + @sio.on("usage") async def usage(sid, data): if sid in SESSION_POOL: @@ -309,11 +341,13 @@ async def user_join(sid, data): ) await sio.enter_room(sid, f"user:{user.id}") + # Join all the channels channels = Channels.get_channels_by_user_id(user.id) log.debug(f"{channels=}") for channel in channels: await sio.enter_room(sid, f"channel:{channel.id}") + return {"id": user.id, "name": user.name} diff --git a/backend/open_webui/utils/audit.py b/backend/open_webui/utils/audit.py index 0cef3c91f8..dc1226a080 100644 --- a/backend/open_webui/utils/audit.py +++ b/backend/open_webui/utils/audit.py @@ -194,7 +194,7 @@ class AuditLoggingMiddleware: auth_header = request.headers.get("Authorization") try: - user = get_current_user( + user = await get_current_user( request, None, None, get_http_authorization_cred(auth_header) ) return user diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 1f96bacdb4..bc5b741146 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -858,7 +858,7 @@ async def chat_image_generation_handler( } ) - system_message_content = f"Image generation was attempted but failed. The system is currently unable to generate the image. Tell the user that an error occurred: {error_message}" + system_message_content = f"Image generation was attempted but failed. The system is currently unable to generate the image. Tell the user that the following error occurred: {error_message}" else: # Create image(s) @@ -921,7 +921,7 @@ async def chat_image_generation_handler( } ) - system_message_content = "The requested image has been created and is now being shown to the user. Let them know that it has been generated." + system_message_content = "The requested image has been created by the system successfully and is now being shown to the user. Let the user know that the image they requested has been generated and is now shown in the chat." except Exception as e: log.debug(e) @@ -942,7 +942,7 @@ async def chat_image_generation_handler( } ) - system_message_content = f"Image generation was attempted but failed. The system is currently unable to generate the image. Tell the user that an error occurred: {error_message}" + system_message_content = f"Image generation was attempted but failed because of an error. The system is currently unable to generate the image. Tell the user that the following error occurred: {error_message}" if system_message_content: form_data["messages"] = add_or_update_system_message( diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index 6bd955e90c..9cd329a861 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -1102,7 +1102,7 @@ class OAuthManager: user_oauth_groups = [] user_current_groups: list[GroupModel] = Groups.get_groups_by_member_id(user.id) - all_available_groups: list[GroupModel] = Groups.get_groups() + all_available_groups: list[GroupModel] = Groups.get_all_groups() # Create groups if they don't exist and creation is enabled if auth_manager_config.ENABLE_OAUTH_GROUP_CREATION: @@ -1146,7 +1146,7 @@ class OAuthManager: # Refresh the list of all available groups if any were created if groups_created: - all_available_groups = Groups.get_groups() + all_available_groups = Groups.get_all_groups() log.debug("Refreshed list of all available groups after creation.") log.debug(f"Oauth Groups claim: {oauth_claim}") diff --git a/backend/requirements.txt b/backend/requirements.txt index ba8cbbfc07..1ddd886a8c 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -139,14 +139,14 @@ ldap3==2.9.1 firecrawl-py==4.5.0 ## Trace -opentelemetry-api==1.37.0 -opentelemetry-sdk==1.37.0 -opentelemetry-exporter-otlp==1.37.0 -opentelemetry-instrumentation==0.58b0 -opentelemetry-instrumentation-fastapi==0.58b0 -opentelemetry-instrumentation-sqlalchemy==0.58b0 -opentelemetry-instrumentation-redis==0.58b0 -opentelemetry-instrumentation-requests==0.58b0 -opentelemetry-instrumentation-logging==0.58b0 -opentelemetry-instrumentation-httpx==0.58b0 -opentelemetry-instrumentation-aiohttp-client==0.58b0 +opentelemetry-api==1.38.0 +opentelemetry-sdk==1.38.0 +opentelemetry-exporter-otlp==1.38.0 +opentelemetry-instrumentation==0.59b0 +opentelemetry-instrumentation-fastapi==0.59b0 +opentelemetry-instrumentation-sqlalchemy==0.59b0 +opentelemetry-instrumentation-redis==0.59b0 +opentelemetry-instrumentation-requests==0.59b0 +opentelemetry-instrumentation-logging==0.59b0 +opentelemetry-instrumentation-httpx==0.59b0 +opentelemetry-instrumentation-aiohttp-client==0.59b0 diff --git a/src/app.css b/src/app.css index 9646c0f9ce..fc093e5a6a 100644 --- a/src/app.css +++ b/src/app.css @@ -637,7 +637,7 @@ input[type='number'] { .tiptap th, .tiptap td { - @apply px-3 py-1.5 border border-gray-100 dark:border-gray-850; + @apply px-3 py-1.5 border border-gray-100/30 dark:border-gray-850/30; } .tiptap th { diff --git a/src/lib/apis/channels/index.ts b/src/lib/apis/channels/index.ts index e7c6b61cf8..0731b2ea9f 100644 --- a/src/lib/apis/channels/index.ts +++ b/src/lib/apis/channels/index.ts @@ -3,6 +3,7 @@ import { WEBUI_API_BASE_URL } from '$lib/constants'; type ChannelForm = { type?: string; name: string; + is_private?: boolean; data?: object; meta?: object; access_control?: object; @@ -103,6 +104,37 @@ export const getChannelById = async (token: string = '', channel_id: string) => return res; }; +export const getDMChannelByUserId = async (token: string = '', user_id: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/channels/users/${user_id}`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .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 getChannelMembersById = async ( token: string, channel_id: string, @@ -193,6 +225,88 @@ export const updateChannelMemberActiveStatusById = async ( 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 ( token: string = '', channel_id: string, diff --git a/src/lib/apis/users/index.ts b/src/lib/apis/users/index.ts index ac057359a5..d6da54bbf9 100644 --- a/src/lib/apis/users/index.ts +++ b/src/lib/apis/users/index.ts @@ -166,11 +166,33 @@ export const getUsers = async ( return res; }; -export const getAllUsers = async (token: string) => { +export const searchUsers = async ( + token: string, + query?: string, + orderBy?: string, + direction?: string, + page = 1 +) => { let error = null; let res = null; - res = await fetch(`${WEBUI_API_BASE_URL}/users/all`, { + const searchParams = new URLSearchParams(); + + searchParams.set('page', `${page}`); + + if (query) { + searchParams.set('query', query); + } + + if (orderBy) { + searchParams.set('order_by', orderBy); + } + + if (direction) { + searchParams.set('direction', direction); + } + + res = await fetch(`${WEBUI_API_BASE_URL}/users/search?${searchParams.toString()}`, { method: 'GET', headers: { 'Content-Type': 'application/json', @@ -194,11 +216,11 @@ export const getAllUsers = async (token: string) => { return res; }; -export const searchUsers = async (token: string, query: string) => { +export const getAllUsers = async (token: string) => { let error = null; let res = null; - res = await fetch(`${WEBUI_API_BASE_URL}/users/search?query=${encodeURIComponent(query)}`, { + res = await fetch(`${WEBUI_API_BASE_URL}/users/all`, { method: 'GET', headers: { 'Content-Type': 'application/json', @@ -305,6 +327,36 @@ export const getUserById = async (token: string, userId: string) => { return res; }; +export const updateUserStatus = async (token: string, formData: object) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/users/user/status/update`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + ...formData + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.error(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const getUserInfo = async (token: string) => { let error = null; const res = await fetch(`${WEBUI_API_BASE_URL}/users/user/info`, { diff --git a/src/lib/components/AddToolServerModal.svelte b/src/lib/components/AddToolServerModal.svelte index 79fe4c97fc..c9b91e2276 100644 --- a/src/lib/components/AddToolServerModal.svelte +++ b/src/lib/components/AddToolServerModal.svelte @@ -818,10 +818,8 @@
-
-
- -
+
+
{/if}
diff --git a/src/lib/components/admin/Evaluations/Feedbacks.svelte b/src/lib/components/admin/Evaluations/Feedbacks.svelte index 16b99108f8..3782e7614f 100644 --- a/src/lib/components/admin/Evaluations/Feedbacks.svelte +++ b/src/lib/components/admin/Evaluations/Feedbacks.svelte @@ -186,7 +186,7 @@ class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full" > - + - +
diff --git a/src/lib/components/admin/Functions/FunctionMenu.svelte b/src/lib/components/admin/Functions/FunctionMenu.svelte index 0b8fc4c714..20406d1299 100644 --- a/src/lib/components/admin/Functions/FunctionMenu.svelte +++ b/src/lib/components/admin/Functions/FunctionMenu.svelte @@ -63,7 +63,7 @@
-
+
{/if} {$i18n.t('Export')}
-
+
{$i18n.t('Speech-to-Text')}
-
+
{#if STT_ENGINE !== 'web'}
@@ -263,7 +263,7 @@
-
+
{$i18n.t('STT Model')}
@@ -289,7 +289,7 @@
-
+
{$i18n.t('STT Model')}
@@ -323,7 +323,7 @@ />
-
+
{$i18n.t('Azure Region')}
@@ -391,7 +391,7 @@
-
+
{$i18n.t('STT Model')}
@@ -416,7 +416,7 @@
-
+
@@ -500,7 +500,7 @@
{$i18n.t('Text-to-Speech')}
-
+
{$i18n.t('Text-to-Speech Engine')}
@@ -557,7 +557,7 @@
-
+
{$i18n.t('Azure Region')}
diff --git a/src/lib/components/admin/Settings/CodeExecution.svelte b/src/lib/components/admin/Settings/CodeExecution.svelte index 5838a2f4d1..7198cb1113 100644 --- a/src/lib/components/admin/Settings/CodeExecution.svelte +++ b/src/lib/components/admin/Settings/CodeExecution.svelte @@ -43,7 +43,7 @@
{$i18n.t('General')}
-
+
@@ -166,7 +166,7 @@
{$i18n.t('Code Interpreter')}
-
+
@@ -288,7 +288,7 @@
{/if} -
+
diff --git a/src/lib/components/admin/Settings/Connections.svelte b/src/lib/components/admin/Settings/Connections.svelte index 93cf755be8..32276ce930 100644 --- a/src/lib/components/admin/Settings/Connections.svelte +++ b/src/lib/components/admin/Settings/Connections.svelte @@ -221,7 +221,7 @@
{$i18n.t('General')}
-
+
@@ -384,7 +384,7 @@
-
+
diff --git a/src/lib/components/admin/Settings/Database.svelte b/src/lib/components/admin/Settings/Database.svelte index 1c966ac356..0baeb78e6c 100644 --- a/src/lib/components/admin/Settings/Database.svelte +++ b/src/lib/components/admin/Settings/Database.svelte @@ -143,7 +143,7 @@
-
+
{#if $config?.features.enable_admin_export ?? true}
diff --git a/src/lib/components/admin/Settings/Documents.svelte b/src/lib/components/admin/Settings/Documents.svelte index 548583ee8a..26c23028ed 100644 --- a/src/lib/components/admin/Settings/Documents.svelte +++ b/src/lib/components/admin/Settings/Documents.svelte @@ -327,7 +327,7 @@
{$i18n.t('General')}
-
+
@@ -762,7 +762,7 @@
{$i18n.t('Embedding')}
-
+
@@ -953,7 +953,7 @@
{$i18n.t('Retrieval')}
-
+
{$i18n.t('Full Context Mode')}
@@ -1211,7 +1211,7 @@
{$i18n.t('Files')}
-
+
{$i18n.t('Allowed File Extensions')}
@@ -1323,7 +1323,7 @@
{$i18n.t('Integration')}
-
+
{$i18n.t('Google Drive')}
@@ -1343,7 +1343,7 @@
{$i18n.t('Danger Zone')}
-
+
{$i18n.t('Reset Upload Directory')}
diff --git a/src/lib/components/admin/Settings/Evaluations.svelte b/src/lib/components/admin/Settings/Evaluations.svelte index 68f8538829..40d792f5d2 100644 --- a/src/lib/components/admin/Settings/Evaluations.svelte +++ b/src/lib/components/admin/Settings/Evaluations.svelte @@ -106,7 +106,7 @@
{$i18n.t('General')}
-
+
{$i18n.t('Arena Models')}
@@ -139,7 +139,7 @@
-
+
{#if (evaluationConfig?.EVALUATION_ARENA_MODELS ?? []).length > 0} diff --git a/src/lib/components/admin/Settings/Evaluations/ArenaModelModal.svelte b/src/lib/components/admin/Settings/Evaluations/ArenaModelModal.svelte index e3d702e6aa..2714d4681c 100644 --- a/src/lib/components/admin/Settings/Evaluations/ArenaModelModal.svelte +++ b/src/lib/components/admin/Settings/Evaluations/ArenaModelModal.svelte @@ -292,10 +292,8 @@
-
-
- -
+
+

diff --git a/src/lib/components/admin/Settings/General.svelte b/src/lib/components/admin/Settings/General.svelte index d46f37a89f..390334b215 100644 --- a/src/lib/components/admin/Settings/General.svelte +++ b/src/lib/components/admin/Settings/General.svelte @@ -129,7 +129,7 @@
{$i18n.t('General')}
-
+
@@ -287,7 +287,7 @@
{$i18n.t('Authentication')}
-
+
{$i18n.t('Default User Role')}
@@ -660,7 +660,7 @@
{$i18n.t('Features')}
-
+
diff --git a/src/lib/components/admin/Settings/Images.svelte b/src/lib/components/admin/Settings/Images.svelte index 1c0c0b07f1..e3c3a2ca4a 100644 --- a/src/lib/components/admin/Settings/Images.svelte +++ b/src/lib/components/admin/Settings/Images.svelte @@ -291,7 +291,7 @@
{$i18n.t('General')}
-
+
@@ -309,7 +309,7 @@
{$i18n.t('Create Image')}
-
+
{#if config.ENABLE_IMAGE_GENERATION}
@@ -882,7 +882,7 @@
{$i18n.t('Edit Image')}
-
+
diff --git a/src/lib/components/admin/Settings/Interface.svelte b/src/lib/components/admin/Settings/Interface.svelte index 080cec09ba..acd5fbf67c 100644 --- a/src/lib/components/admin/Settings/Interface.svelte +++ b/src/lib/components/admin/Settings/Interface.svelte @@ -115,7 +115,7 @@
{$i18n.t('Tasks')}
-
+
{$i18n.t('Task Model')}
@@ -423,7 +423,7 @@
{$i18n.t('UI')}
-
+
diff --git a/src/lib/components/admin/Settings/Pipelines.svelte b/src/lib/components/admin/Settings/Pipelines.svelte index dc69a8f1be..18446da7dd 100644 --- a/src/lib/components/admin/Settings/Pipelines.svelte +++ b/src/lib/components/admin/Settings/Pipelines.svelte @@ -418,7 +418,7 @@
-
+
{#if pipelines !== null} {#if pipelines.length > 0} diff --git a/src/lib/components/admin/Settings/Tools.svelte b/src/lib/components/admin/Settings/Tools.svelte index d6d3bffd1f..47c5452103 100644 --- a/src/lib/components/admin/Settings/Tools.svelte +++ b/src/lib/components/admin/Settings/Tools.svelte @@ -61,7 +61,7 @@
{$i18n.t('General')}
-
+
+ {#if search} -
+
-
+
0}
-
-
+
-->
- {#each users as user, userIdx} + {#each users as user, userIdx (user.id)}
-
+
-
-
+
+
+ + {#if onRemove} +
+ +
+ {/if}
{/each} diff --git a/src/lib/components/channel/MessageInput.svelte b/src/lib/components/channel/MessageInput.svelte index e36819fda7..fef7c3a078 100644 --- a/src/lib/components/channel/MessageInput.svelte +++ b/src/lib/components/channel/MessageInput.svelte @@ -775,7 +775,7 @@ >
{#if replyToMessage !== null} diff --git a/src/lib/components/channel/Messages.svelte b/src/lib/components/channel/Messages.svelte index 05dabfd749..b0d1fb54db 100644 --- a/src/lib/components/channel/Messages.svelte +++ b/src/lib/components/channel/Messages.svelte @@ -189,7 +189,7 @@ if ( (message?.reactions ?? []) .find((reaction) => reaction.name === name) - ?.user_ids?.includes($user?.id) ?? + ?.users?.some((u) => u.id === $user?.id) ?? false ) { messages = messages.map((m) => { @@ -197,8 +197,8 @@ const reaction = m.reactions.find((reaction) => reaction.name === name); if (reaction) { - reaction.user_ids = reaction.user_ids.filter((id) => id !== $user?.id); - reaction.count = reaction.user_ids.length; + reaction.users = reaction.users.filter((u) => u.id !== $user?.id); + reaction.count = reaction.users.length; if (reaction.count === 0) { m.reactions = m.reactions.filter((r) => r.name !== name); @@ -224,12 +224,12 @@ const reaction = m.reactions.find((reaction) => reaction.name === name); if (reaction) { - reaction.user_ids.push($user?.id); - reaction.count = reaction.user_ids.length; + reaction.users.push({ id: $user?.id, name: $user?.name }); + reaction.count = reaction.users.length; } else { m.reactions.push({ name: name, - user_ids: [$user?.id], + users: [{ id: $user?.id, name: $user?.name }], count: 1 }); } diff --git a/src/lib/components/channel/Messages/Message.svelte b/src/lib/components/channel/Messages/Message.svelte index dc3343f4ef..c4006296f2 100644 --- a/src/lib/components/channel/Messages/Message.svelte +++ b/src/lib/components/channel/Messages/Message.svelte @@ -93,7 +93,7 @@ class=" absolute {showButtons ? '' : 'invisible group-hover:visible'} right-1 -top-2 z-10" >
{#if onReaction} {#if message.created_at !== message.updated_at && (message?.meta?.model_id ?? null) === null}({$i18n.t('edited')})({$i18n.t('edited')}){/if} {/if}
@@ -398,24 +399,57 @@
{#each message.reactions as reaction} - + { + const name = u.id === $user?.id ? $i18n.t('You') : u.name; + const total = reaction.users.length; + + // First three names always added normally + if (idx < 3) { + const separator = + idx === 0 + ? '' + : idx === Math.min(2, total - 1) + ? ` ${$i18n.t('and')} ` + : ', '; + return `${acc}${separator}${name}`; + } + + // More than 4 → "and X others" + if (idx === 3 && total > 4) { + return ( + acc + + ` ${$i18n.t('and {{COUNT}} others', { + COUNT: total - 3 + })}` + ); + } + + return acc; + }, '') + .trim(), + REACTION: `:${reaction.name}:` + })} + > diff --git a/src/lib/components/channel/Messages/Message/ProfilePreview.svelte b/src/lib/components/channel/Messages/Message/ProfilePreview.svelte index a7daef9147..0e1d9e4076 100644 --- a/src/lib/components/channel/Messages/Message/ProfilePreview.svelte +++ b/src/lib/components/channel/Messages/Message/ProfilePreview.svelte @@ -15,7 +15,7 @@ let openPreview = false; - + +
+ {/if}
{/if} diff --git a/src/lib/components/channel/Messages/Message/UserStatusLinkPreview.svelte b/src/lib/components/channel/Messages/Message/UserStatusLinkPreview.svelte index 93472226ed..74b2029266 100644 --- a/src/lib/components/channel/Messages/Message/UserStatusLinkPreview.svelte +++ b/src/lib/components/channel/Messages/Message/UserStatusLinkPreview.svelte @@ -14,7 +14,6 @@ export let sideOffset = 6; let user = null; - onMount(async () => { if (id) { user = await getUserById(localStorage.token, id).catch((error) => { @@ -27,7 +26,7 @@ {#if user} {}; + export let onUpdate = () => {}; - +