1 // Copyright (C) 2007 Mark Pustjens <pustjens@dds.nl>
2 // Copyright (C) 2010-2015 Petr Pavlu <setup@dagobah.cz>
4 // This file is part of CenterIM.
6 // CenterIM is free software; you can redistribute it and/or modify
7 // it under the terms of the GNU General Public License as published by
8 // the Free Software Foundation; either version 2 of the License, or
9 // (at your option) any later version.
11 // CenterIM is distributed in the hope that it will be useful,
12 // but WITHOUT ANY WARRANTY; without even the implied warranty of
13 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 // GNU General Public License for more details.
16 // You should have received a copy of the GNU General Public License
17 // along with this program. If not, see <http://www.gnu.org/licenses/>.
19 #include "Conversation.h"
21 #include "BuddyList.h"
22 #include "Conversations.h"
25 #include <cppconsui/ColorScheme.h>
31 Conversation::Conversation(PurpleConversation
*conv
)
32 : Window(0, 0, 80, 24), conv_(conv
), filename_(nullptr), logfile_(nullptr),
33 input_text_length_(0), room_list_(nullptr), room_list_line_(nullptr)
35 g_assert(conv_
!= nullptr);
37 setColorScheme(CenterIM::SCHEME_CONVERSATION
);
39 view_
= new CppConsUI::TextView(width_
- 2, height_
, true, true);
40 input_
= new CppConsUI::TextEdit(width_
- 2, height_
);
41 input_
->signal_text_change
.connect(
42 sigc::mem_fun(this, &Conversation::onInputTextChange
));
43 char *name
= g_strdup_printf("[%s] %s",
44 purple_account_get_protocol_name(purple_conversation_get_account(conv_
)),
45 purple_conversation_get_name(conv_
));
46 line_
= new ConversationLine(name
);
48 addWidget(*view_
, 1, 0);
49 addWidget(*input_
, 1, 1);
50 addWidget(*line_
, 0, height_
);
52 PurpleConversationType type
= purple_conversation_get_type(conv_
);
53 if (type
== PURPLE_CONV_TYPE_CHAT
) {
54 room_list_
= new ConversationRoomList(1, 1, conv_
);
55 room_list_line_
= new CppConsUI::VerticalLine(1);
57 addWidget(*room_list_
, 1, 0);
58 addWidget(*room_list_line_
, 1, 0);
66 GError
*err
= nullptr;
67 logfile_
= g_io_channel_new_file(filename_
, "a", &err
);
68 if (logfile_
== nullptr) {
69 LOG
->error(_("Error opening conversation logfile '%s' (%s)."), filename_
,
80 Conversation::~Conversation()
83 if (logfile_
!= nullptr)
84 g_io_channel_unref(logfile_
);
87 bool Conversation::processInput(const TermKeyKey
&key
)
89 if (view_
->processInput(key
))
92 return Window::processInput(key
);
95 void Conversation::moveResize(int newx
, int newy
, int neww
, int newh
)
97 Window::moveResize(newx
, newy
, neww
, newh
);
99 int view_percentage
= purple_prefs_get_int(CONF_PREFIX
"/chat/partitioning");
100 view_percentage
= CLAMP(view_percentage
, 0, 100);
102 int view_height
= (newh
* view_percentage
) / 100;
106 int input_height
= newh
- view_height
- 1;
107 if (input_height
< 1)
110 int roomlist_percentage
=
111 purple_prefs_get_int(CONF_PREFIX
"/chat/roomlist_partitioning");
112 roomlist_percentage
= CLAMP(roomlist_percentage
, 0, 100);
114 int view_width
= neww
- 2;
115 if (room_list_
!= nullptr)
116 view_width
= (view_width
* roomlist_percentage
) / 100;
118 view_
->moveResize(1, 0, view_width
, view_height
);
120 input_
->moveResize(1, view_height
+ 1, neww
- 2, input_height
);
121 line_
->moveResize(0, view_height
, neww
, 1);
123 // Place the room list if it exists.
124 if (room_list_
!= nullptr) {
125 // +2 accounts for borders.
126 room_list_line_
->moveResize(view_width
+ 1, 0, 1, view_height
);
127 // Give it some padding to make it line up.
128 room_list_
->moveResize(
129 view_width
+ 3, 0, neww
- view_width
- 3, view_height
);
133 bool Conversation::restoreFocus()
135 FOOTER
->setText(_("%s buddy list, %s main menu, "
136 "%s/%s/%s next/prev/act conv, %s send, %s expand"),
137 "centerim|buddylist", "centerim|generalmenu", "centerim|conversation-next",
138 "centerim|conversation-prev", "centerim|conversation-active",
139 "conversation|send", "centerim|conversation-expand");
141 return Window::restoreFocus();
144 void Conversation::ungrabFocus()
146 FOOTER
->setText(nullptr);
147 Window::ungrabFocus();
150 void Conversation::show()
152 // Update the scrollbar setting. It is delayed until the conversation window
153 // is actually displayed, so screen lines recalculations in TextView (caused
154 // by changing the scrollbar setting) are not triggered if it is not really
156 view_
->setScrollBar(!CENTERIM
->isEnabledExpandedConversationMode());
161 void Conversation::close()
165 // Next line deletes this object. Do not touch any member variable after this
167 purple_conversation_destroy(conv_
);
170 void Conversation::onScreenResized()
172 CppConsUI::Rect r
= CENTERIM
->getScreenArea(CenterIM::CHAT_AREA
);
173 // Make room for conversation list.
179 void Conversation::write(const char *name
, const char * /*alias*/,
180 const char *message
, PurpleMessageFlags flags
, time_t mtime
)
183 if (!(flags
& PURPLE_MESSAGE_SEND
) &&
184 purple_prefs_get_bool(CONF_PREFIX
"/chat/beep_on_msg")) {
185 // TODO Implement correct error handling.
186 CppConsUI::Error error
;
187 CppConsUI::Curses::beep(error
);
190 // Update the last_activity property.
191 PurpleConversationType type
= purple_conversation_get_type(conv_
);
192 time_t cur_time
= time(nullptr);
194 if (type
== PURPLE_CONV_TYPE_IM
) {
195 PurpleBlistNode
*bnode
= PURPLE_BLIST_NODE(
196 purple_find_buddy(purple_conversation_get_account(conv_
),
197 purple_conversation_get_name(conv_
)));
199 purple_blist_node_set_int(bnode
, "last_activity", cur_time
);
201 // Inform the buddy list node that it should update its state.
202 BUDDYLIST
->updateNode(bnode
);
206 // Write the message.
210 if (flags
& PURPLE_MESSAGE_SEND
) {
212 mtype
= "MSG2"; // cim5 message.
215 else if (flags
& PURPLE_MESSAGE_RECV
) {
217 mtype
= "MSG2"; // cim5 message.
226 // Write text into logfile.
227 if (!(flags
& PURPLE_MESSAGE_NO_LOG
)) {
229 if (type
== PURPLE_CONV_TYPE_CHAT
)
230 log_msg
= g_strdup_printf("\f\n%s\n%s\n%lu\n%lu\n%s: %s\n", dir
, mtype
,
231 mtime
, cur_time
, name
, message
);
233 log_msg
= g_strdup_printf(
234 "\f\n%s\n%s\n%lu\n%lu\n%s\n", dir
, mtype
, mtime
, cur_time
, message
);
235 if (logfile_
!= nullptr) {
236 GError
*err
= nullptr;
237 if (g_io_channel_write_chars(logfile_
, log_msg
, -1, nullptr, &err
) !=
238 G_IO_STATUS_NORMAL
) {
240 _("Error writing to conversation logfile (%s)."), err
->message
);
243 if (g_io_channel_flush(logfile_
, &err
) != G_IO_STATUS_NORMAL
) {
245 _("Error flushing conversation logfile (%s)."), err
->message
);
252 // We currently do not support displaying HTML in any way.
253 char *nohtml
= stripHTML(message
);
255 // Write text to the window.
256 char *time
= extractTime(mtime
, cur_time
);
258 if (type
== PURPLE_CONV_TYPE_CHAT
)
259 msg
= g_strdup_printf("%s %s: %s", time
, name
, nohtml
);
261 msg
= g_strdup_printf("%s %s", time
, nohtml
);
262 view_
->append(msg
, color
);
268 Conversation::ConversationLine::ConversationLine(const char *text
)
269 : AbstractLine(AUTOSIZE
, 1)
271 g_assert(text
!= nullptr);
273 text_
= g_strdup(text
);
274 text_width_
= CppConsUI::Curses::onScreenWidth(text_
);
277 Conversation::ConversationLine::~ConversationLine()
282 int Conversation::ConversationLine::draw(
283 CppConsUI::Curses::ViewPort area
, CppConsUI::Error
&error
)
285 if (real_width_
== 0 || real_height_
!= 1)
289 if (text_width_
+ 5 >= static_cast<unsigned>(real_width_
))
292 l
= real_width_
- text_width_
- 5;
294 // Use HorizontalLine colors.
297 CppConsUI::ColorScheme::PROPERTY_HORIZONTALLINE_LINE
, &attrs
, error
));
298 DRAW(area
.attrOn(attrs
, error
));
301 for (i
= 0; i
< l
; ++i
)
302 DRAW(area
.addLineChar(i
, 0, CppConsUI::Curses::LINE_HLINE
, error
));
304 DRAW(area
.addString(i
, 0, text_
, error
, &printed
));
306 for (; i
< real_width_
; ++i
)
307 DRAW(area
.addLineChar(i
, 0, CppConsUI::Curses::LINE_HLINE
, error
));
309 DRAW(area
.attrOff(attrs
, error
));
314 char *Conversation::stripHTML(const char *str
) const
316 // Almost copy&paste from libpurple/util.c:purple_markup_strip_html(), but
317 // this version does not convert tab character to a space.
324 bool closing_td_p
= false;
326 const gchar
*cdata_close_tag
= nullptr, *ent
;
327 gchar
*href
= nullptr;
330 str2
= g_strdup(str
);
332 for (i
= 0, j
= 0; str2
[i
] != '\0'; ++i
) {
333 if (str2
[i
] == '<') {
334 if (cdata_close_tag
) {
335 // Note: Do not even assume any other tag is a tag in CDATA.
336 if (g_ascii_strncasecmp(
337 str2
+ i
, cdata_close_tag
, !strlen(cdata_close_tag
))) {
338 i
+= strlen(cdata_close_tag
) - 1;
339 cdata_close_tag
= nullptr;
343 else if (!g_ascii_strncasecmp(str2
+ i
, "<td", 3) && closing_td_p
) {
347 else if (!g_ascii_strncasecmp(str2
+ i
, "</td>", 5)) {
352 closing_td_p
= false;
358 if (g_ascii_isspace(str2
[k
]))
361 // Scan until we end the tag either implicitly (closed start tag) or
362 // explicitly, using a sloppy method (i.e., < or > inside quoted
363 // attributes will screw us up).
364 while (str2
[k
] != '\0' && str2
[k
] != '<' && str2
[k
] != '>')
367 // If we have got an <a> tag with an href, save the address to print
369 if (g_ascii_strncasecmp(str2
+ i
, "<a", 2) == 0 &&
370 g_ascii_isspace(str2
[i
+ 2])) {
371 int st
; // Start of href, inclusive [.
372 int end
; // End of href, exclusive ).
374 // Find start of href.
375 for (st
= i
+ 3; st
< k
; ++st
) {
376 if (g_ascii_strncasecmp(str2
+ st
, "href=", 5) == 0) {
378 if (str2
[st
] == '"' || str2
[st
] == '\'') {
385 // Find end of address.
386 for (end
= st
; end
< k
&& str2
[end
] != delim
; ++end
) {
387 // All the work is done in the loop construct above.
390 // If there is an address, save it. If there was already one saved,
395 tmp
= g_strndup(str2
+ st
, end
- st
);
396 href
= purple_unescape_html(tmp
);
402 // Replace </a> with an ascii representation of the address the link was
404 else if (href
!= nullptr &&
405 g_ascii_strncasecmp(str2
+ i
, "</a>", 4) == 0) {
406 size_t hrlen
= std::strlen(href
);
408 // Only insert the href if it is different from the CDATA.
409 // 7 == strlen("http://").
410 if ((hrlen
!= (unsigned)(j
- href_st
) ||
411 std::strncmp(str2
+ href_st
, href
, hrlen
)) != 0 &&
412 (hrlen
!= (unsigned)(j
- href_st
+ 7) ||
413 std::strncmp(str2
+ href_st
, href
+ 7, hrlen
- 7) != 0)) {
416 g_memmove(str2
+ j
, href
, hrlen
);
424 // Check for tags which should be mapped to newline (but ignore some of
425 // the tags at the beginning of the text).
426 else if ((j
!= 0 && (g_ascii_strncasecmp(str2
+ i
, "<p>", 3) == 0 ||
427 g_ascii_strncasecmp(str2
+ i
, "<tr", 3) == 0 ||
428 g_ascii_strncasecmp(str2
+ i
, "<hr", 3) == 0 ||
429 g_ascii_strncasecmp(str2
+ i
, "<li", 3) == 0 ||
430 g_ascii_strncasecmp(str2
+ i
, "<div", 4) == 0)) ||
431 g_ascii_strncasecmp(str2
+ i
, "<br", 3) == 0 ||
432 g_ascii_strncasecmp(str2
+ i
, "</table>", 8) == 0)
434 else if (g_ascii_strncasecmp(str2
+ i
, "<script", 7) == 0)
435 cdata_close_tag
= "</script>";
436 else if (g_ascii_strncasecmp(str2
+ i
, "<style", 6) == 0)
437 cdata_close_tag
= "</style>";
438 // Update the index and continue checking after the tag.
439 i
= (str2
[k
] == '<' || str2
[k
] == '\0') ? k
- 1 : k
;
443 else if (cdata_close_tag
)
445 else if (!g_ascii_isspace(str2
[i
]))
448 if (str2
[i
] == '&' &&
449 (ent
= purple_markup_unescape_entity(str2
+ i
, &entlen
))) {
457 str2
[j
++] = g_ascii_isspace(str2
[i
]) && str
[i
] != '\t' ? ' ' : str2
[i
];
467 void Conversation::buildLogFilename()
469 PurpleAccount
*account
= purple_conversation_get_account(conv_
);
471 purple_find_prpl(purple_account_get_protocol_id(account
));
472 g_assert(prpl
!= nullptr);
474 const char *proto_name
= purple_account_get_protocol_name(account
);
476 char *acct_name
= g_strdup(purple_escape_filename(
477 purple_normalize(account
, purple_account_get_username(account
))));
479 const char *name
= purple_conversation_get_name(conv_
);
481 filename_
= g_build_filename(purple_user_dir(), "clogs", proto_name
,
482 acct_name
, purple_escape_filename(purple_normalize(account
, name
)), NULL
);
484 char *dir
= g_path_get_dirname(filename_
);
485 if (g_mkdir_with_parents(dir
, S_IRUSR
| S_IWUSR
| S_IXUSR
) == -1)
486 LOG
->error(_("Error creating directory '%s'."), dir
);
492 char *Conversation::extractTime(time_t sent_time
, time_t show_time
) const
494 // Based on the extracttime() function from cim4.
496 // Convert to local time, note that localtime_r() should not really fail.
497 struct tm show_time_local
;
498 struct tm sent_time_local
;
499 if (localtime_r(&show_time
, &show_time_local
) == nullptr)
500 memset(&show_time_local
, 0, sizeof(show_time_local
));
501 if (localtime_r(&sent_time
, &sent_time_local
) == nullptr)
502 memset(&sent_time_local
, 0, sizeof(sent_time_local
));
505 char *t1
= g_strdup(purple_date_format_long(&show_time_local
));
506 char *t2
= g_strdup(purple_date_format_long(&sent_time_local
));
508 int tdiff
= std::abs(sent_time
- show_time
);
510 if (tdiff
> 5 && std::strcmp(t1
, t2
) != 0) {
511 char *res
= g_strdup_printf("%s [%s]", t1
, t2
);
521 void Conversation::loadHistory()
524 GError
*err
= nullptr;
525 GIOChannel
*chan
= g_io_channel_new_file(filename_
, "r", &err
);
526 if (chan
== nullptr) {
527 LOG
->error(_("Error opening conversation logfile '%s' (%s)."), filename_
,
532 // This should never fail.
533 g_io_channel_set_encoding(chan
, nullptr, nullptr);
537 bool new_msg
= false;
538 // Read conversation logfile line by line.
540 (st
= g_io_channel_read_line(chan
, &line
, nullptr, nullptr, &err
)) ==
541 G_IO_STATUS_NORMAL
) {
545 if (std::strcmp(line
, "\f\n") != 0) {
551 // Parse direction (in/out).
552 if ((st
= g_io_channel_read_line(chan
, &line
, nullptr, nullptr, &err
)) !=
556 if (std::strcmp(line
, "OUT\n") == 0)
558 else if (std::strcmp(line
, "IN\n") == 0)
563 if ((st
= g_io_channel_read_line(chan
, &line
, nullptr, nullptr, &err
)) !=
567 if (std::strcmp(line
, "MSG2\n") == 0)
569 else if (std::strcmp(line
, "OTHER\n") == 0) {
576 if ((st
= g_io_channel_read_line(chan
, &line
, nullptr, nullptr, &err
)) !=
579 time_t sent_time
= atol(line
);
583 if ((st
= g_io_channel_read_line(chan
, &line
, nullptr, nullptr, &err
)) !=
586 time_t show_time
= atol(line
);
590 // cim5, read only one line and strip it off HTML.
591 if ((st
= g_io_channel_read_line(chan
, &line
, nullptr, nullptr, &err
)) !=
596 if (!g_utf8_validate(line
, -1, nullptr)) {
598 LOG
->error(_("Invalid message detected in conversation logfile"
599 " '%s'. The message was skipped."),
604 // Write text to the window.
605 char *nohtml
= stripHTML(line
);
606 char *time
= extractTime(sent_time
, show_time
);
607 char *msg
= g_strdup_printf("%s %s", time
, nohtml
);
608 view_
->append(msg
, color
);
615 // cim4, read multiple raw lines.
618 while ((st
= g_io_channel_read_line(
619 chan
, &line
, &length
, nullptr, &err
)) == G_IO_STATUS_NORMAL
&&
621 if (std::strcmp(line
, "\f\n") == 0) {
626 // Strip '\r' if necessary.
627 if (length
> 1 && line
[length
- 2] == '\r') {
628 line
[length
- 2] = '\n';
629 line
[length
- 1] = '\0';
641 if (!g_utf8_validate(msg
.c_str(), -1, nullptr)) {
642 LOG
->error(_("Invalid message detected in conversation logfile"
643 " '%s'. The message was skipped."),
648 // Add the message to the window.
649 char *time
= extractTime(sent_time
, show_time
);
650 char *final_msg
= g_strdup_printf("%s %s", time
, msg
.c_str());
651 view_
->append(final_msg
, color
);
657 if (st
!= G_IO_STATUS_EOF
) {
658 LOG
->error(_("Error reading from conversation logfile '%s' (%s)."),
659 filename_
, err
->message
);
662 g_io_channel_unref(chan
);
665 bool Conversation::processCommand(const char *raw
, const char *html
)
667 // Check that it is a command.
668 if (std::strncmp(raw
, "/", 1) != 0)
671 purple_conversation_write(
672 conv_
, "", html
, PURPLE_MESSAGE_NO_LOG
, time(nullptr));
674 char *error
= nullptr;
675 // Strip the prefix and execute the command.
676 PurpleCmdStatus status
=
677 purple_cmd_do_command(conv_
, raw
+ 1, html
+ 1, &error
);
681 case PURPLE_CMD_STATUS_OK
:
683 case PURPLE_CMD_STATUS_NOT_FOUND
:
684 // It is not a valid command, process it as a message.
687 case PURPLE_CMD_STATUS_WRONG_ARGS
:
688 purple_conversation_write(conv_
, "",
689 _("Wrong number of arguments passed to the command."),
690 PURPLE_MESSAGE_NO_LOG
, time(nullptr));
692 case PURPLE_CMD_STATUS_FAILED
:
693 purple_conversation_write(conv_
, "",
694 error
? error
: _("The command failed for an unknown reason."),
695 PURPLE_MESSAGE_NO_LOG
, time(nullptr));
697 case PURPLE_CMD_STATUS_WRONG_TYPE
:
698 if (purple_conversation_get_type(conv_
) == PURPLE_CONV_TYPE_IM
)
699 purple_conversation_write(conv_
, "",
700 _("The command only works in chats, not IMs."), PURPLE_MESSAGE_NO_LOG
,
703 purple_conversation_write(conv_
, "",
704 _("The command only works in IMs, not chats."), PURPLE_MESSAGE_NO_LOG
,
707 case PURPLE_CMD_STATUS_WRONG_PRPL
:
708 purple_conversation_write(conv_
, "",
709 _("The command does not work on this protocol."), PURPLE_MESSAGE_NO_LOG
,
719 void Conversation::onInputTextChange(CppConsUI::TextEdit
&activator
)
721 PurpleConvIm
*im
= PURPLE_CONV_IM(conv_
);
725 if (!CONVERSATIONS
->getSendTypingPref()) {
726 input_text_length_
= 0;
730 size_t old_text_length
= input_text_length_
;
731 size_t new_text_length
= activator
.getTextLength();
732 input_text_length_
= new_text_length
;
734 if (new_text_length
== 0) {
735 // All text is deleted, turn off typing.
736 purple_conv_im_stop_send_typed_timeout(im
);
738 serv_send_typing(purple_conversation_get_gc(conv_
),
739 purple_conversation_get_name(conv_
), PURPLE_NOT_TYPING
);
743 purple_conv_im_stop_send_typed_timeout(im
);
744 purple_conv_im_start_send_typed_timeout(im
);
746 time_t again
= purple_conv_im_get_type_again(im
);
747 if ((old_text_length
== 0 && new_text_length
!= 0) ||
748 (again
!= 0 && time(nullptr) > again
)) {
749 // The first letter is inserted or update is required for typing status.
750 unsigned int timeout
= serv_send_typing(purple_conversation_get_gc(conv_
),
751 purple_conversation_get_name(conv_
), PURPLE_TYPING
);
752 purple_conv_im_set_type_again(im
, timeout
);
756 void Conversation::actionSend()
758 const char *str
= input_
->getText();
759 if (str
== nullptr || str
[0] == '\0')
764 char *escaped
= purple_markup_escape_text(str
, strlen(str
));
765 char *html
= purple_strdup_withhtml(escaped
);
766 if (processCommand(str
, html
)) {
767 // The command was processed.
770 PurpleConversationType type
= purple_conversation_get_type(conv_
);
771 if (type
== PURPLE_CONV_TYPE_CHAT
)
772 purple_conv_chat_send(PURPLE_CONV_CHAT(conv_
), html
);
773 else if (type
== PURPLE_CONV_TYPE_IM
)
774 purple_conv_im_send(PURPLE_CONV_IM(conv_
), html
);
781 void Conversation::declareBindables()
783 declareBindable("conversation", "send",
784 sigc::mem_fun(this, &Conversation::actionSend
),
785 InputProcessor::BINDABLE_OVERRIDE
);
788 // vim: set tabstop=2 shiftwidth=2 textwidth=80 expandtab: