diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/feed/FeedConfig.java b/runelite-client/src/main/java/net/runelite/client/plugins/feed/FeedConfig.java new file mode 100644 index 0000000000..936a632a88 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/feed/FeedConfig.java @@ -0,0 +1,46 @@ +package net.runelite.client.plugins.feed; + +import net.runelite.client.config.Config; +import net.runelite.client.config.ConfigGroup; +import net.runelite.client.config.ConfigItem; + +@ConfigGroup( + keyName = "feed", + name = "News Feed", + description = "Displays client and game-related news" +) +public interface FeedConfig extends Config +{ + @ConfigItem( + keyName = "includeBlogPosts", + name = "Include Blog Posts", + description = "Configures whether blog posts are displayed", + position = 0 + ) + default boolean includeBlogPosts() + { + return true; + } + + @ConfigItem( + keyName = "includeTweets", + name = "Include Tweets", + description = "Configures whether tweets are displayed", + position = 1 + ) + default boolean includeTweets() + { + return true; + } + + @ConfigItem( + keyName = "includeOsrsNews", + name = "Include OSRS News", + description = "Configures whether OSRS news are displayed", + position = 2 + ) + default boolean includeOsrsNews() + { + return true; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/feed/FeedPanel.java b/runelite-client/src/main/java/net/runelite/client/plugins/feed/FeedPanel.java new file mode 100644 index 0000000000..16472ec01d --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/feed/FeedPanel.java @@ -0,0 +1,358 @@ +/* + * Copyright (c) 2018, Lotto + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package net.runelite.client.plugins.feed; + +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Desktop; +import java.awt.Dimension; +import java.awt.Font; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.awt.font.FontRenderContext; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.time.Duration; +import java.time.Instant; +import java.util.Comparator; +import java.util.function.Supplier; +import javax.imageio.ImageIO; +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.ImageIcon; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.SwingUtilities; +import javax.swing.UIManager; +import javax.swing.border.EmptyBorder; +import lombok.extern.slf4j.Slf4j; +import net.runelite.client.ui.FontManager; +import net.runelite.client.ui.PluginPanel; +import net.runelite.http.api.RuneLiteAPI; +import net.runelite.http.api.feed.FeedItem; +import net.runelite.http.api.feed.FeedItemType; +import net.runelite.http.api.feed.FeedResult; +import okhttp3.Call; +import okhttp3.Callback; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; + +@Slf4j +class FeedPanel extends PluginPanel +{ + private static BufferedImage RUNELITE_ICON; + private static BufferedImage OSRS_ICON; + + private static final Color TWEET_BACKGROUND = new Color(15, 15, 15); + private static final Color OSRS_NEWS_BACKGROUND = new Color(36, 30, 19); + private static final Color BLOG_POST_BACKGROUND = new Color(11, 30, 41); + + private static final int MAX_CONTENT_LINES = 3; + private static final int CONTENT_WIDTH = 148; + private static final int TIME_WIDTH = 20; + + private static final Comparator FEED_ITEM_COMPARATOR = (o1, o2) -> + { + if (o1.getType() != o2.getType()) + { + if (o1.getType() == FeedItemType.BLOG_POST) + { + return -1; + } + else if (o2.getType() == FeedItemType.BLOG_POST) + { + return 1; + } + } + + return -Long.compare(o1.getTimestamp(), o2.getTimestamp()); + }; + + static + { + try + { + RUNELITE_ICON = ImageIO.read(FeedPanel.class.getResourceAsStream("runelite.png")); + } + catch (IOException e) + { + log.warn("Client icon failed to load", e); + } + + try + { + OSRS_ICON = ImageIO.read(FeedPanel.class.getResourceAsStream("osrs.png")); + } + catch (IOException e) + { + log.warn("OSRS icon failed to load", e); + } + } + + private final FeedConfig config; + private final Supplier feedSupplier; + + FeedPanel(FeedConfig config, Supplier feedSupplier) + { + this.config = config; + this.feedSupplier = feedSupplier; + } + + void rebuildFeed() + { + FeedResult feed = feedSupplier.get(); + + if (feed == null) + { + return; + } + + SwingUtilities.invokeLater(() -> + { + removeAll(); + + feed.getItems() + .stream() + .filter(f -> f.getType() != FeedItemType.BLOG_POST || config.includeBlogPosts()) + .filter(f -> f.getType() != FeedItemType.TWEET || config.includeTweets()) + .filter(f -> f.getType() != FeedItemType.OSRS_NEWS || config.includeOsrsNews()) + .sorted(FEED_ITEM_COMPARATOR) + .forEach(this::addItemToPanel); + }); + } + + private void addItemToPanel(FeedItem item) + { + JPanel avatarAndRight = new JPanel(new BorderLayout()); + avatarAndRight.setPreferredSize(new Dimension(0, 56)); + + JLabel avatar = new JLabel(); + // width = 48+4 to compensate for the border + avatar.setPreferredSize(new Dimension(52, 48)); + avatar.setBorder(new EmptyBorder(0, 4, 0, 0)); + + switch (item.getType()) + { + case TWEET: + try + { + Request request = new Request.Builder() + .url(item.getAvatar()) + .build(); + + RuneLiteAPI.CLIENT.newCall(request).enqueue(new Callback() + { + @Override + public void onFailure(Call call, IOException e) + { + log.warn(null, e); + } + + @Override + public void onResponse(Call call, Response response) throws IOException + { + try (ResponseBody responseBody = response.body()) + { + if (!response.isSuccessful()) + { + log.warn("Failed to download image " + item.getAvatar()); + return; + } + + avatar.setIcon(new ImageIcon(ImageIO.read(responseBody.byteStream()))); + } + } + }); + } + catch (IllegalArgumentException | NullPointerException e) + { + log.warn(null, e); + } + avatarAndRight.setBackground(TWEET_BACKGROUND); + break; + case OSRS_NEWS: + if (OSRS_ICON != null) + { + avatar.setIcon(new ImageIcon(OSRS_ICON)); + } + avatarAndRight.setBackground(OSRS_NEWS_BACKGROUND); + break; + default: + if (RUNELITE_ICON != null) + { + avatar.setIcon(new ImageIcon(RUNELITE_ICON)); + } + avatarAndRight.setBackground(BLOG_POST_BACKGROUND); + break; + } + + JPanel upAndContent = new JPanel(); + upAndContent.setLayout(new BoxLayout(upAndContent, BoxLayout.Y_AXIS)); + upAndContent.setBorder(new EmptyBorder(4, 8, 4, 4)); + upAndContent.setBackground(null); + + JPanel titleAndTime = new JPanel(); + titleAndTime.setLayout(new BorderLayout()); + titleAndTime.setBackground(null); + + Color darkerForeground = UIManager.getColor("Label.foreground").darker(); + + JLabel titleLabel = new JLabel(item.getTitle()); + titleLabel.setFont(FontManager.getRunescapeSmallFont()); + titleLabel.setBackground(null); + titleLabel.setForeground(darkerForeground); + titleLabel.setPreferredSize(new Dimension(CONTENT_WIDTH - TIME_WIDTH, 0)); + + Duration duration = Duration.between(Instant.ofEpochMilli(item.getTimestamp()), Instant.now()); + JLabel timeLabel = new JLabel(durationToString(duration)); + timeLabel.setFont(FontManager.getRunescapeSmallFont()); + timeLabel.setForeground(darkerForeground); + + titleAndTime.add(titleLabel, BorderLayout.WEST); + titleAndTime.add(timeLabel, BorderLayout.EAST); + + JPanel content = new JPanel(new BorderLayout()); + content.setBackground(null); + + JLabel contentLabel = new JLabel(lineBreakText(item.getContent(), FontManager.getRunescapeSmallFont())); + contentLabel.setBorder(new EmptyBorder(2, 0, 0, 0)); + contentLabel.setFont(FontManager.getRunescapeSmallFont()); + contentLabel.setForeground(darkerForeground); + + content.add(contentLabel, BorderLayout.CENTER); + + upAndContent.add(titleAndTime); + upAndContent.add(content); + upAndContent.add(new Box.Filler(new Dimension(0, 0), + new Dimension(0, Short.MAX_VALUE), + new Dimension(0, Short.MAX_VALUE))); + + avatarAndRight.add(avatar, BorderLayout.WEST); + avatarAndRight.add(upAndContent, BorderLayout.CENTER); + + Color backgroundColor = avatarAndRight.getBackground(); + Color hoverColor = backgroundColor.brighter().brighter(); + Color pressedColor = hoverColor.brighter(); + + avatarAndRight.addMouseListener(new MouseAdapter() + { + @Override + public void mouseEntered(MouseEvent e) + { + avatarAndRight.setBackground(hoverColor); + } + + @Override + public void mouseExited(MouseEvent e) + { + avatarAndRight.setBackground(backgroundColor); + } + + @Override + public void mousePressed(MouseEvent e) + { + avatarAndRight.setBackground(pressedColor); + } + + @Override + public void mouseReleased(MouseEvent e) + { + avatarAndRight.setBackground(hoverColor); + + Desktop desktop = Desktop.getDesktop(); + + if (!desktop.isSupported(Desktop.Action.BROWSE)) + { + log.info("Desktop browser is not supported"); + return; + } + + try + { + desktop.browse(new URI(item.getUrl())); + } + catch (IOException | URISyntaxException ex) + { + log.warn("Unable to open URL " + item.getUrl(), ex); + } + } + }); + + add(avatarAndRight); + } + + private String durationToString(Duration duration) + { + if (duration.getSeconds() >= 60 * 60 * 24) + { + return (int) (duration.getSeconds() / (60 * 60 * 24)) + "d"; + } + else if (duration.getSeconds() >= 60 * 60) + { + return (int) (duration.getSeconds() / (60 * 60)) + "h"; + } + return (int) (duration.getSeconds() / 60) + "m"; + } + + private String lineBreakText(String text, Font font) + { + StringBuilder newText = new StringBuilder(""); + + FontRenderContext fontRenderContext = new FontRenderContext(font.getTransform(), + true, true); + + int lines = 0; + int pos = 0; + String[] words = text.split(" "); + String line = ""; + + while (lines < MAX_CONTENT_LINES && pos < words.length) + { + String newLine = pos > 0 ? line + " " + words[pos] : words[pos]; + double width = font.getStringBounds(newLine, fontRenderContext).getWidth(); + + if (width >= CONTENT_WIDTH) + { + newText.append(line); + newText.append("
"); + line = ""; + lines++; + } + else + { + line = newLine; + pos++; + } + } + + newText.append(line); + newText.append(""); + + return newText.toString(); + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/feed/FeedPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/feed/FeedPlugin.java new file mode 100644 index 0000000000..714ba106cd --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/feed/FeedPlugin.java @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2018, Lotto + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package net.runelite.client.plugins.feed; + +import com.google.common.base.Suppliers; +import com.google.common.eventbus.Subscribe; +import com.google.inject.Provides; +import java.io.IOException; +import java.time.temporal.ChronoUnit; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; +import javax.imageio.ImageIO; +import javax.inject.Inject; +import lombok.extern.slf4j.Slf4j; +import net.runelite.api.events.ConfigChanged; +import net.runelite.client.config.ConfigManager; +import net.runelite.client.plugins.Plugin; +import net.runelite.client.plugins.PluginDescriptor; +import net.runelite.client.task.Schedule; +import net.runelite.client.ui.ClientUI; +import net.runelite.client.ui.NavigationButton; +import net.runelite.http.api.feed.FeedClient; +import net.runelite.http.api.feed.FeedResult; + +@PluginDescriptor( + name = "News Feed", + loadWhenOutdated = true +) +@Slf4j +public class FeedPlugin extends Plugin +{ + @Inject + private ClientUI ui; + + @Inject + private FeedConfig config; + + @Inject + private ScheduledExecutorService executorService; + + private FeedPanel feedPanel; + private NavigationButton navButton; + + private FeedClient feedClient = new FeedClient(); + private Supplier feedSupplier = Suppliers.memoizeWithExpiration(() -> + { + try + { + return feedClient.lookupFeed(); + } + catch (IOException e) + { + log.warn(null, e); + } + return null; + }, 10, TimeUnit.MINUTES); + + @Override + protected void startUp() throws Exception + { + feedPanel = new FeedPanel(config, feedSupplier); + + navButton = new NavigationButton( + "News Feed", + ImageIO.read(getClass().getResourceAsStream("icon.png")), + () -> feedPanel); + + ui.getPluginToolbar().addNavigation(navButton); + + executorService.submit(this::updateFeed); + } + + @Override + protected void shutDown() throws Exception + { + ui.getPluginToolbar().removeNavigation(navButton); + } + + private void updateFeed() + { + feedPanel.rebuildFeed(); + } + + @Subscribe + public void onConfigChanged(ConfigChanged event) + { + if (event.getGroup().equals("feed")) + { + executorService.submit(this::updateFeed); + } + } + + @Schedule( + period = 10, + unit = ChronoUnit.MINUTES, + asynchronous = true + ) + private void updateFeedTask() + { + updateFeed(); + } + + @Provides + FeedConfig provideConfig(ConfigManager configManager) + { + return configManager.getConfig(FeedConfig.class); + } +} diff --git a/runelite-client/src/main/resources/net/runelite/client/plugins/feed/icon.png b/runelite-client/src/main/resources/net/runelite/client/plugins/feed/icon.png new file mode 100644 index 0000000000..95b048b153 Binary files /dev/null and b/runelite-client/src/main/resources/net/runelite/client/plugins/feed/icon.png differ diff --git a/runelite-client/src/main/resources/net/runelite/client/plugins/feed/osrs.png b/runelite-client/src/main/resources/net/runelite/client/plugins/feed/osrs.png new file mode 100644 index 0000000000..2039454a18 Binary files /dev/null and b/runelite-client/src/main/resources/net/runelite/client/plugins/feed/osrs.png differ diff --git a/runelite-client/src/main/resources/net/runelite/client/plugins/feed/runelite.png b/runelite-client/src/main/resources/net/runelite/client/plugins/feed/runelite.png new file mode 100644 index 0000000000..a6c7fdcbae Binary files /dev/null and b/runelite-client/src/main/resources/net/runelite/client/plugins/feed/runelite.png differ