Added changes made since.

All windows now somewhat functional.
Added rudimentary HTML parser to deal with content.
This commit is contained in:
Snowyfox 2022-04-12 02:37:39 -04:00
parent 1fa1f4ea38
commit 9eb1b5256f
23 changed files with 2324 additions and 229 deletions

View File

@ -2,19 +2,33 @@
import javax.swing.JFrame;
import javax.swing.JPanel;
import javax.swing.JTextArea;
import javax.swing.JTextField;
import javax.swing.JLabel;
import javax.swing.JComboBox;
import javax.swing.JButton;
import javax.swing.Box;
import javax.swing.BorderFactory;
import javax.swing.JOptionPane;
import java.awt.BorderLayout;
import java.awt.Dimension;
import java.awt.event.ActionListener;
import java.awt.event.ActionEvent;
import java.awt.Cursor;
import cafe.biskuteri.hinoki.Tree;
import java.io.IOException;
class
ComposeWindow extends JFrame {
private JKomasto
primaire;
private MastodonApi
api;
// - -%- -
private Composition
composition;
@ -26,26 +40,61 @@ ComposeWindow extends JFrame {
public void
setComposition(Composition composition)
{
if (composition == null)
{
composition = new Composition();
composition.text = "";
composition.visibility = PostVisibility.MENTIONED;
composition.replyToPostId = null;
}
assert composition != null;
this.composition = composition;
syncDisplayToComposition();
}
public void
newComposition()
{
composition = new Composition();
composition.text = "";
composition.visibility = PostVisibility.MENTIONED;
composition.replyToPostId = null;
syncDisplayToComposition();
}
public void
submit()
{
syncCompositionToDisplay();
display.setSubmitting(true);
// Perform fancy submission here..
api.submit(
composition.text, composition.visibility,
composition.replyToPostId,
new RequestListener() {
public void
connectionFailed(IOException eIo)
{
JOptionPane.showMessageDialog(
ComposeWindow.this,
"Tried to submit post, failed..."
+ "\n" + eIo.getMessage()
);
}
public void
requestFailed(int httpCode, Tree<String> json)
{
JOptionPane.showMessageDialog(
ComposeWindow.this,
"Tried to submit post, failed..."
+ "\n" + json.get("error").value
+ "(HTTP error code: " + httpCode + ")"
);
}
public void
requestSucceeded(Tree<String> json)
{
newComposition();
}
}
);
display.setSubmitting(false);
// () Are we going to block the EDT..?
}
// - -%- -
@ -54,6 +103,7 @@ ComposeWindow extends JFrame {
syncDisplayToComposition()
{
display.setText(composition.text);
display.setReplyToPostId(composition.replyToPostId);
display.setVisibility(stringFor(composition.visibility));
}
@ -63,18 +113,23 @@ ComposeWindow extends JFrame {
composition.text = display.getText();
composition.visibility =
visibilityFrom(display.getVisibility());
composition.replyToPostId = display.getReplyToPostId();
}
// ---%-@-%---
ComposeWindow()
ComposeWindow(JKomasto primaire)
{
super("Submit a new post");
this.primaire = primaire;
this.api = primaire.getMastodonApi();
getContentPane().setPreferredSize(new Dimension(360, 270));
pack();
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
display = new ComposeComponent(this);
this.setComposition(null);
newComposition();
setContentPane(display);
}
@ -87,7 +142,7 @@ ComposeWindow extends JFrame {
switch (visibility)
{
case PUBLIC: return "Public";
case LOCAL: return "Local";
case UNLISTED: return "Unlisted";
case FOLLOWERS: return "Followers";
case MENTIONED: return "Mentioned";
}
@ -100,8 +155,8 @@ ComposeWindow extends JFrame {
{
if (string.equals("Public"))
return PostVisibility.PUBLIC;
if (string.equals("Local"))
return PostVisibility.LOCAL;
if (string.equals("Unlisted"))
return PostVisibility.UNLISTED;
if (string.equals("Followers"))
return PostVisibility.FOLLOWERS;
if (string.equals("Mentioned"))
@ -126,17 +181,15 @@ implements ActionListener {
private JTextArea
text;
private JTextField
reply;
private JComboBox<String>
visibility;
private JButton
submit;
// - -%- -
private static final Cursor
WAIT_CURSOR = Cursor.getPredefinedCursor(Cursor.WAIT_CURSOR);
// ---%-@-%---
public void
@ -145,12 +198,18 @@ implements ActionListener {
this.text.setText(text);
}
public void
setReplyToPostId(String postId)
{
this.reply.setText(postId);
}
public void
setVisibility(String visibility)
{
if (visibility.equals("Public"))
this.visibility.setSelectedIndex(0);
else if (visibility.equals("Local"))
else if (visibility.equals("Unlisted"))
this.visibility.setSelectedIndex(1);
else if (visibility.equals("Followers"))
this.visibility.setSelectedIndex(2);
@ -164,6 +223,12 @@ implements ActionListener {
return text.getText();
}
public String
getReplyToPostId()
{
return reply.getText();
}
public String
getVisibility()
{
@ -178,7 +243,7 @@ implements ActionListener {
text.setEnabled(false);
visibility.setEnabled(false);
submit.setEnabled(false);
setCursor(WAIT_CURSOR);
setCursor(new Cursor(Cursor.WAIT_CURSOR));
}
else
{
@ -201,10 +266,14 @@ implements ActionListener {
this.primaire = primaire;
text = new JTextArea();
text.setLineWrap(true);
text.setWrapStyleWord(true);
reply = new JTextField();
visibility = new JComboBox<>(new String[] {
"Public",
"Local",
"Unlisted",
"Followers",
"Mentioned"
// Where should we be saving strings..
@ -220,7 +289,14 @@ implements ActionListener {
bottom.add(Box.createHorizontalStrut(8));
bottom.add(submit);
JPanel top = new JPanel();
top.setOpaque(false);
top.setLayout(new BorderLayout(8, 0));
top.add(new JLabel("In reply to: "), BorderLayout.WEST);
top.add(reply);
setLayout(new BorderLayout(0, 8));
add(top, BorderLayout.NORTH);
add(text, BorderLayout.CENTER);
add(bottom, BorderLayout.SOUTH);

View File

@ -4,10 +4,60 @@ import javax.swing.JPanel;
import javax.swing.JComponent;
import java.awt.Dimension;
import java.awt.BorderLayout;
import java.awt.Cursor;
import java.util.List;
import java.time.ZonedDateTime;
class
JKomasto {
private TimelineWindow
timelineWindow;
private ComposeWindow
composeWindow;
private PostWindow
autoViewWindow;
private LoginWindow
loginWindow;
private MastodonApi
api;
// ---%-@-%---
public MastodonApi
getMastodonApi() { return api; }
public void
finishedLogin()
{
autoViewWindow.setCursor(new Cursor(Cursor.WAIT_CURSOR));
timelineWindow.setCursor(new Cursor(Cursor.WAIT_CURSOR));
timelineWindow.showLatestPage();
timelineWindow.setLocationByPlatform(true);
timelineWindow.setVisible(true);
autoViewWindow.setTitle("Auto view - JKomasto");
//autoViewWindow.setVisible(true);
loginWindow.dispose();
autoViewWindow.setCursor(null);
timelineWindow.setCursor(null);
}
public PostWindow
getAutoViewWindow() { return autoViewWindow; }
public ComposeWindow
getComposeWindow() { return composeWindow; }
// ---%-@-%---
public static void
main(String... args) { new JKomasto(); }
@ -16,27 +66,18 @@ JKomasto {
public
JKomasto()
{
new TimelineWindow().setVisible(true);
//new ComposeWindow().setVisible(true);
//new PostWindow().setVisible(true);
}
api = new MastodonApi();
// - -%- -
timelineWindow = new TimelineWindow(this);
composeWindow = new ComposeWindow(this);
autoViewWindow = new PostWindow(this);
loginWindow = new LoginWindow(this);
private static void
test(JComponent component, int width, int height)
{
JFrame frame = new JFrame();
frame.setTitle(component.getClass().getName());
frame.setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE);
frame.setLocationByPlatform(true);
JPanel panel = new JPanel();
panel.setPreferredSize(new Dimension(width, height));
panel.add(component);
frame.setContentPane(panel);
frame.setVisible(true);
composeWindow.dispose();
autoViewWindow.dispose();
timelineWindow.dispose();
loginWindow.setLocationByPlatform(true);
loginWindow.setVisible(true);
}
}
@ -47,7 +88,7 @@ enum
PostVisibility {
PUBLIC,
LOCAL,
UNLISTED,
FOLLOWERS,
MENTIONED
@ -59,8 +100,8 @@ TimelineType {
FEDERATED,
LOCAL,
HOME,
MENTIONS,
MESSAGES,
NOTIFICATIONS,
CONVERSATIONS,
LIST
}
@ -68,11 +109,14 @@ TimelineType {
class
Timeline {
TimelinePage {
public TimelineType
type;
public List<Post>
posts;
}
@ -81,12 +125,13 @@ class
Post {
public String
text;
text,
contentWarning;
public String
authorId, authorName;
public String
public ZonedDateTime
date;
public PostVisibility

581
LoginWindow.java Normal file
View File

@ -0,0 +1,581 @@
import javax.swing.JFrame;
import javax.swing.JPanel;
import javax.swing.JButton;
import javax.swing.JLabel;
import javax.swing.JTextField;
import javax.swing.JPasswordField;
import javax.swing.JTextArea;
import javax.swing.JCheckBox;
import javax.swing.JOptionPane;
import javax.swing.BorderFactory;
import javax.swing.border.Border;
import java.awt.BorderLayout;
import java.awt.FlowLayout;
import java.awt.Dimension;
import java.awt.Font;
import java.awt.Cursor;
import java.awt.Desktop;
import java.awt.event.ActionListener;
import java.awt.event.ActionEvent;
import java.awt.event.KeyEvent;
import cafe.biskuteri.hinoki.Tree;
import java.net.URI;
import java.io.IOException;
class
LoginWindow extends JFrame {
private JKomasto
primaire;
private MastodonApi
api;
// - -%- -
private LoginComponent
display;
private boolean
serverContacted = false,
haveAppCredentials = false,
haveAccessToken = false,
haveAccountDetails = false;
// ---%-@-%---
public void
updateStatusDisplay()
{
char prefix1 = serverContacted ? '✓' : '✕';
char prefix2 = haveAppCredentials ? '✓' : '✕';
char prefix3 = haveAccessToken ? '✓' : '✕';
char prefix4 = haveAccountDetails ? '✓' : '✕';
StringBuilder b = new StringBuilder();
b.append(prefix1 + " Have connection to server\n");
b.append(prefix2 + " Have app credentials\n");
b.append(prefix3 + " Have access token\n");
b.append(prefix4 + " Have account details\n");
display.setText(b.toString());
display.paintImmediately(display.getBounds(null));
}
public void
useCache()
{
display.setCursor(new Cursor(Cursor.WAIT_CURSOR));
try
{
api.loadCache();
haveAppCredentials = true;
haveAccessToken = true;
display.setInstanceUrl(api.getInstanceUrl());
updateStatusDisplay();
}
catch (IOException eIo)
{
JOptionPane.showMessageDialog(
this,
"We couldn't get login details from the cache.."
+ "\n" + eIo.getClass() + ": " + eIo.getMessage()
);
display.setAutoLoginToggled(false);
}
display.setCursor(null);
if (!haveAccessToken) return;
display.setCursor(new Cursor(Cursor.WAIT_CURSOR));
api.getAccountDetails(new RequestListener() {
public void
connectionFailed(IOException eIo)
{
JOptionPane.showMessageDialog(
LoginWindow.this,
"Tried to get account details, failed.."
+ "\n" + eIo.getMessage()
);
}
public void
requestFailed(int httpCode, Tree<String> json)
{
JOptionPane.showMessageDialog(
LoginWindow.this,
"Tried to get account details, failed.."
+ "\n" + json.get("error").value
+ "\n(HTTP error code: " + httpCode + ")"
);
}
public void
requestSucceeded(Tree<String> json)
{
api.setAccountDetails(json);
haveAccountDetails = true;
updateStatusDisplay();
}
});
display.setCursor(null);
if (!haveAccountDetails) return;
display.setCursor(new Cursor(Cursor.WAIT_CURSOR));
try
{
api.saveToCache();
}
catch (IOException eIo)
{
JOptionPane.showMessageDialog(
this,
"We couldn't save login details into the cache.."
+ "\n" + eIo.getClass() + ": " + eIo.getMessage()
);
}
display.setCursor(null);
primaire.finishedLogin();
}
public void
useInstanceUrl()
{
String url = display.getInstanceUrl();
if (!hasProtocol(url)) {
url = "https://" + url;
display.setInstanceUrl(url);
display.paintImmediately(display.getBounds(null));
}
serverContacted = false;
haveAppCredentials = false;
haveAccessToken = false;
haveAccountDetails = false;
if (display.isAutoLoginToggled()) { useCache(); return; }
display.setCursor(new Cursor(Cursor.WAIT_CURSOR));
api.testUrlConnection(url, new RequestListener() {
public void
connectionFailed(IOException eIo)
{
JOptionPane.showMessageDialog(
LoginWindow.this,
"Tried to connect to URL, failed.."
+ "\n" + eIo.getClass() + ": " + eIo.getMessage()
);
}
public void
requestFailed(int httpCode, Tree<String> response)
{
JOptionPane.showMessageDialog(
LoginWindow.this,
"Tried to connect to URL, failed.."
+ "\n" + response.get("body").value
);
}
public void
requestSucceeded(Tree<String> response)
{
serverContacted = true;
updateStatusDisplay();
}
});
display.setCursor(null);
if (!serverContacted) return;
api.setInstanceUrl(url);
display.setCursor(new Cursor(Cursor.WAIT_CURSOR));
api.getAppCredentials(new RequestListener() {
public void
connectionFailed(IOException eIo)
{
JOptionPane.showMessageDialog(
LoginWindow.this,
"Tried to get app credentials, failed.."
+ "\n" + eIo.getMessage()
);
}
public void
requestFailed(int httpCode, Tree<String> json)
{
JOptionPane.showMessageDialog(
LoginWindow.this,
"Tried to get app credentials, failed.."
+ "\n" + json.get("error").value
+ "\n(HTTP error code: " + httpCode + ")"
);
}
public void
requestSucceeded(Tree<String> json)
{
api.setAppCredentials(json);
haveAppCredentials = true;
updateStatusDisplay();
}
});
display.setCursor(null);
if (!haveAppCredentials) return;
display.setCursor(new Cursor(Cursor.WAIT_CURSOR));
URI uri = api.getAuthorisationURL();
final String MESSAGE1 =
"We will need you to login through a web browser,\n"
+ "and they will give you an authorisation code\n"
+ "that you will paste here. Sorry..!";
boolean supported =
Desktop.isDesktopSupported()
&& Desktop.getDesktop().isSupported(Desktop.Action.BROWSE);
if (supported)
{
JOptionPane.showMessageDialog(
LoginWindow.this,
MESSAGE1,
"Authorisation code renewal",
JOptionPane.INFORMATION_MESSAGE
);
try { Desktop.getDesktop().browse(uri); }
catch (IOException eIo) { supported = false; }
}
if (!supported)
{
final String MESSAGE2 =
"\nWe cannot use Desktop.browse(URI) on your\n"
+ "computer.. You'll have to open your web\n"
+ "browser yourself, and copy this URL in.";
JTextField field = new JTextField();
field.setText(uri.toString());
field.setPreferredSize(new Dimension(120, 32));
field.selectAll();
JOptionPane.showMessageDialog(
LoginWindow.this,
new Object[] { MESSAGE1, MESSAGE2, field },
"Authorisation code renewal",
JOptionPane.INFORMATION_MESSAGE
);
}
display.receiveAuthorisationCode();
display.setCursor(null);
}
public void
useAuthorisationCode()
{
String code = display.getAuthorisationCode();
display.setCursor(new Cursor(Cursor.WAIT_CURSOR));
api.getAccessToken(code, new RequestListener() {
public void
connectionFailed(IOException eIo)
{
JOptionPane.showMessageDialog(
LoginWindow.this,
"Tried to get app token, failed.."
+ "\n" + eIo.getMessage()
);
}
public void
requestFailed(int httpCode, Tree<String> json)
{
JOptionPane.showMessageDialog(
LoginWindow.this,
"Tried to get app token, failed.."
+ "\n" + json.get("error").value
+ "\n(HTTP error code: " + httpCode + ")"
);
}
public void
requestSucceeded(Tree<String> json)
{
api.setAccessToken(json);
haveAccessToken = true;
updateStatusDisplay();
}
});
display.setCursor(null);
if (!haveAccessToken) return;
display.setCursor(new Cursor(Cursor.WAIT_CURSOR));
api.getAccountDetails(new RequestListener() {
public void
connectionFailed(IOException eIo)
{
JOptionPane.showMessageDialog(
LoginWindow.this,
"Tried to get account details, failed.."
+ "\n" + eIo.getMessage()
);
}
public void
requestFailed(int httpCode, Tree<String> json)
{
JOptionPane.showMessageDialog(
LoginWindow.this,
"Tried to get account details, failed.."
+ "\n" + json.get("error").value
+ "\n(HTTP error code: " + httpCode + ")"
);
}
public void
requestSucceeded(Tree<String> json)
{
api.setAccountDetails(json);
haveAccountDetails = true;
updateStatusDisplay();
}
});
display.setCursor(null);
if (!haveAccountDetails) return;
display.setCursor(new Cursor(Cursor.WAIT_CURSOR));
try
{
api.saveToCache();
}
catch (IOException eIo)
{
JOptionPane.showMessageDialog(
this,
"We couldn't save login details into the cache.."
+ "\n" + eIo.getClass() + ": " + eIo.getMessage()
);
}
display.setCursor(null);
primaire.finishedLogin();
}
// - -%- -
public static boolean
hasProtocol(String url) { return url.matches("^.+://.*"); }
// ---%-@-%---
LoginWindow(JKomasto primaire)
{
super("JKomasto - Login");
this.primaire = primaire;
this.api = primaire.getMastodonApi();
setLocationByPlatform(true);
setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE);
display = new LoginComponent(this);
updateStatusDisplay();
display.setPreferredSize(new Dimension(320, 280));
setContentPane(display);
pack();
}
}
class
LoginComponent extends JPanel
implements ActionListener {
private LoginWindow
primaire;
// - -%- -
private JTextArea
statusDisplay;
private JTextField
instanceUrlField,
authorisationCodeField;
private JLabel
instanceUrlLabel,
authorisationCodeLabel;
private JButton
instanceUrlButton,
authorisationCodeButton;
private JPanel
labelArea,
forField,
accountsPanel;
private JCheckBox
autoLoginToggle;
// ---%-@-%---
public String
getInstanceUrl()
{
return instanceUrlField.getText();
}
public void
setInstanceUrl(String url)
{
instanceUrlField.setText(url);
}
public String
getAuthorisationCode()
{
return authorisationCodeField.getText();
}
public void
setText(String status)
{
statusDisplay.setText(status);
}
public void
setAutoLoginToggled(boolean a) { autoLoginToggle.setSelected(a); }
public boolean
isAutoLoginToggled() { return autoLoginToggle.isSelected(); }
public void
actionPerformed(ActionEvent eA)
{
if (eA.getSource() == instanceUrlButton)
primaire.useInstanceUrl();
if (eA.getSource() == authorisationCodeButton)
primaire.useAuthorisationCode();
}
public void
receiveInstanceUrl()
{
labelArea.remove(authorisationCodeLabel);
forField.remove(authorisationCodeButton);
accountsPanel.remove(authorisationCodeField);
labelArea.add(instanceUrlLabel, BorderLayout.NORTH);
forField.add(instanceUrlButton, BorderLayout.EAST);
accountsPanel.add(instanceUrlField);
revalidate();
}
public void
receiveAuthorisationCode()
{
labelArea.remove(instanceUrlLabel);
forField.remove(instanceUrlButton);
accountsPanel.remove(instanceUrlField);
labelArea.add(authorisationCodeLabel, BorderLayout.NORTH);
forField.add(authorisationCodeButton, BorderLayout.EAST);
accountsPanel.add(authorisationCodeField);
revalidate();
}
// ---%-@-%---
LoginComponent(LoginWindow primaire)
{
this.primaire = primaire;
Border b1, b2, bi, bo, be;
b1 = BorderFactory.createEtchedBorder();
b2 = BorderFactory.createEmptyBorder(8, 8, 8, 8);
bi = BorderFactory.createCompoundBorder(b1, b2);
bo = BorderFactory.createEmptyBorder(8, 8, 8, 8);
be = BorderFactory.createEmptyBorder();
Font f1, f2;
f1 = new Font("Dialog", Font.PLAIN, 12);
f2 = new Font("Dialog", Font.PLAIN, 16);
// We can't use swing.Box for layout,
// it's malfunctioning for some reason.
instanceUrlField = new JTextField();
instanceUrlField.setFont(f1);
String s1 = "Enter an instance URL!";
instanceUrlLabel = new JLabel(s1);
instanceUrlLabel.setLabelFor(instanceUrlField);
instanceUrlLabel.setFont(f1);
instanceUrlButton = new JButton("Connect");
instanceUrlButton.setFont(f1);
instanceUrlButton.setMnemonic(KeyEvent.VK_C);
instanceUrlButton.addActionListener(this);
authorisationCodeField = new JPasswordField();
authorisationCodeField.setFont(f1);
String s2 = "Paste the authorisation code!";
authorisationCodeLabel = new JLabel(s2);
authorisationCodeLabel.setLabelFor(authorisationCodeField);
authorisationCodeLabel.setFont(f1);
authorisationCodeButton = new JButton("Login");
authorisationCodeButton.setFont(f1);
authorisationCodeButton.setMnemonic(KeyEvent.VK_L);
authorisationCodeButton.addActionListener(this);
labelArea = new JPanel();
labelArea.setLayout(new BorderLayout());
forField = new JPanel();
forField.setOpaque(false);
forField.setLayout(new BorderLayout());
forField.add(labelArea, BorderLayout.WEST);
String s3 = "Automatically login if possible";
autoLoginToggle = new JCheckBox(s3);
autoLoginToggle.setMnemonic(KeyEvent.VK_A);
autoLoginToggle.setBorder(be);
autoLoginToggle.setFont(f1);
JPanel optionsPanel = new JPanel();
optionsPanel.setOpaque(false);
optionsPanel.setLayout(new FlowLayout(FlowLayout.LEFT));
optionsPanel.setBorder(bi);
optionsPanel.add(autoLoginToggle);
accountsPanel = new JPanel();
accountsPanel.setOpaque(false);
accountsPanel.setLayout(new BorderLayout(0, 4));
accountsPanel.add(instanceUrlField);
accountsPanel.add(forField, BorderLayout.SOUTH);
statusDisplay = new JTextArea();
statusDisplay.setEditable(false);
statusDisplay.setBackground(null);
statusDisplay.setBorder(bi);
statusDisplay.setFont(f2);
receiveInstanceUrl();
setLayout(new BorderLayout(0, 8));
add(accountsPanel, BorderLayout.NORTH);
add(optionsPanel, BorderLayout.CENTER);
add(statusDisplay, BorderLayout.SOUTH);
setBorder(bo);
}
}

455
MastodonApi.java Executable file
View File

@ -0,0 +1,455 @@
import cafe.biskuteri.hinoki.Tree;
import cafe.biskuteri.hinoki.JsonConverter;
import cafe.biskuteri.hinoki.DSVTokeniser;
import java.net.URL;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URLEncoder;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
class
MastodonApi {
private String
instanceUrl;
private Tree<String>
appCredentials,
accessToken,
accountDetails;
// - -%- -
private static final String
SCOPES = "read+write";
// ---%-@-%---
public String
getInstanceUrl() { return instanceUrl; }
public Tree<String>
getAppCredentials() { return appCredentials; }
public Tree<String>
getAccessToken() { return accessToken; }
public Tree<String>
getAccountDetails() { return accountDetails; }
public void
setInstanceUrl(String a) { instanceUrl = a; }
public void
setAppCredentials(Tree<String> a) { appCredentials = a; }
public void
setAccessToken(Tree<String> a) { accessToken = a; }
public void
setAccountDetails(Tree<String> a) { accountDetails = a; }
public void
testUrlConnection(String url, RequestListener handler)
{
try
{
URL endpoint = new URL(url);
HttpURLConnection conn;
conn = (HttpURLConnection)endpoint.openConnection();
conn.connect();
returnResponseInTree(conn, handler);
}
catch (IOException eIo) { handler.connectionFailed(eIo); }
}
public void
getAppCredentials(RequestListener handler)
{
assert instanceUrl != null;
try {
URL endpoint = new URL(instanceUrl + "/api/v1/apps");
HttpURLConnection conn;
conn = (HttpURLConnection)endpoint.openConnection();
conn.setRequestMethod("POST");
conn.setDoOutput(true);
conn.connect();
OutputStreamWriter output;
output = new OutputStreamWriter(conn.getOutputStream());
output.write("client_name=JKomasto alpha");
output.write("&redirect_uris=urn:ietf:wg:oauth:2.0:oob");
output.write("&scopes=" + SCOPES);
output.close();
doStandardJsonReturn(conn, handler);
}
catch (IOException eIo) { handler.connectionFailed(eIo); }
}
public URI
getAuthorisationURL()
{
assert instanceUrl != null;
assert appCredentials != null;
String clientId = appCredentials.get("client_id").value;
try
{
StringBuilder b = new StringBuilder();
b.append(instanceUrl);
b.append("/oauth/authorize");
// Be careful of the spelling!!
b.append("?response_type=code");
b.append("&redirect_uri=urn:ietf:wg:oauth:2.0:oob");
b.append("&scope=" + SCOPES);
b.append("&client_id=" + clientId);
return new URI(b.toString());
}
catch (URISyntaxException eUs) { assert false; return null; }
}
public void
getAccessToken(String authorisationCode, RequestListener handler)
{
assert instanceUrl != null;
assert appCredentials != null;
String id = appCredentials.get("client_id").value;
String secret = appCredentials.get("client_secret").value;
try
{
URL endpoint = new URL(instanceUrl + "/oauth/token");
HttpURLConnection conn;
conn = (HttpURLConnection)endpoint.openConnection();
conn.setRequestMethod("POST");
conn.setDoOutput(true);
conn.connect();
OutputStreamWriter output;
output = new OutputStreamWriter(conn.getOutputStream());
output.write("client_id=" + id);
output.write("&client_secret=" + secret);
output.write("&redirect_uri=urn:ietf:wg:oauth:2.0:oob");
output.write("&grant_type=authorization_code");
output.write("&scope=" + SCOPES);
output.write("&code=" + authorisationCode);
output.close();
doStandardJsonReturn(conn, handler);
}
catch (IOException eIo) { handler.connectionFailed(eIo); }
}
public void
getAccountDetails(RequestListener handler)
{
assert accessToken != null;
String token = accessToken.get("access_token").value;
try
{
String s = "/api/v1/accounts/verify_credentials";
URL endpoint = new URL(instanceUrl + s);
HttpURLConnection conn;
conn = (HttpURLConnection)endpoint.openConnection();
conn.setRequestProperty("Authorization", "Bearer " + token);
conn.connect();
doStandardJsonReturn(conn, handler);
}
catch (IOException eIo) { handler.connectionFailed(eIo); }
}
public void
getTimelinePage(
TimelineType type, int count, String maxId, String minId,
RequestListener handler)
{
String token = accessToken.get("access_token").value;
String url = instanceUrl + "/api/v1";
switch (type)
{
case FEDERATED:
case LOCAL: url += "/timelines/public"; break;
case HOME: url += "/timelines/home"; break;
case NOTIFICATIONS:
url += "/notifications";
// Note that this endpoint returns Notifications,
// not Statuses. But we uniformly return Tree<String>,
// we expect the caller can handle it.
break;
case CONVERSATIONS: url += "/timelines/public"; break;
default: assert false;
}
url += "?limit=" + count;
if (maxId != null) url += "&max_id=" + maxId;
if (minId != null) url += "&min_id=" + minId;
// This is a GET endpoint, it rejects receiving
// query params through the body.
try
{
URL endpoint = new URL(url);
HttpURLConnection conn;
conn = (HttpURLConnection)endpoint.openConnection();
String s2 = "Bearer " + token;
conn.setRequestProperty("Authorization", s2);
conn.connect();
doStandardJsonReturn(conn, handler);
}
catch (IOException eIo) { handler.connectionFailed(eIo); }
}
public void
setPostFavourited(
String postId, boolean favourited,
RequestListener handler)
{
String token = accessToken.get("access_token").value;
String s1 = "/api/v1/statuses/" + postId;
String s2 = favourited ? "/favourite" : "/unfavourite";
String url = instanceUrl + s1 + s2;
try
{
URL endpoint = new URL(url);
HttpURLConnection conn;
conn = (HttpURLConnection)endpoint.openConnection();
String s3 = "Bearer " + token;
conn.setRequestProperty("Authorization", s3);
conn.setRequestMethod("POST");
conn.connect();
doStandardJsonReturn(conn, handler);
}
catch (IOException eIo) { handler.connectionFailed(eIo); }
}
public void
setPostBoosted(
String postId, boolean boosted,
RequestListener handler)
{
String token = accessToken.get("access_token").value;
String s1 = "/api/v1/statuses/" + postId;
String s2 = boosted ? "/reblog" : "/unreblog";
String url = instanceUrl + s1 + s2;
try
{
URL endpoint = new URL(url);
HttpURLConnection conn;
conn = (HttpURLConnection)endpoint.openConnection();
String s3 = "Bearer " + token;
conn.setRequestProperty("Authorization", s3);
conn.setRequestMethod("POST");
conn.connect();
doStandardJsonReturn(conn, handler);
}
catch (IOException eIo) { handler.connectionFailed(eIo); }
}
public void
submit(
String text, PostVisibility visibility, String replyTo,
RequestListener handler)
{
String token = accessToken.get("access_token").value;
String visibilityParam = "direct";
switch (visibility) {
case PUBLIC: visibilityParam = "public"; break;
case UNLISTED: visibilityParam = "unlisted"; break;
case FOLLOWERS: visibilityParam = "private"; break;
case MENTIONED: visibilityParam = "direct"; break;
default: assert false;
}
String url = instanceUrl + "/api/v1/statuses";
try
{
text = URLEncoder.encode(text, "UTF-8");
URL endpoint = new URL(url);
HttpURLConnection conn;
conn = (HttpURLConnection)endpoint.openConnection();
String s1 = "Bearer " + token;
conn.setRequestProperty("Authorization", s1);
String s2 = Integer.toString(text.hashCode());
conn.setRequestProperty("Idempotency-Key", s2);
conn.setDoOutput(true);
conn.setRequestMethod("POST");
conn.connect();
OutputStreamWriter output;
output = new OutputStreamWriter(conn.getOutputStream());
output.write("status=" + text);
output.write("&visibility=" + visibilityParam);
if (replyTo != null) {
output.write("&in_reply_to_id=" + replyTo);
}
output.close();
doStandardJsonReturn(conn, handler);
}
catch (IOException eIo) { handler.connectionFailed(eIo); }
}
// - -%- -
private void
doStandardJsonReturn(HttpURLConnection conn, RequestListener handler)
throws IOException
{
InputStreamReader input;
int code = conn.getResponseCode();
if (code >= 300)
{
input = new InputStreamReader(conn.getErrorStream());
Tree<String> response = JsonConverter.convert(input);
input.close();
handler.requestFailed(code, response);
return;
}
input = new InputStreamReader(conn.getInputStream());
Tree<String> response = JsonConverter.convert(input);
input.close();
handler.requestSucceeded(response);
}
private void
returnResponseInTree(HttpURLConnection conn, RequestListener handler)
throws IOException
{
InputStreamReader input;
int code = conn.getResponseCode();
if (code >= 300)
{
input = new InputStreamReader(conn.getErrorStream());
Tree<String> response = fromPlain(input);
input.close();
handler.requestFailed(code, response);
return;
}
input = new InputStreamReader(conn.getInputStream());
Tree<String> response = fromPlain(input);
input.close();
handler.requestSucceeded(response);
}
// - -%- -
private static Tree<String>
fromPlain(InputStreamReader r)
throws IOException
{
StringBuilder b = new StringBuilder();
int c; while ((c = r.read()) != -1) b.append((char)c);
Tree<String> leaf = new Tree<String>();
leaf.key = "body";
leaf.value = b.toString();
Tree<String> doc = new Tree<String>();
doc.add(leaf);
return doc;
}
// ---%-@-%---
public void
loadCache()
throws IOException
{
FileReader r = new FileReader(getCachePath());
DSVTokeniser.Options o = new DSVTokeniser.Options();
Tree<String> row1 = DSVTokeniser.tokenise(r, o);
Tree<String> row2 = DSVTokeniser.tokenise(r, o);
Tree<String> row3 = DSVTokeniser.tokenise(r, o);
assert !row1.get(0).value.equals(o.endOfStreamValue);
assert !row2.get(0).value.equals(o.endOfStreamValue);
assert !row3.get(0).value.equals(o.endOfStreamValue);
r.close();
// Prepare to bark like mad.
boolean yes10 = !row1.get(0).value.equals(o.endOfStreamValue);
boolean yes20 = !row2.get(0).value.equals(o.endOfStreamValue);
boolean yes30 = !row3.get(0).value.equals(o.endOfStreamValue);
boolean yes11 = row1.size() == 1;
boolean yes21 = row2.size() == 2;
boolean yes31 = row3.size() == 1;
boolean all = yes10 & yes20 & yes30 & yes11 & yes21 & yes31;
if (!all) {
throw new IOException("Cache has invalid format!");
}
setInstanceUrl(row1.get(0).value);
appCredentials = new Tree<String>();
appCredentials.add(new Tree<String>());
appCredentials.add(new Tree<String>());
appCredentials.get(0).key = "client_id";
appCredentials.get(0).value = row2.get(0).value;
appCredentials.get(1).key = "client_secret";
appCredentials.get(1).value = row2.get(1).value;
accessToken = new Tree<String>();
accessToken.add(new Tree<String>());
accessToken.get(0).key = "access_token";
accessToken.get(0).value = row3.get(0).value;
}
public void
saveToCache()
throws IOException
{
String f10 = instanceUrl;
String f20 = appCredentials.get("client_id").value;
String f21 = appCredentials.get("client_secret").value;
String f30 = accessToken.get("access_token").value;
f10 = f10.replaceAll(":", "\\\\:") + "\n";
f20 = f20.replaceAll(":", "\\\\:") + ":";
f21 = f21.replaceAll(":", "\\\\:") + "\n";
f30 = f30.replaceAll(":", "\\\\:") + "\n";
FileWriter w = new FileWriter(getCachePath());
w.write(f10);
w.write(f20);
w.write(f21);
w.write(f30);
w.close();
}
// - -%- -
private static String
getCachePath()
{
String userHome = System.getProperty("user.home");
String osName = System.getProperty("os.name");
boolean isWindows = osName.contains("Windows");
boolean isUnix = !isWindows;
// We assume. If you're running JKomasto in classic Mac OS
// for some reason, you should probably edit the code..
String configDir = isWindows ? "AppData/Local" : ".config";
String filename = "jkomasto.cache.dsv";
String path = userHome + "/" + configDir + "/" + filename;
return path;
}
}

View File

@ -7,21 +7,38 @@ import javax.swing.JMenuItem;
import javax.swing.JButton;
import javax.swing.JLabel;
import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.BorderFactory;
import javax.swing.border.Border;
import javax.swing.JOptionPane;
import java.awt.Graphics;
import java.awt.Font;
import java.awt.FontMetrics;
import java.awt.Shape;
import java.awt.Dimension;
import java.awt.BorderLayout;
import java.awt.GridLayout;
import java.awt.Cursor;
import java.awt.event.ActionListener;
import java.awt.event.ActionEvent;
import java.util.List;
import java.util.ArrayList;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.io.IOException;
import cafe.biskuteri.hinoki.Tree;
class
PostWindow extends JFrame
implements ActionListener {
private JKomasto
primaire;
private MastodonApi
api;
private Post
post;
@ -33,24 +50,18 @@ implements ActionListener {
private RepliesComponent
repliesDisplay;
// - -%- -
private static final DateTimeFormatter
DATE_FORMAT = DateTimeFormatter.ofPattern("d LLLL ''uu"),
TIME_FORMAT = DateTimeFormatter.ofPattern("HH:mm");
// ---%-@-%---
public void
setPost(Post post)
showPost(Post post)
{
if (post == null)
{
post = new Post();
post.text = "This is a sample post.";
post.authorId = "snowyfox@biskuteri.cafe";
post.authorName = "snowyfox";
post.date = "0712hrs, 17 July";
post.visibility = PostVisibility.MENTIONED;
post.postId = "000000000";
post.boosted = false;
post.favourited = true;
}
assert post != null;
this.post = post;
List<RepliesComponent.Reply> replies = null;
@ -77,10 +88,15 @@ implements ActionListener {
replies.add(reply3);
}
postDisplay.setAuthor(post.authorName);
postDisplay.setTime(post.date);
postDisplay.setAuthorName(post.authorName);
postDisplay.setAuthorId(post.authorId);
postDisplay.setDate(DATE_FORMAT.format(post.date));
postDisplay.setTime(TIME_FORMAT.format(post.date));
postDisplay.setText(post.text);
postDisplay.setFavourited(post.favourited);
postDisplay.setBoosted(post.boosted);
repliesDisplay.setReplies(replies);
repaint();
}
public void
@ -90,21 +106,98 @@ implements ActionListener {
}
public void
favourite()
favourite(boolean favourited)
{
postDisplay.setCursor(new Cursor(Cursor.WAIT_CURSOR));
postDisplay.setFavouriteBoostEnabled(false);
postDisplay.paintImmediately(postDisplay.getBounds());
RequestListener handler = new RequestListener() {
public void
connectionFailed(IOException eIo)
{
JOptionPane.showMessageDialog(
PostWindow.this,
"Tried to favourite post, failed.."
+ "\n" + eIo.getClass() + ": " + eIo.getMessage()
);
}
public void
requestFailed(int httpCode, Tree<String> json)
{
JOptionPane.showMessageDialog(
PostWindow.this,
"Tried to favourite post, failed.."
+ "\n" + json.get("error").value
+ "\n(HTTP error code: " + httpCode + ")"
);
}
public void
requestSucceeded(Tree<String> json)
{
post.favourited = favourited;
}
};
api.setPostFavourited(post.postId, favourited, handler);
postDisplay.setFavouriteBoostEnabled(true);
postDisplay.setCursor(null);
}
public void
boost()
boost(boolean boosted)
{
postDisplay.setCursor(new Cursor(Cursor.WAIT_CURSOR));
postDisplay.setFavouriteBoostEnabled(false);
postDisplay.paintImmediately(postDisplay.getBounds());
RequestListener handler = new RequestListener() {
public void
connectionFailed(IOException eIo)
{
JOptionPane.showMessageDialog(
PostWindow.this,
"Tried to boost post, failed.."
+ "\n" + eIo.getClass() + ": " + eIo.getMessage()
);
}
public void
requestFailed(int httpCode, Tree<String> json)
{
JOptionPane.showMessageDialog(
PostWindow.this,
"Tried to boost post, failed.."
+ "\n" + json.get("error").value
+ "\n(HTTP error code: " + httpCode + ")"
);
}
public void
requestSucceeded(Tree<String> json)
{
post.boosted = boosted;
}
};
api.setPostBoosted(post.postId, boosted, handler);
postDisplay.setFavouriteBoostEnabled(true);
postDisplay.setCursor(null);
}
public void
reply()
{
ComposeWindow w = primaire.getComposeWindow();
w.setLocation(getX(), getY() + 100);
w.setVisible(true);
Composition c = new Composition();
c.text = "@" + post.authorId + " ";
c.visibility = PostVisibility.PUBLIC;
c.replyToPostId = post.postId;
w.setComposition(c);
}
public void
@ -142,8 +235,11 @@ implements ActionListener {
// ---%-@-%---
PostWindow()
PostWindow(JKomasto primaire)
{
this.primaire = primaire;
this.api = primaire.getMastodonApi();
getContentPane().setPreferredSize(new Dimension(360, 270));
pack();
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
@ -153,30 +249,20 @@ implements ActionListener {
repliesDisplay = new RepliesComponent();
setPost(null);
JMenu postMenu = new JMenu("Post");
addToMenu(postMenu, "Favourite");
addToMenu(postMenu, "Reply");
JMenu displayMenu = new JMenu("Display");
addToMenu(displayMenu, "Post");
addToMenu(displayMenu, "Replies");
JMenuBar menuBar = new JMenuBar();
menuBar.add(postMenu);
menuBar.add(displayMenu);
//setJMenuBar(menuBar);
Post samplePost = new Post();
samplePost.text = "This is a sample post.";
samplePost.authorId = "snowyfox@biskuteri.cafe";
samplePost.authorName = "snowyfox";
samplePost.date = ZonedDateTime.now();
samplePost.visibility = PostVisibility.MENTIONED;
samplePost.postId = "000000000";
samplePost.boosted = false;
samplePost.favourited = true;
showPost(samplePost);
setContentPane(postDisplay);
}
private void
addToMenu(JMenu menu, String text)
{
JMenuItem item = new JMenuItem(text);
item.addActionListener(this);
menu.add(item);
}
}
@ -189,24 +275,40 @@ implements ActionListener {
primaire;
private String
author, time, text;
authorName, authorId, date, time, text;
// - -%- -
private JButton
profile,
private TwoToggleButton
favouriteBoost,
replyMisc,
nextPrev,
nextPrev;
private JButton
profile,
media;
// ---%-@-%---
public void
setAuthor(String author)
setAuthorName(String authorName)
{
assert author != null;
this.author = author;
assert authorName != null;
this.authorName = authorName;
}
public void
setAuthorId(String authorId)
{
assert authorId != null;
this.authorId = authorId;
}
public void
setDate(String date)
{
assert date != null;
this.date = date;
}
public void
@ -223,124 +325,206 @@ implements ActionListener {
this.text = text;
}
public void
setFavourited(boolean a)
{
favouriteBoost.removeActionListener(this);
favouriteBoost.setPrimaryToggled(a);
favouriteBoost.addActionListener(this);
}
public void
setBoosted(boolean a)
{
favouriteBoost.removeActionListener(this);
favouriteBoost.setSecondaryToggled(a);
favouriteBoost.addActionListener(this);
}
public void
setFavouriteBoostEnabled(boolean a)
{
favouriteBoost.setEnabled(a);
}
// - -%- -
public void
actionPerformed(ActionEvent eA)
{
Object src = eA.getSource();
/*
* Umm, ActionEvent is the wrong thing to listen for here.
* We want mouse presses. The custom button would publish
* events that mention which button it was.
*
* Default JButton ignores RMB.
*/
String command = eA.getActionCommand();
if (src == profile)
{
primaire.openAuthorProfile();
return;
}
else if (src == favouriteBoost)
{
// Fancy!!
}
else if (src == replyMisc)
{
}
else if (src == nextPrev)
if (src == favouriteBoost)
{
if (command.equals("favouriteOn"))
primaire.favourite(true);
if (command.equals("favouriteOff"))
primaire.favourite(false);
if (command.equals("boostOn"))
primaire.boost(true);
if (command.equals("boostOff"))
primaire.boost(false);
return;
}
else if (src == media)
if (src == replyMisc)
{
if (command.startsWith("reply")) primaire.reply();
return;
}
if (src == nextPrev)
{
if (command.equals("next"))
{
}
else
{
}
return;
}
if (src == media)
{
primaire.openMedia();
return;
}
}
protected void
paintComponent(Graphics g)
{
int lineHeight = 20;
g.clearRect(0, 0, getWidth(), getHeight());
FontMetrics met = g.getFontMetrics();
Font f1 = new Font("IPAGothic", Font.PLAIN, 16);
Font f2 = new Font("IPAGothic", Font.PLAIN, 14);
FontMetrics fm1 = g.getFontMetrics(f1);
FontMetrics fm2 = g.getFontMetrics(f2);
int x1 = 56;
int x2 = getWidth() - met.stringWidth(time);
int y1 = lineHeight;
int x1 = 60;
int x4 = getWidth() - 10;
int x2 = x4 - fm2.stringWidth(date);
int x3 = x4 - fm1.stringWidth(time);
int y1 = 10;
int y2 = y1 + fm2.getHeight();
int y3 = y2 + fm1.getHeight();
int y4 = y3 + 8;
g.drawString(author, x1, y1);
g.drawString(time, x2, y1);
Shape defaultClip = g.getClip();
g.setClip(x1, y1, Math.min(x2, x3) - 8 - x1, y4 - y1);
// First time I've used this method..
// Cause, clearRect is not working.
g.setFont(f2);
g.drawString(authorId, x1, y2);
g.setFont(f1);
g.drawString(authorName, x1, y3);
g.setClip(defaultClip);
int y = y1;
for (String line: split(text, 48)) {
y += lineHeight;
g.setFont(f2);
g.drawString(date, x2, y2);
g.setFont(f1);
g.drawString(time, x3, y3);
int y = y4;
for (String line: split(text, 40)) {
y += fm1.getHeight();
g.drawString(line, x1, y);
}
}
private List<String>
// - -%- -
private static List<String>
split(String string, int lineLength)
{
List<String> returnee = new ArrayList<>();
int start, max = string.length();
for (start = 0; start < max; start += lineLength) {
returnee.add(string.substring(
start,
Math.min(max, start + lineLength)
));
StringBuilder line = new StringBuilder();
for (String word: string.split(" "))
{
if (word.length() >= lineLength) {
word = word.substring(0, lineLength - 4) + "...";
}
if (word.equals("\n")) {
returnee.add(empty(line));
continue;
}
if (line.length() + word.length() > lineLength) {
returnee.add(empty(line));
}
line.append(word);
line.append(" ");
}
returnee.add(empty(line));
return returnee;
}
private static String
empty(StringBuilder b)
{
String s = b.toString();
b.delete(0, b.length());
return s;
}
// ---%-@-%---
PostComponent(PostWindow primaire)
{
this.primaire = primaire;
author = time = text = "";
authorName = authorId = time = text = "";
Dimension buttonSize = new Dimension(20, 40);
profile = new JButton("P");
profile.setSize(40, 40);
profile.setPreferredSize(buttonSize);
profile.setMargin(null);
profile.addActionListener(this);
favouriteBoost = new JButton("☆/B");
favouriteBoost.setSize(40, 40);
favouriteBoost = new TwoToggleButton("favourite", "boost");
favouriteBoost.addActionListener(this);
// We have to overload the buttons to accept RMBs.
// Which perform the secondary functionality.
replyMisc = new JButton("R");
replyMisc.setSize(40, 40);
replyMisc = new TwoToggleButton("reply", "misc");
replyMisc.addActionListener(this);
nextPrev = new JButton("↓/↑");
nextPrev.setSize(40, 40);
nextPrev = new TwoToggleButton("next", "prev");
nextPrev.addActionListener(this);
media = new JButton("M");
media.setSize(40, 40);
media = new JButton("media");
media.setPreferredSize(buttonSize);
media.setMargin(null);
media.addActionListener(this);
setLayout(null);
profile.setLocation(8, 8); add(profile);
favouriteBoost.setLocation(8, 8+48); add(favouriteBoost);
replyMisc.setLocation(8, 8+48+48); add(replyMisc);
nextPrev.setLocation(8, 8+48+48+48); add(nextPrev);
media.setLocation(8, 8+48+48+48+48); add(media);
Box ibuttons = Box.createVerticalBox();
ibuttons.setOpaque(false);
ibuttons.add(profile);
ibuttons.add(Box.createVerticalStrut(8));
ibuttons.add(favouriteBoost);
ibuttons.add(Box.createVerticalStrut(8));
ibuttons.add(replyMisc);
ibuttons.add(Box.createVerticalStrut(8));
ibuttons.add(nextPrev);
ibuttons.add(Box.createVerticalStrut(8));
ibuttons.add(media);
ibuttons.setMaximumSize(ibuttons.getPreferredSize());
Box buttons = Box.createVerticalBox();
buttons.setOpaque(false);
buttons.add(ibuttons);
buttons.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
/*
* Also, since it's a linear buttons now, we can just use
* a Box or FlowLayout. Though we have to have a JPanel
* for the left, and this text component on the right.
* If we are custom rendering the text then we can stay
* this way, with manual positioning.
*/
setLayout(new BorderLayout());
add(buttons, BorderLayout.WEST);
setFont(getFont().deriveFont(14f));
}
@ -398,7 +582,7 @@ RepliesComponent extends JPanel {
ReplyPreviewComponent preview = previews[o];
Reply reply = replies.get(o);
preview.setAuthor(reply.author);
preview.setAuthorName(reply.author);
preview.setText(reply.text);
preview.setVisible(true);
}
@ -492,7 +676,7 @@ ReplyPreviewComponent extends JButton {
}
public void
setAuthor(String author)
setAuthorName(String author)
{
assert author != null;
this.author = author;

17
RequestListener.java Executable file
View File

@ -0,0 +1,17 @@
import cafe.biskuteri.hinoki.Tree;
import java.io.IOException;
interface
RequestListener {
void
connectionFailed(IOException eIo);
void
requestFailed(int httpCode, Tree<String> json);
void
requestSucceeded(Tree<String> json);
}

169
RudimentaryHTMLParser.java Executable file
View File

@ -0,0 +1,169 @@
import cafe.biskuteri.hinoki.Tree;
import java.io.StringReader;
import java.io.Reader;
import java.io.IOException;
class
RudimentaryHTMLParser {
public static Tree<String>
depthlessRead(String html)
throws IOException
{
return pass2(pass1(html));
}
// - -%- -
private static Tree<String>
pass1(String html)
throws IOException
{
Reader r = new StringReader(html);
Tree<String> docu = new Tree<String>();
StringBuilder text = new StringBuilder();
StringBuilder htmlEscape = new StringBuilder();
boolean quoted = false;
int c; while ((c = r.read()) != -1)
{
if (c == '&' || htmlEscape.length() > 0)
{
htmlEscape.append((char)c);
if (c == ';')
{
String s = empty(htmlEscape);
if (quoted) text.append('\\');
/*
* If we're quoted (i.e. within unescaped
* quotes), we're in a tag, add escaping
* backslash for pass2 to work with.
* Only necessary for the quotes, but,
* might as well uniformly use.
*/
if (s.equals("&lt;")) text.append('<');
if (s.equals("&gt;")) text.append('>');
if (s.equals("&amp;")) text.append('&');
if (s.equals("&quot;")) text.append('"');
if (s.equals("&apos;")) text.append('\'');
}
continue;
}
if (c == '"')
{
text.append((char)c);
quoted = !quoted;
continue;
}
if (!quoted)
{
if (c == '<')
{
if (text.length() > 0)
{
Tree<String> node = new Tree<>();
node.key = "text";
node.value = empty(text);
docu.add(node);
}
continue;
}
if (c == '>')
{
Tree<String> node = new Tree<>();
node.key = "tag";
node.value = empty(text);
docu.add(node);
continue;
}
}
text.append((char)c);
continue;
}
if (text.length() > 0)
{
Tree<String> node = new Tree<>();
node.key = "text";
node.value = empty(text);
docu.add(node);
}
return docu;
}
private static Tree<String>
pass2(Tree<String> docu)
throws IOException
{
for (Tree<String> node: docu.children)
{
if (node.key.equals("text")) continue;
assert node.key.equals("tag");
Reader r = new StringReader(node.value);
Tree<String> part = new Tree<String>();
boolean escaped = false, quoted = false;
StringBuilder field = new StringBuilder();
int c; while ((c = r.read()) != -1)
{
if (escaped) {
field.append((char)c);
escaped = false;
continue;
}
if (c == '\\') {
escaped = true;
continue;
}
if (c == '"') {
quoted = !quoted;
continue;
}
if (quoted) {
field.append((char)c);
continue;
}
if (c == '=') {
part.key = empty(field);
continue;
}
if (c == ' ') {
if (field.length() > 0) {
boolean v = part.key != null;
if (v) part.value = empty(field);
else part.key = empty(field);
node.add(part);
part = new Tree<String>();
}
continue;
}
field.append((char)c);
}
if (field.length() > 0) {
boolean v = part.key != null;
if (v) part.value = empty(field);
else part.key = empty(field);
node.add(part);
}
node.value = null;
}
return docu;
}
private static String
empty(StringBuilder b)
{
String s = b.toString();
b.delete(0, b.length());
return s;
}
// ---%-@-%---
public static void
main(String... args)
{
final String EX2 =
"<p>2000s energy<br /><a href=\"http://www.pspad.com/en/screenshot.htm\" rel=\"nofollow noopener noreferrer\" target=\"_blank\"><span class=\"invisible\">http://www.</span><span class=\"\">pspad.com/en/screenshot.htm</span><span class=\"invisible\"></span></a></p>";
}
}

View File

@ -17,19 +17,36 @@ import java.awt.FlowLayout;
import java.awt.Font;
import java.awt.Dimension;
import java.awt.Insets;
import java.awt.Cursor;
import java.awt.Color;
import java.awt.Graphics;
import java.util.List;
import java.util.ArrayList;
import java.util.Deque;
import java.util.LinkedList;
import java.awt.event.ActionListener;
import java.awt.event.ActionEvent;
import java.awt.event.MouseListener;
import java.awt.event.MouseEvent;
import cafe.biskuteri.hinoki.Tree;
import java.io.IOException;
import java.time.ZonedDateTime;
import java.time.ZoneId;
import java.time.Period;
import java.time.temporal.ChronoUnit;
import java.time.format.DateTimeParseException;
class
TimelineWindow extends JFrame
implements ActionListener {
private Timeline
timeline;
private JKomasto
primaire;
private MastodonApi
api;
private TimelinePage
page;
// - -%- -
@ -50,69 +67,178 @@ implements ActionListener {
private JMenuItem
flipToNewestPost;
private Deque<Deque<String>>
postIDsSeen;
// - -%- -
private static final int
PREVIEW_COUNT = TimelineComponent.PREVIEW_COUNT;
// ---%-@-%---
public void
setTimeline(Timeline timeline)
setTimelineType(TimelineType type)
{
if (timeline == null)
{
timeline = new Timeline();
timeline.type = TimelineType.LOCAL;
}
page.type = type;
String s = type.toString();
s = s.charAt(0) + s.substring(1).toLowerCase();
setTitle(s + " - JKomasto");
}
assert timeline != null;
this.timeline = timeline;
public void
showLatestPage()
{
display.setCursor(new Cursor(Cursor.WAIT_CURSOR));
api.getTimelinePage(
page.type, PREVIEW_COUNT, null, null,
new RequestListener() {
// Here, we should request for posts for the timeline.
// And pass data to TimelineComponent.
public void
connectionFailed(IOException eIo)
{
String s = eIo.getClass().getName();
setTitle(s + " - JKomasto");
}
public void
requestFailed(int httpCode, Tree<String> json)
{
setTitle(httpCode + " - JKomasto");
// lol...
}
public void
requestSucceeded(Tree<String> json)
{
page.posts = toPosts(json);
display.setPosts(page.posts);
display.setNextPageAvailable(true);
}
}
);
display.setCursor(null);
}
public void
nextPage()
{
/*
* uhh, what if the ID we provide is invalid.
* Or does Mastodon keep IDs as 'deleted'? That
* would make things a lot simpler.
*/
assert page.posts != null;
assert page.posts.size() != 0;
Post last = page.posts.get(page.posts.size() - 1);
display.setCursor(new Cursor(Cursor.WAIT_CURSOR));
api.getTimelinePage(
page.type, PREVIEW_COUNT, last.postId, null,
new RequestListener() {
public void
connectionFailed(IOException eIo)
{
String s = eIo.getClass().getName();
setTitle(s + " - JKomasto");
}
public void
requestFailed(int httpCode, Tree<String> json)
{
setTitle(httpCode + " - JKomasto");
}
public void
requestSucceeded(Tree<String> json)
{
page.posts = toPosts(json);
display.setPosts(page.posts);
display.setPreviousPageAvailable(true);
}
}
);
display.setCursor(null);
}
public void
previousPage()
{
if (postIDsSeen.isEmpty())
{
// Just request the latest first page.
return;
}
assert page.posts != null;
assert page.posts.size() != 0;
Post first = page.posts.get(0);
Deque<String> lastPagePostIDs = postIDsSeen.pop();
display.setCursor(new Cursor(Cursor.WAIT_CURSOR));
api.getTimelinePage(
page.type, PREVIEW_COUNT, null, first.postId,
new RequestListener() {
while (!lastPagePostIDs.isEmpty())
{
String idToTry = lastPagePostIDs.pop();
public void
connectionFailed(IOException eIo)
{
String s = eIo.getClass().getName();
setTitle(s + " - JKomasto");
}
// Fetch page for this ID.
// If successful, render and return.
}
public void
requestFailed(int httpCode, Tree<String> json)
{
setTitle(httpCode + " - JKomasto");
}
// Didn't return from above. Our current page "replaced"
// the previous page, so the previous page's predecessor
// is our previous page. Try to flip to that.
previousPage();
public void
requestSucceeded(Tree<String> json)
{
page.posts = toPosts(json);
display.setPosts(page.posts);
display.setNextPageAvailable(true);
display.setPreviousPageAvailable(true);
}
}
);
display.setCursor(null);
if (page.posts.size() < PREVIEW_COUNT) showLatestPage();
}
// - -%- -
public void
postSelected(Post post)
{
primaire.getAutoViewWindow().showPost(post);
}
public void
actionPerformed(ActionEvent eA)
{
Object src = eA.getSource();
if (src == openHome)
{
setTimelineType(TimelineType.HOME);
showLatestPage();
}
if (src == openFederated)
{
setTimelineType(TimelineType.FEDERATED);
showLatestPage();
}
if (src == openLocal)
{
setTimelineType(TimelineType.LOCAL);
showLatestPage();
}
if (src == createPost)
{
primaire.getComposeWindow().setVisible(true);
}
if (src == openAutoPostView)
{
PostWindow w = primaire.getAutoViewWindow();
w.setLocation(getX() + 10 + getWidth(), getY());
w.setVisible(true);
}
if (src == flipToNewestPost)
{
showLatestPage();
}
if (src == quit)
{
/*
@ -122,14 +248,99 @@ implements ActionListener {
* background threads IIRC (and they can check
* if the Swing thread is alive).
*/
dispose();
}
}
// - -%- -
private static List<Post>
toPosts(Tree<String> json)
{
List<Post> posts = new ArrayList<>();
for (Tree<String> post: json.children)
{
Tree<String> account = post.get("account");
Post addee = new Post();
addee.postId = post.get("id").value;
try {
String s = post.get("created_at").value;
addee.date = ZonedDateTime.parse(s);
ZoneId z = ZoneId.systemDefault();
addee.date = addee.date.withZoneSameInstant(z);
}
catch (DateTimeParseException eDt) {
assert false;
addee.date = ZonedDateTime.now();
}
try {
StringBuilder b = new StringBuilder();
Tree<String> nodes =
RudimentaryHTMLParser
.depthlessRead(post.get("content").value);
for (Tree<String> node: nodes.children)
{
if (node.key.equals("tag"))
{
if (node.get(0).key.equals("br")) {
b.append(" \n ");
}
if (node.get(0).key.equals("/p")) {
b.append(" \n\n ");
}
}
if (node.key.equals("text")) {
b.append(node.value);
}
}
addee.text = b.toString();
}
catch (IOException eIo) {
eIo.printStackTrace();
assert false;
}
String s = post.get("spoiler_text").value;
if (!s.isEmpty()) addee.contentWarning = s;
else addee.contentWarning = null;
addee.authorId = account.get("acct").value;
addee.authorName = account.get("username").value;
String s2 = account.get("display_name").value;
if (!s2.isEmpty()) addee.authorName = s2;
String f = post.get("favourited").value;
String b = post.get("reblogged").value;
addee.favourited = f.equals("true");
addee.boosted = b.equals("true");
posts.add(addee);
}
return posts;
}
private static String
plainify(String html)
{
// Delete all tags.
StringBuilder b = new StringBuilder();
boolean in = false;
for (char c: html.toCharArray()) switch (c) {
case '<': in = true; break;
case '>': in = false; break;
default: if (!in) b.append(c);
}
String s = b.toString();
s = s.replaceAll("&lt;", "<");
s = s.replaceAll("&gt;", ">");
s = s.replaceAll("&nbsp;", "");
return s;
}
// ---%-@-%---
TimelineWindow()
TimelineWindow(JKomasto primaire)
{
getContentPane().setPreferredSize(new Dimension(300, 400));
this.primaire = primaire;
this.api = primaire.getMastodonApi();
getContentPane().setPreferredSize(new Dimension(320, 460));
pack();
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
@ -162,7 +373,13 @@ implements ActionListener {
menuBar.add(timelineMenu);
setJMenuBar(menuBar);
page = new TimelinePage();
page.posts = new ArrayList<>();
setTimelineType(TimelineType.FEDERATED);
display = new TimelineComponent(this);
display.setNextPageAvailable(false);
display.setPreviousPageAvailable(false);
setContentPane(display);
}
@ -173,7 +390,7 @@ implements ActionListener {
class
TimelineComponent extends JPanel
implements ActionListener {
implements ActionListener, MouseListener {
private TimelineWindow
primaire;
@ -190,22 +407,49 @@ implements ActionListener {
pageLabel;
private final List<PostPreviewComponent>
postPreviews = new ArrayList<>();
postPreviews;
private boolean
hoverSelect;
// - -%- -
static final int
PREVIEW_COUNT = 10;
// ---%-@-%---
public void
setPosts(List<Post> posts)
{
if (posts == null)
{
posts = new ArrayList<>();
// Insert sample timeline posts here
}
this.posts.clear();
this.posts.addAll(posts);
syncPreviewsToPosts();
assert posts.size() <= postPreviews.size();
for (int o = 0; o < posts.size(); ++o)
{
PostPreviewComponent c = postPreviews.get(o);
Post p = posts.get(o);
c.setTopLeft(p.authorName);
{
ZonedDateTime now = ZonedDateTime.now();
long d = ChronoUnit.SECONDS.between(p.date, now);
long s = Math.abs(d);
if (s < 30) c.setTopRight("now");
else if (s < 60) c.setTopRight(d + "s");
else if (s < 3600) c.setTopRight((d / 60) + "m");
else if (s < 86400) c.setTopRight((d / 3600) + "h");
else c.setTopRight((d / 86400) + "d");
}
if (p.contentWarning != null)
c.setBottom("(" + p.contentWarning + ")");
else
c.setBottom(p.text + " ");
}
for (int o = posts.size(); o < postPreviews.size(); ++o)
{
postPreviews.get(o).reset();
}
}
public void
@ -227,20 +471,47 @@ implements ActionListener {
prev.setEnabled(available);
}
// - -%- -
public void
setHoverSelect(boolean a) { this.hoverSelect = a; }
private void
syncPreviewsToPosts()
public void
mouseEntered(MouseEvent eM)
{
for (PostPreviewComponent p: postPreviews)
{
p.setTopLeft("Top left");
p.setTopRight("Top right");
p.setBottom("Bottom");
// (In reality we are supposed to map with posts)
}
if (!hoverSelect) return;
mouseClicked(eM);
}
// () First time I'm using one of these..!
public void
mouseClicked(MouseEvent eM)
{
int offset = postPreviews.indexOf(eM.getSource());
assert offset != -1;
primaire.postSelected(posts.get(offset));
postPreviews.get(offset).setSelected(true);
repaint();
}
public void
mouseExited(MouseEvent eM)
{
if (!hoverSelect) return;
int offset = postPreviews.indexOf(eM.getSource());
assert offset != -1;
postPreviews.get(offset).setSelected(false);
repaint();
}
public void
mousePressed(MouseEvent eM) { }
public void
mouseReleased(MouseEvent eM) { }
// - -%- -
public void
actionPerformed(ActionEvent eA)
{
@ -262,10 +533,15 @@ implements ActionListener {
{
this.primaire = primaire;
postPreviews = new ArrayList<>(PREVIEW_COUNT);
hoverSelect = true;
prev = new JButton("<");
next = new JButton(">");
prev.setEnabled(false);
next.setEnabled(false);
prev.addActionListener(this);
next.addActionListener(this);
pageLabel = new JLabel("0");
@ -283,11 +559,13 @@ implements ActionListener {
constraints.gridx = 0;
constraints.weightx = 1;
constraints.insets = new Insets(4, 0, 4, 0);
for (int n = 8; n > 0; --n)
for (int n = PREVIEW_COUNT; n > 0; --n)
{
PostPreviewComponent p = new PostPreviewComponent();
centre.add(p, constraints);
postPreviews.add(p);
PostPreviewComponent c = new PostPreviewComponent();
c.reset();
c.addMouseListener(this);
centre.add(c, constraints);
postPreviews.add(c);
}
setLayout(new BorderLayout());
@ -295,8 +573,6 @@ implements ActionListener {
add(bottom, BorderLayout.SOUTH);
setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
setPosts(null);
}
}
@ -320,37 +596,64 @@ PostPreviewComponent extends JComponent {
public void
setBottom(String text) { bottom.setText(text); }
public void
reset()
{
setTopLeft(" ");
setTopRight(" ");
setBottom(" ");
}
public void
setSelected(boolean selected)
{
if (!selected) setBackground(null);
else setBackground(new Color(0, 0, 0, 25));
}
// - -%- -
protected void
paintComponent(Graphics g)
{
g.setColor(getBackground());
g.fillRect(0, 0, getWidth(), getHeight());
}
// ---%-@-%---
public
PostPreviewComponent()
{
Font font;
Font f = new JLabel().getFont();
Font f1 = f.deriveFont(Font.PLAIN, 12f);
Font f2 = f.deriveFont(Font.ITALIC, 12f);
Font f3 = f.deriveFont(Font.PLAIN, 14f);
topLeft = new JLabel();
font = topLeft.getFont();
topLeft.setFont(font.deriveFont(Font.PLAIN, 12f));
topLeft.setFont(f1);
setOpaque(false);
topRight = new JLabel();
topRight.setHorizontalAlignment(JLabel.RIGHT);
font = topRight.getFont();
topRight.setFont(font.deriveFont(Font.ITALIC, 12f));
topRight.setFont(f2);
setOpaque(false);
bottom = new JLabel();
font = bottom.getFont();
bottom.setFont(font.deriveFont(Font.PLAIN, 16f));
bottom.setFont(f3);
bottom.setOpaque(false);
Box top = Box.createHorizontalBox();
top.setOpaque(false);
top.add(topLeft);
top.add(Box.createGlue());
top.add(topRight);
setOpaque(false);
setSelected(false);
setLayout(new BorderLayout());
add(top, BorderLayout.NORTH);
add(bottom, BorderLayout.SOUTH);
add(bottom);
}
}

265
TwoToggleButton.java Normal file
View File

@ -0,0 +1,265 @@
import javax.swing.AbstractButton;
import javax.swing.DefaultButtonModel;
import javax.swing.Icon;
import javax.swing.ImageIcon;
import java.awt.Image;
import java.awt.Graphics;
import java.awt.Dimension;
import java.awt.event.MouseListener;
import java.awt.event.MouseEvent;
import java.awt.event.KeyListener;
import java.awt.event.KeyEvent;
import java.awt.event.ActionListener;
import java.awt.event.ActionEvent;
import java.awt.event.FocusListener;
import java.awt.event.FocusEvent;
import java.net.URL;
import javax.swing.JFrame;
class
TwoToggleButton extends AbstractButton
implements KeyListener, MouseListener, FocusListener {
private String
primaryName,
secondaryName;
// - -%- -
private boolean
primaryToggled = false,
secondaryToggled = false;
private Image
primaryToggledIcon,
secondaryToggledIcon,
primaryUntoggledIcon,
secondaryUntoggledIcon;
private int
nextEventID = ActionEvent.ACTION_FIRST;
// - -%- -
private static Image
button,
selectedOverlay,
disabledOverlay;
// ---%-@-%---
public void
togglePrimary()
{
setPrimaryToggled(!primaryToggled);
}
public void
toggleSecondary()
{
setSecondaryToggled(!secondaryToggled);
}
public void
setPrimaryToggled(boolean toggled)
{
primaryToggled = toggled;
repaint();
announce(primaryName, toggled);
}
public void
setSecondaryToggled(boolean toggled)
{
secondaryToggled = toggled;
repaint();
announce(secondaryName, toggled);
}
public boolean
getPrimaryToggled()
{
return primaryToggled;
}
public boolean
getSecondaryToggled()
{
return secondaryToggled;
}
// - -%- -
private void
announce(String name, boolean toggled)
{
ActionEvent eA = new ActionEvent(
this, nextEventID++,
name + (toggled ? "On" : "Off")
);
for (ActionListener listener: getActionListeners())
listener.actionPerformed(eA);
}
protected void
paintComponent(Graphics g)
{
g.drawImage(button, 0, 0, this);
if (!isEnabled())
g.drawImage(disabledOverlay, 0, 0, this);
if (isFocusOwner())
g.drawImage(selectedOverlay, 0, 0, this);
if (primaryToggled)
g.drawImage(primaryToggledIcon, 0, 0, this);
else
g.drawImage(primaryUntoggledIcon, 0, 0, this);
if (secondaryToggled)
g.drawImage(secondaryToggledIcon, 0, 0, this);
else
g.drawImage(secondaryUntoggledIcon, 0, 0, this);
}
public void
mouseClicked(MouseEvent eM)
{
switch (eM.getButton()) {
case MouseEvent.BUTTON1: togglePrimary(); break;
case MouseEvent.BUTTON3: toggleSecondary(); break;
}
}
public void
keyPressed(KeyEvent eK)
{
switch (eK.getKeyCode()) {
case KeyEvent.VK_SPACE: togglePrimary(); break;
case KeyEvent.VK_ENTER: toggleSecondary(); break;
}
}
public void
focusGained(FocusEvent eF) { repaint(); }
public void
focusLost(FocusEvent eF) { repaint(); }
public void
mousePressed(MouseEvent eM) { }
public void
mouseReleased(MouseEvent eM) { }
public void
mouseEntered(MouseEvent eM) { }
public void
mouseExited(MouseEvent eM) { }
public void
keyReleased(KeyEvent eK) { }
public void
keyTyped(KeyEvent eK) { }
// () If we don't have the 'armed' behaviour of JButton,
// we really need to rectify that, it's important for this
// use case.
// ---%-@-%---
TwoToggleButton(String primaryName, String secondaryName)
{
if (button == null) loadCommonImages();
this.primaryName = primaryName;
this.secondaryName = secondaryName;
setModel(new DefaultButtonModel());
setFocusable(true);
setOpaque(false);
int w = button.getWidth(null);
int h = button.getHeight(null);
setPreferredSize(new Dimension(w, h));
loadSpecificImages();
this.addKeyListener(this);
this.addMouseListener(this);
this.addFocusListener(this);
}
private void
loadSpecificImages()
{
String p1 = "graphics/" + primaryName + "Toggled.png";
String p2 = "graphics/" + secondaryName + "Toggled.png";
String p3 = "graphics/" + primaryName + "Untoggled.png";
String p4 = "graphics/" + secondaryName + "Untoggled.png";
URL u1 = getClass().getResource(p1);
URL u2 = getClass().getResource(p2);
URL u3 = getClass().getResource(p3);
URL u4 = getClass().getResource(p4);
if (u1 == null) primaryToggledIcon = null;
else primaryToggledIcon = new ImageIcon(u1).getImage();
if (u2 == null) secondaryToggledIcon = null;
else secondaryToggledIcon = new ImageIcon(u2).getImage();
if (u3 == null) primaryUntoggledIcon = null;
else primaryUntoggledIcon = new ImageIcon(u3).getImage();
if (u4 == null) secondaryUntoggledIcon = null;
else secondaryUntoggledIcon = new ImageIcon(u4).getImage();
}
// - -%- -
private static void
loadCommonImages()
{
Class c = TwoToggleButton.class;
URL u1 = c.getResource("graphics/button.png");
URL u2 = c.getResource("graphics/disabledOverlay.png");
URL u3 = c.getResource("graphics/selectedOverlay.png");
assert u1 != null && u2 != null && u3 != null;
button = new ImageIcon(u1).getImage();
disabledOverlay = new ImageIcon(u2).getImage();
selectedOverlay = new ImageIcon(u3).getImage();
}
}
class
TwoToggleButtonTest {
public static void
main(String... args)
{
if (args.length != 2)
{
String err = "Please give two toggle names.";
System.err.println(err);
System.exit(1);
}
String p = args[0], s = args[1];
TwoToggleButton t1 = new TwoToggleButton(p, s);
TwoToggleButton t2 = new TwoToggleButton(p, s);
TwoToggleButton t3 = new TwoToggleButton(p, s);
JFrame mainframe = new JFrame("Two-toggle button test");
mainframe.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
mainframe.setLocationByPlatform(true);
mainframe.add(t1, java.awt.BorderLayout.WEST);
mainframe.add(t2);
mainframe.add(t3, java.awt.BorderLayout.EAST);
mainframe.pack();
t2.setEnabled(false);
t3.requestFocusInWindow();
mainframe.setVisible(true);
}
}

BIN
graphics/Flags.xcf Normal file

Binary file not shown.

BIN
graphics/Hourglass.xcf Normal file

Binary file not shown.

BIN
graphics/boostToggled.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

BIN
graphics/boostUntoggled.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

BIN
graphics/button.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
graphics/favouriteToggled.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 B

BIN
graphics/favouriteUntoggled.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 313 B

BIN
graphics/test1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
graphics/test2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
graphics/test3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
graphics/test4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB