$ git clone http://tcclient.ion.nu/tc_client.git
commit 8d80721e21a97197d43baed05adc107906ee7e9b
Author: Alicia <...>
Date:   Tue Sep 27 23:28:21 2016 +0200

    tc_client-gtk: turn URLs mentioned in chat into clickable links.

diff --git a/ChangeLog b/ChangeLog
index 95fc458..ce0fbe7 100644
--- a/ChangeLog
+++ b/ChangeLog
@@ -8,6 +8,7 @@ tc_client-gtk: changed camera input for broadcasting from using a thread to usin
 tc_client-gtk: use the camera's own copy of its ID for g_timeout_add() rather than what camera_new() was called with. And in configure, include libavutil/mem.h if we fall back on av_freep()
 tc_client-gtk: cleaned up leftover windows compatibility code that is no longer necessary.
 tc_client-gtk: changed the greenscreen postprocessor to use libcamera for the background, allowing you to use either another camera or an image through the virtual "Image" camera as background.
+tc_client-gtk: turn URLs mentioned in chat into clickable links.
 tc_client-gtk and camviewer: updated to libavcodec's avcodec_{send,receive}_{frame,packet} API.
 libcamera(v4l2): cache the frame and if there is no data to be read, return the cache instead of blocking.
 0.39:
diff --git a/gtkgui.glade b/gtkgui.glade
index 141560f..a56a5cf 100644
--- a/gtkgui.glade
+++ b/gtkgui.glade
@@ -1535,4 +1535,24 @@
       </object>
     </child>
   </object>
+  <object class="GtkMenu" id="link_menu">
+    <property name="visible">True</property>
+    <property name="can_focus">False</property>
+    <child>
+      <object class="GtkMenuItem" id="link_menu_open">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="label" translatable="yes">Open Link</property>
+        <property name="use_underline">True</property>
+      </object>
+    </child>
+    <child>
+      <object class="GtkMenuItem" id="link_menu_copy">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="label" translatable="yes">Copy Link</property>
+        <property name="use_underline">True</property>
+      </object>
+    </child>
+  </object>
 </interface>
diff --git a/utilities/gtk/camviewer.c b/utilities/gtk/camviewer.c
index 90691e7..8e4581f 100644
--- a/utilities/gtk/camviewer.c
+++ b/utilities/gtk/camviewer.c
@@ -85,7 +85,7 @@ char frombuild=0; // Running from the build directory
   PROCESS_INFORMATION coreprocess={.hProcess=0};
 #endif
 
