diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 727bfe65dd..127f22e103 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -66,7 +66,6 @@ from open_webui.socket.main import ( periodic_usage_pool_cleanup, get_event_emitter, get_models_in_use, - get_active_user_ids, ) from open_webui.routers import ( audio, @@ -2021,7 +2020,10 @@ async def get_current_usage(user=Depends(get_verified_user)): This is an experimental endpoint and subject to change. """ try: - return {"model_ids": get_models_in_use(), "user_ids": get_active_user_ids()} + return { + "model_ids": get_models_in_use(), + "user_count": Users.get_active_user_count(), + } except Exception as e: log.error(f"Error getting usage statistics: {e}") raise HTTPException(status_code=500, detail="Internal Server Error") diff --git a/backend/open_webui/migrations/versions/38d63c18f30f_add_oauth_session_table.py b/backend/open_webui/migrations/versions/38d63c18f30f_add_oauth_session_table.py index 8ead6db6d4..264ce13b41 100644 --- a/backend/open_webui/migrations/versions/38d63c18f30f_add_oauth_session_table.py +++ b/backend/open_webui/migrations/versions/38d63c18f30f_add_oauth_session_table.py @@ -20,18 +20,46 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: + # Ensure 'id' column in 'user' table is unique and primary key (ForeignKey constraint) + inspector = sa.inspect(op.get_bind()) + columns = inspector.get_columns("user") + + pk_columns = inspector.get_pk_constraint("user")["constrained_columns"] + id_column = next((col for col in columns if col["name"] == "id"), None) + + if id_column and not id_column.get("unique", False): + unique_constraints = inspector.get_unique_constraints("user") + unique_columns = {tuple(u["column_names"]) for u in unique_constraints} + + with op.batch_alter_table("user") as batch_op: + # If primary key is wrong, drop it + if pk_columns and pk_columns != ["id"]: + batch_op.drop_constraint( + inspector.get_pk_constraint("user")["name"], type_="primary" + ) + + # Add unique constraint if missing + if ("id",) not in unique_columns: + batch_op.create_unique_constraint("uq_user_id", ["id"]) + + # Re-create correct primary key + batch_op.create_primary_key("pk_user_id", ["id"]) + # Create oauth_session table op.create_table( "oauth_session", - sa.Column("id", sa.Text(), nullable=False), - sa.Column("user_id", sa.Text(), nullable=False), + sa.Column("id", sa.Text(), primary_key=True, nullable=False, unique=True), + sa.Column( + "user_id", + sa.Text(), + sa.ForeignKey("user.id", ondelete="CASCADE"), + nullable=False, + ), sa.Column("provider", sa.Text(), nullable=False), sa.Column("token", sa.Text(), nullable=False), sa.Column("expires_at", sa.BigInteger(), nullable=False), sa.Column("created_at", sa.BigInteger(), nullable=False), sa.Column("updated_at", sa.BigInteger(), nullable=False), - sa.PrimaryKeyConstraint("id"), - sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"), ) # Create indexes for better performance diff --git a/backend/open_webui/migrations/versions/b10670c03dd5_update_user_table.py b/backend/open_webui/migrations/versions/b10670c03dd5_update_user_table.py new file mode 100644 index 0000000000..f35a382645 --- /dev/null +++ b/backend/open_webui/migrations/versions/b10670c03dd5_update_user_table.py @@ -0,0 +1,251 @@ +"""Update user table + +Revision ID: b10670c03dd5 +Revises: 2f1211949ecc +Create Date: 2025-11-28 04:55:31.737538 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +import open_webui.internal.db +import json +import time + +# revision identifiers, used by Alembic. +revision: str = "b10670c03dd5" +down_revision: Union[str, None] = "2f1211949ecc" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _drop_sqlite_indexes_for_column(table_name, column_name, conn): + """ + SQLite requires manual removal of any indexes referencing a column + before ALTER TABLE ... DROP COLUMN can succeed. + """ + indexes = conn.execute(sa.text(f"PRAGMA index_list('{table_name}')")).fetchall() + + for idx in indexes: + index_name = idx[1] # index name + # Get indexed columns + idx_info = conn.execute( + sa.text(f"PRAGMA index_info('{index_name}')") + ).fetchall() + + indexed_cols = [row[2] for row in idx_info] # col names + if column_name in indexed_cols: + conn.execute(sa.text(f"DROP INDEX IF EXISTS {index_name}")) + + +def _convert_column_to_json(table: str, column: str): + conn = op.get_bind() + dialect = conn.dialect.name + + # SQLite cannot ALTER COLUMN → must recreate column + if dialect == "sqlite": + # 1. Add temporary column + op.add_column(table, sa.Column(f"{column}_json", sa.JSON(), nullable=True)) + + # 2. Load old data + rows = conn.execute(sa.text(f'SELECT id, {column} FROM "{table}"')).fetchall() + + for row in rows: + uid, raw = row + if raw is None: + parsed = None + else: + try: + parsed = json.loads(raw) + except Exception: + parsed = None # fallback safe behavior + + conn.execute( + sa.text(f'UPDATE "{table}" SET {column}_json = :val WHERE id = :id'), + {"val": json.dumps(parsed) if parsed else None, "id": uid}, + ) + + # 3. Drop old TEXT column + op.drop_column(table, column) + + # 4. Rename new JSON column → original name + op.alter_column(table, f"{column}_json", new_column_name=column) + + else: + # PostgreSQL supports direct CAST + op.alter_column( + table, + column, + type_=sa.JSON(), + postgresql_using=f"{column}::json", + ) + + +def _convert_column_to_text(table: str, column: str): + conn = op.get_bind() + dialect = conn.dialect.name + + if dialect == "sqlite": + op.add_column(table, sa.Column(f"{column}_text", sa.Text(), nullable=True)) + + rows = conn.execute(sa.text(f'SELECT id, {column} FROM "{table}"')).fetchall() + + for uid, raw in rows: + conn.execute( + sa.text(f'UPDATE "{table}" SET {column}_text = :val WHERE id = :id'), + {"val": json.dumps(raw) if raw else None, "id": uid}, + ) + + op.drop_column(table, column) + op.alter_column(table, f"{column}_text", new_column_name=column) + + else: + op.alter_column( + table, + column, + type_=sa.Text(), + postgresql_using=f"to_json({column})::text", + ) + + +def upgrade() -> None: + op.add_column( + "user", sa.Column("profile_banner_image_url", sa.Text(), nullable=True) + ) + op.add_column("user", sa.Column("timezone", sa.String(), nullable=True)) + + op.add_column("user", sa.Column("presence_state", sa.String(), nullable=True)) + op.add_column("user", sa.Column("status_emoji", sa.String(), nullable=True)) + op.add_column("user", sa.Column("status_message", sa.Text(), nullable=True)) + op.add_column( + "user", sa.Column("status_expires_at", sa.BigInteger(), nullable=True) + ) + + op.add_column("user", sa.Column("oauth", sa.JSON(), nullable=True)) + + # Convert info (TEXT/JSONField) → JSON + _convert_column_to_json("user", "info") + # Convert settings (TEXT/JSONField) → JSON + _convert_column_to_json("user", "settings") + + op.create_table( + "api_key", + sa.Column("id", sa.Text(), primary_key=True, unique=True), + sa.Column("user_id", sa.Text(), sa.ForeignKey("user.id", ondelete="CASCADE")), + sa.Column("key", sa.Text(), unique=True, nullable=False), + sa.Column("data", sa.JSON(), nullable=True), + sa.Column("expires_at", sa.BigInteger(), nullable=True), + 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), + ) + + conn = op.get_bind() + users = conn.execute( + sa.text('SELECT id, oauth_sub FROM "user" WHERE oauth_sub IS NOT NULL') + ).fetchall() + + for uid, oauth_sub in users: + if oauth_sub: + # Example formats supported: + # provider@sub + # plain sub (stored as {"oidc": {"sub": sub}}) + if "@" in oauth_sub: + provider, sub = oauth_sub.split("@", 1) + else: + provider, sub = "oidc", oauth_sub + + oauth_json = json.dumps({provider: {"sub": sub}}) + conn.execute( + sa.text('UPDATE "user" SET oauth = :oauth WHERE id = :id'), + {"oauth": oauth_json, "id": uid}, + ) + + users_with_keys = conn.execute( + sa.text('SELECT id, api_key FROM "user" WHERE api_key IS NOT NULL') + ).fetchall() + now = int(time.time()) + + for uid, api_key in users_with_keys: + if api_key: + conn.execute( + sa.text( + """ + INSERT INTO api_key (id, user_id, key, created_at, updated_at) + VALUES (:id, :user_id, :key, :created_at, :updated_at) + """ + ), + { + "id": f"key_{uid}", + "user_id": uid, + "key": api_key, + "created_at": now, + "updated_at": now, + }, + ) + + if conn.dialect.name == "sqlite": + _drop_sqlite_indexes_for_column("user", "api_key", conn) + _drop_sqlite_indexes_for_column("user", "oauth_sub", conn) + + with op.batch_alter_table("user") as batch_op: + batch_op.drop_column("api_key") + batch_op.drop_column("oauth_sub") + + +def downgrade() -> None: + # --- 1. Restore old oauth_sub column --- + op.add_column("user", sa.Column("oauth_sub", sa.Text(), nullable=True)) + + conn = op.get_bind() + users = conn.execute( + sa.text('SELECT id, oauth FROM "user" WHERE oauth IS NOT NULL') + ).fetchall() + + for uid, oauth in users: + try: + data = json.loads(oauth) + provider = list(data.keys())[0] + sub = data[provider].get("sub") + oauth_sub = f"{provider}@{sub}" + except Exception: + oauth_sub = None + + conn.execute( + sa.text('UPDATE "user" SET oauth_sub = :oauth_sub WHERE id = :id'), + {"oauth_sub": oauth_sub, "id": uid}, + ) + + op.drop_column("user", "oauth") + + # --- 2. Restore api_key field --- + op.add_column("user", sa.Column("api_key", sa.String(), nullable=True)) + + # Restore values from api_key + keys = conn.execute(sa.text("SELECT user_id, key FROM api_key")).fetchall() + for uid, key in keys: + conn.execute( + sa.text('UPDATE "user" SET api_key = :key WHERE id = :id'), + {"key": key, "id": uid}, + ) + + # Drop new table + op.drop_table("api_key") + + with op.batch_alter_table("user") as batch_op: + batch_op.drop_column("profile_banner_image_url") + batch_op.drop_column("timezone") + + batch_op.drop_column("presence_state") + batch_op.drop_column("status_emoji") + batch_op.drop_column("status_message") + batch_op.drop_column("status_expires_at") + + # Convert info (JSON) → TEXT + _convert_column_to_text("user", "info") + # Convert settings (JSON) → TEXT + _convert_column_to_text("user", "settings") diff --git a/backend/open_webui/models/auths.py b/backend/open_webui/models/auths.py index 0d0b881a78..8b03580e6c 100644 --- a/backend/open_webui/models/auths.py +++ b/backend/open_webui/models/auths.py @@ -88,7 +88,7 @@ class AuthsTable: name: str, profile_image_url: str = "/user.png", role: str = "pending", - oauth_sub: Optional[str] = None, + oauth: Optional[dict] = None, ) -> Optional[UserModel]: with get_db() as db: log.info("insert_new_auth") @@ -102,7 +102,7 @@ class AuthsTable: db.add(result) user = Users.insert_new_user( - id, name, email, profile_image_url, role, oauth_sub + id, name, email, profile_image_url, role, oauth=oauth ) db.commit() diff --git a/backend/open_webui/models/messages.py b/backend/open_webui/models/messages.py index 1094035fd5..2351c4c54c 100644 --- a/backend/open_webui/models/messages.py +++ b/backend/open_webui/models/messages.py @@ -40,7 +40,7 @@ class MessageReactionModel(BaseModel): class Message(Base): __tablename__ = "message" - id = Column(Text, primary_key=True) + id = Column(Text, primary_key=True, unique=True) user_id = Column(Text) channel_id = Column(Text, nullable=True) @@ -90,6 +90,7 @@ class MessageModel(BaseModel): class MessageForm(BaseModel): + temp_id: Optional[str] = None content: str reply_to_id: Optional[str] = None parent_id: Optional[str] = None @@ -111,6 +112,10 @@ class MessageReplyToResponse(MessageUserResponse): reply_to_message: Optional[MessageUserResponse] = None +class MessageWithReactionsResponse(MessageUserResponse): + reactions: list[Reactions] + + class MessageResponse(MessageReplyToResponse): latest_reply_at: Optional[int] reply_count: int @@ -306,6 +311,20 @@ class MessageTable: ) return MessageModel.model_validate(message) if message else None + def get_pinned_messages_by_channel_id( + self, channel_id: str, skip: int = 0, limit: int = 50 + ) -> list[MessageModel]: + with get_db() as db: + all_messages = ( + db.query(Message) + .filter_by(channel_id=channel_id, is_pinned=True) + .order_by(Message.pinned_at.desc()) + .offset(skip) + .limit(limit) + .all() + ) + return [MessageModel.model_validate(message) for message in all_messages] + def update_message_by_id( self, id: str, form_data: MessageForm ) -> Optional[MessageModel]: @@ -325,7 +344,7 @@ class MessageTable: db.refresh(message) return MessageModel.model_validate(message) if message else None - def update_message_pin_by_id( + def update_is_pinned_by_id( self, id: str, is_pinned: bool, pinned_by: Optional[str] = None ) -> Optional[MessageModel]: with get_db() as db: @@ -333,7 +352,6 @@ class MessageTable: message.is_pinned = is_pinned message.pinned_at = int(time.time_ns()) if is_pinned else None message.pinned_by = pinned_by if is_pinned else None - message.updated_at = int(time.time_ns()) db.commit() db.refresh(message) return MessageModel.model_validate(message) if message else None diff --git a/backend/open_webui/models/models.py b/backend/open_webui/models/models.py index 329b87a91f..8ddcf59d39 100755 --- a/backend/open_webui/models/models.py +++ b/backend/open_webui/models/models.py @@ -53,7 +53,7 @@ class ModelMeta(BaseModel): class Model(Base): __tablename__ = "model" - id = Column(Text, primary_key=True) + id = Column(Text, primary_key=True, unique=True) """ The model's id as used in the API. If set to an existing model, it will override the model. """ diff --git a/backend/open_webui/models/notes.py b/backend/open_webui/models/notes.py index f1b11f071e..af75fab598 100644 --- a/backend/open_webui/models/notes.py +++ b/backend/open_webui/models/notes.py @@ -23,7 +23,7 @@ from sqlalchemy.sql import exists class Note(Base): __tablename__ = "note" - id = Column(Text, primary_key=True) + id = Column(Text, primary_key=True, unique=True) user_id = Column(Text) title = Column(Text) diff --git a/backend/open_webui/models/oauth_sessions.py b/backend/open_webui/models/oauth_sessions.py index b0e465dbe7..d07faad35e 100644 --- a/backend/open_webui/models/oauth_sessions.py +++ b/backend/open_webui/models/oauth_sessions.py @@ -25,7 +25,7 @@ log.setLevel(SRC_LOG_LEVELS["MODELS"]) class OAuthSession(Base): __tablename__ = "oauth_session" - id = Column(Text, primary_key=True) + id = Column(Text, primary_key=True, unique=True) user_id = Column(Text, nullable=False) provider = Column(Text, nullable=False) token = Column( diff --git a/backend/open_webui/models/tools.py b/backend/open_webui/models/tools.py index 48f84b3ac4..7f6c7fd3f5 100644 --- a/backend/open_webui/models/tools.py +++ b/backend/open_webui/models/tools.py @@ -24,7 +24,7 @@ log.setLevel(SRC_LOG_LEVELS["MODELS"]) class Tool(Base): __tablename__ = "tool" - id = Column(String, primary_key=True) + id = Column(String, primary_key=True, unique=True) user_id = Column(String) name = Column(Text) content = Column(Text) diff --git a/backend/open_webui/models/users.py b/backend/open_webui/models/users.py index 5809a7124f..ede5f5e761 100644 --- a/backend/open_webui/models/users.py +++ b/backend/open_webui/models/users.py @@ -11,7 +11,17 @@ from open_webui.utils.misc import throttle from pydantic import BaseModel, ConfigDict -from sqlalchemy import BigInteger, Column, String, Text, Date, exists, select +from sqlalchemy import ( + BigInteger, + JSON, + Column, + String, + Boolean, + Text, + Date, + exists, + select, +) from sqlalchemy import or_, case import datetime @@ -21,59 +31,71 @@ import datetime #################### -class User(Base): - __tablename__ = "user" - - id = Column(String, primary_key=True) - name = Column(String) - - email = Column(String) - username = Column(String(50), nullable=True) - - role = Column(String) - profile_image_url = Column(Text) - - bio = Column(Text, nullable=True) - gender = Column(Text, nullable=True) - date_of_birth = Column(Date, nullable=True) - - info = Column(JSONField, nullable=True) - settings = Column(JSONField, nullable=True) - - api_key = Column(String, nullable=True, unique=True) - oauth_sub = Column(Text, unique=True) - - last_active_at = Column(BigInteger) - - updated_at = Column(BigInteger) - created_at = Column(BigInteger) - - class UserSettings(BaseModel): ui: Optional[dict] = {} model_config = ConfigDict(extra="allow") pass +class User(Base): + __tablename__ = "user" + + id = Column(String, primary_key=True, unique=True) + email = Column(String) + username = Column(String(50), nullable=True) + role = Column(String) + + name = Column(String) + + profile_image_url = Column(Text) + profile_banner_image_url = Column(Text, nullable=True) + + bio = Column(Text, nullable=True) + gender = Column(Text, nullable=True) + date_of_birth = Column(Date, nullable=True) + timezone = Column(String, nullable=True) + + presence_state = Column(String, nullable=True) + status_emoji = Column(String, nullable=True) + status_message = Column(Text, nullable=True) + status_expires_at = Column(BigInteger, nullable=True) + + info = Column(JSON, nullable=True) + settings = Column(JSON, nullable=True) + + oauth = Column(JSON, nullable=True) + + last_active_at = Column(BigInteger) + updated_at = Column(BigInteger) + created_at = Column(BigInteger) + + class UserModel(BaseModel): id: str - name: str email: str username: Optional[str] = None - role: str = "pending" + + name: str + profile_image_url: str + profile_banner_image_url: Optional[str] = None bio: Optional[str] = None gender: Optional[str] = None date_of_birth: Optional[datetime.date] = None + timezone: Optional[str] = None + + presence_state: Optional[str] = None + status_emoji: Optional[str] = None + status_message: Optional[str] = None + status_expires_at: Optional[int] = None info: Optional[dict] = None settings: Optional[UserSettings] = None - api_key: Optional[str] = None - oauth_sub: Optional[str] = None + oauth: Optional[dict] = None last_active_at: int # timestamp in epoch updated_at: int # timestamp in epoch @@ -82,6 +104,32 @@ class UserModel(BaseModel): model_config = ConfigDict(from_attributes=True) +class ApiKey(Base): + __tablename__ = "api_key" + + id = Column(Text, primary_key=True, unique=True) + user_id = Column(Text, nullable=False) + key = Column(Text, unique=True, nullable=False) + data = Column(JSON, nullable=True) + expires_at = Column(BigInteger, nullable=True) + last_used_at = Column(BigInteger, nullable=True) + created_at = Column(BigInteger, nullable=False) + updated_at = Column(BigInteger, nullable=False) + + +class ApiKeyModel(BaseModel): + id: str + user_id: str + key: str + data: Optional[dict] = None + expires_at: Optional[int] = None + last_used_at: Optional[int] = None + created_at: int # timestamp in epoch + updated_at: int # timestamp in epoch + + model_config = ConfigDict(from_attributes=True) + + #################### # Forms #################### @@ -125,6 +173,12 @@ class UserIdNameResponse(BaseModel): name: str +class UserIdNameStatusResponse(BaseModel): + id: str + name: str + is_active: bool = False + + class UserInfoListResponse(BaseModel): users: list[UserInfoResponse] total: int @@ -171,20 +225,20 @@ class UsersTable: email: str, profile_image_url: str = "/user.png", role: str = "pending", - oauth_sub: Optional[str] = None, + oauth: Optional[dict] = None, ) -> Optional[UserModel]: with get_db() as db: user = UserModel( **{ "id": id, - "name": name, "email": email, + "name": name, "role": role, "profile_image_url": profile_image_url, "last_active_at": int(time.time()), "created_at": int(time.time()), "updated_at": int(time.time()), - "oauth_sub": oauth_sub, + "oauth": oauth, } ) result = User(**user.model_dump()) @@ -207,8 +261,13 @@ class UsersTable: def get_user_by_api_key(self, api_key: str) -> Optional[UserModel]: try: with get_db() as db: - user = db.query(User).filter_by(api_key=api_key).first() - return UserModel.model_validate(user) + user = ( + db.query(User) + .join(ApiKey, User.id == ApiKey.user_id) + .filter(ApiKey.key == api_key) + .first() + ) + return UserModel.model_validate(user) if user else None except Exception: return None @@ -220,11 +279,15 @@ class UsersTable: except Exception: return None - def get_user_by_oauth_sub(self, sub: str) -> Optional[UserModel]: + def get_user_by_oauth_sub(self, provider: str, sub: str) -> Optional[UserModel]: try: with get_db() as db: - user = db.query(User).filter_by(oauth_sub=sub).first() - return UserModel.model_validate(user) + user = ( + db.query(User) + .filter(User.oauth.contains({provider: {"sub": sub}})) + .first() + ) + return UserModel.model_validate(user) if user else None except Exception: return None @@ -426,7 +489,7 @@ class UsersTable: return None @throttle(DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL) - def update_user_last_active_by_id(self, id: str) -> Optional[UserModel]: + def update_last_active_by_id(self, id: str) -> Optional[UserModel]: try: with get_db() as db: db.query(User).filter_by(id=id).update( @@ -439,16 +502,35 @@ class UsersTable: except Exception: return None - def update_user_oauth_sub_by_id( - self, id: str, oauth_sub: str + def update_user_oauth_by_id( + self, id: str, provider: str, sub: str ) -> Optional[UserModel]: + """ + Update or insert an OAuth provider/sub pair into the user's oauth JSON field. + Example resulting structure: + { + "google": { "sub": "123" }, + "github": { "sub": "abc" } + } + """ try: with get_db() as db: - db.query(User).filter_by(id=id).update({"oauth_sub": oauth_sub}) + user = db.query(User).filter_by(id=id).first() + if not user: + return None + + # Load existing oauth JSON or create empty + oauth = user.oauth or {} + + # Update or insert provider entry + oauth[provider] = {"sub": sub} + + # Persist updated JSON + db.query(User).filter_by(id=id).update({"oauth": oauth}) db.commit() - user = db.query(User).filter_by(id=id).first() return UserModel.model_validate(user) + except Exception: return None @@ -502,23 +584,45 @@ class UsersTable: except Exception: return False - def update_user_api_key_by_id(self, id: str, api_key: str) -> bool: - try: - with get_db() as db: - result = db.query(User).filter_by(id=id).update({"api_key": api_key}) - db.commit() - return True if result == 1 else False - except Exception: - return False - def get_user_api_key_by_id(self, id: str) -> Optional[str]: try: with get_db() as db: - user = db.query(User).filter_by(id=id).first() - return user.api_key + api_key = db.query(ApiKey).filter_by(user_id=id).first() + return api_key.key if api_key else None except Exception: return None + def update_user_api_key_by_id(self, id: str, api_key: str) -> bool: + try: + with get_db() as db: + db.query(ApiKey).filter_by(user_id=id).delete() + db.commit() + + now = int(time.time()) + new_api_key = ApiKey( + id=f"key_{id}", + user_id=id, + key=api_key, + created_at=now, + updated_at=now, + ) + db.add(new_api_key) + db.commit() + + return True + + except Exception: + return False + + def delete_user_api_key_by_id(self, id: str) -> bool: + try: + with get_db() as db: + db.query(ApiKey).filter_by(user_id=id).delete() + db.commit() + return True + except Exception: + return False + def get_valid_user_ids(self, user_ids: list[str]) -> list[str]: with get_db() as db: users = db.query(User).filter(User.id.in_(user_ids)).all() @@ -532,5 +636,23 @@ class UsersTable: else: return None + def get_active_user_count(self) -> int: + with get_db() as db: + # Consider user active if last_active_at within the last 3 minutes + three_minutes_ago = int(time.time()) - 180 + count = ( + db.query(User).filter(User.last_active_at >= three_minutes_ago).count() + ) + return count + + def is_user_active(self, user_id: str) -> bool: + with get_db() as db: + user = db.query(User).filter_by(id=user_id).first() + if user and user.last_active_at: + # Consider user active if last_active_at within the last 3 minutes + three_minutes_ago = int(time.time()) - 180 + return user.last_active_at >= three_minutes_ago + return False + Users = UsersTable() diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py index 24cbd9a03f..1b79d84cfd 100644 --- a/backend/open_webui/routers/auths.py +++ b/backend/open_webui/routers/auths.py @@ -1133,8 +1133,7 @@ async def generate_api_key(request: Request, user=Depends(get_current_user)): # delete api key @router.delete("/api_key", response_model=bool) async def delete_api_key(user=Depends(get_current_user)): - success = Users.update_user_api_key_by_id(user.id, None) - return success + return Users.delete_user_api_key_by_id(user.id) # get api key diff --git a/backend/open_webui/routers/channels.py b/backend/open_webui/routers/channels.py index 1bf905155e..d492176a00 100644 --- a/backend/open_webui/routers/channels.py +++ b/backend/open_webui/routers/channels.py @@ -10,10 +10,10 @@ from pydantic import BaseModel from open_webui.socket.main import ( sio, get_user_ids_from_room, - get_active_status_by_user_id, ) from open_webui.models.users import ( UserIdNameResponse, + UserIdNameStatusResponse, UserListResponse, UserModelResponse, Users, @@ -31,6 +31,7 @@ from open_webui.models.messages import ( Messages, MessageModel, MessageResponse, + MessageWithReactionsResponse, MessageForm, ) @@ -68,7 +69,7 @@ router = APIRouter() class ChannelListItemResponse(ChannelModel): user_ids: Optional[list[str]] = None # 'dm' channels only - users: Optional[list[UserIdNameResponse]] = None # 'dm' channels only + users: Optional[list[UserIdNameStatusResponse]] = None # 'dm' channels only last_message_at: Optional[int] = None # timestamp in epoch (time_ns) unread_count: int = 0 @@ -97,7 +98,9 @@ async def get_channels(user=Depends(get_verified_user)): 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) ] @@ -278,7 +281,7 @@ async def get_channel_members_by_id( return { "users": [ UserModelResponse( - **user.model_dump(), is_active=get_active_status_by_user_id(user.id) + **user.model_dump(), is_active=Users.is_user_active(user.id) ) for user in users ], @@ -310,7 +313,7 @@ async def get_channel_members_by_id( return { "users": [ UserModelResponse( - **user.model_dump(), is_active=get_active_status_by_user_id(user.id) + **user.model_dump(), is_active=Users.is_user_active(user.id) ) for user in users ], @@ -461,6 +464,62 @@ async def get_channel_messages( return messages +############################ +# GetPinnedChannelMessages +############################ + +PAGE_ITEM_COUNT_PINNED = 20 + + +@router.get("/{id}/messages/pinned", response_model=list[MessageWithReactionsResponse]) +async def get_pinned_channel_messages( + id: str, page: int = 1, user=Depends(get_verified_user) +): + 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.type == "dm": + if not Channels.is_user_channel_member(channel.id, user.id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() + ) + else: + if user.role != "admin" and not has_access( + user.id, type="read", access_control=channel.access_control + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() + ) + + page = max(1, page) + skip = (page - 1) * PAGE_ITEM_COUNT_PINNED + limit = PAGE_ITEM_COUNT_PINNED + + message_list = Messages.get_pinned_messages_by_channel_id(id, skip, limit) + users = {} + + messages = [] + for message in message_list: + if message.user_id not in users: + user = Users.get_user_by_id(message.user_id) + users[message.user_id] = user + + messages.append( + MessageWithReactionsResponse( + **{ + **message.model_dump(), + "reactions": Messages.get_reactions_by_message_id(message.id), + "user": UserNameResponse(**users[message.user_id].model_dump()), + } + ) + ) + + return messages + + ############################ # PostNewMessage ############################ @@ -706,7 +765,7 @@ async def new_message_handler( "message_id": message.id, "data": { "type": "message", - "data": message.model_dump(), + "data": {"temp_id": form_data.temp_id, **message.model_dump()}, }, "user": UserNameResponse(**user.model_dump()).model_dump(), "channel": channel.model_dump(), @@ -832,6 +891,69 @@ async def get_channel_message( ) +############################ +# PinChannelMessage +############################ + + +class PinMessageForm(BaseModel): + is_pinned: bool + + +@router.post( + "/{id}/messages/{message_id}/pin", response_model=Optional[MessageUserResponse] +) +async def pin_channel_message( + id: str, message_id: str, form_data: PinMessageForm, user=Depends(get_verified_user) +): + 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.type == "dm": + if not Channels.is_user_channel_member(channel.id, user.id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() + ) + else: + if user.role != "admin" and not has_access( + user.id, type="read", access_control=channel.access_control + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() + ) + + message = Messages.get_message_by_id(message_id) + if not message: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND + ) + + if message.channel_id != id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() + ) + + try: + Messages.update_is_pinned_by_id(message_id, form_data.is_pinned, user.id) + message = Messages.get_message_by_id(message_id) + return MessageUserResponse( + **{ + **message.model_dump(), + "user": UserNameResponse( + **Users.get_user_by_id(message.user_id).model_dump() + ), + } + ) + except Exception as e: + log.exception(e) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() + ) + + ############################ # GetChannelThreadMessages ############################ diff --git a/backend/open_webui/routers/ollama.py b/backend/open_webui/routers/ollama.py index 9606763b00..7873efafce 100644 --- a/backend/open_webui/routers/ollama.py +++ b/backend/open_webui/routers/ollama.py @@ -879,6 +879,7 @@ async def delete_model( url = request.app.state.config.OLLAMA_BASE_URLS[url_idx] key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS) + r = None try: headers = { "Content-Type": "application/json", @@ -892,7 +893,7 @@ async def delete_model( method="DELETE", url=f"{url}/api/delete", headers=headers, - data=form_data.model_dump_json(exclude_none=True).encode(), + json=form_data, ) r.raise_for_status() @@ -949,10 +950,7 @@ async def show_model_info( headers = include_user_info_headers(headers, user) r = requests.request( - method="POST", - url=f"{url}/api/show", - headers=headers, - data=form_data.model_dump_json(exclude_none=True).encode(), + method="POST", url=f"{url}/api/show", headers=headers, json=form_data ) r.raise_for_status() diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index 9b30ba8f20..7c4b801f4d 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -26,12 +26,6 @@ from open_webui.models.users import ( UserUpdateForm, ) - -from open_webui.socket.main import ( - get_active_status_by_user_id, - get_active_user_ids, - get_user_active_status, -) from open_webui.constants import ERROR_MESSAGES from open_webui.env import SRC_LOG_LEVELS, STATIC_DIR @@ -51,23 +45,6 @@ log.setLevel(SRC_LOG_LEVELS["MODELS"]) router = APIRouter() -############################ -# GetActiveUsers -############################ - - -@router.get("/active") -async def get_active_users( - user=Depends(get_verified_user), -): - """ - Get a list of active users. - """ - return { - "user_ids": get_active_user_ids(), - } - - ############################ # GetUsers ############################ @@ -364,7 +341,7 @@ async def update_user_info_by_session_user( class UserActiveResponse(BaseModel): name: str profile_image_url: Optional[str] = None - active: Optional[bool] = None + is_active: bool model_config = ConfigDict(extra="allow") @@ -390,7 +367,7 @@ async def get_user_by_id(user_id: str, user=Depends(get_verified_user)): **{ "id": user.id, "name": user.name, - "active": get_active_status_by_user_id(user_id), + "is_active": Users.is_user_active(user_id), } ) else: @@ -457,7 +434,7 @@ async def get_user_profile_image_by_id(user_id: str, user=Depends(get_verified_u @router.get("/{user_id}/active", response_model=dict) async def get_user_active_status_by_id(user_id: str, user=Depends(get_verified_user)): return { - "active": get_user_active_status(user_id), + "active": Users.is_user_active(user_id), } diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py index 04b67dd786..84705648d9 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py @@ -132,12 +132,6 @@ if WEBSOCKET_MANAGER == "redis": redis_sentinels=redis_sentinels, redis_cluster=WEBSOCKET_REDIS_CLUSTER, ) - USER_POOL = RedisDict( - f"{REDIS_KEY_PREFIX}:user_pool", - redis_url=WEBSOCKET_REDIS_URL, - redis_sentinels=redis_sentinels, - redis_cluster=WEBSOCKET_REDIS_CLUSTER, - ) USAGE_POOL = RedisDict( f"{REDIS_KEY_PREFIX}:usage_pool", redis_url=WEBSOCKET_REDIS_URL, @@ -159,7 +153,6 @@ else: MODELS = {} SESSION_POOL = {} - USER_POOL = {} USAGE_POOL = {} aquire_func = release_func = renew_func = lambda: True @@ -235,16 +228,6 @@ def get_models_in_use(): return models_in_use -def get_active_user_ids(): - """Get the list of active user IDs.""" - return list(USER_POOL.keys()) - - -def get_user_active_status(user_id): - """Check if a user is currently active.""" - return user_id in USER_POOL - - def get_user_id_from_session_pool(sid): user = SESSION_POOL.get(sid) if user: @@ -270,12 +253,6 @@ def get_user_ids_from_room(room): return active_user_ids -def get_active_status_by_user_id(user_id): - if user_id in USER_POOL: - return True - return False - - @sio.on("usage") async def usage(sid, data): if sid in SESSION_POOL: @@ -303,11 +280,6 @@ async def connect(sid, environ, auth): SESSION_POOL[sid] = user.model_dump( exclude=["date_of_birth", "bio", "gender"] ) - if user.id in USER_POOL: - USER_POOL[user.id] = USER_POOL[user.id] + [sid] - else: - USER_POOL[user.id] = [sid] - await sio.enter_room(sid, f"user:{user.id}") @@ -326,11 +298,15 @@ async def user_join(sid, data): if not user: return - SESSION_POOL[sid] = user.model_dump(exclude=["date_of_birth", "bio", "gender"]) - if user.id in USER_POOL: - USER_POOL[user.id] = USER_POOL[user.id] + [sid] - else: - USER_POOL[user.id] = [sid] + SESSION_POOL[sid] = user.model_dump( + exclude=[ + "profile_image_url", + "profile_banner_image_url", + "date_of_birth", + "bio", + "gender", + ] + ) await sio.enter_room(sid, f"user:{user.id}") # Join all the channels @@ -341,6 +317,13 @@ async def user_join(sid, data): return {"id": user.id, "name": user.name} +@sio.on("heartbeat") +async def heartbeat(sid, data): + user = SESSION_POOL.get(sid) + if user: + Users.update_last_active_by_id(user["id"]) + + @sio.on("join-channels") async def join_channel(sid, data): auth = data["auth"] if "auth" in data else None @@ -669,13 +652,6 @@ async def disconnect(sid): if sid in SESSION_POOL: user = SESSION_POOL[sid] del SESSION_POOL[sid] - - user_id = user["id"] - USER_POOL[user_id] = [_sid for _sid in USER_POOL[user_id] if _sid != sid] - - if len(USER_POOL[user_id]) == 0: - del USER_POOL[user_id] - await YDOC_MANAGER.remove_user_from_all_documents(sid) else: pass diff --git a/backend/open_webui/utils/auth.py b/backend/open_webui/utils/auth.py index f3069a093f..3f05256c70 100644 --- a/backend/open_webui/utils/auth.py +++ b/backend/open_webui/utils/auth.py @@ -344,9 +344,7 @@ async def get_current_user( # Refresh the user's last active timestamp asynchronously # to prevent blocking the request if background_tasks: - background_tasks.add_task( - Users.update_user_last_active_by_id, user.id - ) + background_tasks.add_task(Users.update_last_active_by_id, user.id) return user else: raise HTTPException( @@ -397,8 +395,7 @@ def get_current_user_by_api_key(request, api_key: str): current_span.set_attribute("client.user.role", user.role) current_span.set_attribute("client.auth.type", "api_key") - Users.update_user_last_active_by_id(user.id) - + Users.update_last_active_by_id(user.id) return user diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index cc2de8e1c7..031c0e53f6 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -32,7 +32,6 @@ from open_webui.models.users import Users from open_webui.socket.main import ( get_event_call, get_event_emitter, - get_active_status_by_user_id, ) from open_webui.routers.tasks import ( generate_queries, @@ -773,19 +772,23 @@ async def chat_image_generation_handler( if not chat_id: return form_data - chat = Chats.get_chat_by_id_and_user_id(chat_id, user.id) - __event_emitter__ = extra_params["__event_emitter__"] - await __event_emitter__( - { - "type": "status", - "data": {"description": "Creating image", "done": False}, - } - ) - messages_map = chat.chat.get("history", {}).get("messages", {}) - message_id = chat.chat.get("history", {}).get("currentId") - message_list = get_message_list(messages_map, message_id) + if chat_id.startswith("local:"): + message_list = form_data.get("messages", []) + else: + chat = Chats.get_chat_by_id_and_user_id(chat_id, user.id) + await __event_emitter__( + { + "type": "status", + "data": {"description": "Creating image", "done": False}, + } + ) + + messages_map = chat.chat.get("history", {}).get("messages", {}) + message_id = chat.chat.get("history", {}).get("currentId") + message_list = get_message_list(messages_map, message_id) + user_message = get_last_user_message(message_list) prompt = user_message @@ -1915,7 +1918,7 @@ async def process_chat_response( ) # Send a webhook notification if the user is not active - if not get_active_status_by_user_id(user.id): + if not Users.is_user_active(user.id): webhook_url = Users.get_user_webhook_url_by_id(user.id) if webhook_url: await post_webhook( @@ -3210,7 +3213,7 @@ async def process_chat_response( ) # Send a webhook notification if the user is not active - if not get_active_status_by_user_id(user.id): + if not Users.is_user_active(user.id): webhook_url = Users.get_user_webhook_url_by_id(user.id) if webhook_url: await post_webhook( diff --git a/backend/open_webui/utils/misc.py b/backend/open_webui/utils/misc.py index 5591fcdb3f..c7ff2a3edd 100644 --- a/backend/open_webui/utils/misc.py +++ b/backend/open_webui/utils/misc.py @@ -54,7 +54,6 @@ def is_string_allowed(string: str, filter_list: Optional[list[str]] = None) -> b return True allow_list, block_list = get_allow_block_lists(filter_list) - print(string, allow_list, block_list) # If allow list is non-empty, require domain to match one of them if allow_list: diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index 06289fb18f..c23d76cb76 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -1496,7 +1496,10 @@ class OAuthManager: log.warning(f"OAuth callback failed, sub is missing: {user_data}") raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) - provider_sub = f"{provider}@{sub}" + oauth_data = {} + oauth_data[provider] = { + "sub": sub, + } # Email extraction email_claim = auth_manager_config.OAUTH_EMAIL_CLAIM @@ -1543,13 +1546,12 @@ class OAuthManager: log.warning(f"Error fetching GitHub email: {e}") raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) elif ENABLE_OAUTH_EMAIL_FALLBACK: - email = f"{provider_sub}.local" + email = f"{provider}@{sub}.local" else: log.warning(f"OAuth callback failed, email is missing: {user_data}") raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) email = email.lower() - # If allowed domains are configured, check if the email domain is in the list if ( "*" not in auth_manager_config.OAUTH_ALLOWED_DOMAINS @@ -1570,7 +1572,7 @@ class OAuthManager: user = Users.get_user_by_email(email) if user: # Update the user with the new oauth sub - Users.update_user_oauth_sub_by_id(user.id, provider_sub) + Users.update_user_oauth_by_id(user.id, provider, sub) if user: determined_role = await self.get_user_role( diff --git a/backend/open_webui/utils/telemetry/metrics.py b/backend/open_webui/utils/telemetry/metrics.py index 85bd418844..d935ddaafa 100644 --- a/backend/open_webui/utils/telemetry/metrics.py +++ b/backend/open_webui/utils/telemetry/metrics.py @@ -45,7 +45,6 @@ from open_webui.env import ( OTEL_METRICS_OTLP_SPAN_EXPORTER, OTEL_METRICS_EXPORTER_OTLP_INSECURE, ) -from open_webui.socket.main import get_active_user_ids from open_webui.models.users import Users _EXPORT_INTERVAL_MILLIS = 10_000 # 10 seconds @@ -135,7 +134,7 @@ def setup_metrics(app: FastAPI, resource: Resource) -> None: ) -> Sequence[metrics.Observation]: return [ metrics.Observation( - value=len(get_active_user_ids()), + value=Users.get_active_user_count(), ) ] diff --git a/src/lib/apis/channels/index.ts b/src/lib/apis/channels/index.ts index 5b510491fe..e7c6b61cf8 100644 --- a/src/lib/apis/channels/index.ts +++ b/src/lib/apis/channels/index.ts @@ -299,6 +299,44 @@ export const getChannelMessages = async ( return res; }; +export const getChannelPinnedMessages = async ( + token: string = '', + channel_id: string, + page: number = 1 +) => { + let error = null; + + const res = await fetch( + `${WEBUI_API_BASE_URL}/channels/${channel_id}/messages/pinned?page=${page}`, + { + 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 getChannelThreadMessages = async ( token: string = '', channel_id: string, @@ -340,6 +378,7 @@ export const getChannelThreadMessages = async ( }; type MessageForm = { + temp_id?: string; reply_to_id?: string; parent_id?: string; content: string; @@ -379,6 +418,46 @@ export const sendMessage = async (token: string = '', channel_id: string, messag return res; }; +export const pinMessage = async ( + token: string = '', + channel_id: string, + message_id: string, + is_pinned: boolean +) => { + let error = null; + + const res = await fetch( + `${WEBUI_API_BASE_URL}/channels/${channel_id}/messages/${message_id}/pin`, + { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify({ is_pinned }) + } + ) + .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 updateMessage = async ( token: string = '', channel_id: string, diff --git a/src/lib/components/admin/Settings/Models/Manage/ManageOllama.svelte b/src/lib/components/admin/Settings/Models/Manage/ManageOllama.svelte index 75ee9b2b02..1c96ef8127 100644 --- a/src/lib/components/admin/Settings/Models/Manage/ManageOllama.svelte +++ b/src/lib/components/admin/Settings/Models/Manage/ManageOllama.svelte @@ -811,9 +811,8 @@ bind:value={deleteModelTag} placeholder={$i18n.t('Select a model')} > - {#if !deleteModelTag} - - {/if} + + {#each ollamaModels as model} -
+
user
{user.name}
+ + {#if user?.last_active_at && Date.now() / 1000 - user.last_active_at < 180} +
+ + + + +
+ {/if}
{user.email} diff --git a/src/lib/components/admin/Users/UserList/EditUserModal.svelte b/src/lib/components/admin/Users/UserList/EditUserModal.svelte index 9adbac0e4f..f73551219a 100644 --- a/src/lib/components/admin/Users/UserList/EditUserModal.svelte +++ b/src/lib/components/admin/Users/UserList/EditUserModal.svelte @@ -180,12 +180,17 @@
- {#if _user?.oauth_sub} + {#if _user?.oauth}
{$i18n.t('OAuth ID')}
-
- {_user.oauth_sub ?? ''} +
+ {#each Object.keys(_user.oauth) as key} +
+ {key} + {_user.oauth[key]?.sub} +
+ {/each}
{/if} diff --git a/src/lib/components/channel/Channel.svelte b/src/lib/components/channel/Channel.svelte index 3caf18bcab..88e50ad80c 100644 --- a/src/lib/components/channel/Channel.svelte +++ b/src/lib/components/channel/Channel.svelte @@ -4,8 +4,16 @@ import { onDestroy, onMount, tick } from 'svelte'; import { goto } from '$app/navigation'; + import { v4 as uuidv4 } from 'uuid'; - import { chatId, channelId as _channelId, showSidebar, socket, user } from '$lib/stores'; + import { + chatId, + channels, + channelId as _channelId, + showSidebar, + socket, + user + } from '$lib/stores'; import { getChannelById, getChannelMessages, sendMessage } from '$lib/apis/channels'; import Messages from './Messages.svelte'; @@ -15,6 +23,7 @@ import EllipsisVertical from '../icons/EllipsisVertical.svelte'; import Thread from './Thread.svelte'; import i18n from '$lib/i18n'; + import Spinner from '../common/Spinner.svelte'; export let id = ''; @@ -53,6 +62,18 @@ type: 'last_read_at' } }); + + channels.set( + $channels.map((channel) => { + if (channel.id === channelId) { + return { + ...channel, + unread_count: 0 + }; + } + return channel; + }) + ); }; const initHandler = async () => { @@ -98,7 +119,8 @@ if (type === 'message') { if ((data?.parent_id ?? null) === null) { - messages = [data, ...messages]; + const tempId = data?.temp_id ?? null; + messages = [{ ...data, temp_id: null }, ...messages.filter((m) => m?.temp_id !== tempId)]; if (typingUsers.find((user) => user.id === event.user.id)) { typingUsers = typingUsers.filter((user) => user.id !== event.user.id); @@ -163,11 +185,30 @@ return; } - const res = await sendMessage(localStorage.token, id, { + const tempId = uuidv4(); + + const message = { + temp_id: tempId, content: content, data: data, reply_to_id: replyToMessage?.id ?? null - }).catch((error) => { + }; + + const ts = Date.now() * 1000000; // nanoseconds + messages = [ + { + ...message, + id: tempId, + user_id: $user?.id, + user: $user, + reply_to_message: replyToMessage ?? null, + created_at: ts, + updated_at: ts + }, + ...messages + ]; + + const res = await sendMessage(localStorage.token, id, message).catch((error) => { toast.error(`${error}`); return null; }); @@ -255,10 +296,23 @@ > - + { + messages = messages.map((message) => { + if (message.id === messageId) { + return { + ...message, + is_pinned: pinned + }; + } + return message; + }); + }} + /> -
- {#if channel} + {#if channel && messages !== null} +
{/key}
- {/if} -
+
-
- -
+
+ +
+ {:else} +
+
+ +
+
+ {/if}
{#if !largeScreen} diff --git a/src/lib/components/channel/MessageInput.svelte b/src/lib/components/channel/MessageInput.svelte index 02323a5f20..e36819fda7 100644 --- a/src/lib/components/channel/MessageInput.svelte +++ b/src/lib/components/channel/MessageInput.svelte @@ -865,7 +865,7 @@
- {#key $settings?.richTextInput} + {#key $settings?.richTextInput && $settings?.showFormattingToolbar} { messages = messages.filter((m) => m.id !== message.id); @@ -155,6 +162,26 @@ onReply={(message) => { onReply(message); }} + onPin={async (message) => { + messages = messages.map((m) => { + if (m.id === message.id) { + m.is_pinned = !m.is_pinned; + m.pinned_by = !m.is_pinned ? null : $user?.id; + m.pinned_at = !m.is_pinned ? null : Date.now() * 1000000; + } + return m; + }); + + const updatedMessage = await pinMessage( + localStorage.token, + message.channel_id, + message.id, + message.is_pinned + ).catch((error) => { + toast.error(`${error}`); + return null; + }); + }} onThread={(id) => { onThread(id); }} diff --git a/src/lib/components/channel/Messages/Message.svelte b/src/lib/components/channel/Messages/Message.svelte index 5379a7be00..dc3343f4ef 100644 --- a/src/lib/components/channel/Messages/Message.svelte +++ b/src/lib/components/channel/Messages/Message.svelte @@ -36,6 +36,10 @@ import Emoji from '$lib/components/common/Emoji.svelte'; import Skeleton from '$lib/components/chat/Messages/Skeleton.svelte'; import ArrowUpLeftAlt from '$lib/components/icons/ArrowUpLeftAlt.svelte'; + import PinSlash from '$lib/components/icons/PinSlash.svelte'; + import Pin from '$lib/components/icons/Pin.svelte'; + + export let className = ''; export let message; export let showUserProfile = true; @@ -43,10 +47,12 @@ export let replyToMessage = false; export let disabled = false; + export let pending = false; export let onDelete: Function = () => {}; export let onEdit: Function = () => {}; export let onReply: Function = () => {}; + export let onPin: Function = () => {}; export let onThread: Function = () => {}; export let onReaction: Function = () => {}; @@ -69,13 +75,17 @@ {#if message}
{#if !edit && !disabled} @@ -85,37 +95,56 @@
- (showButtons = false)} - onSubmit={(name) => { - showButtons = false; - onReaction(name); - }} - > - - - - - - - + + + {/if} + + {#if onReply} + + + + {/if} + + + - {#if !thread} + {#if !thread && onThread} - + {#if onEdit} + + + + {/if} - - - + {#if onDelete} + + + + {/if} {/if}
{/if} + {#if message?.is_pinned} +
+
+ + {$i18n.t('Pinned')} +
+
+ {/if} + {#if message?.reply_to_message?.user}
{/if} +
-
+
{#if showUserProfile} {#if message?.meta?.model_id} -
+
{#if showUserProfile}
@@ -338,7 +381,7 @@
{:else} -
+
{#if (message?.content ?? '').trim() === '' && message?.meta?.model_id} {:else} @@ -363,7 +406,9 @@ ? ' bg-blue-300/10 outline outline-blue-500/50 outline-1' : 'bg-gray-300/10 dark:bg-gray-500/10 hover:outline hover:outline-gray-700/30 dark:hover:outline-gray-300/30 hover:outline-1'}" on:click={() => { - onReaction(reaction.name); + if (onReaction) { + onReaction(name); + } }} > @@ -377,19 +422,21 @@ {/each} - { - onReaction(name); - }} - > - -
- -
-
-
+ {#if onReaction} + { + onReaction(name); + }} + > + +
+ +
+
+
+ {/if}
{/if} diff --git a/src/lib/components/channel/Messages/Message/ProfilePreview.svelte b/src/lib/components/channel/Messages/Message/ProfilePreview.svelte index 8ed95e63e8..a7daef9147 100644 --- a/src/lib/components/channel/Messages/Message/ProfilePreview.svelte +++ b/src/lib/components/channel/Messages/Message/ProfilePreview.svelte @@ -11,11 +11,21 @@ export let align = 'center'; export let side = 'right'; export let sideOffset = 8; + + let openPreview = false; - - - + + + diff --git a/src/lib/components/channel/Messages/Message/UserStatus.svelte b/src/lib/components/channel/Messages/Message/UserStatus.svelte index 7bca57774c..6ec9caa052 100644 --- a/src/lib/components/channel/Messages/Message/UserStatus.svelte +++ b/src/lib/components/channel/Messages/Message/UserStatus.svelte @@ -23,7 +23,7 @@
- {#if user?.active} + {#if user?.is_active}
{}; + -