/*
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.awt.event.MouseEvent;
import java.io.*;
import java.net.*;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.regex.*;
import org.w3c.dom.*;
import util.*;
import util.xml.*;
import com.leafdigital.highlighter.api.Highlighter;
import com.leafdigital.irc.api.*;
import com.leafdigital.logs.api.Logger;
import com.leafdigital.notification.api.Notification;
import com.leafdigital.prefs.api.*;
import com.leafdigital.ui.api.*;
import leafchat.core.api.*;
/** IRC-style chat windows, generic (not necessarily attached to a server) */
public abstract class ChatWindow implements MessageDisplay,TextView.MenuHandler
{
private Window w;
/** Text-view (main part of window) */
public TextView tvUI;
/** Command box */
public EditBox commandUI;
private long lastMessage;
/** Timer event ID for code that gives users a reminder about the window */
private int noticeTimerID=-1;
protected final static String ACTIONSYMBOL="* ",EVENTSYMBOL="- ";
private PluginContext context;
protected PluginContext getPluginContext() { return context; }
protected Window getWindow() { return w; }
/**
* Keeps track of whether this window has ever been active (for the reminder
* feature)
*/
private boolean everActive;
/**
* Constructs a server-based chat window.
* @param context Plugin context
* @param xml Name of xml file (excluding ".xml")
* @param showNow If true, shows before exiting constructor
* @param visible If true, appears popped-up, otherwise minimised
* @throws GeneralException
*/
public ChatWindow(PluginContext context, String xml, boolean showNow,
boolean visible) throws GeneralException
{
this.context=context;
UI ui=context.getSingle(UI.class);
// Create window
w=ui.createWindow(xml, this);
w.setOnActive("actionOnActive");
// Set up text view and editbox
if(commandUI!=null)
{
commandUI.setOnChange("actionChange");
commandUI.setTabCompletion(((IRCUIPlugin)context.getPlugin()).newTabCompletion(this));
commandUI.setUseFontSettings(true);
commandUI.setOnMultiLine("handleMultiLine");
actionChange();
// On Mac, add spacer
if(PlatformUtils.isMac())
{
BorderPanel bp=(BorderPanel)w.getWidget("mainpanel");
Spacer spacer=ui.newSpacer();
spacer.setWidth(15);
spacer.setHeight(15);
Widget existing=bp.get(BorderPanel.SOUTHEAST);
if(existing==null)
{
bp.set(BorderPanel.SOUTHEAST,spacer);
}
else
{
HorizontalPanel hp=ui.newHorizontalPanel();
hp.add(existing);
hp.add(spacer);
bp.set(BorderPanel.SOUTHEAST,hp);
}
}
}
tvUI.setMenuHandler(this);
tvUI.setLineLimit(2000);
tvUI.setScrolledUpWarning(true);
tvUI.setAction("internalaction",new TextView.ActionHandler()
{
@Override
public void action(Element e, MouseEvent me) throws GeneralException
{
internalAction(e);
}
});
// Show window
if(showNow)
{
((IRCUIPlugin)context.getPlugin()).informShown(this);
w.show(!visible);
}
// Give the user a reminder in current window if they take too long to
// notice this one
if(!visible)
{
noticeTimerID=TimeUtils.addTimedEvent(new Runnable()
{
@Override
public void run()
{
noticeTimerID=-1;
if(everActive) return;
ChatWindow recent= ((IRCUIPlugin)getPluginContext().getPlugin()).getRecentWindow();
if(recent!=null) recent.showInfo(
"<small>In case you missed it: the new window <key>"+w.getTitle()+"</key> appeared a little while ago.</small>");
}
},60*1000,true);
}
// Test cases for colour coding
// addLine("T1 \u0002Bold text\u0002 not bold");
// addLine("T2 \u0002Bold text, unclosed");
// addLine("T3 \u0012Reverse text\u0012 not reverse");
// addLine("T4 \u0012Reverse text, unclosed");
// addLine("T5 \u001fUnderline text\u001f not underline");
// addLine("T6 \u001fUnderline text, unclosed");
// addLine("T7 \u001fUnderline \u0002and bold \u0012and reverse \u001fnot underline \u0002not bold \u0012not reverse");
// addLine("T8 \u001fUnderline \u0002and bold \u0012and reverse \u000fand CUT");
// addLine("blablah \u00035,12to be colored text and background\u0003 blablah");
// addLine("blablah \u00035to be colored text\u0003 blabla");
// addLine("blablah \u00033to be colored text \u00035,2other colored text and also background\u0003 blabla");
// addLine("blabla \u00033,5to be colored text and background \u00038other colored text but SAME background\u0003 blabla");
// addLine("blablah \u00033,5to be colored text and background \u00038,7other colored text and other background\u0003 blabla");
// addLine("\u00033,4!BG!");
lastMessage=System.currentTimeMillis();
}
/**
* Overridable. Called when someone clicks on an <internalaction>. Default
* does nothing.
* @param e Element clicked on
* @throws GeneralException
*/
protected void internalAction(Element e) throws GeneralException
{
}
/**
* Callback: When user hits Return.
* @throws GeneralException
*/
@UIAction
public void actionSend() throws GeneralException
{
String[] asLine=commandUI.getValueLines();
Commands c=context.getSingle(Commands.class);
if(asLine.length==1)
{
doCommand(c,asLine[0]);
}
else
{
for(int i=0;i<asLine.length;i++)
doCommand(c,"/say "+asLine[i]);
}
commandUI.setValue("");
}
/**
* Callback: When text in editbox changes.
* @throws GeneralException
*/
public void actionChange() throws GeneralException
{
if(commandUI.getValue().startsWith("/"))
{
commandUI.setLineBytes(450); // Leave some overhead in case the command needs a lot more chars than its / format
commandUI.setLineWrap(false);
}
else
{
commandUI.setLineBytes(getAvailableBytes());
commandUI.setLineWrap(true);
}
}
protected abstract int getAvailableBytes() throws GeneralException;
protected abstract void doCommand(Commands c,String line) throws GeneralException;
/**
* Callback: When window becomes active.
* @throws GeneralException
*/
public void actionOnActive() throws GeneralException
{
everActive=true;
if(commandUI!=null) commandUI.focus();
((IRCUIPlugin)context.getPlugin()).informActive(this);
fadeMark();
}
/** If appropriate, begins to fade down any mark that might be present */
private void fadeMark()
{
if(w.getCanClearAttention() && w.isActive() && tvUI.hasMark() && markFadeTimerID==-1)
{
addMarkFadeTimer(256);
}
}
/** Timer event ID for code that fades out markers */
private int markFadeTimerID=-1;
private void addMarkFadeTimer(final int currentOpacity)
{
markFadeTimerID=TimeUtils.addTimedEvent(new Runnable()
{
@Override
public void run()
{
// Stop fading if the window is scrolled up
if(!w.getCanClearAttention())
{
tvUI.fadeMark(255);
markFadeTimerID=-1;
return;
}
int newOpacity=currentOpacity-16;
if(newOpacity<=0)
{
tvUI.removeMark();
markFadeTimerID=-1;
}
else
{
tvUI.fadeMark(newOpacity);
addMarkFadeTimer(newOpacity);
}
}
},250,true);
}
/**
* Callback: When user scrolls window
* @throws GeneralException
*/
@UIAction
public void actionScroll() throws GeneralException
{
w.setCanClearAttention(tvUI.isAtEnd());
fadeMark();
}
/**
* Callback: When window is closed.
* @throws GeneralException
*/
public void windowClosed() throws GeneralException
{
if(markFadeTimerID!=-1)
TimeUtils.cancelTimedEvent(markFadeTimerID);
if(multiLineTimerID!=-1)
TimeUtils.cancelTimedEvent(multiLineTimerID);
if(noticeTimerID!=-1)
TimeUtils.cancelTimedEvent(noticeTimerID);
context.unrequestMessages(null,this,PluginContext.ALLREQUESTS);
((IRCUIPlugin)context.getPlugin()).informClosed(this);
}
/** @return Category used for logging */
protected abstract String getLogCategory();
/** @return Item name used for logging */
protected abstract String getLogItem();
/**
* Adds a line of text to the window.
* @param xml XML to add
* @param sLogType Type of text
*/
public void addLine(String xml,String sLogType)
{
addLine(xml,true,sLogType, false);
}
/**
* Adds a line of text to the window.
* @param xml XML to add
*/
public void addLine(String xml)
{
addLine(xml,true,null, false);
}
String lastTimeStamp=null;
protected boolean displayTimeStamps()
{
return false;
}
private String getTimeStamp()
{
if(!displayTimeStamps()) return "";
String timeStamp=new SimpleDateFormat("HH:mm").format(new Date());
if(lastTimeStamp==null || !lastTimeStamp.equals(timeStamp))
{
lastTimeStamp=timeStamp;
return "<timestamp>"+lastTimeStamp+"</timestamp>";
}
return "";
}
private String lastDateStamp=null;
private String getDateStamp()
{
if(!displayTimeStamps()) return "";
String dateStamp=new SimpleDateFormat("EEEEE d MMMM").format(new Date());
if(lastDateStamp==null)
{
// Don't display first one
lastDateStamp=dateStamp;
}
else if(!lastDateStamp.equals(dateStamp))
{
lastDateStamp=dateStamp;
return "<datestamp>"+lastDateStamp+"</datestamp>";
}
return "";
}
protected void clearMark()
{
tvUI.removeMark();
}
private final static Pattern PATTERN_URL=
Pattern.compile("(\\b)((http(s)?://[^<]*?|www\\.[a-zA-Z0-9-]+\\.[a-zA-Z0-9.-]+(?:/[^<]*?)?))(<|, | |\\.$|\\. |\\)|$)");
/**
* Processes colours in text then removes 'unsafe' characters not permitted
* in XML; normally called by addLine, but can be used by other things too.
* @param s Text to process
* @return Processed text including xml colour tags if needed
*/
protected String processColours(String s)
{
Preferences p=context.getSingle(Preferences.class);
PreferencesGroup pg=p.getGroup(p.getPluginOwner("com.leafdigital.ui.UIPlugin"));
boolean allowColours=p.toBoolean(
pg.get(UIPrefs.PREF_IRCCOLOURS,UIPrefs.PREFDEFAULT_IRCCOLOURS));
// Do IRC colours
s = context.getSingle(IRCEncoding.class).processEscapes(
s, true, allowColours);
return s.replaceAll("[\\x00-\\x1f]","");
}
protected void addLine(String s,boolean bAttention,String sLogType, boolean arbitraryXML)
{
try
{
if((!w.getCanClearAttention() || !w.isActive()) && !tvUI.hasMark())
{
tvUI.markPosition();
}
// Do colours and remove special characters
String safe = processColours(s);
// Replace URL
StringBuffer output=new StringBuffer();
Matcher m=PATTERN_URL.matcher(safe);
while(m.find())
{
String replace=m.group(1)+"<url>"+m.group(2)+"</url>"+m.group(5);
try
{
// Same logic used in TextViewImp to allow urls without http
String url=m.group(2);
if(!(url.startsWith("http://") || url.startsWith("https://")))
url="http://"+url;
new URL(url);
}
catch(MalformedURLException e)
{
// Don't put the url tags in
replace=m.group(1)+m.group(2)+m.group(5);
}
// Replace \ with \\.
for(int pos=0;;)
{
int backslash=replace.indexOf('\\',pos);
if(backslash==-1) break;
replace=replace.substring(0,backslash)+"\\\\"+replace.substring(backslash+1);
pos=backslash+2;
}
// Replace $ with \$. I can't get it to do this with a regexp replace
for(int pos=0;;)
{
int dollar=replace.indexOf('$',pos);
if(dollar==-1) break;
replace=replace.substring(0,dollar)+"\\$"+replace.substring(dollar+1);
pos=dollar+2;
}
// OK it's good, let's replace it
m.appendReplacement(output,replace);
}
m.appendTail(output);
safe=output.toString();
// Highlighter
try
{
safe = context.getSingle(Highlighter.class).highlight(
getOwnNick(), safe);
}
catch(XMLException e)
{
throw new GeneralException(e);
}
boolean bAtEnd=tvUI.isAtEnd();
if(arbitraryXML)
{
tvUI.addXML(safe);
}
else
{
tvUI.addXML(getDateStamp()+"<line>"+getTimeStamp()+safe+"</line>");
}
if(bAtEnd) tvUI.scrollToEnd();
if(bAttention) w.attention();
if(sLogType!=null && getLogSource()!=null)
{
getPluginContext().getSingle(Logger.class).log(
getLogSource(),getLogCategory(),getLogItem(),sLogType,safe);
}
}
catch(GeneralException ge)
{
ErrorMsg.report("Error adding text: "+s,ge);
}
}
protected abstract String getLogSource();
protected static String displayTime(long time)
{
Calendar c=Calendar.getInstance();
c.set(Calendar.HOUR_OF_DAY,0);
c.set(Calendar.MINUTE,0);
c.set(Calendar.SECOND,0);
c.set(Calendar.MILLISECOND,0);
long today=c.getTimeInMillis();
if(time<today || time > today+(24*60*60*1000))
{
SimpleDateFormat sdf=new SimpleDateFormat("HH:mm 'on' EEE, dd MMMM yyyy");
return sdf.format(new Date(time));
}
else
{
SimpleDateFormat sdf=new SimpleDateFormat("HH:mm:ss 'today'");
return sdf.format(new Date(time));
}
}
@Override
public void showError(String message)
{
addLine("<error>"+message+"</error>");
}
@Override
public void showInfo(String message)
{
addLine("<info>"+message+"</info>");
}
protected abstract boolean isUs(String target);
@Override
public void showOwnText(int type,String target,String text)
{
if(isUs(target) && type==TYPE_MSG || type==TYPE_ACTION)
{
String sNick=getOwnNick();
switch(type)
{
case MessageDisplay.TYPE_MSG:
addLine("<<nick>"+esc(sNick)+"</nick>> <owntext>"+esc(text)+"</owntext>","msg");
break;
case MessageDisplay.TYPE_ACTION:
addLine(ACTIONSYMBOL+"<nick>"+esc(sNick)+"</nick> <owntext>"+esc(text)+"</owntext>","action");
break;
}
}
else
{
addLine("-> "+esc(target)+": "+esc(text));
}
}
protected static String esc(String text)
{
return XML.esc(XML.convertMultipleSpaces(text));
}
/**
* Chat window should add tab-completion options that it provides.
* @param options List for options
*/
public void fillTabCompletionList(TabCompletionList options)
{
}
protected abstract String getOwnNick();
/** @return Context nickname, if this window has one */
protected String getContextNick()
{
return null;
}
/** @return Context channel, if this window has one */
protected String getContextChannel()
{
return null;
}
@Override
public void addItems(PopupMenu pm, Node n)
{
}
@Override
public void clear()
{
tvUI.clear();
}
/**
* Called to handle multi-line input.
* @param text Input text
* @throws GeneralException
*/
public void handleMultiLine(String text) throws GeneralException
{
new MultiLineDialog(text);
}
/** Dialog used when pasting in multi-line text. */
@UIHandler("multiline")
public class MultiLineDialog
{
private Dialog d;
/** Paste as-is */
public RadioButton asIsUI;
/** Paste joined */
public RadioButton joinUI;
/** Display for preview */
public TextView previewUI;
/** Which character separates joined lines */
public EditBox separatorUI;
/** Info about the number of messages etc */
public Label infoUI;
private String[] originalLines,lines;
MultiLineDialog(String text) throws GeneralException
{
String[] initialLines=text.split("\n");
LinkedList<String> lines = new LinkedList<String>();
for(int i=0;i<initialLines.length;i++)
{
String line=initialLines[i].trim();
if(line.length()>0)
{
lines.add(line);
}
}
originalLines=lines.toArray(new String[lines.size()]);
UI ui=context.getSingle(UI.class);
d = ui.createDialog("multiline", this);
asIsUI.setSelected();
actionAsIs();
d.show(w);
}
/**
* Callback: When separator field is changed.
* @throws GeneralException
*/
@UIAction
public void changeSeparator() throws GeneralException
{
joinUI.setSelected(); // If it isn't already
actionJoin();
}
/**
* Callback: When 'as is' is clicked
* @throws GeneralException
*/
@UIAction
public void actionAsIs() throws GeneralException
{
update(originalLines);
}
/**
* Callback: When 'join' is clicked
* @throws GeneralException
*/
@UIAction
public void actionJoin() throws GeneralException
{
String separator=separatorUI.getValue();
List<String> initialLines = new LinkedList<String>();
StringBuffer currentLine=new StringBuffer(originalLines[0]);
for(int line=1;line<originalLines.length;line++)
{
String trimmed=originalLines[line].trim();
String append=" "+separator+" "+trimmed;
try
{
if((currentLine.toString()+append).getBytes("UTF-8").length
<= getAvailableBytes())
{
currentLine.append(append);
}
else
{
initialLines.add(currentLine.toString());
currentLine.setLength(0);
currentLine.append(trimmed);
}
}
catch(UnsupportedEncodingException e)
{
throw new BugException(e); // Can't happen
}
}
initialLines.add(currentLine.toString());
update(initialLines.toArray(new String[initialLines.size()]));
}
private void update(String[] initialLines) throws GeneralException
{
List<String> wrappedLines = new LinkedList<String>();
String before = commandUI.getValue();
for(int i=0; i<initialLines.length; i++)
{
// Let the edit box do the splitting
commandUI.setValue(initialLines[i]);
String[] addLines=commandUI.getValueLines();
for(int j=0;j<addLines.length;j++)
wrappedLines.add(addLines[j]);
}
commandUI.setValue(before);
lines=wrappedLines.toArray(new String[wrappedLines.size()]);
previewUI.clear();
for(int i=0;i<lines.length;i++)
{
previewUI.addLine("<<nick>"+getOwnNick()+"</nick>> "+XML.esc(lines[i]));
}
infoUI.setText("<key>"+lines.length+"</key> line"+(lines.length!=1 ? "s" : "")+". Paste "+
((lines.length<=2) ? "will be immediate." : "will take "+(lines.length*2)+" seconds."));
}
/**
* Callback: When user clicks OK
* @throws GeneralException
*/
@UIAction
public void actionPaste() throws GeneralException
{
if(lines.length<=2)
{
Commands c=context.getSingle(Commands.class);
for(int i=0;i<lines.length;i++)
doCommand(c,"/say "+lines[i]);
}
else
{
multiLineBuffer.addAll(Arrays.asList(lines));
if(multiLineTimerID==-1)
{
// Do first line straight away
multiLineRunnable.run();
}
}
d.close();
}
/**
* Callback: When user clicks Cancel.
*/
@UIAction
public void actionCancel()
{
d.close();
}
}
private int multiLineTimerID=-1;
private LinkedList<String> multiLineBuffer=new LinkedList<String>();
private Runnable multiLineRunnable=new Runnable()
{
@Override
public void run()
{
String line=multiLineBuffer.removeFirst();
Commands c=context.getSingle(Commands.class);
try
{
doCommand(c,"/say "+line);
}
catch(GeneralException e)
{
ErrorMsg.report("Unexpected error handling multiline paste",e);
}
if(multiLineBuffer.isEmpty())
{
multiLineTimerID=-1;
}
else
{
multiLineTimerID=TimeUtils.addTimedEvent(multiLineRunnable,2000,true);
}
}
};
/** Time before a chan is considered idle (10 mins) */
private final static long IDLE_TIME=10*60*1000;
/**
* Called by subclass every time an actual 'message' (i.e. somebody saying
* something) occurs.
* @param title Title for display of notification message
* @param text Text for said message
*/
protected void reportActualMessage(String title,String text)
{
long now=System.currentTimeMillis();
if(w.isHidden() || w.isMinimized())
{
context.getSingle(Notification.class).notify(
IRCUIPlugin.NOTIFICATION_WINDOWMINIMIZED,title,text);
}
else if(now-lastMessage > IDLE_TIME)
{
long minutes=(now-lastMessage) / (60*1000);
context.getSingle(Notification.class).notify(
IRCUIPlugin.NOTIFICATION_DEIDLE,title,text+"\n\n(Previously idle "+minutes+" minutes)");
}
else if(!context.getSingle(UI.class).isAppActive())
{
context.getSingle(Notification.class).notify(
IRCUIPlugin.NOTIFICATION_APPLICATIONINACTIVE,title,text);
}
lastMessage=now;
}
/**
* @return Command edit box
*/
public EditBox getCommandEdit()
{
return commandUI;
}
}