-void printchat(const char* text, const char* pm)
+void printchat(const char* text, const char* color, unsigned int offset, const char* pm)
 {
   GtkAdjustment* scroll;
   GtkTextBuffer* buffer;
@@ -104,32 +104,21 @@ void printchat(const char* text, const char* pm)
   GtkTextIter end;
   gtk_text_buffer_get_end_iter(buffer, &end);
   gtk_text_buffer_insert(buffer, &end, "\n", -1);
-  gtk_text_buffer_insert(buffer, &end, text, -1);
-  buffer_updatesize(buffer);
-  if(bottom){autoscroll_after(scroll);}
-}
-
-void printchat_color(const char* text, const char* color, unsigned int offset, const char* pm)
-{
-  GtkAdjustment* scroll;
-  GtkTextBuffer* buffer;
-  struct user* user;
-  if(pm && (user=finduser(pm)))
+  int startnum=gtk_text_iter_get_offset(&end);
+  // Insert links and regular text separately
+  const char* linktext=text;
+  const char* link;
+  while((link=strstr(linktext, "://")))
   {
-    pm_open(pm, 0, data->scroll);
-    scroll=user->pm_scroll;
-    buffer=user->pm_buffer;
-  }else{
-    scroll=data->scroll;
-    buffer=data->buffer;
+    while(link>linktext && link[-1]!=' '){link=&link[-1];}
+    unsigned int linklen;
+    for(linklen=0; link[linklen] && link[linklen]!=' '; ++linklen);
+    if(linklen<6){continue;}
+    gtk_text_buffer_insert(buffer, &end, linktext, link-linktext);
+    gui_insert_link(buffer, &end, link, linklen);
+    linktext=&link[linklen];
   }
-  char bottom=autoscroll_before(scroll);
-  // Insert new content
-  GtkTextIter end;
-  gtk_text_buffer_get_end_iter(buffer, &end);
-  gtk_text_buffer_insert(buffer, &end, "\n", -1);
-  int startnum=gtk_text_iter_get_offset(&end);
-  gtk_text_buffer_insert(buffer, &end, text, -1);
+  gtk_text_buffer_insert(buffer, &end, linktext, -1);
   GtkTextIter start;
   // Set color if there was one
   if(color)
@@ -267,7 +256,7 @@ gboolean handledata(GIOChannel* iochannel, GIOCondition condition, gpointer data
   }
   if(!strncmp(buf, "Currently online: ", 18))
   {
-    printchat(buf, 0);
+    printchat(buf, 0, 0, 0);
     char* next=&buf[16];
     while(next)
     {
@@ -298,7 +287,7 @@ gboolean handledata(GIOChannel* iochannel, GIOCondition condition, gpointer data
   // Start streams once we're properly connected
   if(!strncmp(buf, "Currently on cam: ", 18))
   {
-    printchat(buf, 0);
+    printchat(buf, 0, 0, 0);
     if(!config_get_bool("autoopencams") && config_get_set("autoopencams")){return 1;}
     char* next=&buf[16];
     while(next)
@@ -319,7 +308,7 @@ gboolean handledata(GIOChannel* iochannel, GIOCondition condition, gpointer data
   }
   if(buf[0]=='/') // For the /help text
   {
-    printchat(buf, 0);
+    printchat(buf, 0, 0, 0);
     return 1;
   }
   char* color=0;
@@ -427,7 +416,7 @@ gboolean handledata(GIOChannel* iochannel, GIOCondition condition, gpointer data
     }
     if(config_get_bool("enable_logging")){logger_write(buf, channel, pm);}
     // Insert new content
-    printchat_color(buf, color, 8, pm);
+    printchat(buf, color, 8, pm);
     pm_highlight(pm);
     free(pm);
     if(space[-1]!=':') // Not a message
@@ -471,14 +460,14 @@ gboolean handledata(GIOChannel* iochannel, GIOCondition condition, gpointer data
   }
   if(!strcmp(buf, "Changed color") || !strncmp(buf, "Current color: ", 15))
   {
-    printchat_color(buf, color, 0, 0);
+    printchat(buf, color, 0, 0);
     free((void*)mycolor);
     mycolor=color;
     return 1;
   }
   if(!strncmp(buf, "Color ", 6))
   {
-    printchat_color(buf, color, 0, 0);
+    printchat(buf, color, 0, 0);
   }
   free(color);
   if(space && !strcmp(space, " is a moderator."))
@@ -502,7 +491,7 @@ gboolean handledata(GIOChannel* iochannel, GIOCondition condition, gpointer data
   // Start a stream when someone cams up
   if(space && !strcmp(space, " cammed up"))
   {
-    printchat(buf, 0);
+    printchat(buf, 0, 0, 0);
     if(config_get_bool("autoopencams") || !config_get_set("autoopencams"))
     {
       space[0]=0;
@@ -561,12 +550,12 @@ gboolean handledata(GIOChannel* iochannel, GIOCondition condition, gpointer data
   if(!strncmp(buf, "Room topic: ", 12) ||
      (space && (!strcmp(space, " is not logged in") || !strncmp(space, " is logged in as ", 17))))
   {
-    printchat(buf, 0);
+    printchat(buf, 0, 0, 0);
     return 1;
   }
   if(!strcmp(buf, "Server disconnected"))
   {
-    printchat(buf, 0);
+    printchat(buf, 0, 0, 0);
     if(camout_cam)
     {
       cam_close(camout_cam);
@@ -773,7 +762,7 @@ void sendmessage(GtkEntry* entry, void* x)
       if(!user)
       {
         gtk_entry_set_text(entry, "");
-        printchat("No such user", 0);
+        printchat("No such user", 0, 0, 0);
         free(pm);
         sendingmsg=0;
         return;
@@ -791,7 +780,7 @@ void sendmessage(GtkEntry* entry, void* x)
     sprintf(&text[8], "%s: %s", nickname, msg);
   }
   if(config_get_bool("enable_logging")){logger_write(text, channel, pm);}
-  printchat_color(text, mycolor, 8, pm);
+  printchat(text, mycolor, 8, pm);
   gtk_entry_set_text(entry, "");
   free(pm);
   sendingmsg=0;
@@ -989,6 +978,13 @@ int main(int argc, char** argv)
   userlistwidget=GTK_WIDGET(gtk_builder_get_object(gui, "userlistbox"));
   GtkWidget* chatview=GTK_WIDGET(gtk_builder_get_object(gui, "chatview"));
   data->scroll=gtk_scrolled_window_get_vadjustment(GTK_SCROLLED_WINDOW(gtk_builder_get_object(gui, "chatscroll")));
+  // Set up handling of links in chat
+  g_signal_connect(chatview, "button-release-event", G_CALLBACK(gui_click_link), 0);
+  g_signal_connect(chatview, "button-press-event", G_CALLBACK(gui_rightclick_link), 0);
+  g_signal_connect(chatview, "motion-notify-event", G_CALLBACK(gui_hover_link), 0);
+  GdkDisplay* display=gtk_widget_get_display(chatview);
+  gui_cursor_text=gdk_cursor_new_from_name(display, "text");
+  gui_cursor_link=gdk_cursor_new_from_name(display, "pointer");
 
   data->buffer=gtk_text_view_get_buffer(GTK_TEXT_VIEW(chatview));
   buffer_setup_colors(data->buffer);
@@ -1022,9 +1018,9 @@ int main(int argc, char** argv)
 
   // Start window and channel password window signals
   GtkWidget* button=GTK_WIDGET(gtk_builder_get_object(gui, "channelpasswordbutton"));
-  g_signal_connect(button, "clicked", G_CALLBACK(startsession), (void*)-1); // &data);
+  g_signal_connect(button, "clicked", G_CALLBACK(startsession), (void*)-1);
   button=GTK_WIDGET(gtk_builder_get_object(gui, "channelpassword"));
-  g_signal_connect(button, "activate", G_CALLBACK(startsession), (void*)-1); // &data);
+  g_signal_connect(button, "activate", G_CALLBACK(startsession), (void*)-1);
   GtkWidget* startwindow=GTK_WIDGET(gtk_builder_get_object(gui, "startwindow"));
   // Connect signal for quick connect
   item=GTK_WIDGET(gtk_builder_get_object(gui, "start_menu_connect"));
@@ -1039,6 +1035,9 @@ int main(int argc, char** argv)
   g_signal_connect(item, "switch-page", G_CALLBACK(pm_select), 0);
   // Connect signal for captcha
   g_signal_connect(gtk_builder_get_object(gui, "captcha_done"), "clicked", G_CALLBACK(captcha_done), 0);
+  // Connect signals for link menus
+  g_signal_connect(gtk_builder_get_object(gui, "link_menu_open"), "activate", G_CALLBACK(gui_link_menu_open), 0);
+  g_signal_connect(gtk_builder_get_object(gui, "link_menu_copy"), "activate", G_CALLBACK(gui_link_menu_copy), 0);
   // Connect signals for camera postprocessing
   g_signal_connect(gtk_builder_get_object(gui, "cam_menu_colors"), "activate", G_CALLBACK(gui_show_camcolors), 0);
   g_signal_connect(gtk_builder_get_object(gui, "camcolors_min_brightness"), "value-changed", G_CALLBACK(camcolors_adjust_min), 0);
diff --git a/utilities/gtk/gui.c b/utilities/gtk/gui.c
index ca4cbe8..d51534a 100644
--- a/utilities/gtk/gui.c
+++ b/utilities/gtk/gui.c
@@ -1,6 +1,6 @@
 /*
     tc_client-gtk, a graphical user interface for tc_client
-    Copyright (C) 2015  alicia@ion.nu
+    Copyright (C) 2015-2016  alicia@ion.nu
 
     This program is free software: you can redistribute it and/or modify
     it under the terms of the GNU Affero General Public License as published by
@@ -31,6 +31,8 @@ GtkBuilder* gui;
 GtkWidget* gui_greenscreen_preview_img=0;
 unsigned int gui_greenscreen_preview_event=0;
 extern gboolean gui_greenscreen_preview(void* x);
+GdkCursor* gui_cursor_text;
+GdkCursor* gui_cursor_link;
 
 char autoscroll_before(GtkAdjustment* scroll)
 {
@@ -355,6 +357,9 @@ void pm_open(const char* nick, char select, GtkAdjustment* scroll)
   }
   char bottom=autoscroll_before(scroll); // If PM tabs (with close buttons) are taller we need to make sure pushing down the chat field doesn't make it stop scrolling
   GtkWidget* textview=gtk_text_view_new();
+  g_signal_connect(textview, "button-release-event", G_CALLBACK(gui_click_link), 0);
+  g_signal_connect(textview, "button-press-event", G_CALLBACK(gui_rightclick_link), 0);
+  g_signal_connect(textview, "motion-notify-event", G_CALLBACK(gui_hover_link), 0);
   user->pm_tab=gtk_scrolled_window_new(0, 0);
   user->pm_buffer=gtk_text_view_get_buffer(GTK_TEXT_VIEW(textview));
   user->pm_scroll=gtk_scrolled_window_get_vadjustment(GTK_SCROLLED_WINDOW(user->pm_tab));
@@ -677,3 +682,88 @@ void gui_set_greenscreen_tolerance(GtkAdjustment* adjustment, void* x)
   if(!cam){return;}
   cam->postproc.greenscreen_tolerance[(intptr_t)x]=gtk_adjustment_get_value(adjustment);
 }
+
+void gui_insert_link(GtkTextBuffer* buffer, GtkTextIter* iter, const char* url, int length)
+{
+  int startnum=gtk_text_iter_get_offset(iter);
+  gtk_text_buffer_insert(buffer, iter, url, length);
+  // Make it look like a link and store the URL for later
+  GtkTextTag* tag=gtk_text_buffer_create_tag(buffer, 0, "underline", PANGO_UNDERLINE_SINGLE, (char*)0);
+  g_object_set_data(G_OBJECT(tag), "url", strndup(url, length));
+  GtkTextIter start;
+  gtk_text_buffer_get_iter_at_offset(buffer, &start, startnum);
+  gtk_text_buffer_apply_tag(buffer, tag, &start, iter);
+}
+
+const char* gui_find_link(GtkTextView* textview, int xpos, int ypos)
+{
+  int x;
+  int y;
+  gtk_text_view_window_to_buffer_coords(textview, GTK_TEXT_WINDOW_TEXT, xpos, ypos, &x, &y);
+  GtkTextIter iter;
+  gtk_text_view_get_iter_at_location(textview, &iter, x, y);
+  const char* url=0;
+  GSList* tags=gtk_text_iter_get_tags(&iter);
+  GSList* i;
+  for(i=tags; i; i=g_slist_next(i))
+  {
+    url=g_object_get_data(G_OBJECT(i->data), "url");
+    if(url){break;}
+  }
+  g_slist_free(tags);
+  return url;
+}
+
+gboolean gui_click_link(GtkTextView* textview, GdkEventButton* event, void* data)
+{
+  const char* url=gui_find_link(textview, event->x, event->y);
+  if(url && event->button!=3) // Not right-click, just open it
+  {
+    gtk_show_uri(gtk_widget_get_screen(GTK_WIDGET(textview)), url, gtk_get_current_event_time(), 0);
+    return 1;
+  }
+  return 0;
+}
+
+static const char* gui_link_menu_url=0;
+gboolean gui_rightclick_link(GtkTextView* textview, GdkEventButton* event, void* data)
+{
+  const char* url=gui_find_link(textview, event->x, event->y);
+  if(url && event->button==3)
+  {
+    // Show a menu with options to either open the link or copy it
+    gui_link_menu_url=url;
+    GtkMenu* menu=GTK_MENU(gtk_builder_get_object(gui, "link_menu"));
+    gtk_menu_popup(menu, 0, 0, 0, 0, event->button, event->time);
+    return 1;
+  }
+  return 0;
+}
+
+gboolean gui_hover_link(GtkTextView* textview, GdkEventMotion* event, void* data)
+{
+  const char* url=gui_find_link(textview, event->x, event->y);
+  GdkWindow* window=gtk_text_view_get_window(textview, GTK_TEXT_WINDOW_TEXT);
+  GdkCursor* cursor;
+  if(url)
+  {
+    cursor=gui_cursor_link;
+  }else{
+    cursor=gui_cursor_text;
+  }
+  gdk_window_set_cursor(window, cursor);
+  return 0;
+}
+
+void gui_link_menu_open(GtkWidget* menuitem, void* x)
+{
+  if(!gui_link_menu_url){return;}
+  gtk_show_uri(gtk_widget_get_screen(GTK_WIDGET(menuitem)), gui_link_menu_url, gtk_get_current_event_time(), 0);
+}
+
+void gui_link_menu_copy(GtkWidget* menuitem, void* x)
+{
+  if(!gui_link_menu_url){return;}
+  GtkClipboard* clipboard=gtk_clipboard_get_for_display(gtk_widget_get_display(menuitem), GDK_SELECTION_CLIPBOARD);
+  gtk_clipboard_set_text(clipboard, gui_link_menu_url, -1);
+}
diff --git a/utilities/gtk/gui.h b/utilities/gtk/gui.h
index 3b63f62..33af3ef 100644
--- a/utilities/gtk/gui.h
+++ b/utilities/gtk/gui.h
@@ -1,6 +1,6 @@
 /*
     tc_client-gtk, a graphical user interface for tc_client
-    Copyright (C) 2015  alicia@ion.nu
+    Copyright (C) 2015-2016  alicia@ion.nu
 
     This program is free software: you can redistribute it and/or modify
     it under the terms of the GNU Affero General Public License as published by
@@ -48,5 +48,13 @@ extern void gui_hide_cam(GtkMenuItem* menuitem, void* x);
 extern void gui_set_greenscreen_img(GtkButton* button, void* x);
 extern void gui_set_greenscreen_color(GtkColorButton* button, void* x);
 extern void gui_set_greenscreen_tolerance(GtkAdjustment* adjustment, void* x);
+extern void gui_insert_link(GtkTextBuffer* buffer, GtkTextIter* iter, const char* url, int length);
+extern gboolean gui_click_link(GtkTextView* textview, GdkEventButton* event, void* data);
+extern gboolean gui_rightclick_link(GtkTextView* textview, GdkEventButton* event, void* data);
+extern gboolean gui_hover_link(GtkTextView* textview, GdkEventMotion* event, void* data);
+extern void gui_link_menu_open(GtkWidget* menuitem, void* x);
+extern void gui_link_menu_copy(GtkWidget* menuitem, void* x);
 
 extern GtkBuilder* gui;
+extern GdkCursor* gui_cursor_text;
+extern GdkCursor* gui_cursor_link;