biskuteri-cafe-JKomasto2/TimelineWindow.java

1052 lines
28 KiB
Java

/* copyright
This file is part of JKomasto2.
Written in 2022 by Usawashi <usawashi16@yahoo.co.jp>
This program 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.
This program 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 this program. If not, see <https://www.gnu.org/licenses/>.
copyright */
import javax.swing.JFrame;
import javax.swing.JPanel;
import javax.swing.JComponent;
import javax.swing.JButton;
import javax.swing.JLabel;
import javax.swing.JMenu;
import javax.swing.JMenuItem;
import javax.swing.JMenuBar;
import javax.swing.JSeparator;
import javax.swing.JRadioButton;
import javax.swing.JOptionPane;
import javax.swing.ButtonGroup;
import javax.swing.Box;
import javax.swing.BorderFactory;
import java.awt.BorderLayout;
import java.awt.GridBagLayout;
import java.awt.GridBagConstraints;
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.awt.Image;
import java.util.List;
import java.util.ArrayList;
import java.net.URL;
import java.net.MalformedURLException;
import java.awt.event.ActionListener;
import java.awt.event.ActionEvent;
import java.awt.event.MouseListener;
import java.awt.event.MouseEvent;
import java.awt.event.FocusListener;
import java.awt.event.FocusEvent;
import java.awt.event.KeyListener;
import java.awt.event.KeyEvent;
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 JKomasto
primaire;
private MastodonApi
api;
private WindowUpdater
windowUpdater;
private TimelinePage
page;
// - -%- -
private TimelineComponent
display;
private JMenuItem
openHome,
openMessages,
openLocal,
openFederated,
openNotifications,
openOwnProfile,
openProfile,
createPost,
openAutoPostView,
quit;
private JMenuItem
flipToNewestPost;
private boolean
showingLatest;
// - -%- -
private static final int
PREVIEW_COUNT = TimelineComponent.PREVIEW_COUNT;
// ---%-@-%---
public synchronized void
use(TimelinePage page)
{
assert page != null;
this.page = page;
List<PostPreviewComponent> previews;
previews = display.getPostPreviews();
int available = page.posts.length;
int max = previews.size();
assert available <= max;
for (int o = 0; o < available; ++o)
{
PostPreviewComponent preview = previews.get(o);
Post post = page.posts[o];
preview.setTopLeft(post.author.name);
if (post.boostedPost != null)
{
String s = "boosted by " + post.author.name;
preview.setTopLeft(s);
post = post.boostedPost;
}
String flags = "";
if (post.attachments.length > 0) flags += "a";
post.resolveRelativeTime();
preview.setTopRight(flags + " " + post.relativeTime);
post.resolveApproximateText();
if (post.contentWarning != null)
preview.setBottom("(" + post.contentWarning + ")");
else
preview.setBottom(post.approximateText);
}
for (int o = available; o < max; ++o)
{
previews.get(o).reset();
}
boolean full = !(available < PREVIEW_COUNT);
display.setNextPageAvailable(full);
display.setPreviousPageAvailable(true);
display.resetFocus();
}
public void
readEntity(Tree<String> postEntityArray)
{
TimelinePage page = new TimelinePage(postEntityArray);
page.type = this.page.type;
page.accountNumId = this.page.accountNumId;
page.listId = this.page.listId;
use(page);
}
public void
refresh()
{
String firstId = null;
if (!showingLatest)
{
assert page.posts != null;
assert page.posts.length != 0;
firstId = page.posts[0].id;
}
display.setCursor(new Cursor(Cursor.WAIT_CURSOR));
api.getTimelinePage(
page.type,
PREVIEW_COUNT, firstId, null,
page.accountNumId, page.listId,
new RequestListener() {
public void
connectionFailed(IOException eIo)
{
JOptionPane.showMessageDialog(
TimelineWindow.this,
"Failed to fetch page.."
+ "\n" + eIo.getMessage()
);
}
public void
requestFailed(int httpCode, Tree<String> json)
{
JOptionPane.showMessageDialog(
TimelineWindow.this,
"Failed to fetch page.."
+ "\n" + json.get("error").value
+ "\n(HTTP code: " + httpCode + ")"
);
}
public void
requestSucceeded(Tree<String> json)
{
if (json.size() < PREVIEW_COUNT)
{
showLatestPage();
return;
}
readEntity(json);
}
}
);
display.setCursor(null);
}
public void
showLatestPage()
{
display.setCursor(new Cursor(Cursor.WAIT_CURSOR));
api.getTimelinePage(
page.type,
PREVIEW_COUNT, null, null,
page.accountNumId, page.listId,
new RequestListener() {
public void
connectionFailed(IOException eIo)
{
JOptionPane.showMessageDialog(
TimelineWindow.this,
"Failed to fetch page.."
+ "\n" + eIo.getMessage()
);
}
public void
requestFailed(int httpCode, Tree<String> json)
{
JOptionPane.showMessageDialog(
TimelineWindow.this,
"Failed to fetch page.."
+ "\n" + json.get("error").value
+ "\n(HTTP code: " + httpCode + ")"
);
}
public void
requestSucceeded(Tree<String> json)
{
readEntity(json);
showingLatest = true;
windowUpdater.add(TimelineWindow.this);
}
}
);
display.setCursor(null);
}
public void
showNextPage()
{
assert page.posts != null;
assert page.posts.length != 0;
String lastId = page.posts[page.posts.length - 1].id;
display.setCursor(new Cursor(Cursor.WAIT_CURSOR));
api.getTimelinePage(
page.type,
PREVIEW_COUNT, lastId, null,
page.accountNumId, page.listId,
new RequestListener() {
public void
connectionFailed(IOException eIo)
{
JOptionPane.showMessageDialog(
TimelineWindow.this,
"Failed to fetch page.."
+ "\n" + eIo.getMessage()
);
}
public void
requestFailed(int httpCode, Tree<String> json)
{
JOptionPane.showMessageDialog(
TimelineWindow.this,
"Failed to fetch page.."
+ "\n" + json.get("error").value
+ "\n(HTTP code: " + httpCode + ")"
);
}
public void
requestSucceeded(Tree<String> json)
{
if (json.size() == 0)
{
// We should probably say something
// to the user here? For now, we
// quietly cancel.
return;
}
readEntity(json);
showingLatest = false;
windowUpdater.remove(TimelineWindow.this);
}
}
);
display.setCursor(null);
}
public void
showPreviousPage()
{
assert page.posts != null;
assert page.posts.length != 0;
String firstId = page.posts[0].id;
display.setCursor(new Cursor(Cursor.WAIT_CURSOR));
api.getTimelinePage(
page.type,
PREVIEW_COUNT, null, firstId,
page.accountNumId, page.listId,
new RequestListener() {
public void
connectionFailed(IOException eIo)
{
JOptionPane.showMessageDialog(
TimelineWindow.this,
"Failed to fetch page.."
+ "\n" + eIo.getMessage()
);
}
public void
requestFailed(int httpCode, Tree<String> json)
{
JOptionPane.showMessageDialog(
TimelineWindow.this,
"Failed to fetch page.."
+ "\n" + json.get("error").value
+ "\n(HTTP code: " + httpCode + ")"
);
}
public void
requestSucceeded(Tree<String> json)
{
if (json.size() < PREVIEW_COUNT)
{
showLatestPage();
return;
}
readEntity(json);
showingLatest = false;
windowUpdater.remove(TimelineWindow.this);
}
}
);
display.setCursor(null);
}
public synchronized void
setTimelineType(TimelineType type)
{
assert type != TimelineType.LIST;
assert type != TimelineType.PROFILE;
page.type = type;
page.accountNumId = null;
setTitle(toString(type) + " timeline - JKomasto");
String f = type.toString().toLowerCase();
display.setBackgroundImage(ImageApi.local(f));
/*
* (注) Java's image renderer draws images with transparency
* darker than GIMP does. Overcompensate in lightening.
*/
display.repaint();
}
public synchronized TimelineType
getTimelineType() { return page.type; }
public synchronized void
showAuthorPosts(String authorNumId)
{
assert authorNumId != null;
page.type = TimelineType.PROFILE;
page.accountNumId = authorNumId;
setTitle(authorNumId + " - JKomasto");
display.setBackgroundImage(ImageApi.local("profile"));
display.repaint();
}
public synchronized void
openOwnProfile()
{
display.setCursor(new Cursor(Cursor.WAIT_CURSOR));
Tree<String> accountDetails = api.getAccountDetails();
ProfileWindow w = new ProfileWindow(primaire);
w.use(new Account(accountDetails));
w.setLocationByPlatform(true);
w.setVisible(true);
display.setCursor(null);
}
public void
openProfile()
{
String query = JOptionPane.showInputDialog(
this,
"Whose account do you want to see?\n"
+ "Type an account name with the instance,\n"
+ "or a display name if you can't remember.\n",
"Profile search",
JOptionPane.PLAIN_MESSAGE
);
if (query == null) return;
class Handler implements RequestListener {
public Tree<String>
json;
// -=%=-
public void
connectionFailed(IOException eIo)
{
JOptionPane.showMessageDialog(
TimelineWindow.this,
"Tried to fetch accounts, but it failed.."
+ "\n" + eIo.getMessage()
);
}
public void
requestFailed(int httpCode, Tree<String> json)
{
JOptionPane.showMessageDialog(
TimelineWindow.this,
"Tried to fetch accounts, but it failed.."
+ "\n" + json.get("error").value
+ "\n(HTTP code: " + httpCode + ")"
);
}
public void
requestSucceeded(Tree<String> json) { this.json = json; }
}
// (知) Have to create a named class because
// it has to hold the variable.
Handler handler = new Handler();
api.getAccounts(query, handler);
if (handler.json == null) return;
if (handler.json.size() == 0)
{
JOptionPane.showMessageDialog(
this,
"There were no results from the query.. ☹️"
);
return;
}
Tree<String> openee = null;
if (query.startsWith("@")) query = query.substring(1);
List<Object> message = new ArrayList<>();
message.add("Maybe one of these?");
ButtonGroup selGroup = new ButtonGroup();
for (Tree<String> account: handler.json)
{
String dname = account.get("display_name").value;
String acct = account.get("acct").value;
if (query.equals(acct)) {
openee = account;
break;
}
JRadioButton b = new JRadioButton();
b.setText(dname + " (" + acct + ")");
selGroup.add(b);
message.add(b);
}
if (openee == null)
{
int response = JOptionPane.showConfirmDialog(
this,
message.toArray(),
"Search results",
JOptionPane.OK_CANCEL_OPTION
);
if (response == JOptionPane.CANCEL_OPTION) return;
for (int o = 1; o < message.size(); ++o)
{
JRadioButton b = (JRadioButton)message.get(o);
if (selGroup.isSelected(b.getModel()))
{
openee = handler.json.get(o - 1);
break;
}
}
if (openee == null) return;
/*
* It seems like this can happen if someone
* presses escape out of the confirm dialog.
* I don't know why that doesn't map to cancel.
*/
}
ProfileWindow w = new ProfileWindow(primaire);
w.use(new Account(openee));
w.setLocationByPlatform(true);
w.setVisible(true);
}
// - -%- -
public synchronized void
previewSelected(int index)
{
if (index > page.posts.length) return;
Post post = page.posts[index - 1];
primaire.getAutoViewWindow().use(post);
}
public synchronized void
previewOpened(int index)
{
if (index > page.posts.length) return;
Post post = page.posts[index - 1];
PostWindow w = new PostWindow(primaire);
w.use(post);
w.setLocationRelativeTo(this);
w.setVisible(true);
}
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 == openOwnProfile)
{
openOwnProfile();
}
if (src == openProfile)
{
openProfile();
}
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 == openNotifications)
{
NotificationsWindow w =
primaire.getNotificationsWindow();
if (!w.isVisible())
{
w.setLocationByPlatform(true);
w.setVisible(true);
}
}
if (src == flipToNewestPost)
{
showLatestPage();
}
if (src == quit)
{
/*
* Umm.. should we even have a quit option?
* Wouldn't closing every window work? By
* disposing of everyone. We won't be having any
* background threads IIRC (and they can check
* if the Swing thread is alive).
*/
dispose();
}
}
// - -%- -
private static String
toString(TimelineType type)
{
switch (type)
{
case FEDERATED: return "Federated";
case LOCAL: return "Local";
case HOME: return "Home";
default: return "";
}
}
// ---%-@-%---
TimelineWindow(JKomasto primaire)
{
this.primaire = primaire;
this.api = primaire.getMastodonApi();
this.windowUpdater = primaire.getWindowUpdater();
getContentPane().setPreferredSize(new Dimension(320, 460));
pack();
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
openHome = new JMenuItem("Open home timeline");
openFederated = new JMenuItem("Open federated timeline");
openNotifications = new JMenuItem("Open notifications");
openOwnProfile = new JMenuItem("Open own profile");
openProfile = new JMenuItem("Open profile..");
createPost = new JMenuItem("Create a post");
openAutoPostView = new JMenuItem("Open auto post view");
quit = new JMenuItem("Quit");
openHome.addActionListener(this);
openFederated.addActionListener(this);
openNotifications.addActionListener(this);
openOwnProfile.addActionListener(this);
openProfile.addActionListener(this);
createPost.addActionListener(this);
openAutoPostView.addActionListener(this);
quit.addActionListener(this);
flipToNewestPost = new JMenuItem("Flip to newest post");
flipToNewestPost.addActionListener(this);
JMenu programMenu = new JMenu("Program");
programMenu.setMnemonic(KeyEvent.VK_P);
programMenu.add(openHome);
programMenu.add(openFederated);
programMenu.add(openNotifications);
programMenu.add(openOwnProfile);
programMenu.add(openProfile);
programMenu.add(new JSeparator());
programMenu.add(createPost);
programMenu.add(openAutoPostView);
programMenu.add(new JSeparator());
programMenu.add(quit);
JMenu timelineMenu = new JMenu("Timeline");
timelineMenu.setMnemonic(KeyEvent.VK_T);
timelineMenu.add(flipToNewestPost);
JMenuBar menuBar = new JMenuBar();
menuBar.add(programMenu);
menuBar.add(timelineMenu);
setJMenuBar(menuBar);
page = new TimelinePage();
page.posts = new Post[0];
display = new TimelineComponent(this);
display.setNextPageAvailable(false);
display.setPreviousPageAvailable(false);
setContentPane(display);
setTimelineType(TimelineType.HOME);
setIconImage(primaire.getProgramIcon());
}
}
class
TimelineComponent extends JPanel
implements
ActionListener, KeyListener,
MouseListener, FocusListener {
private TimelineWindow
primaire;
// - -%- -
private JButton
next, prev;
private JLabel
pageLabel;
private final List<PostPreviewComponent>
postPreviews;
private boolean
hoverSelect;
private Image
backgroundImage;
// - -%- -
static final int
PREVIEW_COUNT = 10;
// ---%-@-%---
public List<PostPreviewComponent>
getPostPreviews()
{
return postPreviews;
}
public void
setPageLabel(String label)
{
assert label != null;
pageLabel.setText("" + label);
}
public void
setNextPageAvailable(boolean n) { next.setEnabled(n); }
public void
setPreviousPageAvailable(boolean n) { prev.setEnabled(n); }
public void
setHoverSelect(boolean n) { hoverSelect = n; }
public void
setBackgroundImage(Image n) { backgroundImage = n; }
public void
resetFocus() { postPreviews.get(0).requestFocusInWindow(); }
// - -%- -
protected void
paintComponent(Graphics g)
{
int w = getWidth(), h = getHeight();
g.clearRect(0, 0, w, h);
if (backgroundImage != null)
{
int b = h * 5 / 10;
int iw = backgroundImage.getWidth(this);
int ih = backgroundImage.getHeight(this);
if (ih > iw) {
ih = ih * b / iw;
iw = b;
}
else {
iw = iw * b / ih;
ih = b;
}
int x = w - iw, y = h - ih;
g.drawImage(backgroundImage, x, y, iw, ih, this);
}
((java.awt.Graphics2D)g).setRenderingHint(
java.awt.RenderingHints.KEY_ANTIALIASING,
java.awt.RenderingHints.VALUE_ANTIALIAS_ON
);
}
private void
select(Object c)
{
assert c instanceof PostPreviewComponent;
for (PostPreviewComponent p: postPreviews)
{
if (p == c) {
int index = 1 + postPreviews.indexOf(p);
primaire.previewSelected(index);
p.setSelected(true);
p.repaint();
}
else {
p.setSelected(false);
p.repaint();
}
}
}
private void
deselect(Object c)
{
assert c instanceof PostPreviewComponent;
PostPreviewComponent p = (PostPreviewComponent)c;
p.setSelected(false);
p.repaint();
}
private void
open(Object c)
{
int o = postPreviews.indexOf(c);
assert o != -1;
primaire.previewOpened(1 + o);
}
public void
focusGained(FocusEvent eF) { select(eF.getSource()); }
public void
focusLost(FocusEvent eF) { deselect(eF.getSource()); }
public void
mouseClicked(MouseEvent eM)
{
if (eM.getClickCount() == 2) open(eM.getSource());
else select(eM.getSource());
}
public void
mouseEntered(MouseEvent eM)
{
if (!hoverSelect) return;
mouseClicked(eM);
}
public void
mouseExited(MouseEvent eM)
{
if (!hoverSelect) return;
deselect(eM.getSource());
}
// (知) First time I'm using these two..!
public void
keyPressed(KeyEvent eK)
{
if (eK.getKeyCode() != KeyEvent.VK_ENTER) return;
PostPreviewComponent selected = null;
for (PostPreviewComponent c: postPreviews)
if (c.isSelected()) selected = c;
if (selected == null) return;
open(selected);
}
public void
actionPerformed(ActionEvent eA)
{
Object src = eA.getSource();
if (src == next) primaire.showNextPage();
else if (src == prev) primaire.showPreviousPage();
/*
* I think the page previews will just forward to us.
* But I think they'll have to tell us where they are
* in the list somehow, because we need to show only
* one post as selected.
*/
}
public void
mousePressed(MouseEvent eM) { }
public void
mouseReleased(MouseEvent eM) { }
public void
keyTyped(KeyEvent eK) { }
public void
keyReleased(KeyEvent eK) { }
// ---%-@-%---
TimelineComponent(TimelineWindow primaire)
{
this.primaire = primaire;
postPreviews = new ArrayList<>(PREVIEW_COUNT);
hoverSelect = true;
prev = new JButton("<");
next = new JButton(">");
prev.setEnabled(false);
next.setEnabled(false);
prev.setMnemonic(KeyEvent.VK_PAGE_UP);
next.setMnemonic(KeyEvent.VK_PAGE_DOWN);
prev.addActionListener(this);
next.addActionListener(this);
pageLabel = new JLabel("0");
Box bottom = Box.createHorizontalBox();
bottom.setBackground(null);
bottom.setOpaque(false);
bottom.add(Box.createGlue());
bottom.add(prev);
bottom.add(Box.createHorizontalStrut(8));
bottom.add(next);
JPanel centre = new JPanel();
centre.setOpaque(false);
centre.setLayout(new GridBagLayout());
GridBagConstraints constraints = new GridBagConstraints();
constraints.fill = GridBagConstraints.HORIZONTAL;
constraints.gridx = 0;
constraints.weightx = 1;
constraints.insets = new Insets(4, 0, 4, 0);
for (int n = PREVIEW_COUNT; n > 0; --n)
{
PostPreviewComponent c = new PostPreviewComponent();
c.reset();
c.addMouseListener(this);
c.addFocusListener(this);
c.addKeyListener(this);
centre.add(c, constraints);
postPreviews.add(c);
}
setLayout(new BorderLayout());
add(centre, BorderLayout.CENTER);
add(bottom, BorderLayout.SOUTH);
setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
}
}
class
PostPreviewComponent extends JComponent {
private JLabel
topLeft, topRight, bottom;
private boolean
selected;
// ---%-@-%---
public void
setTopLeft(String text) { topLeft.setText(text); }
public void
setTopRight(String text) { topRight.setText(text); }
public void
setBottom(String text) { bottom.setText(text); }
public void
reset()
{
setTopLeft(" ");
setTopRight(" ");
setBottom(" ");
}
public void
setSelected(boolean selected) { this.selected = selected; }
public boolean
isSelected() { return selected; }
// - -%- -
protected void
paintComponent(Graphics g)
{
if (selected)
{
g.setColor(new Color(0, 0, 0, 25));
g.fillRect(0, 0, getWidth(), getHeight());
}
}
// ---%-@-%---
public
PostPreviewComponent()
{
Font f1 = new Font("Dialog", Font.PLAIN, 12);
Font f2 = new Font("Dialog", Font.ITALIC, 12);
Font f3 = new Font("Dialog", Font.PLAIN, 14);
topLeft = new JLabel();
topLeft.setFont(f1);
topLeft.setOpaque(false);
topRight = new JLabel();
topRight.setHorizontalAlignment(JLabel.RIGHT);
topRight.setFont(f2);
topRight.setOpaque(false);
Box top = Box.createHorizontalBox();
top.setOpaque(false);
top.add(topLeft);
top.add(Box.createGlue());
top.add(topRight);
bottom = new JLabel();
bottom.setFont(f3);
bottom.setOpaque(false);
JPanel left = new JPanel();
left.setOpaque(false);
left.setLayout(new BorderLayout());
left.add(top, BorderLayout.NORTH);
left.add(bottom);
setFocusable(true);
setOpaque(false);
setLayout(new BorderLayout());
add(left);
}
}