diff --git a/README.md b/README.md index f3008ac..4df1f8c 100644 --- a/README.md +++ b/README.md @@ -3,126 +3,137 @@ ClefinCode Chat logo

ClefinCode Chat

-

Enhance business communication with multimedia messaging and ERPNext integration. +

Enhance business communication with multimedia messaging, ERPNext integration, and multi-platform customer messaging.

- Learn More » -
-
+ Documentation » + · Issues · Discussions +
+
+ Google Play + · + App Store

-
-Welcome to ClefinCode, where innovation meets digital transformation. We’re proud to introduce ClefinCode Chat, a groundbreaking solution designed to revolutionize the way businesses communicate. Our expertise in web and mobile application development has led us to create a platform that enhances, secures, and streamlines communication across your organization, ensuring that your business stays ahead in today’s digital world. - -Comprehensive Communication Solution: ClefinCode Chat offers a full suite of multimedia messaging capabilities, allowing your team to share pictures, videos, files, and voice clips effortlessly. With an intuitive interface, our chat application facilitates easy adoption, enabling direct messaging or group conversations without the complexity. -Advanced Features for Business Efficiency: Our application supports dynamic participation in conversations, topic-integrated discussions, and guest messaging via a website support portal, ensuring that your communication is both efficient and comprehensive. Manage privacy and collaboration within your organization with ease, fostering a secure and productive environment. - -Access Anywhere, Anytime: ClefinCode Chat is a free mobile app available for download from Google Play. This ensures that you and your team can stay connected, whether on the go or at the office. +
-Open Source and Customizable: Behind ClefinCode Chat is the powerful [ERPNext system](https://erpnext.com), supported by the open-source [Frappe application](https://frappeframework.com). You can download the backend code from GitHub and install it on your own server. This flexibility allows you to customize your ERPNext instance to suit your specific business needs, seamlessly integrating with both our web and mobile applications. +ClefinCode Chat is a business communication app built for ERPNext/Frappe. It brings internal team chat, customer support conversations, multimedia sharing, WhatsApp messaging, Telegram, Instagram, Messenger, topics, templates, notifications, and document-linked discussions into one connected workspace. -Dedicated Support: Our support section within the app is designed to assist you whenever you need information, help with an issue, or have questions about our ERPNext services and mobile application development. We are here to ensure that your experience with ClefinCode Chat and ERPNext is nothing short of exceptional. +The app supports direct messages, group conversations, document-linked topics, guest/support messaging, mobile access, and web chat controls designed for productivity inside ERPNext. - +
- Show more screenshots + Show more web screenshots -
-
+

-
-
+

-
-
+

-
-
+

-
-
+


+## Documentation + +Full product documentation is available here: + +- [ClefinCode Chat Documentation](https://website.clefincode.com/clefincode_chat_docs) +- [Web UI Features](https://website.clefincode.com/clefincode_chat_docs#web-ui-features) +- [Twilio WhatsApp Integration](https://website.clefincode.com/clefincode_chat_docs#twilio-whatsapp-integration) +- [Twilio Template](https://website.clefincode.com/clefincode_chat_docs#twilio-template) +- [Meta WhatsApp Cloud API Setup](https://website.clefincode.com/clefincode_chat_docs#meta-whatsapp-cloud-api-setup) +- [Edit and Add Contacts](https://website.clefincode.com/clefincode_chat_docs#edit-and-add-contacts) + ## Features -## 🌐 Comprehensive Communication Solution +### 🌐 Comprehensive Communication Solution + +- 💬 **Direct & Group Messaging**: Start one-on-one conversations or group chats with selected contacts. +- 🖼 **Multimedia Messaging**: Share images, videos, documents, links, and files directly in chat. +- 🎙 **Voice Clips**: Record and send short voice messages from the composer. +- 🔎 **Conversation Search**: Search chats and locate important conversations quickly. +- 🧵 **Multiple Web Conversations**: Keep multiple chat windows open in ERPNext web. +- 🌙 **Dark Theme**: Use a comfortable dark interface for extended work sessions. + +### 🚀 Business Collaboration Inside ERPNext + +- 🔗 **DocType / Topic Linking**: Link conversations to ERPNext records such as Task, Issue, Project, Invoice, Item, or other business documents. +- 👥 **Contributors**: Add or remove contributors from group conversations. +- @️⃣ **Mentions**: Mention users to direct attention in team conversations. +- 🧩 **Topic Conversations**: Organize related messages under topics and open dedicated topic conversations. +- 🔁 **ReLink Messages**: Link one or more existing messages to an existing or new topic. +- 🧾 **Message Info**: View message metadata, sender, linked topic, sent time, message type, and message ID. + +### 📲 Multi-Platform Messaging + +ClefinCode Chat supports customer and business messaging across multiple channels: -- 💬 **Direct & Group Messaging**: Smooth engagement in one-on-one or group chats. -- 🖼 **Multimedia Messaging**: Share pictures, videos, files, and voice clips effortlessly. -- 📱 **Intuitive Interface**: An easy-to-use application for quick adoption. +- WhatsApp +- Telegram +- Instagram +- Facebook Messenger -## 🚀 Advanced Features for Business Efficiency +Conversations can show platform badges so users can identify the channel directly from the chat interface. -- 🔄 **User / Doctype Mentions**: Flexibly join and contribute to conversations and topic-integrated discussions. -- 🌟 **Guest Messaging**: Enhance customer service with a website support portal. -- 📲 **WhatsApp Business API Integration**: Manage WhatsApp conversations directly within your ERP, centralizing and organizing interactions for invoices, projects, and day-to-day communications. +### 🌍 Access Anywhere, Anytime -## 🌍 Access Anywhere, Anytime +- 📲 **Mobile App Availability**: Free mobile app on [Google Play](https://play.google.com/store/apps/details?id=com.clefincode.chat&hl=en&gl=US) and [App Store](https://apps.apple.com/ae/app/clefincode-chat/id6478499855). +- 💻 **Web Access**: Runs inside your ERPNext/Frappe web environment. +- 🆘 **Support Conversation**: Contact ClefinCode Support from inside the app when enabled. -- 📲 **Mobile App Availability**: Free to download from [Google Play](https://play.google.com/store/apps/details?id=com.clefincode.chat&hl=en&gl=US) and [App Store](https://apps.apple.com/ae/app/clefincode-chat/id6478499855), keeping you connected whether on the go or at the office. -
- - Show mobile screenshots - -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
-
+
+ Show mobile screenshots + + +

+ +

+ +

+ +

+ +

+ +

+ +

+
-## 💻 Open Source and Customizable +### 💻 Open Source and Customizable -- 🛠 **Powered by ERPNext & Frappe**: Utilize the flexibility of open-source to customize your experience. -- 🔄 **Seamless Integration**: Seamlessly integrate with our web and mobile applications to suit specific business needs. +ClefinCode Chat is powered by [ERPNext](https://erpnext.com) and the [Frappe Framework](https://frappeframework.com). You can install the backend app on your own server and customize it to match your organization’s workflow. -## 🤝 Dedicated Support +### 🤝 Dedicated Support -- 🆘 **In-App Support Section**: Get instant assistance, information, and answers to your ERPNext and mobile app development queries. -- 🌟 **Exceptional Experience**: We're here to ensure your experience with ClefinCode Chat is nothing short of exceptional. +Use the in-app support section to request help, ask questions, or discuss ERPNext and mobile app development support with the ClefinCode team. -## Built with +## Built With -ClefinCode Chat is built using the [Frappe Framework](https://frappeframework.com) - an open-source full stack development framework. +ClefinCode Chat is built using the [Frappe Framework](https://frappeframework.com), an open-source full-stack development framework. -These are some of the tools it's built on: +Core technologies include: - [Python](https://www.python.org) - [Redis](https://redis.io/) - [MariaDB](https://mariadb.org/) - [Socket.io](https://socket.io/) - -The mobile app is built using [Flutter](https://flutter.dev/) -
+- [Flutter](https://flutter.dev/) for the mobile app ## Installation -Since ClefinCode Chat is a Frappe app, it can be installed via [frappe-bench](https://frappeframework.com/docs/v14/user/en/bench) on your local machine or on your production site. - -Once you have setup your bench and your site, you can install the app via the following commands: +Since ClefinCode Chat is a Frappe app, it can be installed via [frappe-bench](https://frappeframework.com/docs/v14/user/en/bench) on a local machine or production site. ```bash bench get-app https://github.com/clefincode/clefincode_chat.git @@ -131,229 +142,401 @@ bench --site yoursite.name migrate bench build ``` -**Note:** If migrating from version < 1.3.0 to > 1.3.0, run the following command before migrate: +**Note:** If migrating from version `< 1.3.0` to `> 1.3.0`, run the following command before migrate: ```bash bench setup requirements ``` -## Getting Started with WhatsApp in ERPNext -You'll first need to set up developer assets and obtain credentials from the Meta Developer Portal. Follow this guide to get started: -[Meta Developer Portal Guide](https://developers.facebook.com/docs/whatsapp/cloud-api/get-started#set-up-developer-assets) +## Quickstart -### 1. Enter WhatsApp Credentials +### Log in with your Frappe account - +Use your email and password, then confirm the correct server URL. You can switch servers when needed. -### 2. Set Up the WhatsApp Profile +- Enter your email and password. +- Confirm the server domain, for example `erp.clefincode.com`. +- Use **Change server** when you need to switch environments. - +Web access depends on your ERPNext permissions and enabled chat configuration. -**Important Tips:** +### Start a direct chat -- When entering the WhatsApp number, do not include `00` or `+`. Start directly with the country code and the number, e.g., `971xxxxxxxxx`. - -- There are two types of WhatsApp profiles you can create: - - 1. **Personal** 👤: Opens a direct communication channel between the sender and the receiver. - 2. **Support** 👥: Opens a group channel between the sender and receiver, allowing the admin to add or remove other members from the channel. +- Open the chat panel. +- Click the `+` button or new chat action. +- Search and select a contact. +- Send your first message to start the conversation. -- After saving the WhatsApp profile, a WhatsApp template will be automatically created. + - - +### Start a group chat -🎉Now, you can begin sending and receiving WhatsApp messages directly using our chat app within your ERP system🎉 +- Open the chat panel. +- Start a new conversation. +- Select multiple contacts. +- Confirm to create the group. +- Use group tools to manage the subject, topic, and contributors. - -Twilio WhatsApp Integration + -Connect WhatsApp through Twilio to manage inbound and outbound messages directly inside ERPNext. +### Manage contributors -1. Enter Twilio Credentials +- Open group details. +- Add contributors as needed. +- Remove contributors when the scope changes. +- Use **Exit group** when you no longer need access. -Add your Twilio credentials: + -Twilio Account SID +### Link chats to DocTypes and topics -Auth Token +Attach a chat topic to a document so the conversation stays aligned with a specific ERPNext record. - -2. Create a Twilio WhatsApp Profile +- Open the chat and access topic controls. +- Select the related DocType, such as Task, Issue, Project, Sales Invoice, or Item. +- Save the topic so linked messages remain organized. -Select the WhatsApp-enabled number from Twilio + -Select Provider: Twilio +## Messaging & Sharing -Choose a Type (Personal or Support) and save +### Share media and documents -Important tip: A template will be created automatically and its preview will be visible. +Users can share PDFs, images, camera captures, videos, links, and other files directly in the chat thread. - -3. Test WhatsApp Messaging -Test Sent Messages +On web, users can upload files or drag and drop them into the chat window. -Text +### Voice clips -Image +Voice messages are supported from the chat composer alongside other sharing options. -Voice note +Web voice clips - -Test Received Messages +## Multi-Platform Messaging Setup -Text +### WhatsApp setup through Meta Cloud API -Image +Use this option to connect WhatsApp Business Platform / Cloud API directly without Twilio. -Voice note +#### Requirements -Location +- Personal Facebook account to manage Meta Business. +- Meta Business Portfolio for the company. +- Work email, preferably on the company domain. +- Official company documents for Business Verification. +- Phone number not currently registered on WhatsApp and able to receive SMS or voice OTP. +- Public HTTPS webhook endpoint for incoming messages and delivery statuses. -Contact +#### Setup steps - -Twilio Templates +1. Create a Meta Business Portfolio. +2. Complete Business Verification and enable security requirements. +3. Create a Meta Developer App and add WhatsApp. +4. Add and verify the WhatsApp phone number. +5. Save the Phone Number ID and WhatsApp Business Account ID. +6. Create a System User token with these permissions: + - `whatsapp_business_messaging` + - `whatsapp_business_management` +7. Configure the webhook callback URL and subscribe to `messages`. + +Webhook endpoint example: + +```text +https://YOUR_DOMAIN.com/api/method/clefincode_chat.webhook.handle +``` -Create message templates, submit them for approval, then send them from chat using /. +**Security:** Never expose Access Tokens in frontend code, screenshots, or GitHub. Store them in secure settings or environment variables. -Create Twilio Text Template +### WhatsApp setup inside ClefinCode Chat -Enter Friendly Name +#### 1. Enter WhatsApp credentials -Select Template Type: twilio/text +- Access Token +- Webhook Verify Token -Select Category + -Select Language +#### 2. Create a WhatsApp profile -Write message in Body +- Enter WhatsApp Number. +- Enter Phone Number ID. +- Enter WhatsApp Business Account ID. +- Select Type: **Personal** or **Support**. -Save, submit, and wait for approval +Important tip: When entering the WhatsApp number, do not include `00` or `+`. Start directly with the country code, for example `971xxxxxxxxx`. - + + + -Important tips (Meta/WhatsApp template name rules) +### Instagram setup -Allowed characters: lowercase alphanumeric only (a-z, 0-9) +Configure Instagram credentials and create a profile to route messages to the correct system user. -Allowed separator: underscore _ only +- Enter Instagram credentials. +- Create an Instagram profile. +- Enter Instagram Profile ID and Instagram App ID. +- Select Type: Personal or Business. -No spaces +Important tip: Enter the username without the `@` symbol, for example `example_username`. -No special characters (! @ # $ % - . etc.) +Documentation: [Instagram setup](https://website.clefincode.com/clefincode_chat_docs#instagram-setup-web) -Use meaningful names (example: order_delivery) +### Telegram setup -Name should be unique for your account if template content is unique +Configure the Telegram bot token, confirm that the site webhook is set successfully, then create a Telegram profile and assign it to the correct system user. -Create Twilio Media Template +- Open BotFather in Telegram. +- Create a new bot or select an existing one. +- Copy the Bot Token. +- Open the Telegram integration Bot DocType. +- Enter a clear name. +- Paste the Bot Token and save. +- Make sure **Site Webhook Successfully Set** appears. +- Create a ClefinCode Telegram Profile and assign users. -- Enter Friendly Name +Documentation: [Telegram setup](https://website.clefincode.com/clefincode_chat_docs#telegram-setup-web) -- Select Template Type: twilio/media +### Facebook Messenger setup -- Select Category +Configure Messenger integration and profiles to enable inbound and outbound messaging through Facebook Page IDs. -- Select Language +Important tip: Ensure the Messenger Profile ID matches the correct Facebook Page ID and confirm required app permissions for messaging. -- Write message in Body +Documentation: [Facebook Messenger setup](https://website.clefincode.com/clefincode_chat_docs#facebook-messenger-setup-web) -- Add media link in Media URL +## Twilio Meta Connection Guide -- Save, submit, and wait for approval +Use this setup when WhatsApp will be connected through Twilio instead of direct Meta Cloud API calls. Twilio acts as the provider, while Meta Business Portfolio and WABA own the WhatsApp business setup. - -Create Quick Reply Template +### Requirements before Twilio onboarding -- Enter Friendly Name +- Upgraded Twilio account with billing enabled. +- Meta Business Portfolio owned by the company. +- Admin access to the Meta Business Portfolio. +- Twilio number or external number not already registered on WhatsApp. +- Access to receive OTP by SMS or voice call. +- Clear WhatsApp display name aligned with the business or brand. -- Select Template Type: quick-replay +### Twilio onboarding steps -- Write message in Body +1. Buy or select a Twilio phone number that can receive the WhatsApp verification code. +2. Go to Twilio Console → Messaging → Senders → WhatsApp Senders. +3. Start WhatsApp Sender registration. +4. Continue with Facebook. +5. Authorize Twilio and select the correct Meta Business Portfolio. +6. Create or select the WhatsApp Business Account (WABA). +7. Enter WhatsApp Business Profile details and display name. +8. Add and verify the phone number by SMS or voice OTP. +9. Refresh WhatsApp Senders in Twilio until the sender is ready. -- Add button and value in actions table +Documentation: [Twilio Meta Connection Guide](https://website.clefincode.com/clefincode_chat_docs#twilio-meta-connection-guide) -- Save, submit, and wait for approval +## Twilio WhatsApp Integration - -Create List Picker Template +Connect WhatsApp through Twilio to manage inbound and outbound messages directly inside ERPNext. -Enter Friendly Name +### 1. Enter Twilio credentials -Select Template Type: list-picker +Add your Twilio credentials: -Write message in Body +- Twilio Account SID +- Auth Token -Add list items in Items table +Twilio integration credentials -Save, submit, and wait for approval +### 2. Create a Twilio WhatsApp profile - -Use Variables +- Select the WhatsApp-enabled number from Twilio. +- Select Provider: **Twilio**. +- Choose Type: **Personal** or **Support**. +- Save the profile. + +Important tip: A template will be created automatically and its preview will be visible. -Twilio uses variables in content templates to personalize messages. Variables follow the {{...}} syntax and are populated with dynamic data when the message is sent. + -You can map variables to fields from the associated DocType by filling the variable table. +### 3. Configure Twilio webhook - +Configure Twilio webhooks to receive incoming WhatsApp messages and track outbound message status updates. + +Webhook URL: + +```text +https://Base_URL/api/method/whatsapp_twillio +``` + +Twilio webhook configuration -Important tips +### 4. Test WhatsApp messaging -Variables must be sequential: {{1}}, {{2}}, {{3}} +Test sent messages: -Variables should not be adjacent +- Text +- Image +- Voice note -Variables should not start or end the message + -Must have enough text: (2x + 1) non-variable words per x variables +Test received messages: -Avoid too many variables in short messages +- Text +- Image +- Voice note +- Location +- Contact + + -Add Variable to Media Template +## Twilio Templates -Set base URL of your site +Create message templates, submit them for approval, then send them from chat using `/`. + +### Create Twilio text template + +- Enter Friendly Name. +- Select Template Type: `twilio/text`. +- Select Category. +- Select Language. +- Write the message in Body. +- Save, submit, and wait for approval. + + -Select field with attached file +### Meta / WhatsApp template name rules -Add default value +- Allowed characters: lowercase alphanumeric only (`a-z`, `0-9`). +- Allowed separator: underscore `_` only. +- No spaces. +- No special characters such as `! @ # $ % - .`. +- Use meaningful names, for example `order_delivery`. +- Keep names unique when template content is unique. + +### Create Twilio media template + +- Enter Friendly Name. +- Select Template Type: `twilio/media`. +- Select Category. +- Select Language. +- Write the message in Body. +- Add media link in Media URL. +- Save, submit, and wait for approval. + + + +### Create quick reply template + +- Enter Friendly Name. +- Select Template Type: `quick-replay`. +- Write the message in Body. +- Add button and value in the actions table. +- Save, submit, and wait for approval. + + + +### Create list picker template + +- Enter Friendly Name. +- Select Template Type: `list-picker`. +- Write the message in Body. +- Add list items in the Items table. +- Save, submit, and wait for approval. + + + +### Use variables + +Twilio variables personalize content templates. Variables use `{{...}}` syntax and are populated with dynamic data when the message is sent. + +Variables can be mapped to fields from the associated DocType by filling the variables table. + + + +Important tips: + +- Variables must be sequential: `{{1}}`, `{{2}}`, `{{3}}`. +- Variables should not be adjacent. +- Variables should not start or end the message. +- Must have enough text: `(2x + 1)` non-variable words per `x` variables. +- Avoid too many variables in short messages. + +### Add variable to media template + +- Set the base URL of your site. +- Select the field with the attached file. +- Add a default value. -Attach DocType Print to Template + +### Attach DocType print to template To send the connected DocType print: -- Use a Media Template and add a variable (as above) +- Use a Media Template and add a variable. +- No need to add a DocType field, but a default value is required. +- Enable **Attach Document Print**. +- Select Print Format. +- Select Language Format: English. +- Select Letter Head. -- No need to add a DocType field, but default value is required + -- Enable Attach Document Print +### Send template from chat -- Select Print Format +- Open Chat. +- Type `/` and wait for approved templates. +- Select a template. +- If linked to a DocType, choose the required document. -- Select Language Format: English + -- Select Letter Head +## ClefinCode Notification - +ClefinCode Notification sends WhatsApp notifications automatically based on DocType events. -Send Template from Chat +### Create ClefinCode Notification -- Open Chat +- Create a WhatsApp notification and link it to the required DocType. +- Add a Custom Field to the DocType of type Link connected to Chat Profile. +- Select the trigger event, such as **On Save** or **On Submit**. +- Choose the message type: **Template** or **Normal Message**. +- If Normal Message is selected, variables can be added dynamically from DocType fields. +- If Template is selected, only templates linked to the selected DocType will appear. +- Preview displays automatically when a template is selected. +- If the selected template supports document printing, **Attach Print** is enabled automatically. +- Configure the recipient using the selected Chat Profile. +- Optionally attach the document as a PDF by selecting the required Print Format. +- Save and enable the notification to activate automatic WhatsApp sending. -- Type / and wait for approved templates +Create ClefinCode Notification -- Select a template +## ClefinCode Chat Template -- If linked to a DocType, choose the required document +ClefinCode Chat Template is used to create and manage reusable WhatsApp templates. Templates can include variables, optional document prints, and links to specific DocTypes. - +### Create template + +- Open ClefinCode Chat Template. +- Enter the Template Name. +- Select the Reference DocType, if needed. +- Write the message. +- If a DocType is selected, add variables similar to Twilio templates. +- Save the template. + +### Test sending template + +- Open a chat conversation. +- Type `/` in the chat input. +- Select the created template. +- If linked to a DocType, choose the required document. + +Documentation: [ClefinCode Chat Template](https://website.clefincode.com/clefincode_chat_docs#clefincode-chat-template) ## Manage Contacts Add and manage contacts directly from the chat interface, including multiple identifiers per contact. -### Manage Contacts (Admin) +### Manage Contacts - Click the **Chat** icon. - Click the **+** button. @@ -366,118 +549,199 @@ Add and manage contacts directly from the chat interface, including multiple ide ### ClefinCode Chat Profile -A **Chat Profile** centralizes all identifiers for a person/company (WhatsApp, Telegram, Instagram, Messenger, etc.) and maps them to a system user. +A Chat Profile centralizes all identifiers for a person or company and maps them to a system user. -- Create a **Chat Profile** and set its name. -- Assign a **System User** (email). -- Add one or more contact identifiers (WhatsApp / Telegram / Instagram / Messenger). +- Create a Chat Profile and define its name. +- Assign a System User. +- Add one or more contact identifiers. +- Supported identifiers include WhatsApp, Telegram, Instagram, and Messenger. - The profile keeps all identifiers organized in one place. +## ClefinCode WhatsApp Profile + +Personal messages are delivered directly to the selected user. + +For Support Profiles: + +- **Receive By User** automatically adds the selected user to the conversation. +- **Receiver Role** distributes messages randomly among users with that role. +- To always include a specific user in all support chats, use **User – Receive By User**. + +Documentation: [ClefinCode WhatsApp Profile](https://website.clefincode.com/clefincode_chat_docs#clefincode-whatsapp-profile) ## 🖥 Web UI Features -Powerful and intuitive message controls designed to enhance productivity and keep conversations structured inside ERPNext. +Powerful and intuitive message controls designed to enhance productivity, organize conversations by topics, and keep discussions structured inside ERPNext. + +### ⚡ Message Actions Menu + +Each message has a small arrow beside it. Click this arrow to open available message actions. + +Available actions include: + +- **Edit** — edit the message content. +- **Reply** — reply to a specific message. +- **Copy** — copy message text. +- **React** — add an emoji reaction. +- **Forward** — forward one or more messages. +- **ReLink** — link one or more messages to a topic. +- **Delete** — delete the message. +- **Message Info** — view message details. + +Web message actions menu ### ↩️ Reply to Messages -To reply to a specific message: -1. Click directly on the message bubble. + +Reply to a specific message and keep the context linked. + +1. Click the small arrow beside the message. 2. Select **Reply**. 3. Type your response and send. -Your reply will remain linked to the original message to preserve context and make conversations easier to follow. - - +Web reply to messages -**GIF Link:** screenshots/web/reply_message.gif +### 🔁 Forward Messages ---- +Forward supports multi-select, allowing users to forward one or more messages at the same time. -### 🔁 Forward Messages -To forward a message: -1. Click on the message bubble. +1. Click the small arrow beside a message. 2. Select **Forward**. -3. Choose the contact or channel you want to forward the message to. -4. You can select **multiple recipients at the same time**. -5. Confirm to send. - -Forwarded messages are clearly labeled for transparency. +3. The chat enters selection mode and checkboxes appear beside messages. +4. Select one or more messages. +5. Choose the contact, group, or channel. +6. Confirm to forward the selected messages. - +Web forward multi-select -**GIF Link:** screenshots/web/forward_message.gif +### ✏️ Edit Sent Messages ---- +Edit a sent message using a rich text editor with formatting tools. -### ✏️ Edit Sent Messages -To edit a message: -1. Click on the message bubble. +1. Click the small arrow beside the message. 2. Select **Edit**. -3. Update the message content. -4. Save the changes. +3. The edit dialog opens with a rich text editor. +4. Update the message content. +5. Click **Save** to apply changes, or **Cancel** to close without saving. -⚠️ Editing is only allowed within **7 minutes** of sending the message. -This duration can be modified from the **Chat Settings**. +The editor supports formatting such as bold, italic, underline, lists, links, images, alignment, and tables. - +**Important:** Editing is only allowed within **7 minutes** of sending the message. This can be changed from **Chat Settings**. -**GIF Link:** screenshots/web/edit_message.gif - ---- +Web edit message dialog ### 🗑 Delete Messages -To delete a message: -1. Click on the message bubble. + +Delete a message within the configured time limit. + +1. Click the small arrow beside the message. 2. Select **Delete**. 3. Confirm the deletion. -⚠️ **Important:** -Messages cannot be edited or deleted after **7 minutes** from the time they were sent. -The time limit can be customized from the **Chat Settings**. +**Important:** Messages cannot be edited or deleted after **7 minutes** from sending, unless the time limit is changed from **Chat Settings**. - +Web delete messages -**GIF Link:** screenshots/web/delete_message.gif +### 😀 Message Reactions ---- +React with emojis to reduce unnecessary replies. -### 😀 Message Reactions -To react to a message: -1. Press and hold (long press) on the message bubble. -2. Select the desired emoji reaction. +1. Click the small arrow beside the message. +2. Select **React**. +3. Choose the desired emoji reaction. -You can: -- Remove a reaction by clicking the same emoji again. -- Change your reaction by selecting a different emoji. +You can remove a reaction by clicking the same emoji again, or change your reaction by selecting a different emoji. -Reactions help reduce unnecessary replies and improve collaboration speed. +Web message reactions - +### 🔗 ReLink Messages to Topic -**GIF Link:** screenshots/web/reactions.gif +ReLink allows users to connect one or more messages to an existing topic or create a new topic. ---- +1. Click the small arrow beside a message. +2. Select **ReLink**. +3. The chat enters selection mode and checkboxes appear beside messages. +4. Select one or more messages. +5. Click **ReLink Topic**. +6. Select an existing topic, or click **Add New Topic** to create a new one. -### 🔎 In-Chat Search -1. Open the desired chat (contact or channel). -2. Click on the top area of the conversation (chat header). -3. The chat details interface will open. -4. Click on the **Search icon**. -5. Enter the keyword you want to find. -6. Navigate through the results to locate the exact message. +ReLink helps organize related messages under a specific topic, making it easier to follow discussions connected to a document, issue, invoice, item, or work context. + +Web select messages for ReLink + +### 🧵 Topic Conversations + +After messages are linked to a topic, the topic appears inside the main conversation as a colored topic bar. + +- Each topic has its own color to make it easy to identify. +- Click the topic bar or arrow to open a separate conversation for that topic. +- Topic conversations show only messages linked to that topic. +- The topic chat header displays the topic name. +- From the topic header, users can add or remove linked DocTypes. +- Users can update the topic Status, Color, and Subject. + +Web topic bar in conversation +Web topic conversation + +### ➕ Chat Plus Menu -This allows you to quickly find important details, decisions, or shared information. +The plus button beside the chat input gives quick access to attachments and topic actions. + +Available actions: + +- **Attach File** — upload and send a file in the conversation. +- **Select Topic** — choose an existing topic and open or link to it. +- **Add New Topic** — create a new topic from the chat. + +Web chat plus menu + +### 🏷 Select Topic + +Select Topic lets users search and choose from existing topics directly inside the chat. + +1. Click the `+` button beside the chat input. +2. Select **Select Topic**. +3. Use the search field to find a topic. +4. Click the arrow beside the topic to open or select it. + +Each topic appears with its own colored indicator. A number may appear beside the topic name to show related message count or topic activity. + +Web select topic + +### ℹ️ Message Info + +Message Info displays detailed information about a selected message. + +1. Click the small arrow beside the message. +2. Select **Message Info**. +3. Review message details in the popup. + +Information includes: + +- Sender +- Linked Topic +- Sent At +- Message Type +- Message ID + +Web message info + +### 🔎 In-Chat Search - +Find messages quickly inside the same conversation. -**GIF Link:** screenshots/web/search.gif +1. Open the desired chat. +2. Click the top area of the conversation. +3. Click the Search icon. +4. Enter your keyword and navigate results. +Web in-chat search ## Reporting Bugs -If you find any bugs, feel free to report them here on [GitHub Issues](https://github.com/clefincode/clefincode_chat/issues). +If you find any bugs, report them through [GitHub Issues](https://github.com/clefincode/clefincode_chat/issues). ## License -GNU General Public License (v3) \ No newline at end of file +GNU General Public License (v3) diff --git a/clefincode_chat/__init__.py b/clefincode_chat/__init__.py index a16e534..7459b19 100644 --- a/clefincode_chat/__init__.py +++ b/clefincode_chat/__init__.py @@ -1 +1 @@ -__version__ = '1.3.903' \ No newline at end of file +__version__ = '1.3.907' \ No newline at end of file diff --git a/clefincode_chat/api/api_1_3_3/api.py b/clefincode_chat/api/api_1_3_3/api.py index fd4c59a..4dce0a8 100644 --- a/clefincode_chat/api/api_1_3_3/api.py +++ b/clefincode_chat/api/api_1_3_3/api.py @@ -48,7 +48,7 @@ from frappe import _ from frappe.desk.search import validate_and_sanitize_search_inputs - +from packaging import version TOPIC_COLOR_PALETTE = [ @@ -3603,7 +3603,7 @@ def search_in_message_content(user , query): return my_messages # ========================================================================================== @frappe.whitelist() -def search_in_message_contents(channel, query, sub_channel=None): +def search_in_message_contents(channel, query, sub_channel=None, chat_topic=None): if not channel or not query: return {"results": []} @@ -3616,6 +3616,9 @@ def search_in_message_contents(channel, query, sub_channel=None): if sub_channel: filters["sub_channel"] = sub_channel + if chat_topic: + filters["chat_topic"] = chat_topic + results = frappe.get_all( "ClefinCode Chat Message", filters=filters, diff --git a/clefincode_chat/api/api_1_3_4/api.py b/clefincode_chat/api/api_1_3_4/api.py index 8f8c6d5..45eb314 100644 --- a/clefincode_chat/api/api_1_3_4/api.py +++ b/clefincode_chat/api/api_1_3_4/api.py @@ -46,7 +46,7 @@ from frappe.utils.password import check_password, update_password from frappe.sessions import clear_sessions from frappe.desk.search import validate_and_sanitize_search_inputs - +from packaging import version @@ -325,7 +325,7 @@ def get_registration_tokens(user_email): "registration_token": row.registration_token, "platform": row.platform }) - frappe.log_error('tokens',tokens) + return tokens def get_registration_token(user_email): @@ -468,10 +468,31 @@ def get_social_config_for_user(user): } # ========================================================================================== +DEFAULT_LIMITED_ROLES = [ + "Customer", + "Supplier", + "Student", + "Instructor", + "Sales Partner", + "Member", + "Shareholder", + "Guardian", +] def is_limited_user(user): - roles = frappe.get_roles(user) - limited_roles = ["Customer", "Supplier", "Student", "Instructor", "Sales Partner", "Member", "Shareholder", "Guardian"] - return any(role in roles for role in limited_roles) + user_roles = frappe.get_roles(user) + + settings = frappe.get_single("ClefinCode Chat Settings") + + limited_roles = [ + row.role + for row in settings.limited_roles + if row.role + ] + + if not limited_roles: + limited_roles = DEFAULT_LIMITED_ROLES + + return any(role in user_roles for role in limited_roles) # ========================================================================================== def get_user_type(): if frappe.session.user == "Guest": @@ -666,20 +687,12 @@ def create_sub_channel(new_contributors , parent_channel , user , user_email , c frappe.publish_realtime(event= parent_channel, message=results, user= member.user) results2 = {'parent_channel' : parent_channel, "sub_channel" : "" , "realtime_type" : "create_sub_channel", "target_user" : user_to_remove, "chat_topic": chat_topic[0].name if chat_topic else None} - # frappe.publish_realtime(event= "receive_message", message= results2, user= user_to_remove) - frappe.publish_realtime(event= last_active_sub_channel, message={'parent_channel' : parent_channel, "sub_channel" : "" , "realtime_type" : "create_sub_channel"}, user= user_to_remove) - notification_title = get_room_name(parent_channel, "Contributor") - send_notification(user_to_remove , results2, "create_sub_channel", notification_title) return {"results" : [{"channel" : parent_channel}]} else: if user_to_remove: res = {'parent_channel' : parent_channel, "sub_channel" : "" , "realtime_type" : "create_sub_channel" , "target_user" : user_to_remove, "chat_topic": chat_topic[0].name if chat_topic else None} disable_contributor(parent_channel_doc , user_to_remove) frappe.db.sql(f"""UPDATE `tabClefinCode Chat Channel User` SET active = 0 WHERE parent = '{last_active_sub_channel}' AND user = '{user_to_remove}'""") - # frappe.publish_realtime(event= "receive_message", message= res, user= user_to_remove) - frappe.publish_realtime(event= last_active_sub_channel, message={'parent_channel' : parent_channel, "sub_channel" : "" , "realtime_type" : "create_sub_channel"}, user= user_to_remove) - notification_title = get_room_name(parent_channel, "Contributor") - send_notification(user_to_remove , res, "create_sub_channel", notification_title) if isinstance(new_contributors , str): new_contributors = json.loads(new_contributors) @@ -768,18 +781,11 @@ def leave_contributor(parent_channel , user , creation_date = None , last_active if member.platform == "Chat": frappe.publish_realtime(event= parent_channel, message=results, user= member.user) res = {'parent_channel' : parent_channel, "sub_channel" : "" , "realtime_type" : "create_sub_channel", "target_user" : user_to_remove} - # frappe.publish_realtime(event= "receive_message", message= res, user= user_to_remove) - frappe.publish_realtime(event= last_active_sub_channel, message={'parent_channel' : parent_channel, "sub_channel" : "" , "realtime_type" : "create_sub_channel"}, user= user_to_remove) - send_notification(user_to_remove , res, "create_sub_channel") - return {"results" : [{"channel" : parent_channel}]} else: res = {'parent_channel' : parent_channel, "sub_channel" : "" , "realtime_type" : "create_sub_channel" , "target_user" : user_to_remove} disable_contributor(parent_channel_doc , user_to_remove) frappe.db.sql(f"""UPDATE `tabClefinCode Chat Channel User` SET active = 0 WHERE parent = '{last_active_sub_channel}' AND user = '{user_to_remove}'""") - # frappe.publish_realtime(event="receive_message", message= res, user= user_to_remove) - frappe.publish_realtime(event=last_active_sub_channel, message={'parent_channel' : parent_channel, "sub_channel" : "" , "realtime_type" : "create_sub_channel"}, user= user_to_remove) - send_notification(user_to_remove , res, "create_sub_channel") sub_channel_doc = frappe.get_doc({ @@ -1186,8 +1192,9 @@ def get_all_sub_channels_for_contributor(parent_channel , user_email): ############################################################################################# ######################################## Messages ########################################### ############################################################################################# + @frappe.whitelist() -def send(content, user, room , email, send_date = None , is_first_message = 0,is_forwarded=0, attachment = None , sub_channel = None , is_link = None , is_media = None , is_document = None, is_voice_clip = None , file_id = None , message_type = "" , message_template_type= "", only_receive_by = None , id_message_local_from_app = None , id_channel_local_from_app = None , chat_topic = None, is_screenshot = 0,reply_to_message_name=None,forwarded_from=None,whatsapp_message_id=None): +def send(content, user, room , email, send_date = None , is_first_message = 0,is_forwarded=0, attachment = None , sub_channel = None , is_link = None , is_media = None , is_document = None, is_voice_clip = None , file_id = None , message_type = "" , message_template_type= "", only_receive_by = None , id_message_local_from_app = None , id_channel_local_from_app = None , chat_topic = None, is_screenshot = 0,reply_to_message_name=None,forwarded_from=None,whatsapp_message_id=None,override_variables=None ): try: @@ -1258,7 +1265,10 @@ def send(content, user, room , email, send_date = None , is_first_message = 0,is new_message.file_id = frappe.db.get_value("File" , {"attached_to_name": new_message.name}, "name") new_message.save(ignore_permissions = True) - if attachment: set_attach_message(attachment, new_message.name) + if file_id: + set_attach_message(file_id=file_id, message_name=new_message.name) + elif attachment: + set_attach_message(attachment=attachment, message_name=new_message.name) if reply_to_message_name: try: @@ -1370,9 +1380,21 @@ def send(content, user, room , email, send_date = None , is_first_message = 0,is "utc_message_date" : send_date, "is_forwarded":is_forwarded, - "platform": platform + "platform": platform , + "chat_channel": room, + "chat_topic": chat_topic, } + if chat_topic: + try: + topic_info = frappe.db.get_value("ClefinCode Chat Topic", chat_topic, ["topic_subject", "topic_color"], as_dict=True) + if topic_info: + results["chat_topic_subject"] = topic_info.topic_subject + results["topic_color"] = topic_info.topic_color + except Exception: + pass + + if reply_to_message_name : results.update({"reply_to_message":reply_to_message_name, "reply_preview_type": new_message.reply_preview_type , @@ -1464,7 +1486,7 @@ def send(content, user, room , email, send_date = None , is_first_message = 0,is send_notification(member.user , results, "send_message", room_name if channel_doc.type == "Group" else get_contact_full_name(email), message_template_type) elif member.platform == "WhatsApp" and email != member.user and message_template_type not in ["Rename Group" , "Send Confirmation"] and not is_mention(content) and member.is_removed == 0: - process_whatsapp_message(member.platform_gateway, member.user , email, channel_doc, last_responder_user, new_message, file_type, attachment, content, is_voice_clip, is_screenshot,results,is_forwarded) + process_whatsapp_message(member.platform_gateway, member.user , email, channel_doc, last_responder_user, new_message, file_type, attachment, content, is_voice_clip, is_screenshot,results,is_forwarded,override_variables ) if member.pending_messages >= 1: frappe.db.set_value('ClefinCode Chat Channel User', member.name, 'pending_messages', member.pending_messages +1) elif member.platform == "Instagram" and str(email) != str(member.user) and message_template_type not in ["Rename Group" , "Send Confirmation"] and not is_mention(content) and member.is_removed == 0: @@ -1614,6 +1636,7 @@ def get_messages(room, user_email, room_type, chat_topic=None, msg.chat_topic, topic.subject AS chat_topic_subject, topic.topic_color AS topic_color, + topic.topic_status AS topic_status, msg.is_edited, backup.original_content AS original_content FROM `tabClefinCode Chat Message` msg @@ -1653,6 +1676,7 @@ def get_messages(room, user_email, room_type, chat_topic=None, msg.chat_topic, topic.subject AS chat_topic_subject, topic.topic_color AS topic_color, + topic.topic_status AS topic_status, msg.is_edited, backup.original_content AS original_content FROM `tabClefinCode Chat Message` msg @@ -2563,13 +2587,39 @@ def get_file_view_size(file_id,is_video=None): file_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8') return {"results" : [{'file_size':file_doc.file_size,'data':file_base64,'duration':duration}]} # ========================================================================================== -def set_attach_message(attachment, message): - file_doc = frappe.get_doc("File", {"file_url": attachment}) - file_doc.update({ - "attached_to_doctype": "ClefinCode Chat Message", - "attached_to_name": message - }) - file_doc.save(ignore_permissions = True) +def set_attach_message(file_id=None, attachment=None, message_name=None): + import frappe + from urllib.parse import urlparse, unquote + + file_doc = None + + if file_id: + file_doc = frappe.get_doc("File", file_id) + + elif attachment: + parsed = urlparse(attachment) + + file_url = parsed.path if parsed.scheme and parsed.netloc else attachment + file_url = unquote(file_url) + + if not file_url.startswith("/"): + file_url = "/" + file_url + + file_name = frappe.db.get_value("File", {"file_url": file_url}, "name") + + if not file_name: + frappe.throw(f"File not found yet or wrong file_url: {file_url}") + + file_doc = frappe.get_doc("File", file_name) + + if not file_doc: + frappe.throw("No file_id or attachment provided") + + file_doc.attached_to_doctype = "ClefinCode Chat Message" + file_doc.attached_to_name = message_name + file_doc.save(ignore_permissions=True) + + return file_doc # ========================================================================================== def get_file_type(file_name): ext = os.path.splitext(file_name)[1] # Get the file extension @@ -2873,8 +2923,9 @@ def ensure_video_thumbnail(message_doc, max_size=(320, 320), quality=80): ############################################################################################# @frappe.whitelist() def get_topic_info(chat_channel): - chat_topic = frappe.get_all("ClefinCode Chat Topic" , ["name" , "subject" , "is_private"] , {"chat_channel":chat_channel , "topic_status" : "Open"}) + chat_topic = frappe.get_all("ClefinCode Chat Topic" , ["name" , "subject" , "is_private" , "topic_color"] , {"chat_channel":chat_channel , "topic_status" : "Open"}) if chat_topic: + topic_color = chat_topic[0].topic_color reference_doctypes = frappe.db.sql(f""" SELECT doctype_link AS doctype , docname FROM `tabClefinCode Chat Topic Reference` @@ -2882,9 +2933,9 @@ def get_topic_info(chat_channel): ORDER BY idx """ , as_dict = True) if reference_doctypes: - return {"results" : [{"chat_topic" : chat_topic[0].name , "reference_doctypes" : reference_doctypes, "chat_topic_subject" : chat_topic[0].subject , "chat_topic_status": "private" if chat_topic[0].is_private == 1 else "public"}]} + return {"results" : [{"chat_topic" : chat_topic[0].name , "reference_doctypes" : reference_doctypes, "chat_topic_subject" : chat_topic[0].subject , "topic_color": topic_color, "chat_topic_status": "private" if chat_topic[0].is_private == 1 else "public"}]} else: - return {"results" : [{"chat_topic" : chat_topic[0].name , "reference_doctypes" : [] , "chat_topic_subject" : chat_topic[0].subject , "chat_topic_status": "private" if chat_topic[0].is_private == 1 else "public"}]} + return {"results" : [{"chat_topic" : chat_topic[0].name , "reference_doctypes" : [] , "chat_topic_subject" : chat_topic[0].subject , "topic_color": topic_color, "chat_topic_status": "private" if chat_topic[0].is_private == 1 else "public"}]} else: return {"results" : [{"chat_topic" : None , "reference_doctypes" : [] , "chat_topic_subject" : None}]} # ========================================================================================== @@ -2901,13 +2952,22 @@ def get_references_doctypes(chat_topic): return {"results" : [{"reference_doctypes" : reference_doctypes , "chat_topic_subject" : frappe.db.get_value("ClefinCode Chat Topic" , chat_topic, "subject") , "chat_topic_status" : frappe.db.get_value("ClefinCode Chat Topic" , chat_topic, "is_private")}]} # ========================================================================================== @frappe.whitelist() -def create_chat_topic(mention_doctypes, chat_channel, last_active_sub_channel = None): +def create_chat_topic(mention_doctypes, chat_channel, last_active_sub_channel = None, subject = None): mention_doctypes = json.loads(mention_doctypes) + + if not subject and not mention_doctypes: + frappe.throw("Subject or at least one reference is required to create a topic.") + + if not subject and mention_doctypes: + subject = "{}:{}".format(mention_doctypes[0]["doctype"], mention_doctypes[0]["docname"]) + chat_topic = frappe.get_doc({ "doctype" : "ClefinCode Chat Topic", "chat_channel" : chat_channel, "topic_status": "Open", - "is_private" : 1 + "is_private" : 1, + "topic_color": get_random_topic_color(), + "subject": subject, }).insert(ignore_permissions = True) for doc in mention_doctypes: chat_topic.append("references", {"doctype_link": doc["doctype"] , "docname":doc["docname"], "active":1}) @@ -2917,7 +2977,10 @@ def create_chat_topic(mention_doctypes, chat_channel, last_active_sub_channel = results = { "realtime_type" : "set_topic", "chat_topic": chat_topic.name, - "mention_doctypes" : mention_doctypes + "mention_doctypes" : mention_doctypes, + "topic_color": chat_topic.topic_color + + } for member in frappe.get_doc("ClefinCode Chat Channel" , chat_channel).members: @@ -2939,12 +3002,15 @@ def create_chat_topic(mention_doctypes, chat_channel, last_active_sub_channel = # frappe.publish_realtime(event="receive_message", message=results, user= contributor.user) send_notification(contributor.user , results, "set_topic") - return {"results" : [{"chat_topic" : chat_topic.name}]} + return {"results" : [{"chat_topic" : chat_topic.name, "topic_color" : chat_topic.topic_color, "subject": subject, "chat_topic_subject": subject}]} + # ========================================================================================== @frappe.whitelist() def remove_chat_topic(chat_topic, chat_channel, last_active_sub_channel = None): frappe.db.set_value("ClefinCode Chat Topic" , chat_topic , "topic_status" , "Closed") + clear_active_chat_topic_for_all(chat_channel, chat_topic) + results = { "realtime_type" : "remove_topic" } @@ -2967,18 +3033,513 @@ def remove_chat_topic(chat_topic, chat_channel, last_active_sub_channel = None): send_notification(contributor.user , results, "remove_topic") return {"results" : [{"chat_topic_subject" : frappe.db.get_value("ClefinCode Chat Topic" , chat_topic , "subject")}]} + + +def _get_chat_topic_subject_for_close(chat_topic): + if not chat_topic: + return None + + topic_subject = frappe.db.get_value("ClefinCode Chat Topic", chat_topic, "subject") + if topic_subject: + return topic_subject + + first_reference = frappe.get_all( + "ClefinCode Chat Topic Reference", + filters={ + "parent": chat_topic, + "parenttype": "ClefinCode Chat Topic", + "parentfield": "references", + "active": 1 + }, + fields=["docname"], + order_by="idx asc", + limit_page_length=1 + ) + + if first_reference: + return first_reference[0].docname + + return chat_topic + + +def _publish_closed_topic_realtime( + chat_topic, + chat_channel, + chat_topic_subject=None, + last_active_sub_channel=None, + realtime_type="remove_topic" +): + results = { + "realtime_type": realtime_type, + "chat_topic": chat_topic, + "chat_topic_subject": chat_topic_subject, + "topic_status": "Closed", + "chat_topic_status": "Closed", + "clear_message_topic": 1 + } + + for member in frappe.get_doc("ClefinCode Chat Channel" , chat_channel).members: + if member.is_removed == 0 and member.platform == "Chat": + member_results = dict(results) + member_results["room"] = chat_channel + member_results["target_user"] = member.user + frappe.publish_realtime(event= chat_channel, message=member_results, user=member.user) + frappe.publish_realtime(event="receive_message", message=member_results, user= member.user) + send_notification(member.user , member_results, realtime_type) + + for contributor in frappe.get_doc("ClefinCode Chat Channel" , chat_channel).contributors: + if contributor.active == 1 and contributor.platform == "Chat": + notification_results = None + for contributor_room in _get_realtime_contributor_rooms(chat_channel, contributor, last_active_sub_channel): + contributor_results = dict(results) + contributor_results["room"] = contributor_room + contributor_results["parent_channel"] = chat_channel + contributor_results["target_user"] = contributor.user + if notification_results is None: + notification_results = contributor_results + frappe.publish_realtime(event= contributor_room, message=contributor_results, user=contributor.user) + frappe.publish_realtime(event="receive_message", message=contributor_results, user= contributor.user) + if notification_results: + send_notification(contributor.user , notification_results, realtime_type) + + +def _get_realtime_contributor_rooms(chat_channel, contributor=None, last_active_sub_channel=None): + rooms = [] + + def add_room(room): + if room and room not in rooms: + rooms.append(room) + + add_room(last_active_sub_channel) + add_room(getattr(contributor, "channel", None)) + + open_sub_channels = frappe.get_all( + "ClefinCode Chat Channel", + filters={"parent_channel": chat_channel, "chat_status": "Open"}, + pluck="name" + ) + + for sub_channel in open_sub_channels: + add_room(sub_channel) + + add_room(chat_channel) + return rooms + + +def _publish_clear_message_topic_realtime( + chat_channel, + chat_topic=None, + chat_topic_subject=None, + last_active_sub_channel=None +): + results = { + "realtime_type": "clear_message_topic", + "chat_topic": chat_topic, + "chat_topic_subject": chat_topic_subject, + "clear_message_topic": 1 + } + + channel = frappe.get_doc("ClefinCode Chat Channel", chat_channel) + + for member in channel.members: + if member.is_removed == 0 and member.platform == "Chat": + member_results = dict(results) + member_results["room"] = chat_channel + member_results["target_user"] = member.user + frappe.publish_realtime(event=chat_channel, message=member_results, user=member.user) + frappe.publish_realtime(event="receive_message", message=member_results, user=member.user) + + for contributor in channel.contributors: + if contributor.active == 1 and contributor.platform == "Chat": + for contributor_room in _get_realtime_contributor_rooms(chat_channel, contributor, last_active_sub_channel): + contributor_results = dict(results) + contributor_results["room"] = contributor_room + contributor_results["parent_channel"] = chat_channel + contributor_results["target_user"] = contributor.user + frappe.publish_realtime(event=contributor_room, message=contributor_results, user=contributor.user) + frappe.publish_realtime(event="receive_message", message=contributor_results, user=contributor.user) + + +@frappe.whitelist() +def clear_message_topic(chat_channel, chat_topic=None, last_active_sub_channel=None): + if not chat_channel and chat_topic: + chat_channel = frappe.db.get_value("ClefinCode Chat Topic", chat_topic, "chat_channel") + + if not chat_channel: + frappe.throw(_("chat_channel is required")) + + chat_topic_subject = _get_chat_topic_subject_for_close(chat_topic) if chat_topic else None + _publish_clear_message_topic_realtime( + chat_channel=chat_channel, + chat_topic=chat_topic, + chat_topic_subject=chat_topic_subject, + last_active_sub_channel=last_active_sub_channel + ) + + return { + "results": [{ + "status": "Cleared", + "chat_channel": chat_channel, + "chat_topic": chat_topic, + "chat_topic_subject": chat_topic_subject, + "clear_message_topic": 1 + }] + } + + +@frappe.whitelist() +def close_chat_topic(chat_channel, chat_topic=None, last_active_sub_channel=None, user_email=None, user_name=None): + if not chat_channel and chat_topic: + chat_channel = frappe.db.get_value("ClefinCode Chat Topic", chat_topic, "chat_channel") + + if not chat_channel: + frappe.throw(_("chat_channel is required")) + + if not chat_topic: + open_topic = frappe.get_all( + "ClefinCode Chat Topic", + filters={"chat_channel": chat_channel, "topic_status": "Open"}, + fields=["name"], + order_by="creation desc", + limit_page_length=1 + ) + chat_topic = open_topic[0].name if open_topic else None + + if not chat_topic: + frappe.throw(_("No open topic found for this channel.")) + + topic = frappe.get_doc("ClefinCode Chat Topic", chat_topic) + if topic.chat_channel and topic.chat_channel != chat_channel: + frappe.throw(_("Topic does not belong to this channel.")) + + chat_topic_subject = _get_chat_topic_subject_for_close(chat_topic) + should_send_close_message = topic.topic_status != "Closed" + + if should_send_close_message: + topic.topic_status = "Closed" + topic.save(ignore_permissions=True) + frappe.db.commit() + + clear_active_chat_topic_for_all(chat_channel, chat_topic) + + _publish_closed_topic_realtime( + chat_topic=chat_topic, + chat_channel=chat_channel, + chat_topic_subject=chat_topic_subject, + last_active_sub_channel=last_active_sub_channel, + realtime_type="close_topic" + ) + + if should_send_close_message: + if not user_email: + session_user = frappe.session.user + user_email = frappe.db.get_value("User", session_user, "email") or session_user + + sender_name = user_name or get_profile_id(user_email) or user_email + safe_user_email = frappe.utils.escape_html(user_email or "") + safe_topic_subject = frappe.utils.escape_html(chat_topic_subject or chat_topic) + close_content = f""" +
+ closed topic: "{safe_topic_subject}" +
+ """ + + send( + content=close_content, + user=sender_name, + room=chat_channel, + email=user_email, + is_first_message=0, + sub_channel=last_active_sub_channel, + message_type="information", + message_template_type="Close Topic", + chat_topic=None + ) + + return { + "results": [{ + "status": "Closed", + "chat_topic": chat_topic, + "chat_channel": chat_channel, + "chat_topic_subject": chat_topic_subject, + "topic_status": "Closed", + "chat_topic_status": "Closed" + }] + } + +# ========================================================================================== +@frappe.whitelist() +def publish_user_active_topic_realtime(user_email, chat_channel, chat_topic=None, action="select", topic_subject=None, topic_color=None, topic_status=None): + if not user_email: + return + + payload = { + "realtime_type": "user_active_topic", + "action": action, + "chat_channel": chat_channel, + "chat_topic": chat_topic, + "topic_name": chat_topic, + "chat_topic_subject": topic_subject, + "topic_subject": topic_subject, + "topic_color": topic_color, + "topic_status": topic_status, + } + + frappe.publish_realtime( + event=f"user_active_topic:{user_email}", + message=payload, + user=user_email + ) + + frappe.publish_realtime( + event="user_active_topic", + message=payload, + user=user_email + ) + + +@frappe.whitelist() +def set_user_active_chat_topic(chat_channel, chat_topic, user_email=None): + if not user_email: + user_email = frappe.session.user + + if not chat_channel or not chat_topic: + frappe.throw(_("chat_channel and chat_topic are required")) + + topic_exists = frappe.db.get_value("ClefinCode Chat Topic", chat_topic, "name") + if not topic_exists: + frappe.throw(_("Topic does not exist")) + + topic_channel = frappe.db.get_value("ClefinCode Chat Topic", chat_topic, "chat_channel") + if topic_channel != chat_channel: + frappe.throw(_("Topic does not belong to the given channel")) + + topic_status = frappe.db.get_value("ClefinCode Chat Topic", chat_topic, "topic_status") + if topic_status == "Closed": + frappe.throw(_("Cannot select a closed topic")) + + frappe.db.set_value( + "ClefinCode Chat Channel User", + {"parent": chat_channel, "user": user_email, "is_removed": 0}, + "active_chat_topic", + chat_topic + ) + + frappe.db.set_value( + "ClefinCode Chat Channel Contributor", + {"parent": chat_channel, "user": user_email, "active": 1}, + "active_chat_topic", + chat_topic + ) + + topic_subject = frappe.db.get_value("ClefinCode Chat Topic", chat_topic, "subject") + topic_color = frappe.db.get_value("ClefinCode Chat Topic", chat_topic, "topic_color") + + publish_user_active_topic_realtime( + user_email=user_email, + chat_channel=chat_channel, + chat_topic=chat_topic, + action="select", + topic_subject=topic_subject, + topic_color=topic_color, + topic_status="Open" + ) + + return { + "results": [{ + "status": 1, + "chat_topic": chat_topic, + "chat_topic_subject": topic_subject, + "topic_color": topic_color, + "topic_status": "Open" + }] + } + +# ========================================================================================== +@frappe.whitelist() +def get_user_active_chat_topic(chat_channel, user_email=None): + if not user_email: + user_email = frappe.session.user + + if not chat_channel: + frappe.throw(_("chat_channel is required")) + + active_topic = frappe.db.get_value( + "ClefinCode Chat Channel User", + {"parent": chat_channel, "user": user_email, "is_removed": 0}, + "active_chat_topic" + ) + + if not active_topic: + active_topic = frappe.db.get_value( + "ClefinCode Chat Channel Contributor", + {"parent": chat_channel, "user": user_email, "active": 1}, + "active_chat_topic" + ) + + if not active_topic: + return {"results": [{"chat_topic": None}]} + + topic = frappe.db.get_value( + "ClefinCode Chat Topic", + active_topic, + ["name", "subject", "topic_color", "topic_status", "chat_channel"], + as_dict=True + ) + + if not topic: + clear_user_active_chat_topic(chat_channel, chat_topic=active_topic, user_email=user_email) + return {"results": [{"chat_topic": None}]} + + if topic.chat_channel != chat_channel: + clear_user_active_chat_topic(chat_channel, chat_topic=active_topic, user_email=user_email) + return {"results": [{"chat_topic": None}]} + + if topic.topic_status == "Closed": + clear_user_active_chat_topic(chat_channel, chat_topic=active_topic, user_email=user_email) + return {"results": [{"chat_topic": None}]} + + return { + "results": [{ + "chat_topic": topic.name, + "chat_topic_subject": topic.subject, + "topic_color": topic.topic_color, + "topic_status": topic.topic_status + }] + } + +# ========================================================================================== +@frappe.whitelist() +def clear_user_active_chat_topic(chat_channel, chat_topic=None, user_email=None): + if not user_email: + user_email = frappe.session.user + + if not chat_channel: + frappe.throw(_("chat_channel is required")) + + filters_user = {"parent": chat_channel, "user": user_email} + filters_contributor = {"parent": chat_channel, "user": user_email} + + if chat_topic: + filters_user["active_chat_topic"] = chat_topic + filters_contributor["active_chat_topic"] = chat_topic + + frappe.db.set_value( + "ClefinCode Chat Channel User", + filters_user, + "active_chat_topic", + None + ) + + frappe.db.set_value( + "ClefinCode Chat Channel Contributor", + filters_contributor, + "active_chat_topic", + None + ) + + payload = { + "realtime_type": "close_topic", + "action": "clear", + "user_scoped": 1, + "do_not_close_topic": 1, + "chat_channel": chat_channel, + "chat_topic": chat_topic, + "topic_name": chat_topic, + } + + frappe.publish_realtime( + event=f"user_active_topic:{user_email}", + message=payload, + user=user_email + ) + + frappe.publish_realtime( + event="receive_message", + message=payload, + user=user_email + ) + + frappe.publish_realtime( + event="msg", + message=payload, + user=user_email + ) + + send_notification( + user_email, + payload, + "close_topic" + ) + + return {"results": [{"status": 1}]} + +# ========================================================================================== +def clear_active_chat_topic_for_all(chat_channel, chat_topic): + if not chat_channel or not chat_topic: + return + + affected_users = set() + + user_rows = frappe.get_all( + "ClefinCode Chat Channel User", + filters={"parent": chat_channel, "active_chat_topic": chat_topic}, + fields=["name", "user"] + ) + + contributor_rows = frappe.get_all( + "ClefinCode Chat Channel Contributor", + filters={"parent": chat_channel, "active_chat_topic": chat_topic}, + fields=["name", "user"] + ) + + for row in user_rows: + frappe.db.set_value( + "ClefinCode Chat Channel User", + row.name, + "active_chat_topic", + None, + update_modified=False + ) + if row.user: + affected_users.add(row.user) + + for row in contributor_rows: + frappe.db.set_value( + "ClefinCode Chat Channel Contributor", + row.name, + "active_chat_topic", + None, + update_modified=False + ) + if row.user: + affected_users.add(row.user) + + for user in affected_users: + publish_user_active_topic_realtime( + user_email=user, + chat_channel=chat_channel, + chat_topic=chat_topic, + action="clear", + topic_status="Closed" + ) + # ========================================================================================== @frappe.whitelist() def add_reference_doctype(mention_doctypes, chat_topic, last_active_sub_channel = None): chat_topic_doc = frappe.get_doc("ClefinCode Chat Topic" , chat_topic) for doc in json.loads(mention_doctypes): - reference_doctype_name = frappe.db.get_value("ClefinCode Chat Topic Reference" , {"parent" : chat_topic , "docname" : doc["docname"], "active" : 0} , "name") - if reference_doctype_name: - frappe.db.set_value("ClefinCode Chat Topic Reference" , reference_doctype_name , "active", 1) + existing_name = frappe.db.get_value("ClefinCode Chat Topic Reference", { + "parent": chat_topic, + "doctype_link": doc["doctype"], + "docname": doc["docname"], + }, "name") + if existing_name: + frappe.db.set_value("ClefinCode Chat Topic Reference", existing_name, "active", 1) else: - chat_topic_doc = frappe.get_doc("ClefinCode Chat Topic" , chat_topic) - chat_topic_doc.append("references", {"doctype_link": doc["doctype"] , "docname":doc["docname"], "active":1}) - chat_topic_doc.save(ignore_permissions = True) + chat_topic_doc = frappe.get_doc("ClefinCode Chat Topic", chat_topic) + chat_topic_doc.append("references", {"doctype_link": doc["doctype"], "docname": doc["docname"], "active": 1}) + chat_topic_doc.save(ignore_permissions=True) frappe.db.commit() results = { @@ -3041,7 +3602,8 @@ def set_topic_subject(chat_topic, new_subject, chat_channel, last_active_sub_cha results = { "realtime_type" : "rename_topic", - "new_subject": new_subject + "new_subject": new_subject, + "chat_topic": chat_topic, } for member in frappe.get_doc("ClefinCode Chat Channel" , chat_channel).members: @@ -3099,6 +3661,120 @@ def set_topic_status(chat_topic, chat_topic_status, chat_channel, last_active_su return {"results" : [{"status" : "Done"}]} # ========================================================================================== + +@frappe.whitelist() +def replace_topic_references( + chat_topic, + mention_doctypes, + chat_channel=None, + user_email=None, + user_name=None, + last_active_sub_channel=None +): + import json + + if not chat_topic: + frappe.throw(_("chat_topic is required")) + + if isinstance(mention_doctypes, str): + mention_doctypes_list = json.loads(mention_doctypes or "[]") + else: + mention_doctypes_list = mention_doctypes or [] + + if not isinstance(mention_doctypes_list, list) or not mention_doctypes_list: + frappe.throw(_("mention_doctypes must be a non-empty list")) + + if not chat_channel: + chat_channel = frappe.db.get_value("ClefinCode Chat Topic", chat_topic, "chat_channel") + + if not chat_channel: + frappe.throw(_("chat_channel is required")) + + + old_topic_label = chat_topic + + old_refs = frappe.get_all( + "ClefinCode Chat Topic Reference", + filters={ + "parent": chat_topic, + "parenttype": "ClefinCode Chat Topic", + "parentfield": "references", + "active": 1 + }, + fields=["doctype_link", "docname"], + order_by="idx asc", + limit=1 + ) + + if old_refs: + old_topic_label = old_refs[0].docname + + + remove_chat_topic(chat_topic, chat_channel, last_active_sub_channel) + + remove_content = f""" +
+ removed topic: "{old_topic_label}" +
+ """ + + send( + content=remove_content, + user=user_name or user_email, + room=chat_channel, + email=user_email, + is_first_message=0, + sub_channel=last_active_sub_channel, + message_type="information", + message_template_type="Remove Topic", + chat_topic=None + ) + + + created = create_chat_topic( + json.dumps(mention_doctypes_list), + chat_channel, + last_active_sub_channel + ) + + new_chat_topic = None + if isinstance(created, dict): + results = created.get("results") or [] + if results: + new_chat_topic = results[0].get("chat_topic") + + if not new_chat_topic: + frappe.throw(_("Failed to create replacement topic")) + + first_ref = mention_doctypes_list[0] + ref_docname = first_ref.get("docname", "") + + set_content = f""" +
+ set topic: "{ref_docname}" +
+ """ + + send( + content=set_content, + user=user_name or user_email, + room=chat_channel, + email=user_email, + is_first_message=0, + sub_channel=last_active_sub_channel, + message_type="information", + message_template_type="Set Topic", + chat_topic=new_chat_topic + ) + + return { + "results": [{ + "old_chat_topic": chat_topic, + "chat_topic": new_chat_topic, + "chat_channel": chat_channel + }] + } +# ========================================================================================== @frappe.whitelist() def check_if_user_has_permission(user_email, chat_topic, chat_channel): channel_members = frappe.get_doc("ClefinCode Chat Channel" , chat_channel).members @@ -3200,7 +3876,7 @@ def get_topic_messages(chat_topic): WHERE chat_topic = '{chat_topic}' """ , as_dict = True) - return topic_messages + return topic_messages # ========================================================================================== ############################################################################################# ######################################## Contacts ########################################### @@ -3531,6 +4207,10 @@ def app_text(key, lang="en"): "en": "The topic has been removed", "ar": "تمت إزالة الموضوع" }, + "close_topic": { + "en": "The topic has been closed", + "ar": "تم إغلاق الموضوع" + }, "add_doctype": { "en": "A new doctype has been added", "ar": "تمت إضافة مستند جديد" @@ -3884,7 +4564,7 @@ def search_in_message_content(user , query): return my_messages # ========================================================================================== @frappe.whitelist() -def search_in_message_contents(channel, query, sub_channel=None): +def search_in_message_contents(channel, query, sub_channel=None, chat_topic=None): if not channel or not query: return {"results": []} @@ -3897,6 +4577,9 @@ def search_in_message_contents(channel, query, sub_channel=None): if sub_channel: filters["sub_channel"] = sub_channel + if chat_topic: + filters["chat_topic"] = chat_topic + results = frappe.get_all( "ClefinCode Chat Message", filters=filters, @@ -3918,7 +4601,7 @@ def search_in_message_contents(channel, query, sub_channel=None): ############################################################################################# ######################################## WhatsApp Functions ################################# ############################################################################################# -def process_whatsapp_message(platform_gateway, whatsapp_customer_number , email, channel_doc, last_responder_user, new_message, file_type, attachment, content, is_voice_clip, is_screenshot,results,is_forwarded=0): +def process_whatsapp_message(platform_gateway, whatsapp_customer_number , email, channel_doc, last_responder_user, new_message, file_type, attachment, content, is_voice_clip, is_screenshot,results,is_forwarded=0,override_variables=None ): responder_user_profile = get_profile_id(email) message = None reply_preview_message=None @@ -3988,7 +4671,7 @@ def process_whatsapp_message(platform_gateway, whatsapp_customer_number , email, send_whatsapp_message(new_message, platform_gateway, whatsapp_customer_number , message, file_type if file_type in ["image", "video", "audio", "document"] else None, is_voice_clip) else: if new_message.message_template_type=="Send Template": - send_whatsapp_message_from_template(new_message, whatsapp_customer_number,platform_gateway,results,attachment) + send_whatsapp_message_from_template(new_message, whatsapp_customer_number,platform_gateway,results,attachment,override_variables ) else: if reply_preview_message: send_whatsapp_message_twilio(new_message, platform_gateway,whatsapp_customer_number,reply_preview_message, None, 0) @@ -4947,7 +5630,7 @@ def normalize_realtime_key(value): "new_topic": "set_topic", "remove_topic": "remove_topic", - "close_topic": "remove_topic", + "close_topic": "close_topic", "rename_topic": "rename_topic", "set_topic_status": "set_topic_status", @@ -4993,6 +5676,7 @@ def get_body_message_information(realtime_type, lang="en"): "remove_group_member", "set_topic", "remove_topic", + "close_topic", "add_doctype", "remove_doctype", "create_sub_channel", @@ -6124,7 +6808,7 @@ def send_whatsapp_message_twilio(new_message_doc, sender, receiver, message, mes ) # Save Twilio SID - + new_message_doc.whatsapp_message_id = msg.sid new_message_doc.save(ignore_permissions=True) frappe.db.commit() @@ -9562,7 +10246,7 @@ def get_topic_open_context(chat_topic, message_name=None): or getattr(channel, "room_type", None) or "Group" ) - frappe.log_error( "can_write", can_write) + return { "can_write": can_write, "can_reopen": bool(is_member or is_active_contributor), @@ -10052,10 +10736,14 @@ def create_chat_topic_with_message( frappe.throw(_("mention_doctypes is required")) # 1) create topic + first_ref = mention_doctypes_list[0] + subject = "{}:{}".format(first_ref.get("doctype", ""), first_ref.get("docname", "")) + created = create_chat_topic( json.dumps(mention_doctypes_list), chat_channel, - last_active_sub_channel + last_active_sub_channel, + subject ) chat_topic = None @@ -10225,6 +10913,41 @@ def _normalize_topic_doc(topic_doc): } +@frappe.whitelist() +def remove_message_topic(message_name): + if not message_name: + frappe.throw("Message name is required") + + msg = frappe.get_doc("ClefinCode Chat Message", message_name) + + current_user_email = frappe.session.user + is_admin = "System Manager" in frappe.get_roles(frappe.session.user) + + sender_email = getattr(msg, "sender_email", None) or getattr(msg, "email", None) + + if sender_email and sender_email != current_user_email and not is_admin: + frappe.throw("You can remove topic only from your own messages") + + old_topic = getattr(msg, "chat_topic", None) + + if hasattr(msg, "chat_topic"): + msg.chat_topic = None + + if hasattr(msg, "chat_topic_subject"): + msg.chat_topic_subject = None + + if hasattr(msg, "topic_color"): + msg.topic_color = None + + msg.save(ignore_permissions=True) + frappe.db.commit() + + return { + "ok": True, + "message_name": message_name, + "old_chat_topic": old_topic + } + @frappe.whitelist() def get_channel_topics(chat_channel, topic_status=None): @@ -10253,4 +10976,3 @@ def get_channel_topics(chat_channel, topic_status=None): "count": len(topics), "topics": topics, } - diff --git a/clefincode_chat/clefincode_chat/doctype/clefincode_chat_channel_contributor/clefincode_chat_channel_contributor.json b/clefincode_chat/clefincode_chat/doctype/clefincode_chat_channel_contributor/clefincode_chat_channel_contributor.json index 1237efc..8a3b158 100644 --- a/clefincode_chat/clefincode_chat/doctype/clefincode_chat_channel_contributor/clefincode_chat_channel_contributor.json +++ b/clefincode_chat/clefincode_chat/doctype/clefincode_chat_channel_contributor/clefincode_chat_channel_contributor.json @@ -11,6 +11,7 @@ "platform", "active", "channel", + "active_chat_topic", "last_message_read", "unread_messages" ], @@ -35,10 +36,16 @@ "label": "Channel" }, { - "fieldname": "last_message_read", - "fieldtype": "Icon", - "in_list_view": 1, - "label": "Last Message Read" + "fieldname": "active_chat_topic", + "fieldtype": "Link", + "label": "Active Chat Topic", + "options": "ClefinCode Chat Topic" + }, + { + "fieldname": "last_message_read", + "fieldtype": "Icon", + "in_list_view": 1, + "label": "Last Message Read" }, { "default": "1", diff --git a/clefincode_chat/clefincode_chat/doctype/clefincode_chat_channel_user/clefincode_chat_channel_user.json b/clefincode_chat/clefincode_chat/doctype/clefincode_chat_channel_user/clefincode_chat_channel_user.json index 5e08282..0f07d4f 100644 --- a/clefincode_chat/clefincode_chat/doctype/clefincode_chat_channel_user/clefincode_chat_channel_user.json +++ b/clefincode_chat/clefincode_chat/doctype/clefincode_chat_channel_user/clefincode_chat_channel_user.json @@ -15,8 +15,9 @@ "is_removed", "remove_date", "is_admin", - "channel_last_message_number", - "platform_profile", + "channel_last_message_number", + "active_chat_topic", + "platform_profile", "platform_gateway", "pending_messages" ], @@ -87,12 +88,18 @@ "label": "Profile Id", "options": "ClefinCode Chat Profile" }, - { - "fieldname": "platform_profile", - "fieldtype": "Link", - "label": "Platform", - "options": "DocType" - }, + { + "fieldname": "active_chat_topic", + "fieldtype": "Link", + "label": "Active Chat Topic", + "options": "ClefinCode Chat Topic" + }, + { + "fieldname": "platform_profile", + "fieldtype": "Link", + "label": "Platform", + "options": "DocType" + }, { "fieldname": "platform_gateway", "fieldtype": "Dynamic Link", diff --git a/clefincode_chat/clefincode_chat/doctype/clefincode_chat_profile/clefincode_chat_profile.py b/clefincode_chat/clefincode_chat/doctype/clefincode_chat_profile/clefincode_chat_profile.py index 9022bde..986e945 100644 --- a/clefincode_chat/clefincode_chat/doctype/clefincode_chat_profile/clefincode_chat_profile.py +++ b/clefincode_chat/clefincode_chat/doctype/clefincode_chat_profile/clefincode_chat_profile.py @@ -9,7 +9,10 @@ class ClefinCodeChatProfile(Document): def before_save(self): if self.is_guest == 1 or self.is_support == 1: self.token = frappe.generate_hash() - self.update_contact_from_details() + if getattr(frappe.flags, "skip_migrate_fcm_devices_contact_sync", False): + return + + self.update_contact_from_details() def before_insert(self): if self.is_guest == 1: diff --git a/clefincode_chat/clefincode_chat/doctype/clefincode_chat_settings/clefincode_chat_settings.json b/clefincode_chat/clefincode_chat/doctype/clefincode_chat_settings/clefincode_chat_settings.json index a21de0b..2e4628b 100644 --- a/clefincode_chat/clefincode_chat/doctype/clefincode_chat_settings/clefincode_chat_settings.json +++ b/clefincode_chat/clefincode_chat/doctype/clefincode_chat_settings/clefincode_chat_settings.json @@ -13,6 +13,8 @@ "enable_mobile_notifications", "with_message_content", "enable_system_notification_on_mobile_app", + "limited_roles_section", + "limited_roles", "column_break_jxln", "max_edit_time", "max_delete_time", @@ -149,6 +151,17 @@ "fieldtype": "Check", "label": "Enable System Notification on Mobile App" }, + { + "fieldname": "limited_roles_section", + "fieldtype": "Section Break", + "label": "Limited User Roles" +}, +{ + "fieldname": "limited_roles", + "fieldtype": "Table MultiSelect", + "label": "Limited Roles", + "options": "ClefinCode Limited Role" +}, { "default": "7", "fieldname": "max_delete_time", diff --git a/clefincode_chat/clefincode_chat/doctype/clefincode_limited_role/__init__.py b/clefincode_chat/clefincode_chat/doctype/clefincode_limited_role/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/clefincode_chat/clefincode_chat/doctype/clefincode_limited_role/clefincode_limited_role.json b/clefincode_chat/clefincode_chat/doctype/clefincode_limited_role/clefincode_limited_role.json new file mode 100644 index 0000000..38e4252 --- /dev/null +++ b/clefincode_chat/clefincode_chat/doctype/clefincode_limited_role/clefincode_limited_role.json @@ -0,0 +1,28 @@ +{ + "actions": [], + "allow_rename": 1, + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "role" + ], + "fields": [ + { + "fieldname": "role", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Role", + "options": "Role", + "reqd": 1 + } + ], + "istable": 1, + "links": [], + "module": "ClefinCode Chat", + "name": "ClefinCode Limited Role", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/clefincode_chat/clefincode_chat/doctype/clefincode_limited_role/clefincode_limited_role.py b/clefincode_chat/clefincode_chat/doctype/clefincode_limited_role/clefincode_limited_role.py new file mode 100644 index 0000000..68817e9 --- /dev/null +++ b/clefincode_chat/clefincode_chat/doctype/clefincode_limited_role/clefincode_limited_role.py @@ -0,0 +1,5 @@ +from frappe.model.document import Document + + +class ClefinCodeLimitedRole(Document): + pass \ No newline at end of file diff --git a/clefincode_chat/patches/v1_3_4/migrate_fcm_devices.py b/clefincode_chat/patches/v1_3_4/migrate_fcm_devices.py index da72122..bbc7f01 100644 --- a/clefincode_chat/patches/v1_3_4/migrate_fcm_devices.py +++ b/clefincode_chat/patches/v1_3_4/migrate_fcm_devices.py @@ -3,47 +3,53 @@ def execute(): - frappe.reload_doc( - "clefincode_chat", - "doctype", - "clefincode_chat_profile_fcm_device" - ) - - frappe.reload_doc( - "clefincode_chat", - "doctype", - "clefincode_chat_profile" - ) - - profiles = frappe.get_all( - "ClefinCode Chat Profile", - fields=["name", "registration_token", "platform"] - ) - - for profile in profiles: - if not profile.registration_token: - continue - - doc = frappe.get_doc("ClefinCode Chat Profile", profile.name) - - already_exists = any( - row.registration_token == profile.registration_token - for row in (doc.get("fcm_devices") or []) + frappe.flags.skip_migrate_fcm_devices_contact_sync = True + + try: + frappe.reload_doc( + "clefincode_chat", + "doctype", + "clefincode_chat_profile_fcm_device" + ) + + frappe.reload_doc( + "clefincode_chat", + "doctype", + "clefincode_chat_profile" + ) + + profiles = frappe.get_all( + "ClefinCode Chat Profile", + fields=["name", "registration_token", "platform"] ) - if already_exists: - continue + for profile in profiles: + if not profile.registration_token: + continue + + doc = frappe.get_doc("ClefinCode Chat Profile", profile.name) + + already_exists = any( + row.registration_token == profile.registration_token + for row in (doc.get("fcm_devices") or []) + ) + + if already_exists: + continue + + doc.append("fcm_devices", { + "device_id": "legacy", + "registration_token": profile.registration_token, + "platform": profile.platform or "", + "app_version": "", + "device_name": "", + "is_active": 1, + "last_seen": now_datetime() + }) - doc.append("fcm_devices", { - "device_id": "legacy", - "registration_token": profile.registration_token, - "platform": profile.platform or "", - "app_version": "", - "device_name": "", - "is_active": 1, - "last_seen": now_datetime() - }) + doc.save(ignore_permissions=True) - doc.save(ignore_permissions=True) + frappe.db.commit() - frappe.db.commit() \ No newline at end of file + finally: + frappe.flags.skip_migrate_fcm_devices_contact_sync = False \ No newline at end of file diff --git a/clefincode_chat/public/js/clefincode_chat.bundle.js b/clefincode_chat/public/js/clefincode_chat.bundle.js index a09a64e..be9fd54 100644 --- a/clefincode_chat/public/js/clefincode_chat.bundle.js +++ b/clefincode_chat/public/js/clefincode_chat.bundle.js @@ -879,7 +879,7 @@ window.CCChatContactList = ChatContactList; async function get_settings(token) { const res = await frappe.call({ type: "GET", - method: "clefincode_chat.api.api_1_2_1.api.get_settings", + method: "clefincode_chat.api.api_1_3_4.api.get_settings", args: { token: token, }, diff --git a/clefincode_chat/public/js/components/chat_portal_space.js b/clefincode_chat/public/js/components/chat_portal_space.js index c1ebe17..7d10c12 100644 --- a/clefincode_chat/public/js/components/chat_portal_space.js +++ b/clefincode_chat/public/js/components/chat_portal_space.js @@ -672,6 +672,15 @@ performSearchLocal(query) { this.$chatbot_container.find("[data-message-name]").each(function () { const $msg = $(this); const name = $msg.data("message-name"); + + const $longBody = $msg.find(".message-text-body.long-message-body"); + if ($longBody.length) { + $longBody + .removeClass("long-message-body") + .addClass("long-message-expanded"); + $msg.find(".chat-read-more-btn").remove(); + } + const $bubble = $msg.find(".message-bubble").first(); const originalHtml = $bubble.html(); @@ -715,6 +724,14 @@ navigateToSearchResult() { const $msg = this.$chatbot_container.find(`#msg-${name}`); if (!$msg.length) return; + const $longBody = $msg.find(".message-text-body.long-message-body"); + if ($longBody.length) { + $longBody + .removeClass("long-message-body") + .addClass("long-message-expanded"); + $msg.find(".chat-read-more-btn").remove(); + } + this.$chatbot_container.find(".search-highlight-active") .removeClass("search-highlight-active"); @@ -1055,6 +1072,29 @@ me.$chatbot_container.on("click.portal", ".reply-link", function () { const $msg = me.$chatbot_container.find(`#msg-${target}`); if ($msg.length) $msg[0].scrollIntoView({ behavior: "smooth", block: "center" }); }); +// Read more for long messages +me.$chatbot_container.on("click.portal", ".chat-read-more-btn", function (e) { + e.preventDefault(); + e.stopPropagation(); + const $btn = $(this); + const $textBody = $btn.siblings(".message-text-body").first(); + if (!$textBody.length) return; + + let step = parseInt($textBody.attr("data-read-more-step") || "0", 10); + const nextStep = step + 1; + + if (nextStep >= 3) { + $textBody + .removeClass("long-message-body") + .addClass("long-message-expanded"); + $btn.remove(); + } else { + const heights = [200, 400, 600]; + $textBody.css("max-height", heights[nextStep] + "px"); + $textBody.attr("data-read-more-step", nextStep); + } +}); + // Toggle search bar me.$chatbot_space.on("click.portal", ".toggle-search", function () { const $search = me.$chatbot_space.find(".chat-search"); @@ -1340,7 +1380,32 @@ me.$chatbot_space.on("click.portal", ".search-clear", () => { `); } - $message_element.append($sanitized_content); + let $contentToAppend = $sanitized_content; + + if (!is_deleted && type !== "info-message") { + const plainText = content.replace(/<[^>]*>/g, ""); + const isTextOnly = + !/<\s*(img|video|audio|source|canvas)\b/i.test(content) && + !content.includes("data-audio"); + + if (isTextOnly && plainText.length > 300) { + const $wrapper = $( + '
' + ); + $wrapper.append($sanitized_content.contents()); + $contentToAppend = $wrapper; + } + } + + $message_element.append($contentToAppend); + if ($contentToAppend.is(".long-message-body")) { + const $readMoreBtn = $( + '" + ); + $message_element.append($readMoreBtn); + } const isDark = document.documentElement.dataset.themeMode === "dark"; const deleteIcon = isDark ? "/assets/clefincode_chat/icons/delete.png" diff --git a/clefincode_chat/public/js/components/erpnext_chat_contact.js b/clefincode_chat/public/js/components/erpnext_chat_contact.js index 3355233..0a613a0 100644 --- a/clefincode_chat/public/js/components/erpnext_chat_contact.js +++ b/clefincode_chat/public/js/components/erpnext_chat_contact.js @@ -508,6 +508,16 @@ const isSystemManager = roles.includes("System Manager"); if (contact_element.hasClass("options-icon")) { return; } + const contacts = this.profile.contact_details || []; + const onlyEmail = contacts.length > 0 && + contacts.every(c => c.contact_type === "Email"); + + if (onlyEmail) { + this.handle_mail_icon_click({ + data: () => contacts[0].contact_info + }); + return; + } // Handle based on contact type if (contact_element.length > 0) { diff --git a/clefincode_chat/public/js/components/erpnext_chat_info.js b/clefincode_chat/public/js/components/erpnext_chat_info.js index f65c08b..fa380e5 100644 --- a/clefincode_chat/public/js/components/erpnext_chat_info.js +++ b/clefincode_chat/public/js/components/erpnext_chat_info.js @@ -1862,7 +1862,7 @@ save_all_contacts(dialog) { this.chat_space.$chat_space .find(`.chat-topic-separator[data-topic-name="${safe}"] .topic-separator-title`) - .text(`${__("Topic")}: ${subject}`); + .text(subject); this.chat_space.$chat_space .find(`.message-bubble[data-topic-name="${safe}"]`) diff --git a/clefincode_chat/public/js/components/erpnext_chat_list.js b/clefincode_chat/public/js/components/erpnext_chat_list.js index 71a7ecb..e675a6b 100644 --- a/clefincode_chat/public/js/components/erpnext_chat_list.js +++ b/clefincode_chat/public/js/components/erpnext_chat_list.js @@ -149,6 +149,40 @@ export default class ChatList { ${frappe.utils.icon("close", "lg")}
+ ${this.is_limited_user ? ` +
+ +
+ ` : ""} + `; @@ -164,6 +198,39 @@ export default class ChatList { title='Close'> ${frappe.utils.icon("close", "lg")} + ${this.is_limited_user ? ` +
+ +
+ ` : ""} `; @@ -510,7 +577,7 @@ this.$chat_list.on("click", ".toggle-webview-mode", function () { }, }); } - + console.log(me); let profile = { is_admin: me.is_admin, @@ -524,7 +591,7 @@ this.$chat_list.on("click", ".toggle-webview-mode", function () { room_type: "Group", // contact: contact, is_first_message: 1, - platform: platform, + platform: me.platform || "Chat", is_website_support_group: 1 }; this.chat_space = new ChatSpace({ @@ -569,6 +636,30 @@ this.$chat_list.on("click", ".toggle-webview-mode", function () { frappe.realtime.off("remove_group_member"); }); + this.$chat_list.off("click", ".limited-user-info-icon"); + this.$chat_list.on("click", ".limited-user-info-icon", function (e) { + e.preventDefault(); + e.stopPropagation(); + + frappe.msgprint({ + title: __("Limited User"), + indicator: "blue", + message: ` +
+

+ ${__("This user has limited chat access.")} +

+

+ ${__("A limited user can communicate with internal users or support team members, but cannot see or start conversations with other limited users.")} +

+

+ ${__("This restriction helps protect external users' privacy and prevents them from discovering or contacting each other inside the system.")} +

+
+ ` + }); + }); + // Add resize event listener for zoom out/in $(window).on("resize", () => { if (me._resize_timeout) clearTimeout(me._resize_timeout); diff --git a/clefincode_chat/public/js/components/erpnext_chat_room.js b/clefincode_chat/public/js/components/erpnext_chat_room.js index a5948f8..ec4852f 100644 --- a/clefincode_chat/public/js/components/erpnext_chat_room.js +++ b/clefincode_chat/public/js/components/erpnext_chat_room.js @@ -535,6 +535,23 @@ export default class ChatRoom { return $content.children("div").first().prop("outerHTML"); } // =============================================================== + else if ( + $content.find(".close-topic").data("template") == "close_topic_template" + ) { + if ( + $content.find(".sender-user").attr("data-user") == + this.profile.user_email + ) { + $content.find(".sender-user").html("You"); + } else { + const sender_name = await get_profile_full_name( + $content.find(".sender-user").attr("data-user") + ); + $content.find(".sender-user").html(sender_name); + } + return $content.children("div").first().prop("outerHTML"); + } + // =============================================================== else if ( $content.find(".remove-doctype").data("template") == "remove_doctype_template" diff --git a/clefincode_chat/public/js/components/erpnext_chat_space.js b/clefincode_chat/public/js/components/erpnext_chat_space.js index 52920aa..5edb2b7 100644 --- a/clefincode_chat/public/js/components/erpnext_chat_space.js +++ b/clefincode_chat/public/js/components/erpnext_chat_space.js @@ -120,6 +120,16 @@ export default class ChatSpace { }; this.$plusMenu = null; this.$topicSelectPopup = null; + this.$allTopicsView = null; + this._allTopicsState = { + topics: [], + offset: 0, + limit: 20, + totalCount: 0, + hasMore: true, + loading: false, + query: '' + }; this.topicPalette = [ "#7c3aed", // purple @@ -333,7 +343,10 @@ isRealTopicTimelineMessage(message = {}) { templateType === "settopic" || templateType === "remove topic" || templateType === "remove-topic" || - templateType === "removetopic" + templateType === "removetopic" || + templateType === "close topic" || + templateType === "close-topic" || + templateType === "closetopic" ) return false; return true; } @@ -356,7 +369,7 @@ makeTopicStartSeparatorHtml(topicName, topicSubject = null, topicColor = null) { data-topic-color="${safeColor}" style="--topic-color:${safeColor};" > - ${__("Topic")}: ${safeSubject} + ${safeSubject} - + `; const plus_btn = ``; @@ -3777,6 +3831,23 @@ this.$wrapper.off("click.chatMenuActions", ".delete-action") me.startBulkSelection("delete", messageName); }); +this.$wrapper.off("click.chatMenuActions", ".remove-message-topic-action") + .on("click.chatMenuActions", ".remove-message-topic-action", async function (e) { + e.preventDefault(); + e.stopPropagation(); + + const messageName = $(this).data("message-name"); + me.closeMessageActionMenu(); + + if (!messageName) return; + + frappe.confirm( + __("Remove topic from this message?"), + async () => { + await me.removeTopicFromMessage(messageName); + } + ); + }); this.$wrapper.off("click.chatBubbleActions").on("click.chatBubbleActions", function (e) { const insideMenu = $(e.target).closest(".message-action-menu, .message-menu-trigger").length; @@ -4278,11 +4349,13 @@ if (!me.chat_topic_space) { me.openTopicSelectPopup(); }); - me.$chat_space.on("click", ".chat-plus-remove-topic", function (e) { + me.$chat_space.on("click", ".chat-plus-remove-topic", async function (e) { e.preventDefault(); e.stopPropagation(); + const topicToClear = me.activeMessageTopic || null; me.closePlusMenu(); - me.clearMessageTopic(); + me.clearMessageTopic(false); + await me.clearUserActiveChatTopic(topicToClear); }); me.$chat_space.on("click", ".chat-plus-add-topic", async function (e) { @@ -4292,6 +4365,28 @@ if (!me.chat_topic_space) { await me.openCreateTopicFromPlusDialog(); }); + me.$chat_space.on("click", ".chat-read-more-btn", function (e) { + e.preventDefault(); + e.stopPropagation(); + const $btn = $(this); + const $textBody = $btn.siblings(".message-text-body").first(); + if (!$textBody.length) return; + + let step = parseInt($textBody.attr("data-read-more-step") || "0", 10); + const nextStep = step + 1; + + if (nextStep >= 2) { + $textBody + .removeClass("long-message-body") + .addClass("long-message-expanded"); + $btn.remove(); + } else { + const heights = [320, 640, 960]; + $textBody.css("max-height", heights[nextStep] + "px"); + $textBody.attr("data-read-more-step", nextStep); + } + }); + me.$chat_space.on("input", ".topic-select-search", function () { const query = $(this).val() || ""; me.renderTopicSelectList(query); @@ -4316,9 +4411,10 @@ if (!me.chat_topic_space) { topic_color: topicColor }); - me.closeTopicSelectPopup?.(); + await me.saveUserActiveChatTopic(topicName); - // await me.scrollToFirstRealTopicMessage(topicName); + me.closeTopicSelectPopup?.(); + me.closeAllTopicsView?.(); }); me.$chat_space.on("click", ".topic-select-open", async function (e) { @@ -4332,6 +4428,33 @@ if (!me.chat_topic_space) { if (!topicName) return; await me.openTopicChatWindow(topicName, safeSubject); }); + + me.$chat_space.on("click", ".topic-select-see-more", function (e) { + e.preventDefault(); + e.stopPropagation(); + me.closeTopicSelectPopup(); + me.openAllTopicsView(); + }); + + me.$chat_space.on("click", ".all-topics-back", function (e) { + e.preventDefault(); + e.stopPropagation(); + me.closeAllTopicsView(); + }); + + me.$chat_space.on("input", ".all-topics-search", function () { + const query = $(this).val() || ""; + me._allTopicsState.query = query; + me.renderAllTopicsList(); + }); + + me.$chat_space.on("scroll", ".all-topics-list", function () { + const el = this; + if (me._allTopicsState.loading || !me._allTopicsState.hasMore) return; + if (el.scrollTop + el.clientHeight >= el.scrollHeight - 100) { + me.loadMoreAllTopics(); + } + }); } $(document).on("click.plusMenuClose", function (e) { @@ -4711,6 +4834,20 @@ async setup_messages(messages_list) { ); } + isCloseTopicInfoMessage(params = {}) { + const template = String(params.message_template_type || "").toLowerCase(); + const contentText = this.stripHtml(params.content || "").toLowerCase(); + + return ( + template === "close topic" || + template === "close-topic" || + template === "closetopic" || + contentText.includes("closed topic") || + contentText.includes("you closed topic") || + contentText.includes("topic closed") + ); + } + makeTopicSeparatorHtml(topicName, topicSubject) { if (!topicName) return ""; if (this.isDedicatedTopicContext?.()) return ""; @@ -4735,7 +4872,7 @@ async setup_messages(messages_list) { title="${__("Show / Hide topic messages")}" > - ${__("Topic")}: ${title} + ${title} @@ -4777,7 +4914,11 @@ async setup_messages(messages_list) { } updatePlusTopicButton() { - const $btn = this.$chat_actions?.find(".open-chat-plus-menu, .plus-btn").first(); + const $btn = ( + this.$chat_actions?.find(".open-chat-plus-menu, .plus-btn").first()?.length + ? this.$chat_actions.find(".open-chat-plus-menu, .plus-btn").first() + : this.$chat_space?.find(".open-chat-plus-menu, .plus-btn").first() + ); if (!$btn?.length) return; if (this.activeMessageTopic) { @@ -4819,6 +4960,68 @@ getOutgoingTopicInfo() { }; } +async getReplyMessageTopicInfo() { + const replyMessageName = this.reply_to_message_name; + if (!replyMessageName) return null; + + let original = this.messageCache.get(replyMessageName); + + if (!original || (!original.chat_topic && !original.topic && !original.topic_name)) { + try { + const msg = await this.fetch_single_message(replyMessageName); + if (msg) { + original = { + ...(original || {}), + ...msg, + chat_topic: msg.chat_topic || msg.topic || msg.topic_name || null, + chat_topic_subject: + msg.chat_topic_subject || + msg.topic_subject || + msg.chat_topic_title || + msg.subject || + null, + topic_color: msg.topic_color || msg.chat_topic_color || null + }; + + this.messageCache.set(replyMessageName, original); + } + } catch (e) { + console.warn("Failed to fetch reply topic info", e); + } + } + + const topicName = + original?.chat_topic || + original?.topic || + original?.topic_name || + null; + + if (!topicName) return null; + + const topicSubject = + original.chat_topic_subject || + original.topic_subject || + original.chat_topic_title || + original.subject || + topicName; + + const topicColor = + original.topic_color || + original.chat_topic_color || + this.topicColorMap?.get(topicName) || + this.getTopicColor(topicName); + + if (topicColor) { + this.topicColorMap?.set(topicName, topicColor); + } + + return { + chat_topic: topicName, + chat_topic_subject: topicSubject, + topic_color: topicColor + }; +} + getIncomingMessageTopic(payload = {}) { const msg = payload.message || payload.data || payload; return ( @@ -5208,6 +5411,78 @@ applyTopicToRenderedMessage(messageName, topicName, topicSubject = null, topicCo } } +async removeTopicFromMessage(messageName) { + if (!messageName) return; + + const cached = this.messageCache.get(messageName) || {}; + const oldTopic = + cached.chat_topic || + cached.topic || + cached.topic_name || + null; + + if (!oldTopic) { + frappe.show_alert({ + message: __("This message has no linked topic"), + indicator: "orange" + }); + return; + } + + try { + await frappe.call({ + method: "clefincode_chat.api.api_1_3_4.api.remove_message_topic", + args: { + message_name: messageName + } + }); + + cached.chat_topic = null; + cached.topic = null; + cached.topic_name = null; + cached.chat_topic_subject = null; + cached.topic_subject = null; + cached.chat_topic_title = null; + cached.topic_color = null; + cached.chat_topic_color = null; + this.messageCache.set(messageName, cached); + + const $msg = this.$chat_space.find(`[data-message-name="${messageName}"]`); + const $bubble = $msg.find(".message-bubble").first(); + + $msg + .removeClass("topic-message-item has-message-topic") + .removeAttr("data-topic-name") + .removeAttr("data-topic-subject") + .removeAttr("data-topic-color") + .css("--topic-color", ""); + + $bubble + .removeClass("has-message-topic") + .removeAttr("data-topic-name") + .removeAttr("title"); + + $bubble.find(".message-topic-bar").remove(); + + this.normalizeTopicSeparators?.(); + this.buildTopicMetaMap?.(); + this.applyTopicVisibility?.(); + this.refreshTopicNavBar?.(); + + frappe.show_alert({ + message: __("Topic removed from message"), + indicator: "green" + }); + } catch (e) { + console.error("Failed to remove message topic", e); + frappe.msgprint({ + title: __("Error"), + message: __("Failed to remove topic from message."), + indicator: "red" + }); + } +} + applyRelinkedTopicToMessages(messageNames = [], topicName, topicSubject = null, topicColor = null) { if (!messageNames || !messageNames.length || !topicName) return; @@ -5561,6 +5836,187 @@ closeTopicSelectPopup() { } } +// ================= All Topics Full Page View ================= + +openAllTopicsView() { + this.closeAllTopicsView(); + if (!this.$chat_space || !this.$chat_space.length) return; + + const $existing = this.$chat_space.find(".all-topics-view"); + if ($existing.length) return; + + this.$allTopicsView = $(` +
+
+ + +
+
+ +
+ `); + + this.$chat_space.append(this.$allTopicsView); + + this._allTopicsState = { + topics: [], + offset: 0, + limit: 20, + totalCount: 0, + hasMore: true, + loading: false, + query: '' + }; + + this.loadMoreAllTopics(); + + setTimeout(() => { + this.$allTopicsView.find(".all-topics-search").focus(); + }, 0); +} + +closeAllTopicsView() { + if (this.$allTopicsView) { + this.$allTopicsView.remove(); + this.$allTopicsView = null; + } + this._allTopicsState = { + topics: [], + offset: 0, + limit: 20, + totalCount: 0, + hasMore: true, + loading: false, + query: '' + }; +} + +renderAllTopicsList() { + if (!this.$allTopicsView) return; + + const $list = this.$allTopicsView.find(".all-topics-list"); + const $loading = this.$allTopicsView.find(".all-topics-loading"); + const query = (this._allTopicsState.query || "").toLowerCase(); + + let displayTopics = this._allTopicsState.topics; + if (query) { + displayTopics = displayTopics.filter((topic) => { + const name = String(topic.name || "").toLowerCase(); + const subject = String(topic.subject || "").toLowerCase(); + return name.includes(query) || subject.includes(query); + }); + } + + if (!displayTopics.length) { + $list.html(`
${__("No topics found")}
`); + $loading.hide(); + return; + } + + $list.html(displayTopics.map((topic) => this.makeTopicSelectRowHtml(topic)).join("")); + $loading.hide(); +} + +async loadMoreAllTopics() { + if (this._allTopicsState.loading || !this._allTopicsState.hasMore) return; + this._allTopicsState.loading = true; + + const $list = this.$allTopicsView ? this.$allTopicsView.find(".all-topics-list") : null; + const $loading = this.$allTopicsView ? this.$allTopicsView.find(".all-topics-loading") : null; + if ($loading && $loading.length) $loading.show(); + + try { + const chatChannel = this.getCurrentChatChannel(); + if (!chatChannel) { + this._allTopicsState.loading = false; + if ($loading && $loading.length) $loading.hide(); + return; + } + + const r = await frappe.call({ + method: "clefincode_chat.api.api_1_3_4.api.get_channel_topics", + args: { + chat_channel: chatChannel, + topic_status: "All", + limit: this._allTopicsState.limit, + offset: this._allTopicsState.offset + } + }); + + const topics = r.message?.topics || []; + const totalCount = r.message?.total_count || 0; + + let newRowsHtml = ''; + topics.forEach((topic) => { + const name = topic.name; + if (!name) return; + const subject = topic.subject || topic.chat_topic_subject || name; + const dbColor = topic.topic_color || topic.color || null; + const color = this.getTopicColor(name, dbColor); + + const existing = this.allKnownTopicMetaMap.get(name) || {}; + this.allKnownTopicMetaMap.set(name, { + ...existing, + name, + subject, + color, + topic_color: color, + topic_status: topic.topic_status || existing.topic_status || "Open", + status: topic.topic_status || existing.status || "Open", + count: existing.count || 0, + latestIndex: existing.latestIndex ?? -1, + latestDate: existing.latestDate || topic.modified || topic.creation || "" + }); + + if (dbColor) { + this.topicColorMap.set(name, dbColor); + } + + if (!this._allTopicsState.topics.some(t => t.name === name)) { + this._allTopicsState.topics.push({ + name, + subject, + color, + topic_color: color, + count: topic.references_count || 0, + latestDate: topic.modified || topic.creation || "" + }); + newRowsHtml += this.makeTopicSelectRowHtml({ + name, subject, color, topic_color: color, + count: topic.references_count || 0 + }); + } + }); + + this._allTopicsState.offset += topics.length; + this._allTopicsState.totalCount = totalCount; + this._allTopicsState.hasMore = this._allTopicsState.offset < totalCount; + + if ($list && $list.length) { + const query = (this._allTopicsState.query || "").toLowerCase(); + if (query) { + this.renderAllTopicsList(); + } else { + if (newRowsHtml) { + const $empty = $list.find(".all-topics-empty"); + if ($empty.length) $empty.replaceWith(newRowsHtml); + else $list.append(newRowsHtml); + } + if (!this._allTopicsState.topics.length) { + $list.html(`
${__("No topics found")}
`); + } + } + } + } catch (e) { + console.warn("Failed to load more topics", e); + } + + this._allTopicsState.loading = false; + if ($loading && $loading.length) $loading.hide(); +} + renderTopicSelectList(query = "") { if (!this.$topicSelectPopup) return; @@ -5584,6 +6040,9 @@ renderTopicSelectList(query = "") { const lowerQuery = String(query).toLowerCase(); const filtered = topicSource.filter((topic) => { + const status = String(topic.topic_status || topic.status || topic.chat_topic_status || "").toLowerCase(); + if (status === "closed") return false; + if (!lowerQuery) return true; return ( String(topic.name).toLowerCase().includes(lowerQuery) || @@ -5599,7 +6058,15 @@ renderTopicSelectList(query = "") { return; } - $list.html(filtered.map((topic) => this.makeTopicSelectRowHtml(topic)).join("")); + const DISPLAY_LIMIT = 6; + const displayItems = filtered.slice(0, DISPLAY_LIMIT); + const hasMore = filtered.length > DISPLAY_LIMIT; + + let html = displayItems.map((topic) => this.makeTopicSelectRowHtml(topic)).join(""); + if (hasMore) { + html += ``; + } + $list.html(html); } makeTopicSelectRowHtml(topic) { @@ -5635,7 +6102,7 @@ async loadChannelTopicsForSelect() { method: "clefincode_chat.api.api_1_3_4.api.get_channel_topics", args: { chat_channel: chatChannel, - topic_status: "All" + topic_status: "Open" } }); @@ -5656,6 +6123,8 @@ async loadChannelTopicsForSelect() { subject, color, topic_color: color, + topic_status: topic.topic_status || existing.topic_status || "Open", + status: topic.topic_status || existing.status || "Open", count: existing.count || 0, latestIndex: existing.latestIndex ?? -1, latestDate: existing.latestDate || topic.modified || topic.creation || "" @@ -5734,7 +6203,7 @@ selectMessageTopic(topicName, topicSubject = null, opts = {}) { // } } -clearMessageTopic() { +clearMessageTopic(showAlert = true) { this.activeMessageTopic = null; this.activeMessageTopicSubject = null; this.activeMessageTopicColor = null; @@ -5745,10 +6214,283 @@ clearMessageTopic() { $removeBtn.hide(); } - frappe.show_alert({ - message: __("Topic removed from new messages"), - indicator: "green" + if (showAlert) { + frappe.show_alert({ + message: __("Topic removed from new messages"), + indicator: "green" + }); + } +} + +clearClosedTopicUi(topicName = null) { + const closedTopic = topicName ? String(topicName) : null; + + this.clearMessageTopic(false); + this.chat_topic = null; + this.chat_topic_subject = null; + this.chat_topic_status = "Closed"; + this.reference_doctypes = []; + this.updateActiveTopicButton(null); + this.updatePlusTopicButton?.(); + + if (closedTopic && this.activeTopicFilter && String(this.activeTopicFilter) === closedTopic) { + this.activeTopicFilter = null; + } + + if (closedTopic && this.collapsedTopics) { + this.collapsedTopics.delete(closedTopic); + } + + [this.topicMetaMap, this.allKnownTopicMetaMap].forEach((map) => { + if (!map || !closedTopic || !map.has(closedTopic)) return; + const meta = map.get(closedTopic) || {}; + meta.status = "Closed"; + meta.topic_status = "Closed"; + meta.chat_topic_status = "Closed"; + map.set(closedTopic, meta); }); + + if (closedTopic && this.messageCache) { + this.messageCache.forEach((cached) => { + const cachedTopic = + cached.chat_topic || + cached.topic || + cached.topic_name || + null; + + if (String(cachedTopic || "") !== closedTopic) return; + + cached.chat_topic = null; + cached.topic = null; + cached.topic_name = null; + cached.chat_topic_subject = null; + cached.topic_subject = null; + cached.chat_topic_title = null; + cached.topic_color = null; + cached.chat_topic_color = null; + }); + } + + const $root = this.$chat_space_container?.length + ? this.$chat_space_container + : this.$chat_space; + + if (closedTopic && $root?.length && !this.isDedicatedTopicContext?.()) { + const safe = this.escapeSelectorValue(closedTopic); + + $root + .find( + `.chat-topic-separator[data-topic-name="${safe}"], ` + + `.topic-system-separator[data-topic-name="${safe}"], ` + + `.topic-start-separator[data-topic-name="${safe}"]` + ) + .remove(); + + $root.find(`.topic-message-item[data-topic-name="${safe}"]`).each((_, el) => { + const $msg = $(el); + const $bubble = $msg.find(".message-bubble").first(); + + $msg + .removeClass("topic-message-item has-message-topic") + .removeAttr("data-topic-name") + .removeAttr("data-topic-subject") + .removeAttr("data-topic-color") + .css("--topic-color", "") + .show(); + + $bubble + .removeClass("has-message-topic") + .removeAttr("data-topic-name") + .removeAttr("title"); + + $bubble.find(".message-topic-bar").remove(); + }); + } + + this.normalizeTopicSeparators?.(); + this.buildTopicMetaMap?.(); + this.refreshTopicNavBar?.(); + this.applyTopicVisibility?.(); + + if (this.$topicSelectPopup?.length) { + const query = this.$topicSelectPopup.find(".topic-select-search").val() || ""; + this.renderTopicSelectList(query); + } +} + +async saveUserActiveChatTopic(topicName) { + const chatChannel = this.getCurrentChatChannel?.() || + (this.profile.room_type === "Contributor" ? this.profile.parent_channel : this.profile.room); + + if (!chatChannel || !topicName || this.is_topic_window || this.profile.room_type === "Topic") { + return; + } + + try { + await frappe.call({ + method: "clefincode_chat.api.api_1_3_4.api.set_user_active_chat_topic", + args: { + chat_channel: chatChannel, + chat_topic: topicName, + user_email: this.profile.user_email + } + }); + } catch (e) { + console.warn("Failed to save active chat topic", e); + } +} + +async applySavedActiveTopic() { + const chatChannel = this.getCurrentChatChannel?.() || + (this.profile.room_type === "Contributor" ? this.profile.parent_channel : this.profile.room); + + if (!chatChannel || this.is_topic_window || this.profile.room_type === "Topic" || this.chat_topic_space) { + return; + } + + try { + const r = await frappe.call({ + method: "clefincode_chat.api.api_1_3_4.api.get_user_active_chat_topic", + args: { + chat_channel: chatChannel, + user_email: this.profile.user_email + } + }); + + const topic = r.message?.results?.[0]; + + if (!topic || !topic.chat_topic) { + return; + } + + this.selectMessageTopic( + topic.chat_topic, + topic.chat_topic_subject || topic.chat_topic, + { + scroll: false, + color: topic.topic_color, + topic_color: topic.topic_color + } + ); + } catch (e) { + console.warn("Failed to apply saved active topic", e); + } +} + +async clearUserActiveChatTopic(topicName = null) { + const chatChannel = this.getCurrentChatChannel?.() || + (this.profile.room_type === "Contributor" ? this.profile.parent_channel : this.profile.room); + + if (!chatChannel || this.is_topic_window || this.profile.room_type === "Topic") { + return; + } + + try { + await frappe.call({ + method: "clefincode_chat.api.api_1_3_4.api.clear_user_active_chat_topic", + args: { + chat_channel: chatChannel, + chat_topic: topicName, + user_email: this.profile.user_email + } + }); + } catch (e) { + console.warn("Failed to clear active chat topic", e); + } +} + +getUserActiveTopicEventName() { + return `user_active_topic:${this.profile.user_email}`; +} + +bindUserActiveTopicRealtime() { + if (!this.profile?.user_email) return; + + const eventName = this.getUserActiveTopicEventName(); + + const handler = (res = {}) => { + this.handleUserActiveTopicRealtime(res); + }; + + this.bindRealtimeChannel(eventName, handler); +} + +handleUserActiveTopicRealtime(res = {}) { + if (!res) return; + + const rt = res.realtime_type; + if (rt !== "user_active_topic" && !(rt === "close_topic" && res.user_scoped == 1)) return; + + const chatChannel = this.getCurrentChatChannel?.() || + (this.profile.room_type === "Contributor" ? this.profile.parent_channel : this.profile.room); + + if (!chatChannel || res.chat_channel !== chatChannel) { + return; + } + + if (this.is_topic_window || this.profile.room_type === "Topic") { + return; + } + + const action = res.action; + const topicName = res.chat_topic || res.topic_name || null; + + if (action === "select") { + if (!topicName) return; + + this.selectMessageTopic( + topicName, + res.chat_topic_subject || res.topic_subject || topicName, + { + scroll: false, + color: res.topic_color, + topic_color: res.topic_color + } + ); + + return; + } + + if (action === "clear") { + if (!topicName || this.activeMessageTopic === topicName) { + this.clearMessageTopic(false); + this.updatePlusTopicButton?.(); + } + } +} + +async closeTopicForConversation(chatTopic = null) { + const chatChannel = + this.profile?.room_type === "Contributor" + ? this.profile?.parent_channel + : this.profile?.room; + + if (!chatChannel) return; + + try { + const results = await close_chat_topic( + chatChannel, + chatTopic, + this.last_active_sub_channel, + this.profile?.user_email, + this.profile?.user + ); + const closedTopic = results?.[0]?.chat_topic || chatTopic || this.chat_topic || null; + + await this.clearUserActiveChatTopic(closedTopic); + this.clearClosedTopicUi(closedTopic); + + frappe.show_alert({ + message: __("Topic closed for this conversation"), + indicator: "green" + }); + } catch (error) { + console.error("Failed to close topic", error); + frappe.show_alert({ + message: __("Could not close topic"), + indicator: "red" + }); + } } // ================= Scroll to Topic ================= @@ -5956,7 +6698,12 @@ async getFirstRealTopicMessageName(topicName) { message_template_type: element.message_template_type }); - if (isSetTopicInfo || isRemoveTopicInfo) { + const isCloseTopicInfo = this.isCloseTopicInfoMessage({ + content: element.content, + message_template_type: element.message_template_type + }); + + if (isSetTopicInfo || isRemoveTopicInfo || isCloseTopicInfo) { const topicNameForSystemMessage = element.chat_topic || element.topic || @@ -6115,6 +6862,12 @@ async getFirstRealTopicMessageName(topicName) { `; } + _getPlainTextLength(html) { + const div = document.createElement("div"); + div.innerHTML = html; + return (div.textContent || div.innerText || "").length; + } + async make_message(params) { const { content, @@ -6211,7 +6964,40 @@ async getFirstRealTopicMessageName(topicName) { $message_element.append($forwarded_label); } - $message_element.append($sanitized_content); + let $contentToAppend = $sanitized_content; + + if ( + !is_deleted && + type !== "info-message" && + this._getPlainTextLength(content) > 300 + ) { + const cacheEntry = this.messageCache.get(message_name); + const isTextOnly = + !cacheEntry || + (!cacheEntry.is_media && + !cacheEntry.is_document && + !cacheEntry.is_voice_clip && + !cacheEntry.is_screenshot && + !cacheEntry.attachment); + + if (isTextOnly) { + const $wrapper = $( + '
' + ); + $wrapper.append($sanitized_content.contents()); + $contentToAppend = $wrapper; + } + } + + $message_element.append($contentToAppend); + if ($contentToAppend.is(".long-message-body")) { + const $readMoreBtn = $( + '" + ); + $message_element.append($readMoreBtn); + } // ================= Message Topic Badge ================= if (chat_topic && !is_deleted) { @@ -6543,6 +7329,18 @@ if (!is_deleted && type !== "info-message") { } } // =========================================== + else if (message_template_type == "Close Topic") { + const sender_email = $sanitized_content + .find(".sender-user") + .attr("data-user"); + if (sender_email == this.profile.user_email) { + $sanitized_content.find(".sender-user").html("You"); + } else { + const sender_name = await get_profile_full_name(sender_email); + $sanitized_content.find(".sender-user").html(sender_name); + } + } + // =========================================== else if (message_template_type == "Remove Doctype") { const sender_email = $sanitized_content .find(".sender-user") @@ -6649,7 +7447,10 @@ if (!is_deleted && type !== "info-message") { const $editor = this.$chat_space.find(".type-message .ql-editor"); const editorText = ($editor.text() || "").trim(); const hasImages = $editor.find("img").length > 0; - const messageChatTopic = this.chat_topic || this.activeMessageTopic || null; + const messageChatTopic = + this.isDedicatedTopicContext?.() + ? (this.chat_topic || this.chat_topic_space || this.profile.chat_topic || null) + : (this.activeMessageTopic || null); const messageChatTopicSubject = this.activeMessageTopicSubject || null; if (editorText.length === 0 && !attachment && !hasImages) { @@ -7086,6 +7887,7 @@ if (!is_deleted && type !== "info-message") { createdTopicColor || this.getTopicColor(createdTopicName); this.updatePlusTopicButton?.(); + this.saveUserActiveChatTopic(createdTopicName); } message_info.chat_topic = createdTopicName; @@ -7117,6 +7919,8 @@ if (!is_deleted && type !== "info-message") { // ================= End Handling with Mentions =========================== const outgoingTopic = this.getOutgoingTopicInfo(); + const replyTopic = outgoingTopic ? null : await this.getReplyMessageTopicInfo(); + const finalOutgoingTopic = outgoingTopic || replyTopic; const message_info = { content: @@ -7135,24 +7939,24 @@ if (!is_deleted && type !== "info-message") { is_document: this.is_document, is_voice_clip: this.is_voice_clip, file_id: file_id, - chat_topic: outgoingTopic ? outgoingTopic.chat_topic : (this.chat_topic || messageChatTopic), - chat_topic_subject: outgoingTopic ? outgoingTopic.chat_topic_subject : null, - topic_color: outgoingTopic ? outgoingTopic.topic_color : null, + chat_topic: finalOutgoingTopic ? finalOutgoingTopic.chat_topic : (this.chat_topic || messageChatTopic), + chat_topic_subject: finalOutgoingTopic ? finalOutgoingTopic.chat_topic_subject : null, + topic_color: finalOutgoingTopic ? finalOutgoingTopic.topic_color : null, is_screenshot: is_screenshot, reply_to_message_name: this.reply_to_message_name, }; this.last_chat_space_message = await send_message(message_info); - if (this.last_chat_space_message && outgoingTopic) { + if (this.last_chat_space_message && finalOutgoingTopic) { setTimeout(() => { this.applyTopicToRenderedMessage( this.last_chat_space_message, - outgoingTopic.chat_topic, - outgoingTopic.chat_topic_subject, - outgoingTopic.topic_color + finalOutgoingTopic.chat_topic, + finalOutgoingTopic.chat_topic_subject, + finalOutgoingTopic.topic_color ); - }, 150); + }, 600); } this.reply_to_message_name = null; @@ -7753,7 +8557,12 @@ async fetchTemplateSuggestions(textValue) { message_template_type: res.message_template_type }); - if (isSetTopicInfoRealtime || isRemoveTopicInfoRealtime) { + const isCloseTopicInfoRealtime = this.isCloseTopicInfoMessage({ + content: res.content, + message_template_type: res.message_template_type + }); + + if (isSetTopicInfoRealtime || isRemoveTopicInfoRealtime || isCloseTopicInfoRealtime) { const topicNameForSystemMessage = res.chat_topic || res.topic || @@ -7875,6 +8684,14 @@ async fetchTemplateSuggestions(textValue) { this.$chat_space_container.append(message_content); this.normalizeTopicSeparators(); + if (topicNameForRealtime) { + this.applyTopicToRenderedMessage( + res.message_name, + topicNameForRealtime, + res.chat_topic_subject || res.topic_subject || topicNameForRealtime, + topicColorForRealtime + ); + } if (!this.chat_topic_space) { this.buildTopicMetaMap(); this.applyTopicVisibility(); @@ -8040,21 +8857,50 @@ openMessageActionMenu({ $trigger, messageName, isMyMessage, isTextOnly }) { ` : ""; - items.push(` - + `); + } else if (menuTopicName) { + items.push(` + + `); + } + + if (isMyMessage && menuTopicName) { + items.push(item({ + actionClass: "remove-message-topic-action", + iconName: "delete", + label: "Remove Topic", + danger: true + })); + } if (isMyMessage) { items.push(item({ @@ -8226,7 +9072,7 @@ openMessageActionMenu({ $trigger, messageName, isMyMessage, isTextOnly }) { const incomingTopic = res.chat_topic || res.topic || null; if (incomingTopic && !me.chat_topic_space && !me.is_topic_window && me.profile.room_type !== "Topic") { - me.setActiveMessageTopic(incomingTopic, res.chat_topic_subject || null); + // me.setActiveMessageTopic(incomingTopic, res.chat_topic_subject || null); me.expandTopicMessages(incomingTopic); } } else if (res.realtime_type == "add_group_member") { @@ -8311,7 +9157,33 @@ openMessageActionMenu({ $trigger, messageName, isMyMessage, isTextOnly }) { } else if (res.realtime_type == "remove_topic") { me.chat_topic = null; me.reference_doctypes = []; + me.clearMessageTopic(false); me.updateActiveTopicButton(null); + } else if (res.realtime_type == "clear_message_topic") { + const topicToClear = res.chat_topic ? String(res.chat_topic) : null; + const activeTopic = me.activeMessageTopic ? String(me.activeMessageTopic) : null; + if (!topicToClear || !activeTopic || activeTopic === topicToClear) { + me.clearMessageTopic(false); + } + } else if (res.realtime_type == "close_topic" && res.user_scoped == 1) { + const topicToClear = res.chat_topic || res.topic_name || null; + const rtChatChannel = me.getCurrentChatChannel?.() || + (me.profile.room_type === "Contributor" ? me.profile.parent_channel : me.profile.room); + + if (res.chat_channel && rtChatChannel && res.chat_channel !== rtChatChannel) { + return; + } + + if (!topicToClear || me.activeMessageTopic === topicToClear) { + me.clearMessageTopic(false); + me.updatePlusTopicButton?.(); + } + } else if (res.realtime_type == "close_topic") { + const closedTopic = res.chat_topic || me.chat_topic || null; + if (closedTopic && (!me.activeMessageTopic || String(me.activeMessageTopic) === String(closedTopic))) { + me.clearMessageTopic(false); + } + me.clearClosedTopicUi(closedTopic); } else if (res.realtime_type == "remove_doctype") { if (me.reference_doctypes.length == 1) { me.reference_doctypes = []; @@ -9398,6 +10270,42 @@ export async function remove_chat_topic( return await res.message.results; } +async function close_chat_topic( + chat_channel, + chat_topic, + last_active_sub_channel, + user_email, + user_name +) { + const res = await frappe.call({ + method: "clefincode_chat.api.api_1_3_4.api.close_chat_topic", + args: { + chat_channel: chat_channel, + chat_topic: chat_topic, + last_active_sub_channel: last_active_sub_channel, + user_email: user_email, + user_name: user_name, + }, + }); + return await res.message.results; +} + +async function clear_message_topic( + chat_channel, + chat_topic, + last_active_sub_channel +) { + const res = await frappe.call({ + method: "clefincode_chat.api.api_1_3_4.api.clear_message_topic", + args: { + chat_channel: chat_channel, + chat_topic: chat_topic, + last_active_sub_channel: last_active_sub_channel, + }, + }); + return await res.message.results; +} + async function check_if_user_has_permission( user_email, chat_topic_space, @@ -9510,4 +10418,4 @@ function show_doctype_selector(doctype, callback) { : doctype.toLowerCase().replace(/\s+/g, "-"); return `${getDeskBasePath()}/${routeDoctype}/${encodeURIComponent(docname)}`; -} \ No newline at end of file +} diff --git a/clefincode_chat/public/js/components/topic_open_helper.js b/clefincode_chat/public/js/components/topic_open_helper.js index b9d5364..bf51a0e 100644 --- a/clefincode_chat/public/js/components/topic_open_helper.js +++ b/clefincode_chat/public/js/components/topic_open_helper.js @@ -23,7 +23,7 @@ export async function open_topic_chat_window_from_context({ chat_topic, chat_topic_subject, chat_channel: null, - room_name: chat_topic_subject || "Topic", + room_name: "#" + (chat_topic_subject || "Topic"), room_type: "Group", is_private_topic: 0, }; diff --git a/clefincode_chat/public/js/topic_button.js b/clefincode_chat/public/js/topic_button.js index b7a9f19..c752331 100644 --- a/clefincode_chat/public/js/topic_button.js +++ b/clefincode_chat/public/js/topic_button.js @@ -21,16 +21,11 @@ console.log("topic_button.js loaded"); return result; }; - function get_email_button(frm) { - const wrapper = $(frm.page.wrapper); - - return wrapper.find("button").filter(function () { - const txt = $(this).text().trim(); - return ( - txt.includes("New Email") - - ); - }).first(); + function get_action_buttons_container(frm) { + if (frm.timeline && frm.timeline.timeline_actions_wrapper) { + return frm.timeline.timeline_actions_wrapper.find(".action-buttons"); + } + return $(frm.page.wrapper).find(".timeline-actions .action-buttons").first(); } function get_chat_profile() { @@ -47,17 +42,14 @@ console.log("topic_button.js loaded"); function add_topic_chat_button(frm) { if (!frm || !frm.page || !frm.doc) return; - const wrapper = $(frm.page.wrapper); - const email_btn = get_email_button(frm); - - if (!email_btn.length) return; + const container = get_action_buttons_container(frm); + if (!container.length) return; - wrapper.find(".custom-topic-chat-btn").remove(); + container.find(".custom-topic-chat-btn").remove(); const btn = $(` `); @@ -67,7 +59,7 @@ console.log("topic_button.js loaded"); open_forward_like_picker(frm); }); - email_btn.after(btn); + container.append(btn); } async function open_forward_like_picker(frm) { @@ -520,7 +512,7 @@ function get_topic_references(topicRow) { if (Array.isArray(topicRow.reference_doctypes)) { - console.log("dsds"); + return topicRow.reference_doctypes.map((r) => ({ doctype: r.doctype, docname: r.docname, diff --git a/clefincode_chat/public/scss/clefincode_chat.bundle.scss b/clefincode_chat/public/scss/clefincode_chat.bundle.scss index 62a3a93..96f67a5 100644 --- a/clefincode_chat/public/scss/clefincode_chat.bundle.scss +++ b/clefincode_chat/public/scss/clefincode_chat.bundle.scss @@ -1336,6 +1336,7 @@ $height: 582px; //END chat-topic-space .chat-space { + position: relative; display: flex; flex-direction: column; height: 100%; @@ -3861,8 +3862,6 @@ html[data-theme-mode="dark"] .chat-plus-menu { left: 8px; right: 8px; bottom: 54px; - max-height: 320px; - overflow: auto; background: #fff; border: 1px solid #e5e7eb; border-radius: 12px; @@ -3966,6 +3965,105 @@ html[data-theme-mode="dark"] .chat-plus-menu { opacity: .65; } +.topic-select-see-more { + display: block; + width: 100%; + margin-top: 6px; + padding: 8px; + text-align: center; + font-size: 13px; + font-weight: 500; + color: #2563eb; + background: transparent; + border: 1px dashed #d1d5db; + border-radius: 10px; + cursor: pointer; + transition: background 0.15s; +} + +.topic-select-see-more:hover { + background: #f3f4f6; +} + +/* ========= All Topics Full Page ========= */ + +.all-topics-view { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + background: var(--card-bg, #fff); + z-index: 100; + overflow: hidden; +} + +.all-topics-header { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + border-bottom: 1px solid var(--border-color, #e5e7eb); + flex-shrink: 0; +} + +.all-topics-back { + flex: 0 0 auto; + width: 32px; + height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 0; + background: transparent; + border-radius: 8px; + cursor: pointer; + color: var(--text-color, #374151); + font-size: 18px; +} + +.all-topics-back:hover { + background: var(--control-bg, #f3f4f6); +} + +.all-topics-search { + flex: 1; + min-height: 32px; + border: 1px solid var(--border-color, #e5e7eb); + border-radius: 10px; + padding: 6px 9px; + font-size: 13px; + outline: none; + background: var(--control-bg, #fff); + color: var(--text-color, #374151); +} + +.all-topics-search:focus { + border-color: #2563eb; +} + +.all-topics-list { + flex: 1; + overflow-y: auto; + padding: 8px 12px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.all-topics-loading { + padding: 16px; + text-align: center; + font-size: 12px; + opacity: .65; +} + +.all-topics-empty { + padding: 24px; + text-align: center; + font-size: 13px; + opacity: .65; +} + /* ========= Set Topic Info Message ========= */ .info-message.set-topic { @@ -3994,7 +4092,12 @@ html[data-theme-mode="dark"] .chat-plus-menu { @media (max-width: 480px) { .topic-select-panel { - max-height: 280px; + max-height: 60vh; + overflow-y: auto; + } + + .all-topics-list { + padding: 8px 8px; } } @@ -4050,4 +4153,68 @@ html[data-theme-mode="dark"] .chat-plus-menu { .chat-topic-separator .topic-separator-open-btn:hover { background: rgba(255,255,255,.28); +} + +/* ===== Read More for Long Messages ===== */ +.message-text-body { + word-break: break-word; + white-space: pre-wrap; +} + +.message-text-body.long-message-body { + max-height: 320px; + overflow: hidden; + position: relative; + + -webkit-mask-image: linear-gradient(to bottom, black 70%, transparent 100%); + mask-image: linear-gradient(to bottom, black 70%, transparent 100%); +} + +.chat-read-more-btn { + display: inline-block; + border: none; + background: none; + color: var(--primary, #0d6efd); + cursor: pointer; + font-size: 12px; + padding: 0; + margin: 2px 0 0; + line-height: 1.4; + user-select: none; + opacity: 0.85; +} + +.chat-read-more-btn:hover { + opacity: 1; + text-decoration: underline; +} + +.message-text-body.long-message-expanded { + max-height: none !important; + overflow: visible !important; + + -webkit-mask-image: none !important; + mask-image: none !important; +} + +/* ===== Read More – Dark Mode Overrides ===== */ +[data-theme="dark"] .chat-read-more-btn { + color: #8ec5ff; + background: rgba(255, 255, 255, 0.07); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 4px; + padding: 1px 7px; + opacity: 1; +} + +[data-theme="dark"] .chat-read-more-btn:hover { + color: #b7dcff; + background: rgba(255, 255, 255, 0.13); + border-color: rgba(255, 255, 255, 0.22); + text-decoration: underline; +} + +[data-theme="dark"] .message-text-body.long-message-body { + -webkit-mask-image: linear-gradient(to bottom, black 75%, transparent 100%); + mask-image: linear-gradient(to bottom, black 75%, transparent 100%); } \ No newline at end of file diff --git a/clefincode_chat/translations/ar.csv b/clefincode_chat/translations/ar.csv new file mode 100644 index 0000000..316fc9e --- /dev/null +++ b/clefincode_chat/translations/ar.csv @@ -0,0 +1,6 @@ +Open Topic,بدء نقاش +"Limited User","مستخدم محدود" +"This user has limited chat access.","هذا المستخدم لديه وصول محدود إلى المحادثة." +"A limited user can communicate with internal users or support team members, but cannot see or start conversations with other limited users.","يمكن للمستخدم المحدود التواصل مع المستخدمين الداخليين أو أعضاء فريق الدعم، لكنه لا يستطيع رؤية أو بدء محادثات مع مستخدمين محدودين آخرين." +"This restriction helps protect external users' privacy and prevents them from discovering or contacting each other inside the system.","يساعد هذا التقييد على حماية خصوصية المستخدمين الخارجيين ومنعهم من اكتشاف أو التواصل مع بعضهم داخل النظام." +"Is Limited","مستخدم محدود" \ No newline at end of file diff --git a/screenshots/web/chat_plus_menu.png b/screenshots/web/chat_plus_menu.png new file mode 100644 index 0000000..e83e2a3 Binary files /dev/null and b/screenshots/web/chat_plus_menu.png differ diff --git a/screenshots/web/clefincode_notification.gif b/screenshots/web/clefincode_notification.gif new file mode 100644 index 0000000..2df250e Binary files /dev/null and b/screenshots/web/clefincode_notification.gif differ diff --git a/screenshots/web/forward_multi_select.gif b/screenshots/web/forward_multi_select.gif new file mode 100644 index 0000000..27ed28f Binary files /dev/null and b/screenshots/web/forward_multi_select.gif differ diff --git a/screenshots/web/message_actions_menu.png b/screenshots/web/message_actions_menu.png new file mode 100644 index 0000000..e933582 Binary files /dev/null and b/screenshots/web/message_actions_menu.png differ diff --git a/screenshots/web/message_info.png b/screenshots/web/message_info.png new file mode 100644 index 0000000..1192757 Binary files /dev/null and b/screenshots/web/message_info.png differ diff --git a/screenshots/web/relink_messages.gif b/screenshots/web/relink_messages.gif new file mode 100644 index 0000000..d567919 Binary files /dev/null and b/screenshots/web/relink_messages.gif differ diff --git a/screenshots/web/select_topic.png b/screenshots/web/select_topic.png new file mode 100644 index 0000000..75b37a7 Binary files /dev/null and b/screenshots/web/select_topic.png differ diff --git a/screenshots/web/topic_bar.gif b/screenshots/web/topic_bar.gif new file mode 100644 index 0000000..08d3936 Binary files /dev/null and b/screenshots/web/topic_bar.gif differ diff --git a/screenshots/web/topic_conversation.gif b/screenshots/web/topic_conversation.gif new file mode 100644 index 0000000..b3c047c Binary files /dev/null and b/screenshots/web/topic_conversation.gif differ diff --git a/screenshots/web/twilio_webhook_settings.png b/screenshots/web/twilio_webhook_settings.png new file mode 100644 index 0000000..fad2c04 Binary files /dev/null and b/screenshots/web/twilio_webhook_settings.png differ diff --git a/screenshots/web/voice_clip.gif b/screenshots/web/voice_clip.gif new file mode 100644 index 0000000..791f83c Binary files /dev/null and b/screenshots/web/voice_clip.gif differ diff --git a/setup.py b/setup.py index 80548b6..f49af7b 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setup( name="clefincode_chat", - version='1.3.903', + version='1.3.907', description="ERPNext & Frappe Business Chat: A self-hosted communication solution.", author="ClefinCode L.L.C-FZ", author_email="info@clefincode.com",