biskuteri-cafe-JKomasto2/JKomasto.java
Snowyfox e6fea4c061 Fixed bug when redraft makes no changes
(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.)
2022-05-31 03:39:56 -04:00

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;
}
}