mirror of
https://gitlab.com/biskuteri-cafe/JKomasto2.git
synced 2024-11-20 06:44:51 +01:00
e6fea4c061
(Before this, JKomasto and sometimes the Mastodon web client would get '411 Record Not Found' when submitting the same text after deleting and redrafting. Presumably the Mastodon server caches both whether an idempotency key was fulfilled and which post it leads to, and for some reason it looks up the second and fails.)
717 lines
15 KiB
Java
717 lines
15 KiB
Java
|
|
import javax.swing.JFrame;
|
|
import javax.swing.JPanel;
|
|
import javax.swing.JComponent;
|
|
import javax.swing.ImageIcon;
|
|
import java.awt.Dimension;
|
|
import java.awt.BorderLayout;
|
|
import java.awt.Cursor;
|
|
import java.awt.Image;
|
|
import java.awt.FontMetrics;
|
|
import java.util.List;
|
|
import java.util.Locale;
|
|
import java.text.BreakIterator;
|
|
import java.time.ZonedDateTime;
|
|
import java.time.ZoneId;
|
|
import java.time.Period;
|
|
import java.time.temporal.ChronoUnit;
|
|
import java.time.format.DateTimeFormatter;
|
|
import java.time.format.DateTimeParseException;
|
|
import javax.swing.plaf.metal.MetalLookAndFeel;
|
|
import javax.swing.plaf.metal.DefaultMetalTheme;
|
|
import javax.swing.plaf.metal.OceanTheme;
|
|
import javax.swing.plaf.ColorUIResource;
|
|
import javax.swing.UIDefaults;
|
|
import java.io.File;
|
|
import cafe.biskuteri.hinoki.Tree;
|
|
|
|
|
|
class
|
|
JKomasto {
|
|
|
|
private TimelineWindow
|
|
timelineWindow;
|
|
|
|
private ComposeWindow
|
|
composeWindow;
|
|
|
|
private PostWindow
|
|
autoViewWindow;
|
|
|
|
private LoginWindow
|
|
loginWindow;
|
|
|
|
private ImageWindow
|
|
mediaWindow;
|
|
|
|
private NotificationsWindow
|
|
notificationsWindow;
|
|
|
|
private WindowUpdater
|
|
windowUpdater;
|
|
|
|
private MastodonApi
|
|
api;
|
|
|
|
private Image
|
|
programIcon;
|
|
|
|
// ---%-@-%---
|
|
|
|
public MastodonApi
|
|
getMastodonApi() { return api; }
|
|
|
|
public void
|
|
finishedLogin()
|
|
{
|
|
timelineWindow.setCursor(new Cursor(Cursor.WAIT_CURSOR));
|
|
|
|
timelineWindow.showLatestPage();
|
|
notificationsWindow.showLatestPage();
|
|
timelineWindow.setVisible(true);
|
|
loginWindow.dispose();
|
|
|
|
timelineWindow.setCursor(null);
|
|
}
|
|
|
|
public PostWindow
|
|
getAutoViewWindow() { return autoViewWindow; }
|
|
|
|
public ComposeWindow
|
|
getComposeWindow() { return composeWindow; }
|
|
|
|
public ImageWindow
|
|
getMediaWindow() { return mediaWindow; }
|
|
|
|
public NotificationsWindow
|
|
getNotificationsWindow() { return notificationsWindow; }
|
|
|
|
public WindowUpdater
|
|
getWindowUpdater() { return windowUpdater; }
|
|
|
|
public Image
|
|
getProgramIcon() { return programIcon; }
|
|
|
|
// ---%-@-%---
|
|
|
|
private static class
|
|
MetalTheme extends OceanTheme {
|
|
|
|
private ColorUIResource
|
|
lightPink = new ColorUIResource(246, 240, 240),
|
|
mildPink = new ColorUIResource(238, 233, 233),
|
|
white = new ColorUIResource(250, 250, 250),
|
|
darkPink = new ColorUIResource(242, 230, 230),
|
|
veryDarkPink = new ColorUIResource(164, 160, 160);
|
|
|
|
// -=%=-
|
|
|
|
public ColorUIResource
|
|
getPrimary2() { return darkPink; }
|
|
|
|
public ColorUIResource
|
|
getSecondary2() { return white; }
|
|
|
|
public ColorUIResource
|
|
getSecondary3() { return mildPink; }
|
|
|
|
public ColorUIResource
|
|
getSecondary1() { return veryDarkPink; }
|
|
|
|
public ColorUIResource
|
|
getPrimary1() { return veryDarkPink; }
|
|
|
|
public void
|
|
addCustomEntriesToTable(UIDefaults table)
|
|
{
|
|
super.addCustomEntriesToTable(table);
|
|
table.put(
|
|
"TabbedPane.tabAreaBackground",
|
|
getPrimary1()
|
|
);
|
|
table.put(
|
|
"TabbedPane.contentAreaColor",
|
|
getSecondary3()
|
|
);
|
|
table.put(
|
|
"TabbedPane.selected",
|
|
getSecondary3()
|
|
);
|
|
table.put(
|
|
"MenuBar.gradient",
|
|
java.util.Arrays.asList(new Object[] {
|
|
1f, 0f,
|
|
getWhite(),
|
|
getSecondary3(),
|
|
getSecondary1()
|
|
})
|
|
);
|
|
}
|
|
|
|
}
|
|
|
|
// ---%-@-%---
|
|
|
|
public static void
|
|
main(String... args)
|
|
{
|
|
//System.setProperty("swing.boldMetal", "false");
|
|
MetalLookAndFeel.setCurrentTheme(new MetalTheme());
|
|
|
|
new JKomasto().loginWindow.setVisible(true);
|
|
}
|
|
|
|
// ---%-@-%---
|
|
|
|
public
|
|
JKomasto()
|
|
{
|
|
api = new MastodonApi();
|
|
windowUpdater = new WindowUpdater(this);
|
|
programIcon = ImageApi.local("kettle");
|
|
|
|
timelineWindow = new TimelineWindow(this);
|
|
composeWindow = new ComposeWindow(this);
|
|
autoViewWindow = new PostWindow(this);
|
|
loginWindow = new LoginWindow(this);
|
|
mediaWindow = new ImageWindow();
|
|
notificationsWindow = new NotificationsWindow(this);
|
|
|
|
autoViewWindow.setTitle("Auto view - JKomasto");
|
|
|
|
composeWindow.dispose();
|
|
autoViewWindow.dispose();
|
|
timelineWindow.dispose();
|
|
mediaWindow.dispose();
|
|
notificationsWindow.dispose();
|
|
|
|
timelineWindow.setLocationByPlatform(true);
|
|
loginWindow.setLocationByPlatform(true);
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
enum
|
|
PostVisibility {
|
|
|
|
PUBLIC,
|
|
UNLISTED,
|
|
FOLLOWERS,
|
|
MENTIONED
|
|
|
|
}
|
|
|
|
enum
|
|
TimelineType {
|
|
|
|
FEDERATED,
|
|
LOCAL,
|
|
HOME,
|
|
LIST,
|
|
PROFILE
|
|
|
|
}
|
|
|
|
enum
|
|
NotificationType {
|
|
|
|
MENTION,
|
|
BOOST,
|
|
FAVOURITE,
|
|
FOLLOW,
|
|
FOLLOWREQ,
|
|
POLL,
|
|
ALERT
|
|
|
|
}
|
|
|
|
|
|
|
|
class
|
|
TimelinePage {
|
|
|
|
public TimelineType
|
|
type;
|
|
|
|
public String
|
|
accountNumId, listId;
|
|
|
|
public Post[]
|
|
posts;
|
|
|
|
// ---%-@-%---
|
|
|
|
public
|
|
TimelinePage() { }
|
|
|
|
public
|
|
TimelinePage(Tree<String> entity)
|
|
{
|
|
posts = new Post[entity.size()];
|
|
for (int o = 0; o < posts.length; ++o)
|
|
posts[o] = new Post(entity.get(o));
|
|
}
|
|
|
|
}
|
|
|
|
|
|
class
|
|
Notification {
|
|
|
|
public NotificationType
|
|
type;
|
|
|
|
public String
|
|
id;
|
|
|
|
public String
|
|
postId, postText, actorNumId, actorName;
|
|
|
|
}
|
|
|
|
|
|
|
|
class
|
|
Post {
|
|
|
|
public String
|
|
id,
|
|
uri;
|
|
|
|
public Account
|
|
author;
|
|
|
|
public PostVisibility
|
|
visibility;
|
|
|
|
public String
|
|
text,
|
|
approximateText;
|
|
|
|
public String
|
|
contentWarning;
|
|
|
|
public ZonedDateTime
|
|
dateTime;
|
|
|
|
public String
|
|
date, time, relativeTime;
|
|
|
|
public boolean
|
|
boosted,
|
|
favourited;
|
|
|
|
public Post
|
|
boostedPost;
|
|
|
|
public Attachment[]
|
|
attachments;
|
|
|
|
public String[][]
|
|
emojiUrls;
|
|
|
|
public String[]
|
|
mentions;
|
|
|
|
// - -%- -
|
|
|
|
private static final DateTimeFormatter
|
|
DATE_FORMAT = DateTimeFormatter.ofPattern("d LLLL ''uu"),
|
|
TIME_FORMAT = DateTimeFormatter.ofPattern("HH:mm");
|
|
|
|
// ---%-@-%---
|
|
|
|
public void
|
|
resolveApproximateText()
|
|
{
|
|
assert text != null;
|
|
if (approximateText != null) return;
|
|
|
|
Tree<String> nodes;
|
|
nodes = RudimentaryHTMLParser.depthlessRead(text);
|
|
if (nodes.size() == 0)
|
|
{
|
|
approximateText = "-";
|
|
return;
|
|
}
|
|
|
|
StringBuilder b = new StringBuilder();
|
|
Tree<String> first = nodes.get(0);
|
|
for (Tree<String> node: nodes)
|
|
{
|
|
if (node.key.equals("tag"))
|
|
{
|
|
if (node.get(0).key.equals("br"))
|
|
b.append("; ");
|
|
if (node.get(0).key.equals("p") && node != first)
|
|
b.append("; ");
|
|
}
|
|
if (node.key.equals("emoji"))
|
|
{
|
|
b.append(":" + node.value + ":");
|
|
}
|
|
if (node.key.equals("text"))
|
|
{
|
|
b.append(node.value);
|
|
}
|
|
}
|
|
approximateText = b.toString();
|
|
}
|
|
|
|
public void
|
|
resolveRelativeTime()
|
|
{
|
|
assert date != null;
|
|
|
|
ZonedDateTime now = ZonedDateTime.now();
|
|
long d = ChronoUnit.SECONDS.between(dateTime, now);
|
|
long s = Math.abs(d);
|
|
if (s < 30) relativeTime = "now";
|
|
else if (s < 60) relativeTime = d + "s";
|
|
else if (s < 3600) relativeTime = (d / 60) + "m";
|
|
else if (s < 86400) relativeTime = (d / 3600) + "h";
|
|
else relativeTime = (d / 86400) + "d";
|
|
}
|
|
|
|
// ---%-@-%---
|
|
|
|
public
|
|
Post() { }
|
|
|
|
public
|
|
Post(Tree<String> entity)
|
|
{
|
|
id = entity.get("id").value;
|
|
|
|
uri = entity.get("url").value;
|
|
if (uri == null) uri = entity.get("uri").value;
|
|
|
|
author = new Account(entity.get("account"));
|
|
|
|
String v = entity.get("visibility").value;
|
|
boolean p = v.equals("public");
|
|
boolean u = v.equals("unlisted");
|
|
boolean f = v.equals("private");
|
|
boolean m = v.equals("direct");
|
|
if (p) visibility = PostVisibility.PUBLIC;
|
|
if (u) visibility = PostVisibility.UNLISTED;
|
|
if (f) visibility = PostVisibility.FOLLOWERS;
|
|
if (m) visibility = PostVisibility.MENTIONED;
|
|
|
|
dateTime =
|
|
ZonedDateTime.parse(entity.get("created_at").value)
|
|
.withZoneSameInstant(ZoneId.systemDefault());
|
|
date = DATE_FORMAT.format(dateTime);
|
|
time = TIME_FORMAT.format(dateTime);
|
|
|
|
text = entity.get("content").value;
|
|
String st = entity.get("spoiler_text").value;
|
|
contentWarning = st.trim().isEmpty() ? null : st;
|
|
|
|
String favourited = entity.get("favourited").value;
|
|
String boosted = entity.get("reblogged").value;
|
|
this.favourited = favourited.equals("true");
|
|
this.boosted = boosted.equals("true");
|
|
|
|
Tree<String> media = entity.get("media_attachments");
|
|
attachments = new Attachment[media.size()];
|
|
for (int o = 0; o < attachments.length; ++o)
|
|
{
|
|
attachments[o] = new Attachment(media.get(o));
|
|
}
|
|
|
|
Tree<String> emojis = entity.get("emojis");
|
|
emojiUrls = new String[emojis.size()][];
|
|
for (int o = 0; o < emojiUrls.length; ++o)
|
|
{
|
|
Tree<String> emoji = emojis.get(o);
|
|
String[] mapping = emojiUrls[o] = new String[2];
|
|
mapping[0] = ":" + emoji.get("shortcode").value + ":";
|
|
mapping[1] = emoji.get("url").value;
|
|
}
|
|
|
|
Tree<String> boostedPost = entity.get("reblog");
|
|
if (boostedPost.size() > 0)
|
|
this.boostedPost = new Post(boostedPost);
|
|
|
|
Tree<String> mentions = entity.get("mentions");
|
|
this.mentions = new String[mentions.size()];
|
|
for (int o = 0; o < mentions.size(); ++o)
|
|
{
|
|
String acct = mentions.get(o).get("acct").value;
|
|
this.mentions[o] = acct;
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
|
|
class
|
|
Account {
|
|
|
|
public String
|
|
numId;
|
|
|
|
public String
|
|
id,
|
|
name;
|
|
|
|
public List<RichTextPane.Segment>
|
|
formattedName;
|
|
|
|
public String
|
|
avatarUrl;
|
|
|
|
public Image
|
|
avatar;
|
|
|
|
public ZonedDateTime
|
|
creationDate;
|
|
|
|
public int
|
|
followedCount,
|
|
followerCount;
|
|
|
|
public int
|
|
postCount;
|
|
|
|
public String[][]
|
|
fields;
|
|
|
|
public String
|
|
description;
|
|
|
|
// ---%-@-%---
|
|
|
|
public void
|
|
resolveFormattedName()
|
|
{
|
|
assert name != null;
|
|
formattedName =
|
|
new RichTextPane.Builder().text(name).finish();
|
|
}
|
|
|
|
public void
|
|
resolveAvatar()
|
|
{
|
|
assert avatarUrl != null;
|
|
if (avatar != null) return;
|
|
avatar = ImageApi.remote(avatarUrl);
|
|
}
|
|
|
|
// ---%-@-%---
|
|
|
|
public
|
|
Account() { }
|
|
|
|
public
|
|
Account(Tree<String> entity)
|
|
{
|
|
numId = entity.get("id").value;
|
|
id = entity.get("acct").value;
|
|
|
|
String displayName = entity.get("display_name").value;
|
|
String username = entity.get("username").value;
|
|
name = displayName.isEmpty() ? username : displayName;
|
|
|
|
avatarUrl = entity.get("avatar").value;
|
|
|
|
creationDate =
|
|
ZonedDateTime.parse(entity.get("created_at").value)
|
|
.withZoneSameInstant(ZoneId.systemDefault());
|
|
|
|
String c1 = entity.get("following_count").value;
|
|
String c2 = entity.get("followers_count").value;
|
|
String c3 = entity.get("statuses_count").value;
|
|
try {
|
|
followedCount = (int)Double.parseDouble(c1);
|
|
followerCount = (int)Double.parseDouble(c2);
|
|
postCount = (int)Double.parseDouble(c3);
|
|
}
|
|
catch (NumberFormatException eNf) {
|
|
assert false;
|
|
}
|
|
|
|
Tree<String> fs = entity.get("fields");
|
|
fields = new String[fs.size()][];
|
|
for (int o = 0; o < fields.length; ++o)
|
|
{
|
|
Tree<String> f = fs.get(o);
|
|
String[] field = fields[o] = new String[3];
|
|
field[0] = f.get("name").value;
|
|
field[1] = f.get("value").value;
|
|
boolean v = f.get("verified_at").value != null;
|
|
field[2] = Boolean.toString(v);
|
|
}
|
|
|
|
description = entity.get("note").value;
|
|
}
|
|
|
|
}
|
|
|
|
|
|
class
|
|
Attachment {
|
|
|
|
public String
|
|
id;
|
|
|
|
public String
|
|
type;
|
|
|
|
public String
|
|
url;
|
|
|
|
public String
|
|
description;
|
|
|
|
public Image
|
|
image;
|
|
|
|
public File
|
|
uploadee;
|
|
|
|
// ---%-@-%---
|
|
|
|
public void
|
|
resolveImage()
|
|
{
|
|
assert url != null;
|
|
if (image != null) return;
|
|
if (!type.equals("image")) return;
|
|
image = ImageApi.remote(url);
|
|
}
|
|
|
|
// ---%-@-%---
|
|
|
|
public
|
|
Attachment() { }
|
|
|
|
public
|
|
Attachment(Tree<String> entity)
|
|
{
|
|
url = entity.get("remote_url").value;
|
|
if (url == null) url = entity.get("url").value;
|
|
|
|
id = entity.get("id").value;
|
|
type = entity.get("type").value;
|
|
description = entity.get("description").value;
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class
|
|
Composition {
|
|
|
|
public String
|
|
text,
|
|
contentWarning;
|
|
|
|
public PostVisibility
|
|
visibility;
|
|
|
|
public String
|
|
replyToPostId;
|
|
|
|
public Attachment[]
|
|
attachments;
|
|
|
|
private File
|
|
uploadee;
|
|
|
|
// ---%-@-%---
|
|
|
|
public
|
|
Composition() { }
|
|
|
|
public static Composition
|
|
reply(Tree<String> entity, String ownNumId)
|
|
{
|
|
Composition c = new Composition();
|
|
|
|
Tree<String> boosted = entity.get("reblog");
|
|
if (boosted.size() > 0) entity = boosted;
|
|
|
|
String st = entity.get("spoiler_text").value;
|
|
String ri = entity.get("id").value;
|
|
c.contentWarning = st.trim().isEmpty() ? null : st;
|
|
c.replyToPostId = ri.trim().isEmpty() ? null : ri;
|
|
|
|
Tree<String> author = entity.get("account");
|
|
String authorId = author.get("acct").value;
|
|
String authorNumId = author.get("id").value;
|
|
c.text = "";
|
|
if (!authorNumId.equals(ownNumId))
|
|
c.text = "@" + authorId + " ";
|
|
|
|
String visibility = entity.get("visibility").value;
|
|
boolean p = visibility.equals("public");
|
|
boolean u = visibility.equals("unlisted");
|
|
boolean f = visibility.equals("private");
|
|
boolean m = visibility.equals("direct");
|
|
assert p || u || f || m;
|
|
if (p) c.visibility = PostVisibility.PUBLIC;
|
|
if (u) c.visibility = PostVisibility.UNLISTED;
|
|
if (f) c.visibility = PostVisibility.FOLLOWERS;
|
|
if (m) c.visibility = PostVisibility.MENTIONED;
|
|
// Less eye strain arranged this way.
|
|
|
|
return c;
|
|
}
|
|
|
|
public static Composition
|
|
recover(Tree<String> entity)
|
|
{
|
|
assert entity.get("text") != null;
|
|
|
|
Composition c = new Composition();
|
|
c.text = entity.get("text").value;
|
|
c.contentWarning = entity.get("spoiler_text").value;
|
|
c.replyToPostId = entity.get("in_reply_to_id").value;
|
|
|
|
String visibility = entity.get("visibility").value;
|
|
boolean p = visibility.equals("public");
|
|
boolean u = visibility.equals("unlisted");
|
|
boolean f = visibility.equals("private");
|
|
boolean m = visibility.equals("direct");
|
|
assert p || u || f || m;
|
|
if (p) c.visibility = PostVisibility.PUBLIC;
|
|
if (u) c.visibility = PostVisibility.UNLISTED;
|
|
if (f) c.visibility = PostVisibility.FOLLOWERS;
|
|
if (m) c.visibility = PostVisibility.MENTIONED;
|
|
|
|
return c;
|
|
}
|
|
|
|
public static Composition
|
|
reply(Post post, String ownId)
|
|
{
|
|
if (post.boostedPost != null) post = post.boostedPost;
|
|
|
|
Composition c = new Composition();
|
|
c.replyToPostId = post.id;
|
|
c.visibility = post.visibility;
|
|
c.contentWarning = post.contentWarning;
|
|
|
|
StringBuilder text = new StringBuilder();
|
|
for (String id: post.mentions)
|
|
{
|
|
if (id.equals(ownId)) continue;
|
|
text.append("@" + id);
|
|
}
|
|
if (!post.author.id.equals(ownId))
|
|
{
|
|
text.append("@" + post.author.id);
|
|
}
|
|
|
|
c.text = text.toString();
|
|
|
|
return c;
|
|
}
|
|
|
|
}
|