/*
This file is part of leafdigital leafChat.
leafChat is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
leafChat is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with leafChat. If not, see <http://www.gnu.org/licenses/>.
Copyright 2012 Samuel Marshall.
*/
package com.leafdigital.ircui;
import java.util.*;
import util.xml.*;
import com.leafdigital.irc.api.*;
import com.leafdigital.ircui.api.*;
import com.leafdigital.ircui.api.GeneralChatWindow.Handler;
import com.leafdigital.notification.api.*;
import com.leafdigital.prefs.api.*;
import com.leafdigital.prefsui.api.PreferencesUI;
import com.leafdigital.ui.api.*;
import leafchat.core.api.*;
/** IRC user-interface plugin. */
public class IRCUIPlugin implements Plugin,IRCUI,DefaultMessageDisplay
{
/** Turn this on to make it fake connection on run */
final static boolean USEFAKESERVER="y".equals(System.getProperty("leafchat.fakeserver"));
final static String
NOTIFICATION_DEIDLE="New message after long idle period",
NOTIFICATION_NEWWINDOW="New message window appears",
NOTIFICATION_DISCONNECTED="Server disconnected",
NOTIFICATION_WINDOWMINIMIZED="New message when window is minimized",
NOTIFICATION_APPLICATIONINACTIVE="New message when application is inactive";
final static String
PREF_CLOSESPAREWINDOWS="close-spare-windows",
PREFDEFAULT_CLOSESPAREWINDOWS="t";
/** Will not autoreconnect again to same server within 2 minutes */
private final static long AUTO_RECONNECT_FREQUENCY = 120 * 1000;
private PluginContext context;
private ConnectTool ct;
private JoinTool jt;
private EntryTool et;
private ActionListOwner alo;
private KnownUsers ku;
private static class ReconnectInfo
{
String host;
int port;
long time;
private ReconnectInfo(String host, int port)
{
this.host = host;
this.port = port;
this.time = System.currentTimeMillis();
}
}
private HashMap<ServerDisconnectedMsg, ReconnectInfo> autoReconnects =
new HashMap<ServerDisconnectedMsg, ReconnectInfo>();
@Override
public void init(PluginContext context, PluginLoadReporter plr) throws GeneralException
{
this.context=context;
if(USEFAKESERVER)
new FakeServer();
context.getSingle(Connections.class).setDefaultMessageDisplay(this);
ct=new ConnectTool(context,this);
UI ui=context.getSingle(UI.class);
ui.registerTool(ct);
jt=new JoinTool(context);
ui.registerTool(jt);
et=new EntryTool(context);
ui.registerTool(et);
context.requestMessages(IRCMsg.class,this,Msg.PRIORITY_LATE);
context.requestMessages(ServerConnectionFinishedMsg.class,this);
context.requestMessages(ServerConnectedMsg.class, this);
context.requestMessages(WatchMsg.class,this,Msg.PRIORITY_LATE);
context.requestMessages(ServerRearrangeMsg.class,this,Msg.PRIORITY_FIRST);
context.requestMessages(SystemStateMsg.class,this);
context.requestMessages(MinuteMsg.class, this);
context.registerSingleton(IRCUI.class,this);
alo=new ActionListOwner(context);
PreferencesUI preferencesUI=context.getSingle(PreferencesUI.class);
preferencesUI.registerPage(this,(new IgnoreListPage(context)).getPage());
preferencesUI.registerPage(this,(new WatchListPage(context)).getPage());
preferencesUI.registerPage(this,(new EncodingPage(context)).getPage());
preferencesUI.registerPage(this,(new PrefsMiscPage(context)).getPage());
preferencesUI.registerPage(this,(new CommandListPage(context)).getPage());
preferencesUI.registerWizardPage(this,100,(new WizardProfilePage(context)).getPage());
preferencesUI.registerWizardPage(this,200,(new WizardPersonalPage(context)).getPage());
new UICommands(context);
context.requestMessages(IRCActionListMsg.class,this);
ku=new KnownUsers(context);
context.requestMessages(NotificationListMsg.class,this);
context.requestMessages(ServerDisconnectedMsg.class,this);
}
/**
* Notification message: called to get list of notification types.
* @param msg Message
*/
public void msg(NotificationListMsg msg)
{
msg.addType(NOTIFICATION_DEIDLE,true);
msg.addType(NOTIFICATION_DISCONNECTED,true);
msg.addType(NOTIFICATION_NEWWINDOW,true);
msg.addType(NOTIFICATION_WINDOWMINIMIZED,true);
msg.addType(NOTIFICATION_APPLICATIONINACTIVE,false);
}
/**
* Server message: server is disconnected.
* @param msg Message
*/
public void msg(ServerDisconnectedMsg msg)
{
// Notify unless it was caused by user/system quit request
if(!msg.getServer().wasQuitRequested())
context.getSingle(Notification.class).notify(
NOTIFICATION_DISCONNECTED,"Disconnected: "+msg.getServer().getReportedOrConnectedHost(),"");
}
/**
* Trigger direct connection to named server. Used for reconnects and /server.
* @param host Hostname
* @param port Port
* @throws GeneralException Any error
*/
void directConnect(String host, int port) throws GeneralException
{
ct.directConnect(host,port);
}
private LinkedList<JoinRequest> joinRequests = null;
private static class JoinRequest
{
/**
* Join requests time-out after 2 minutes so it doesn't randomly join
* a channel when you connect 2 hours later.
*/
private final static long TIMEOUT=120000;
private PreferencesGroup server;
private String name, key, host;
private long time;
public JoinRequest(PreferencesGroup server,String name,String key)
{
this.server=server;
this.name=name;
this.key=key;
time=System.currentTimeMillis();
}
public JoinRequest(String host, String name, String key)
{
this.host = host;
this.name = name;
this.key = key;
time = System.currentTimeMillis();
}
boolean timedOut()
{
return System.currentTimeMillis()-time > TIMEOUT;
}
}
/**
* Adds a join request for a specific channel and hostname, but does not
* actually initiate connect.
* @param host Hostname
* @param channel Channel name
* @param key Channel key or empty string if none
*/
void addJoinRequest(String host, String channel, String key)
{
synchronized(this)
{
if(joinRequests==null) joinRequests = new LinkedList<JoinRequest>();
joinRequests.add(new JoinRequest(host, channel, key));
}
}
/**
* Connects to a server and joins a channel.
* @param server Preferences entry referring to server or network
* @param name Name of channel
* @param key Key (password) or empty string if none
* @throws GeneralException
*/
void connectAndJoin(PreferencesGroup server,String name,String key) throws GeneralException
{
ct.directConnect(server);
synchronized(this)
{
if(joinRequests==null) joinRequests = new LinkedList<JoinRequest>();
joinRequests.add(new JoinRequest(server,name,key));
}
}
@Override
public void close() throws GeneralException
{
context.getSingle(Connections.class).setDefaultMessageDisplay(null);
}
@Override
public String toString()
{
return "IRC UI plugin";
}
/**
* Message: Connected. Used to turn off auto-join if needed.
* @param msg Message
*/
public void msg(ServerConnectedMsg msg)
{
JoinRequest request = getMatchingJoinRequest(msg.getServer(), false);
if(request != null)
{
msg.getServer().suppressAutoJoin();
}
}
private JoinRequest getMatchingJoinRequest(Server server, boolean remove)
{
synchronized(this)
{
if(joinRequests!=null)
{
for(Iterator<JoinRequest> i=joinRequests.iterator(); i.hasNext();)
{
JoinRequest request=i.next();
if(request.timedOut())
{
i.remove();
continue;
}
if(request.server != null)
{
// Join request specified relating to a preferences item
PreferencesGroup pg = server.getPreferences();
if(pg==request.server || pg.getAnonParent()==request.server)
{
if(remove)
{
i.remove();
}
return request;
}
}
else
{
// Join request specified by hostname
if(server.getConnectedHost().equals(request.host))
{
if(remove)
{
i.remove();
}
return request;
}
}
}
}
}
return null;
}
/**
* Message: Connection finished. Used to join channels requested by
* {@link #connectAndJoin(PreferencesGroup, String, String)}.
* @param msg Message
* @throws GeneralException
*/
public void msg(ServerConnectionFinishedMsg msg) throws GeneralException
{
JoinRequest request = getMatchingJoinRequest(msg.getServer(), true);
if(request != null)
{
msg.getServer().sendLine(IRCMsg.constructBytes("JOIN " +
request.name + (request.key.length()>0 ? " " + request.key : "")));
}
}
/**
* Message: join. Used to open new channel window.
* @param msg Message
* @throws GeneralException
*/
public void msg(JoinIRCMsg msg) throws GeneralException
{
if(!msg.isHandled())
new ChanWindow(context,msg);
}
/**
* Message: user message. Used to open new message window.
* @param msg Message
* @throws GeneralException
*/
public void msg(UserMessageIRCMsg msg) throws GeneralException
{
if(!msg.isHandled())
openNewMessageWindow(msg);
}
/**
* Message: user action. Used to open new message window.
* @param msg Message
* @throws GeneralException
*/
public void msg(UserActionIRCMsg msg) throws GeneralException
{
if(!msg.isHandled())
openNewMessageWindow(msg);
}
private void openNewMessageWindow(UserIRCMsg msg)
throws GeneralException
{
new MsgWindow(context,msg,false);
}
/**
* General IRC message. Used to ensure message is displayed in available
* window.
* @param msg Message
* @throws GeneralException
*/
public void msg(IRCMsg msg) throws GeneralException
{
// Messages that are supposed not to be displayed
if(msg instanceof PartIRCMsg)
{
msg.markHandled();
}
// Unhandled messages
if(!msg.isHandled())
{
getWindow(msg.getServer()).handleUnhandledMsg(msg);
}
}
/**
* Message: watch message. Used to ensure message is displayed.
* @param msg Message
* @throws GeneralException
*/
public void msg(WatchMsg msg) throws GeneralException
{
if(!msg.isHandled())
{
getWindow(msg.getServer()).handleWatchMsg(msg);
}
}
/**
* Message: server rearrange. Used to offer the choice of placing a new
* server in a network.
* @param msg Message
* @throws GeneralException
*/
public void msg(ServerRearrangeMsg msg) throws GeneralException
{
UI u=context.getSingle(UI.class);
int result=u.showQuestion(null,
"Server organisation",msg.getText(),UI.BUTTON_YES|UI.BUTTON_NO,
msg.getButtonConfirm(),msg.getButtonOther(),null,UI.BUTTON_YES);
if(result==UI.BUTTON_YES)
msg.confirm();
else
msg.reject();
msg.markStopped();
}
/** List of windows, most recently active first */
private LinkedList<ChatWindow> activeChatWindows =
new LinkedList<ChatWindow>();
/**
* Obtains the most recent window for a given server.
* @param s Server (may be null)
* @return Window or null if no windows for that server
*/
synchronized ServerChatWindow getRecentWindow(Server s)
{
for(ChatWindow cw : activeChatWindows)
{
if((cw instanceof ServerChatWindow)
&& ((ServerChatWindow)cw).getServer()==s)
{
return (ServerChatWindow)cw;
}
}
return null;
}
/**
* Obtains any recent window (regardless of server)
* @return Window
*/
synchronized ChatWindow getRecentWindow()
{
if(activeChatWindows.isEmpty())
return null;
else
return activeChatWindows.getFirst();
}
/**
* Called when a window is closed.
* @param cw Closed window
*/
void informClosed(ChatWindow cw)
{
activeChatWindows.remove(cw);
}
/**
* Called when a window is made active. Keeps a list of active windows
* so we know the current window for a server.
* @param cw Window
*/
void informActive(ChatWindow cw)
{
activeChatWindows.remove(cw);
activeChatWindows.addFirst(cw);
}
/**
* Called when a window is shown, just to make sure it's in the list of
* windows even if it is never made active.
* @param cw Window
*/
void informShown(ChatWindow cw)
{
activeChatWindows.remove(cw);
// Last, because it's never been active
activeChatWindows.addLast(cw);
}
EditBox.TabCompletion newTabCompletion(ChatWindow first)
{
return new NickChanCompletion(first);
}
private class NickChanCompletion implements EditBox.TabCompletion
{
private ChatWindow first;
NickChanCompletion(ChatWindow first)
{
this.first=first;
}
@Override
public String[] complete(String partial, boolean atStart)
{
TabCompletionList tcl = new TabCompletionList(partial, atStart);
// Do the specified chat window
if(first != null)
{
first.fillTabCompletionList(tcl);
}
// Do all other windows
for(ChatWindow cw : activeChatWindows)
{
if(cw == first)
{
continue;
}
cw.fillTabCompletionList(tcl);
}
// Include favourite channels
try
{
JoinTool.ChannelInfo[] favourites = JoinTool.getFavouriteChannels(context);
for(int i=0; i<favourites.length; i++)
{
tcl.add(favourites[i].name, false);
}
}
catch(GeneralException ge)
{
ErrorMsg.report("Error obtaining favourite channels for tab-completion list", ge);
}
// Include commands
if(atStart && partial.startsWith("/"))
{
Server server = null;
if(first != null && (first instanceof ServerChatWindow))
{
server = ((ServerChatWindow)first).getServer();
}
UserCommandListMsg m = new UserCommandListMsg(server);
try
{
context.dispatchExternalMessage(UserCommandListMsg.class, m, true);
}
catch(GeneralException e)
{
ErrorMsg.report("Error obtaining command list for tab completion", e);
}
for(String command : m.getCommands())
{
tcl.add("/" + command + " ", false);
}
}
return tcl.getResult();
}
}
private Map<Server, SpareWindow> spareWindows =
new HashMap<Server, SpareWindow>();
synchronized void spareWindowClosed(Server s)
{
spareWindows.remove(s);
}
synchronized void gotNonSpareWindow(Server s)
{
SpareWindow w=spareWindows.get(s);
if(w!=null) w.gotOtherWindow();
}
synchronized boolean hasNonSpareWindow(Server s)
{
for(ChatWindow cw : activeChatWindows)
{
if((cw instanceof ServerChatWindow) && !(cw instanceof SpareWindow) &&
((ServerChatWindow)cw).getServer()==s) return true;
}
return false;
}
synchronized ServerChatWindow getWindow(Server s) throws GeneralException
{
ServerChatWindow scw = getRecentWindow(s);
if(scw == null)
{
SpareWindow spare = new SpareWindow(context, s);
spareWindows.put(s, spare);
scw = spare;
informActive(scw);
// Should get called anyway but maybe this will be too
// late, if we request the same spare window twice very quickly
// (appears to affect external windows only)
}
return scw;
}
@Override
public MessageDisplay getMessageDisplay(final Server s)
{
return new MessageDisplay()
{
private ChatWindow cw=null;
private ChatWindow getChatWindow()
{
if(cw!=null && !cw.getWindow().isClosed())
{
return cw;
}
try
{
cw=getWindow(s);
}
catch(GeneralException ge)
{
ErrorMsg.report("Error obtaining default window",ge);
}
return cw;
}
@Override
public void showInfo(String message)
{
getChatWindow().showInfo(message);
}
@Override
public void showError(String message)
{
getChatWindow().showError(message);
}
@Override
public void showOwnText(int type,String target,String text)
{
getChatWindow().showOwnText(type,target,text);
}
@Override
public void clear()
{
getChatWindow().clear();
}
};
}
ActionListOwner getActionListOwner()
{
return alo;
}
/**
* Actionlist message: used to add nickname actions to menus.
* @param m Message
*/
public void msg(IRCActionListMsg m)
{
if(m.hasSingleNick())
{
boolean us = !m.notUs();
m.addIRCAction(new NickAction(context,
"Chat privately with " + (us ? "yourself" : m.getSingleNick()),
IRCAction.CATEGORY_USER, 10, "/query %%NICK%%"));
m.addIRCAction(new NickAction(context,
"Get info about " + (us ? "yourself" : m.getSingleNick()),
IRCAction.CATEGORY_USER, 20, "/whois %%NICK%%"));
if(!us)
{
m.addIRCAction(new NickAction(context,
"Block messages from " + m.getSingleNick(), IRCAction.CATEGORY_USER, 1000,
"/ignore %%NICK%%"));
}
}
}
/**
* Quit confirmation dialog that knows about connected servers.
*/
@UIHandler("quitconfirm")
public class QuitConfirm
{
QuitConfirm(SystemStateMsg m,Server[] connected) throws GeneralException
{
objectToQuit=false;
UI ui=context.getSingle(UI.class);
quitConfirm = ui.createDialog("quitconfirm", this);
StringBuffer list=new StringBuffer();
for(int i=0;i<connected.length;i++)
{
list.append("<line>"+XML.esc(connected[i].getReportedOrConnectedHost())+"</line>");
}
serverListUI.setText(list.toString());
quitConfirm.show(null);
if(objectToQuit) m.markStopped();
}
private Dialog quitConfirm;
/** Label listing server names */
public Label serverListUI;
private boolean objectToQuit;
/** Action: User chooses to quit. */
@UIAction
public void actionQuit()
{
quitConfirm.close();
}
/** Action: User cancels quit. */
@UIAction
public void actionCancel()
{
quitConfirm.close();
objectToQuit=true;
}
}
/**
* System state message. Used to warn about quit causing disconnect.
* @param msg Message
* @throws GeneralException
*/
public void msg(SystemStateMsg msg) throws GeneralException
{
if(msg.getType()==SystemStateMsg.REQUESTSHUTDOWN)
{
Connections c=context.getSingle(Connections.class);
Server[] connected=c.getConnected();
if(connected.length==0) return; // Not connected, so quit is ok
new QuitConfirm(msg,connected);
}
}
@Override
public GeneralChatWindow createGeneralChatWindow(PluginContext owner,
Handler h, String logSource, String logCategory, String logItem,
int availableBytes, String ownNick, String target, boolean startMinimised)
{
try
{
PluginChatWindow pcw=new PluginChatWindow(context,
owner, h, logSource, logCategory, logItem, availableBytes, ownNick, target,startMinimised);
informActive(pcw);
return pcw;
}
catch(GeneralException e)
{
throw new BugException(e);
}
}
KnownUsers getKnownUsers()
{
return ku;
}
/**
* Minute message: used to clear out old reconnect info to make sure it
* doesn't cause a memory leak.
* @param msg Message
*/
public void msg(MinuteMsg msg)
{
long threshold = System.currentTimeMillis() - AUTO_RECONNECT_FREQUENCY;
for(Iterator<ReconnectInfo> i = autoReconnects.values().iterator(); i.hasNext(); )
{
ReconnectInfo info = i.next();
if(info.time < threshold)
{
i.remove();
}
}
}
/**
* Called when a ServerDisconnectedMsg is received. As this is passed on by
* ServerChatWindow, it may be called multiple times for a single disconnected
* message; the actual reconnect should only happen once. However, the
* return value will still be true if a reconnect is occuring for this
* message, even if it was already triggered.
* @param msg Message
* @return True if autoreconnect is happening for this disconnect event
* @throws GeneralException Any error during reconnect
*/
public boolean considerAutoReconnect(ServerDisconnectedMsg msg) throws GeneralException
{
// Don't auto-reconnect if quit was requested by user!
if(msg.getServer().wasQuitRequested())
{
return false;
}
// Check auto-reconnect is turned on
Preferences prefs = context.getSingle(Preferences.class);
PreferencesGroup group = prefs.getGroup(prefs.getPluginOwner(
IRCPrefs.IRCPLUGIN_CLASS));
if(!prefs.toBoolean(group.get(
IRCPrefs.PREF_AUTORECONNECT, IRCPrefs.PREFDEFAULT_AUTORECONNECT)))
{
return false;
}
// Did we already do a reconnect for this message?
if(autoReconnects.containsKey(msg))
{
return true;
}
// See if we already have a connection to the same network
String network = msg.getServer().getPreferences().getAnonHierarchical(
IRCPrefs.PREF_NETWORK, null);
if(network != null)
{
Connections c = context.getSingle(Connections.class);
for(Server connected : c.getConnected())
{
String other = connected.getPreferences().getAnonHierarchical(
IRCPrefs.PREF_NETWORK, null);
if(network.equals(other))
{
return false;
}
}
}
// Get server and port
String host = msg.getServer().getConnectedHost();
int port = msg.getServer().getConnectedPort();
for(ReconnectInfo info : autoReconnects.values())
{
if(info.host.equals(host) && info.port == port)
{
return false;
}
}
// If somebody marks the message handled, don't do anything
if(msg.isHandled())
{
return false;
}
// Trigger reconnect, mark handled, and add to list
autoReconnects.put(msg, new ReconnectInfo(host, port));
directConnect(host, port);
msg.markHandled();
return true;
}
}