From c885a5fc2848ad0de8d78467223a406dc4a01fca Mon Sep 17 00:00:00 2001 From: Grishka Date: Wed, 2 Feb 2022 09:40:29 +0300 Subject: [PATCH] Better char counter and custom emoji in compose --- .../twittertext/TwitterTextEmojiRegex.java | 194 ++++++++++++++++++ .../joinmastodon/android/MainActivity.java | 1 + .../android/api/MastodonAPIController.java | 4 + .../android/api/requests/GetCustomEmojis.java | 14 ++ .../android/api/session/AccountSession.java | 2 + .../api/session/AccountSessionManager.java | 141 ++++++++++++- .../android/fragments/ComposeFragment.java | 55 ++++- .../android/model/EmojiCategory.java | 13 ++ .../android/ui/CustomEmojiPopupKeyboard.java | 194 ++++++++++++++++++ .../android/ui/PopupKeyboard.java | 170 +++++++++++++++ .../ui/views/SizeListenerLinearLayout.java | 47 +++++ .../joinmastodon/android/utils/CharRegex.java | 41 ++++ .../drawable/ic_fluent_emoji_24_filled.xml | 3 + .../drawable/ic_fluent_emoji_24_regular.xml | 3 + .../drawable/ic_fluent_emoji_24_selector.xml | 8 + .../main/res/layout/display_item_header.xml | 2 +- .../src/main/res/layout/fragment_compose.xml | 31 ++- .../main/res/layout/item_emoji_section.xml | 14 ++ mastodon/src/main/res/values/colors.xml | 6 +- 19 files changed, 927 insertions(+), 16 deletions(-) create mode 100644 mastodon/src/main/java/com/twitter/twittertext/TwitterTextEmojiRegex.java create mode 100644 mastodon/src/main/java/org/joinmastodon/android/api/requests/GetCustomEmojis.java create mode 100644 mastodon/src/main/java/org/joinmastodon/android/model/EmojiCategory.java create mode 100644 mastodon/src/main/java/org/joinmastodon/android/ui/CustomEmojiPopupKeyboard.java create mode 100644 mastodon/src/main/java/org/joinmastodon/android/ui/PopupKeyboard.java create mode 100644 mastodon/src/main/java/org/joinmastodon/android/ui/views/SizeListenerLinearLayout.java create mode 100644 mastodon/src/main/java/org/joinmastodon/android/utils/CharRegex.java create mode 100644 mastodon/src/main/res/drawable/ic_fluent_emoji_24_filled.xml create mode 100644 mastodon/src/main/res/drawable/ic_fluent_emoji_24_regular.xml create mode 100644 mastodon/src/main/res/drawable/ic_fluent_emoji_24_selector.xml create mode 100644 mastodon/src/main/res/layout/item_emoji_section.xml diff --git a/mastodon/src/main/java/com/twitter/twittertext/TwitterTextEmojiRegex.java b/mastodon/src/main/java/com/twitter/twittertext/TwitterTextEmojiRegex.java new file mode 100644 index 000000000..05d77d512 --- /dev/null +++ b/mastodon/src/main/java/com/twitter/twittertext/TwitterTextEmojiRegex.java @@ -0,0 +1,194 @@ +// +// TwitterTextEmojiRegex.java +// +// Copyright 2018 Twitter, Inc. +// Licensed under the Apache License, Version 2.0 +// http://www.apache.org/licenses/LICENSE-2.0 +// +// DO NOT MODIFY THIS FILE -- it is generated code +package com.twitter.twittertext; + +import java.util.regex.Pattern; + +public abstract class TwitterTextEmojiRegex { + // This regex attempts to match all known Unicode Emoji sequences as specified by + // http://www.unicode.org/reports/tr51/ + + // The goal is to parse them in the way iOS, Google, and others actually encode Emoji + // using variant selectors, skin tones, regional symbols, zero width joiners, etc.. + + private static final String EMOJI = + // Zero Width Joiners need to be ahead of other Emoji to get priority + "(?:\ud83d\udc68\ud83c\udffb\u200d\ud83e\udd1d\u200d\ud83d\udc68[\ud83c\udffc-\ud83c\udfff]" + + "|\ud83d\udc68\ud83c\udffc\u200d\ud83e\udd1d\u200d\ud83d\udc68[\ud83c\udffb\ud83c\udffd-" + + "\ud83c\udfff]|\ud83d\udc68\ud83c\udffd\u200d\ud83e\udd1d\u200d\ud83d\udc68[\ud83c\udffb" + + "\ud83c\udffc\ud83c\udffe\ud83c\udfff]|\ud83d\udc68\ud83c\udffe\u200d\ud83e\udd1d\u200d" + + "\ud83d\udc68[\ud83c\udffb-\ud83c\udffd\ud83c\udfff]|\ud83d\udc68\ud83c\udfff\u200d\ud83e" + + "\udd1d\u200d\ud83d\udc68[\ud83c\udffb-\ud83c\udffe]|\ud83d\udc69\ud83c\udffb\u200d\ud83e" + + "\udd1d\u200d\ud83d\udc68[\ud83c\udffc-\ud83c\udfff]|\ud83d\udc69\ud83c\udffb\u200d\ud83e" + + "\udd1d\u200d\ud83d\udc69[\ud83c\udffc-\ud83c\udfff]|\ud83d\udc69\ud83c\udffc\u200d\ud83e" + + "\udd1d\u200d\ud83d\udc68[\ud83c\udffb\ud83c\udffd-\ud83c\udfff]|\ud83d\udc69\ud83c\udffc" + + "\u200d\ud83e\udd1d\u200d\ud83d\udc69[\ud83c\udffb\ud83c\udffd-\ud83c\udfff]|\ud83d\udc69" + + "\ud83c\udffd\u200d\ud83e\udd1d\u200d\ud83d\udc68[\ud83c\udffb\ud83c\udffc\ud83c\udffe" + + "\ud83c\udfff]|\ud83d\udc69\ud83c\udffd\u200d\ud83e\udd1d\u200d\ud83d\udc69[\ud83c\udffb" + + "\ud83c\udffc\ud83c\udffe\ud83c\udfff]|\ud83d\udc69\ud83c\udffe\u200d\ud83e\udd1d\u200d" + + "\ud83d\udc68[\ud83c\udffb-\ud83c\udffd\ud83c\udfff]|\ud83d\udc69\ud83c\udffe\u200d\ud83e" + + "\udd1d\u200d\ud83d\udc69[\ud83c\udffb-\ud83c\udffd\ud83c\udfff]|\ud83d\udc69\ud83c\udfff" + + "\u200d\ud83e\udd1d\u200d\ud83d\udc68[\ud83c\udffb-\ud83c\udffe]|\ud83d\udc69\ud83c\udfff" + + "\u200d\ud83e\udd1d\u200d\ud83d\udc69[\ud83c\udffb-\ud83c\udffe]|\ud83e\uddd1\ud83c\udffb" + + "\u200d\ud83e\udd1d\u200d\ud83e\uddd1[\ud83c\udffb-\ud83c\udfff]|\ud83e\uddd1\ud83c\udffc" + + "\u200d\ud83e\udd1d\u200d\ud83e\uddd1[\ud83c\udffb-\ud83c\udfff]|\ud83e\uddd1\ud83c\udffd" + + "\u200d\ud83e\udd1d\u200d\ud83e\uddd1[\ud83c\udffb-\ud83c\udfff]|\ud83e\uddd1\ud83c\udffe" + + "\u200d\ud83e\udd1d\u200d\ud83e\uddd1[\ud83c\udffb-\ud83c\udfff]|\ud83e\uddd1\ud83c\udfff" + + "\u200d\ud83e\udd1d\u200d\ud83e\uddd1[\ud83c\udffb-\ud83c\udfff]|\ud83e\uddd1\u200d\ud83e" + + "\udd1d\u200d\ud83e\uddd1|\ud83d\udc6b[\ud83c\udffb-\ud83c\udfff]|\ud83d\udc6c[\ud83c\udffb" + + "-\ud83c\udfff]|\ud83d\udc6d[\ud83c\udffb-\ud83c\udfff]|[\ud83d\udc6b-\ud83d\udc6d])" + "|" + + + // Leading woman/man zwj with optional skin tone + "[\ud83d\udc68\ud83d\udc69\ud83e\uddd1][\ud83c\udffb-\ud83c\udfff]?\u200d" + + "(?:\u2695\ufe0f|\u2696\ufe0f|\u2708\ufe0f|[\ud83c\udf3e\ud83c\udf73\ud83c\udf93\ud83c" + + "\udfa4\ud83c\udfa8\ud83c\udfeb\ud83c\udfed\ud83d\udcbb\ud83d\udcbc\ud83d\udd27\ud83d\udd2c" + + "\ud83d\ude80\ud83d\ude92\ud83e\uddaf-\ud83e\uddb3\ud83e\uddbc\ud83e\uddbd])" + "|" + + + // Variant or skin tone before trailing female/male zwj (+ a captured group) + // The group provides a way to detect that the base has VS16 instead of skin tone + "[\u26f9\ud83c\udfcb\ud83c\udfcc\ud83d\udd74\ud83d\udd75]" + + "([\ufe0f\ud83c\udffb-\ud83c\udfff]\u200d[\u2640\u2642]\ufe0f)|" + + + // Optional skin tone before trailing female/male zwj + "[\ud83c\udfc3\ud83c\udfc4\ud83c\udfca\ud83d\udc6e\ud83d\udc71\ud83d\udc73\ud83d\udc77" + + "\ud83d\udc81\ud83d\udc82\ud83d\udc86\ud83d\udc87\ud83d\ude45-\ud83d\ude47\ud83d\ude4b" + + "\ud83d\ude4d\ud83d\ude4e\ud83d\udea3\ud83d\udeb4-\ud83d\udeb6\ud83e\udd26\ud83e\udd35" + + "\ud83e\udd37-\ud83e\udd39\ud83e\udd3d\ud83e\udd3e\ud83e\uddb8\ud83e\uddb9\ud83e\uddcd-" + + "\ud83e\uddcf\ud83e\uddd6-\ud83e\udddd]" + + "[\ud83c\udffb-\ud83c\udfff]?\u200d[\u2640\u2642]\ufe0f|" + + + // Other zwj sequences + "(?:\ud83d\udc68\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68|\ud83d\udc69\u200d" + + "\u2764\ufe0f\u200d\ud83d\udc8b\u200d[\ud83d\udc68\ud83d\udc69]|\ud83d\udc68\u200d\ud83d" + + "\udc68\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d" + + "\udc67\u200d[\ud83d\udc66\ud83d\udc67]|\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d\udc66" + + "\u200d\ud83d\udc66|\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d\udc67\u200d[\ud83d\udc66" + + "\ud83d\udc67]|\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d" + + "\udc69\u200d\ud83d\udc69\u200d\ud83d\udc67\u200d[\ud83d\udc66\ud83d\udc67]|\ud83d\udc68" + + "\u200d\u2764\ufe0f\u200d\ud83d\udc68|\ud83d\udc69\u200d\u2764\ufe0f\u200d[\ud83d\udc68" + + "\ud83d\udc69]|\ud83c\udff3\ufe0f\u200d\u26a7\ufe0f|\ud83d\udc68\u200d\ud83d\udc66\u200d" + + "\ud83d\udc66|\ud83d\udc68\u200d\ud83d\udc67\u200d[\ud83d\udc66\ud83d\udc67]|\ud83d\udc68" + + "\u200d\ud83d\udc68\u200d[\ud83d\udc66\ud83d\udc67]|\ud83d\udc68\u200d\ud83d\udc69\u200d[" + + "\ud83d\udc66\ud83d\udc67]|\ud83d\udc69\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc69" + + "\u200d\ud83d\udc67\u200d[\ud83d\udc66\ud83d\udc67]|\ud83d\udc69\u200d\ud83d\udc69\u200d[" + + "\ud83d\udc66\ud83d\udc67]|\ud83c\udff3\ufe0f\u200d\ud83c\udf08|\ud83c\udff4\u200d\u2620" + + "\ufe0f|\ud83d\udc6f\u200d\u2640\ufe0f|\ud83d\udc6f\u200d\u2642\ufe0f|\ud83e\udd3c\u200d" + + "\u2640\ufe0f|\ud83e\udd3c\u200d\u2642\ufe0f|\ud83e\uddde\u200d\u2640\ufe0f|\ud83e\uddde" + + "\u200d\u2642\ufe0f|\ud83e\udddf\u200d\u2640\ufe0f|\ud83e\udddf\u200d\u2642\ufe0f|\ud83d" + + "\udc15\u200d\ud83e\uddba|\ud83d\udc41\u200d\ud83d\udde8|\ud83d\udc68\u200d[\ud83d\udc66" + + "\ud83d\udc67]|\ud83d\udc69\u200d[\ud83d\udc66\ud83d\udc67])" + "|" + + + // Emojified symbols #, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 + possible variant selector + keycap + "[#*0-9]" + + "\ufe0f?\u20e3|" + + + // Emoji which default to text must be followed by U+fe0f + "(?:[©®\u2122\u265f]\ufe0f)|" + + + // Variants that may be followed by U+fe0f but cannot be followed by U+fe0e + "[\u203c\u2049\u2139\u2194-\u2199\u21a9\u21aa\u231a\u231b\u2328\u23cf\u23ed-\u23ef\u23f1" + + "\u23f2\u23f8-\u23fa\u24c2\u25aa\u25ab\u25b6\u25c0\u25fb-\u25fe\u2600-\u2604\u260e\u2611" + + "\u2614\u2615\u2618\u2620\u2622\u2623\u2626\u262a\u262e\u262f\u2638-\u263a\u2640\u2642" + + "\u2648-\u2653\u2660\u2663\u2665\u2666\u2668\u267b\u267f\u2692-\u2697\u2699\u269b\u269c" + + "\u26a0\u26a1\u26a7\u26aa\u26ab\u26b0\u26b1\u26bd\u26be\u26c4\u26c5\u26c8\u26cf\u26d1\u26d3" + + "\u26d4\u26e9\u26ea\u26f0-\u26f5\u26f8\u26fa\u26fd\u2702\u2708\u2709\u270f\u2712\u2714" + + "\u2716\u271d\u2721\u2733\u2734\u2744\u2747\u2757\u2763\u2764\u27a1\u2934\u2935\u2b05-" + + "\u2b07\u2b1b\u2b1c\u2b50\u2b55\u3030\u303d\u3297\u3299\ud83c\udc04\ud83c\udd70\ud83c\udd71" + + "\ud83c\udd7e\ud83c\udd7f\ud83c\ude02\ud83c\ude1a\ud83c\ude2f\ud83c\ude37\ud83c\udf21\ud83c" + + "\udf24-\ud83c\udf2c\ud83c\udf36\ud83c\udf7d\ud83c\udf96\ud83c\udf97\ud83c\udf99-\ud83c" + + "\udf9b\ud83c\udf9e\ud83c\udf9f\ud83c\udfcd\ud83c\udfce\ud83c\udfd4-\ud83c\udfdf\ud83c" + + "\udff3\ud83c\udff5\ud83c\udff7\ud83d\udc3f\ud83d\udc41\ud83d\udcfd\ud83d\udd49\ud83d\udd4a" + + "\ud83d\udd6f\ud83d\udd70\ud83d\udd73\ud83d\udd76-\ud83d\udd79\ud83d\udd87\ud83d\udd8a-" + + "\ud83d\udd8d\ud83d\udda5\ud83d\udda8\ud83d\uddb1\ud83d\uddb2\ud83d\uddbc\ud83d\uddc2-" + + "\ud83d\uddc4\ud83d\uddd1-\ud83d\uddd3\ud83d\udddc-\ud83d\uddde\ud83d\udde1\ud83d\udde3" + + "\ud83d\udde8\ud83d\uddef\ud83d\uddf3\ud83d\uddfa\ud83d\udecb\ud83d\udecd-\ud83d\udecf" + + "\ud83d\udee0-\ud83d\udee5\ud83d\udee9\ud83d\udef0\ud83d\udef3]" + + "(?:\ufe0f|(?!\ufe0e))|" + + + // Diversity Emoji followed by optional skin tone + "(?:" + + // Diversity variants that may be followed by U+fe0f but cannot be followed by U+fe0e + "[\u261d\u26f7\u26f9\u270c\u270d\ud83c\udfcb\ud83c\udfcc\ud83d\udd74\ud83d\udd75\ud83d" + + "\udd90]" + + "(?:\ufe0f|(?!\ufe0e))|" + + + // Diversity non-variants + "[\u270a\u270b\ud83c\udf85\ud83c\udfc2-\ud83c\udfc4\ud83c\udfc7\ud83c\udfca\ud83d\udc42" + + "\ud83d\udc43\ud83d\udc46-\ud83d\udc50\ud83d\udc66-\ud83d\udc69\ud83d\udc6e\ud83d\udc70-" + + "\ud83d\udc78\ud83d\udc7c\ud83d\udc81-\ud83d\udc83\ud83d\udc85-\ud83d\udc87\ud83d\udcaa" + + "\ud83d\udd7a\ud83d\udd95\ud83d\udd96\ud83d\ude45-\ud83d\ude47\ud83d\ude4b-\ud83d\ude4f" + + "\ud83d\udea3\ud83d\udeb4-\ud83d\udeb6\ud83d\udec0\ud83d\udecc\ud83e\udd0f\ud83e\udd18-" + + "\ud83e\udd1c\ud83e\udd1e\ud83e\udd1f\ud83e\udd26\ud83e\udd30-\ud83e\udd39\ud83e\udd3d" + + "\ud83e\udd3e\ud83e\uddb5\ud83e\uddb6\ud83e\uddb8\ud83e\uddb9\ud83e\uddbb\ud83e\uddcd-" + + "\ud83e\uddcf\ud83e\uddd1-\ud83e\udddd]" + + ")[\ud83c\udffb-\ud83c\udfff]?|" + + + // Flags, Regional Symbols, and Normal Emoji + "(?:\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc65\udb40\udc6e\udb40\udc67\udb40\udc7f|" + + "\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc73\udb40\udc63\udb40\udc74\udb40\udc7f|" + + "\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc77\udb40\udc6c\udb40\udc73\udb40\udc7f|" + + "\ud83c\udde6[\ud83c\udde8-\ud83c\uddec\ud83c\uddee\ud83c\uddf1\ud83c\uddf2\ud83c\uddf4" + + "\ud83c\uddf6-\ud83c\uddfa\ud83c\uddfc\ud83c\uddfd\ud83c\uddff]|\ud83c\udde7[\ud83c\udde6" + + "\ud83c\udde7\ud83c\udde9-\ud83c\uddef\ud83c\uddf1-\ud83c\uddf4\ud83c\uddf6-\ud83c\uddf9" + + "\ud83c\uddfb\ud83c\uddfc\ud83c\uddfe\ud83c\uddff]|\ud83c\udde8[\ud83c\udde6\ud83c\udde8" + + "\ud83c\udde9\ud83c\uddeb-\ud83c\uddee\ud83c\uddf0-\ud83c\uddf5\ud83c\uddf7\ud83c\uddfa-" + + "\ud83c\uddff]|\ud83c\udde9[\ud83c\uddea\ud83c\uddec\ud83c\uddef\ud83c\uddf0\ud83c\uddf2" + + "\ud83c\uddf4\ud83c\uddff]|\ud83c\uddea[\ud83c\udde6\ud83c\udde8\ud83c\uddea\ud83c\uddec" + + "\ud83c\udded\ud83c\uddf7-\ud83c\uddfa]|\ud83c\uddeb[\ud83c\uddee-\ud83c\uddf0\ud83c\uddf2" + + "\ud83c\uddf4\ud83c\uddf7]|\ud83c\uddec[\ud83c\udde6\ud83c\udde7\ud83c\udde9-\ud83c\uddee" + + "\ud83c\uddf1-\ud83c\uddf3\ud83c\uddf5-\ud83c\uddfa\ud83c\uddfc\ud83c\uddfe]|\ud83c\udded[" + + "\ud83c\uddf0\ud83c\uddf2\ud83c\uddf3\ud83c\uddf7\ud83c\uddf9\ud83c\uddfa]|\ud83c\uddee[" + + "\ud83c\udde8-\ud83c\uddea\ud83c\uddf1-\ud83c\uddf4\ud83c\uddf6-\ud83c\uddf9]|\ud83c\uddef[" + + "\ud83c\uddea\ud83c\uddf2\ud83c\uddf4\ud83c\uddf5]|\ud83c\uddf0[\ud83c\uddea\ud83c\uddec-" + + "\ud83c\uddee\ud83c\uddf2\ud83c\uddf3\ud83c\uddf5\ud83c\uddf7\ud83c\uddfc\ud83c\uddfe\ud83c" + + "\uddff]|\ud83c\uddf1[\ud83c\udde6-\ud83c\udde8\ud83c\uddee\ud83c\uddf0\ud83c\uddf7-\ud83c" + + "\uddfb\ud83c\uddfe]|\ud83c\uddf2[\ud83c\udde6\ud83c\udde8-\ud83c\udded\ud83c\uddf0-\ud83c" + + "\uddff]|\ud83c\uddf3[\ud83c\udde6\ud83c\udde8\ud83c\uddea-\ud83c\uddec\ud83c\uddee\ud83c" + + "\uddf1\ud83c\uddf4\ud83c\uddf5\ud83c\uddf7\ud83c\uddfa\ud83c\uddff]|\ud83c\uddf4\ud83c" + + "\uddf2|\ud83c\uddf5[\ud83c\udde6\ud83c\uddea-\ud83c\udded\ud83c\uddf0-\ud83c\uddf3\ud83c" + + "\uddf7-\ud83c\uddf9\ud83c\uddfc\ud83c\uddfe]|\ud83c\uddf6\ud83c\udde6|\ud83c\uddf7[\ud83c" + + "\uddea\ud83c\uddf4\ud83c\uddf8\ud83c\uddfa\ud83c\uddfc]|\ud83c\uddf8[\ud83c\udde6-\ud83c" + + "\uddea\ud83c\uddec-\ud83c\uddf4\ud83c\uddf7-\ud83c\uddf9\ud83c\uddfb\ud83c\uddfd-\ud83c" + + "\uddff]|\ud83c\uddf9[\ud83c\udde6\ud83c\udde8\ud83c\udde9\ud83c\uddeb-\ud83c\udded\ud83c" + + "\uddef-\ud83c\uddf4\ud83c\uddf7\ud83c\uddf9\ud83c\uddfb\ud83c\uddfc\ud83c\uddff]|\ud83c" + + "\uddfa[\ud83c\udde6\ud83c\uddec\ud83c\uddf2\ud83c\uddf3\ud83c\uddf8\ud83c\uddfe\ud83c" + + "\uddff]|\ud83c\uddfb[\ud83c\udde6\ud83c\udde8\ud83c\uddea\ud83c\uddec\ud83c\uddee\ud83c" + + "\uddf3\ud83c\uddfa]|\ud83c\uddfc[\ud83c\uddeb\ud83c\uddf8]|\ud83c\uddfd\ud83c\uddf0|\ud83c" + + "\uddfe[\ud83c\uddea\ud83c\uddf9]|\ud83c\uddff[\ud83c\udde6\ud83c\uddf2\ud83c\uddfc]|[" + + "\u23e9-\u23ec\u23f0\u23f3\u267e\u26ce\u2705\u2728\u274c\u274e\u2753-\u2755\u2795-\u2797" + + "\u27b0\u27bf\ue50a\ud83c\udccf\ud83c\udd8e\ud83c\udd91-\ud83c\udd9a\ud83c\udde6-\ud83c" + + "\uddff\ud83c\ude01\ud83c\ude32-\ud83c\ude36\ud83c\ude38-\ud83c\ude3a\ud83c\ude50\ud83c" + + "\ude51\ud83c\udf00-\ud83c\udf20\ud83c\udf2d-\ud83c\udf35\ud83c\udf37-\ud83c\udf7c\ud83c" + + "\udf7e-\ud83c\udf84\ud83c\udf86-\ud83c\udf93\ud83c\udfa0-\ud83c\udfc1\ud83c\udfc5\ud83c" + + "\udfc6\ud83c\udfc8\ud83c\udfc9\ud83c\udfcf-\ud83c\udfd3\ud83c\udfe0-\ud83c\udff0\ud83c" + + "\udff4\ud83c\udff8-\ud83d\udc3e\ud83d\udc40\ud83d\udc44\ud83d\udc45\ud83d\udc51-\ud83d" + + "\udc65\ud83d\udc6a\ud83d\udc6f\ud83d\udc79-\ud83d\udc7b\ud83d\udc7d-\ud83d\udc80\ud83d" + + "\udc84\ud83d\udc88-\ud83d\udca9\ud83d\udcab-\ud83d\udcfc\ud83d\udcff-\ud83d\udd3d\ud83d" + + "\udd4b-\ud83d\udd4e\ud83d\udd50-\ud83d\udd67\ud83d\udda4\ud83d\uddfb-\ud83d\ude44\ud83d" + + "\ude48-\ud83d\ude4a\ud83d\ude80-\ud83d\udea2\ud83d\udea4-\ud83d\udeb3\ud83d\udeb7-\ud83d" + + "\udebf\ud83d\udec1-\ud83d\udec5\ud83d\uded0-\ud83d\uded2\ud83d\uded5\ud83d\udeeb\ud83d" + + "\udeec\ud83d\udef4-\ud83d\udefa\ud83d\udfe0-\ud83d\udfeb\ud83e\udd0d\ud83e\udd0e\ud83e" + + "\udd10-\ud83e\udd17\ud83e\udd1d\ud83e\udd20-\ud83e\udd25\ud83e\udd27-\ud83e\udd2f\ud83e" + + "\udd3a\ud83e\udd3c\ud83e\udd3f-\ud83e\udd45\ud83e\udd47-\ud83e\udd71\ud83e\udd73-\ud83e" + + "\udd76\ud83e\udd7a-\ud83e\udda2\ud83e\udda5-\ud83e\uddaa\ud83e\uddae-\ud83e\uddb4\ud83e" + + "\uddb7\ud83e\uddba\ud83e\uddbc-\ud83e\uddca\ud83e\uddd0\ud83e\uddde-\ud83e\uddff\ud83e" + + "\ude70-\ud83e\ude73\ud83e\ude78-\ud83e\ude7a\ud83e\ude80-\ud83e\ude82\ud83e\ude90-\ud83e" + + "\ude95])" + "|" + + + // Stray VS16s can adversely affect preceeding non-emoji characters + "\ufe0f"; + + public static final Pattern VALID_EMOJI_PATTERN; + + static { + synchronized (TwitterTextEmojiRegex.class) { + VALID_EMOJI_PATTERN = Pattern.compile(EMOJI); + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/MainActivity.java b/mastodon/src/main/java/org/joinmastodon/android/MainActivity.java index c1e193699..e4292315f 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/MainActivity.java +++ b/mastodon/src/main/java/org/joinmastodon/android/MainActivity.java @@ -18,6 +18,7 @@ public class MainActivity extends FragmentStackActivity{ if(AccountSessionManager.getInstance().getLoggedInAccounts().isEmpty()){ showFragmentClearingBackStack(new SplashFragment()); }else{ + AccountSessionManager.getInstance().maybeUpdateLocalInfo(); Bundle args=new Bundle(); args.putString("account", AccountSessionManager.getInstance().getLastActiveAccountID()); HomeFragment fragment=new HomeFragment(); diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java index 6702c7201..e6a8c696f 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java @@ -163,4 +163,8 @@ public class MastodonAPIController{ } }, 0); } + + public static void runInBackground(Runnable action){ + thread.postRunnable(action, 0); + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/GetCustomEmojis.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/GetCustomEmojis.java new file mode 100644 index 000000000..9a70a477f --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/GetCustomEmojis.java @@ -0,0 +1,14 @@ +package org.joinmastodon.android.api.requests; + +import com.google.gson.reflect.TypeToken; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.Emoji; + +import java.util.List; + +public class GetCustomEmojis extends MastodonAPIRequest>{ + public GetCustomEmojis(){ + super(HttpMethod.GET, "/custom_emojis", new TypeToken<>(){}); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java index f007235d4..793f8ea4e 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java @@ -11,6 +11,7 @@ public class AccountSession{ public String domain; public int tootCharLimit; public Application app; + public long infoLastUpdated; private transient MastodonAPIController apiController; AccountSession(Token token, Account self, Application app, String domain, int tootCharLimit){ @@ -19,6 +20,7 @@ public class AccountSession{ this.domain=domain; this.app=app; this.tootCharLimit=tootCharLimit; + infoLastUpdated=System.currentTimeMillis(); } AccountSession(){} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java index e19540b0c..2685dbe06 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java @@ -2,21 +2,22 @@ package org.joinmastodon.android.api.session; import android.app.ProgressDialog; import android.content.Context; -import android.content.Intent; import android.content.SharedPreferences; import android.net.Uri; import android.util.Log; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; +import com.google.gson.JsonParseException; import org.joinmastodon.android.MastodonApp; -import org.joinmastodon.android.OAuthActivity; import org.joinmastodon.android.R; import org.joinmastodon.android.api.MastodonAPIController; +import org.joinmastodon.android.api.requests.GetCustomEmojis; +import org.joinmastodon.android.api.requests.accounts.GetOwnAccount; import org.joinmastodon.android.api.requests.oauth.CreateOAuthApp; import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.Application; +import org.joinmastodon.android.model.Emoji; +import org.joinmastodon.android.model.EmojiCategory; import org.joinmastodon.android.model.Instance; import org.joinmastodon.android.model.Token; @@ -28,8 +29,13 @@ import java.io.InputStreamReader; import java.io.OutputStreamWriter; import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; import java.util.HashMap; +import java.util.HashSet; import java.util.List; +import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; import androidx.annotation.NonNull; @@ -46,11 +52,14 @@ public class AccountSessionManager{ private static final AccountSessionManager instance=new AccountSessionManager(); private HashMap sessions=new HashMap<>(); + private HashMap> customEmojis=new HashMap<>(); + private HashMap customEmojisLastUpdated=new HashMap<>(); private MastodonAPIController unauthenticatedApiController=new MastodonAPIController(null); private Instance authenticatingInstance; private Application authenticatingApp; private String lastActiveAccountID; private SharedPreferences prefs; + private boolean loadedCustomEmojis; public static AccountSessionManager getInstance(){ return instance; @@ -61,15 +70,18 @@ public class AccountSessionManager{ File file=new File(MastodonApp.context.getFilesDir(), "accounts.json"); if(!file.exists()) return; + HashSet domains=new HashSet<>(); try(FileInputStream in=new FileInputStream(file)){ SessionsStorageWrapper w=MastodonAPIController.gson.fromJson(new InputStreamReader(in, StandardCharsets.UTF_8), SessionsStorageWrapper.class); for(AccountSession session:w.accounts){ + domains.add(session.domain.toLowerCase()); sessions.put(session.getID(), session); } - }catch(IOException x){ + }catch(IOException|JsonParseException x){ Log.e(TAG, "Error loading accounts", x); } lastActiveAccountID=prefs.getString("lastActiveAccount", null); + MastodonAPIController.runInBackground(()->readCustomEmojis(domains)); } public void addAccount(Instance instance, Token token, Account self, Application app){ @@ -129,6 +141,10 @@ public class AccountSessionManager{ lastActiveAccountID=getLoggedInAccounts().get(0).getID(); } writeAccountsFile(); + String domain=session.domain.toLowerCase(); + if(sessions.isEmpty() || !sessions.values().stream().map(s->s.domain.toLowerCase()).collect(Collectors.toSet()).contains(domain)){ + getCustomEmojisFile(domain).delete(); + } } @NonNull @@ -181,7 +197,122 @@ public class AccountSessionManager{ return authenticatingApp; } + public void maybeUpdateLocalInfo(){ + long now=System.currentTimeMillis(); + HashSet domains=new HashSet<>(); + for(AccountSession session:sessions.values()){ + domains.add(session.domain.toLowerCase()); + if(now-session.infoLastUpdated>24L*3600_000L){ + updateSessionLocalInfo(session); + } + } + if(loadedCustomEmojis){ + maybeUpdateCustomEmojis(domains); + } + } + + private void maybeUpdateCustomEmojis(Set domains){ + long now=System.currentTimeMillis(); + for(String domain:domains){ + Long lastUpdated=customEmojisLastUpdated.get(domain); + if(lastUpdated==null || now-lastUpdated>24L*3600_000L){ + updateCustomEmojis(domain); + } + } + } + + private void updateSessionLocalInfo(AccountSession session){ + new GetOwnAccount() + .setCallback(new Callback<>(){ + @Override + public void onSuccess(Account result){ + session.self=result; + session.infoLastUpdated=System.currentTimeMillis(); + writeAccountsFile(); + } + + @Override + public void onError(ErrorResponse error){ + + } + }) + .exec(session.getID()); + } + + private void updateCustomEmojis(String domain){ + new GetCustomEmojis() + .setCallback(new Callback<>(){ + @Override + public void onSuccess(List result){ + CustomEmojisStorageWrapper emojis=new CustomEmojisStorageWrapper(); + emojis.lastUpdated=System.currentTimeMillis(); + emojis.emojis=result; + customEmojis.put(domain, groupCustomEmojis(emojis)); + customEmojisLastUpdated.put(domain, emojis.lastUpdated); + MastodonAPIController.runInBackground(()->writeCustomEmojisFile(emojis, domain)); + } + + @Override + public void onError(ErrorResponse error){ + + } + }) + .execNoAuth(domain); + } + + private File getCustomEmojisFile(String domain){ + return new File(MastodonApp.context.getFilesDir(), "emojis_"+domain.replace('.', '_')+".json"); + } + + private void writeCustomEmojisFile(CustomEmojisStorageWrapper emojis, String domain){ + try(FileOutputStream out=new FileOutputStream(getCustomEmojisFile(domain))){ + OutputStreamWriter writer=new OutputStreamWriter(out, StandardCharsets.UTF_8); + MastodonAPIController.gson.toJson(emojis, writer); + writer.flush(); + }catch(IOException x){ + Log.w(TAG, "Error writing emojis file for "+domain, x); + } + } + + private void readCustomEmojis(Set domains){ + for(String domain:domains){ + try(FileInputStream in=new FileInputStream(getCustomEmojisFile(domain))){ + InputStreamReader reader=new InputStreamReader(in, StandardCharsets.UTF_8); + CustomEmojisStorageWrapper emojis=MastodonAPIController.gson.fromJson(reader, CustomEmojisStorageWrapper.class); + customEmojis.put(domain, groupCustomEmojis(emojis)); + customEmojisLastUpdated.put(domain, emojis.lastUpdated); + }catch(IOException|JsonParseException x){ + Log.w(TAG, "Error reading emojis file for "+domain, x); + } + } + if(!loadedCustomEmojis){ + loadedCustomEmojis=true; + maybeUpdateCustomEmojis(domains); + } + } + + private List groupCustomEmojis(CustomEmojisStorageWrapper emojis){ + return emojis.emojis.stream() + .filter(e->e.visibleInPicker) + .collect(Collectors.groupingBy(e->e.category==null ? "" : e.category)) + .entrySet() + .stream() + .map(e->new EmojiCategory(e.getKey(), e.getValue())) + .sorted(Comparator.comparing(c->c.title)) + .collect(Collectors.toList()); + } + + public List getCustomEmojis(String domain){ + List r=customEmojis.get(domain.toLowerCase()); + return r==null ? Collections.emptyList() : r; + } + private static class SessionsStorageWrapper{ public List accounts; } + + private static class CustomEmojisStorageWrapper{ + public List emojis; + public long lastUpdated; + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java index 6f923e4fe..624df127f 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java @@ -1,7 +1,10 @@ package org.joinmastodon.android.fragments; +import android.annotation.SuppressLint; import android.app.Activity; +import android.content.res.Configuration; import android.graphics.Outline; +import android.icu.text.BreakIterator; import android.os.Bundle; import android.text.Editable; import android.text.TextWatcher; @@ -14,10 +17,12 @@ import android.view.ViewGroup; import android.view.ViewOutlineProvider; import android.view.inputmethod.InputMethodManager; import android.widget.EditText; +import android.widget.ImageButton; import android.widget.ImageView; import android.widget.TextView; import com.twitter.twittertext.Regex; +import com.twitter.twittertext.TwitterTextEmojiRegex; import org.joinmastodon.android.E; import org.joinmastodon.android.R; @@ -26,9 +31,14 @@ import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.events.StatusCreatedEvent; import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.model.Emoji; +import org.joinmastodon.android.model.EmojiCategory; import org.joinmastodon.android.model.Status; +import org.joinmastodon.android.ui.CustomEmojiPopupKeyboard; +import org.joinmastodon.android.ui.PopupKeyboard; +import org.joinmastodon.android.ui.views.SizeListenerLinearLayout; -import java.text.BreakIterator; +import java.util.List; import java.util.UUID; import java.util.regex.Pattern; @@ -59,8 +69,10 @@ public class ComposeFragment extends ToolbarFragment{ ")" + ")"; private static final Pattern URL_PATTERN=Pattern.compile(VALID_URL_PATTERN_STRING, Pattern.CASE_INSENSITIVE); + @SuppressLint("NewApi") // this class actually exists on 6.0 private final BreakIterator breakIterator=BreakIterator.getCharacterInstance(); + private SizeListenerLinearLayout contentView; private TextView selfName, selfUsername; private ImageView selfAvatar; private Account self; @@ -72,6 +84,10 @@ public class ComposeFragment extends ToolbarFragment{ private int charCount, charLimit; private MenuItem publishButton; + private ImageButton emojiBtn; + + private List customEmojis; + private CustomEmojiPopupKeyboard emojiKeyboard; @Override public void onAttach(Activity activity){ @@ -84,6 +100,9 @@ public class ComposeFragment extends ToolbarFragment{ charLimit=500; self=session.self; instanceDomain=session.domain; + customEmojis=AccountSessionManager.getInstance().getCustomEmojis(instanceDomain); + emojiKeyboard=new CustomEmojiPopupKeyboard(activity, customEmojis, instanceDomain); + emojiKeyboard.setListener(this::onCustomEmojiClick); } @Override @@ -108,6 +127,18 @@ public class ComposeFragment extends ToolbarFragment{ selfAvatar.setOutlineProvider(roundCornersOutline); selfAvatar.setClipToOutline(true); + emojiBtn=view.findViewById(R.id.btn_emoji); + emojiBtn.setOnClickListener(v->emojiKeyboard.toggleKeyboardPopup(mainEditText)); + emojiKeyboard.setOnIconChangedListener(new PopupKeyboard.OnIconChangeListener(){ + @Override + public void onIconChanged(int icon){ + emojiBtn.setSelected(icon!=PopupKeyboard.ICON_HIDDEN); + } + }); + + contentView=(SizeListenerLinearLayout) view; + contentView.addView(emojiKeyboard.getView()); + return view; } @@ -119,9 +150,10 @@ public class ComposeFragment extends ToolbarFragment{ @Override public void onViewCreated(View view, Bundle savedInstanceState){ super.onViewCreated(view, savedInstanceState); + contentView.setSizeListener(emojiKeyboard::onContentViewSizeChanged); InputMethodManager imm=getActivity().getSystemService(InputMethodManager.class); + mainEditText.requestFocus(); view.postDelayed(()->{ - mainEditText.requestFocus(); imm.showSoftInput(mainEditText, 0); }, 100); @@ -173,10 +205,21 @@ public class ComposeFragment extends ToolbarFragment{ return true; } + @Override + public void onConfigurationChanged(Configuration newConfig){ + super.onConfigurationChanged(newConfig); + emojiKeyboard.onConfigurationChanged(); + } + + @SuppressLint("NewApi") private void updateCharCounter(CharSequence text){ - String countableText=MENTION_PATTERN.matcher(URL_PATTERN.matcher(text).replaceAll("$2xxxxxxxxxxxxxxxxxxxxxxx")).replaceAll("$1@$3"); - breakIterator.setText(countableText); + String countableText=TwitterTextEmojiRegex.VALID_EMOJI_PATTERN.matcher( + MENTION_PATTERN.matcher( + URL_PATTERN.matcher(text).replaceAll("$2xxxxxxxxxxxxxxxxxxxxxxx") + ).replaceAll("$1@$3") + ).replaceAll("x"); charCount=0; + breakIterator.setText(countableText); while(breakIterator.next()!=BreakIterator.DONE){ charCount++; } @@ -188,4 +231,8 @@ public class ComposeFragment extends ToolbarFragment{ private void updatePublishButtonState(){ publishButton.setEnabled(charCount>0 && charCount<=charLimit); } + + private void onCustomEmojiClick(Emoji emoji){ + mainEditText.getText().replace(mainEditText.getSelectionStart(), mainEditText.getSelectionEnd(), ':'+emoji.shortcode+':'); + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/EmojiCategory.java b/mastodon/src/main/java/org/joinmastodon/android/model/EmojiCategory.java new file mode 100644 index 000000000..902a60eb2 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/EmojiCategory.java @@ -0,0 +1,13 @@ +package org.joinmastodon.android.model; + +import java.util.List; + +public class EmojiCategory{ + public String title; + public List emojis; + + public EmojiCategory(String title, List emojis){ + this.title=title; + this.emojis=emojis; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/CustomEmojiPopupKeyboard.java b/mastodon/src/main/java/org/joinmastodon/android/ui/CustomEmojiPopupKeyboard.java new file mode 100644 index 000000000..e236d4783 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/CustomEmojiPopupKeyboard.java @@ -0,0 +1,194 @@ +package org.joinmastodon.android.ui; + +import android.app.Activity; +import android.content.res.TypedArray; +import android.graphics.Rect; +import android.graphics.drawable.Animatable; +import android.graphics.drawable.Drawable; +import android.text.TextUtils; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.model.Emoji; +import org.joinmastodon.android.model.EmojiCategory; + +import java.util.List; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import me.grishka.appkit.imageloader.ImageLoaderRecyclerAdapter; +import me.grishka.appkit.imageloader.ImageLoaderViewHolder; +import me.grishka.appkit.imageloader.ListImageLoaderWrapper; +import me.grishka.appkit.imageloader.RecyclerViewDelegate; +import me.grishka.appkit.imageloader.requests.ImageLoaderRequest; +import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest; +import me.grishka.appkit.utils.BindableViewHolder; +import me.grishka.appkit.utils.MergeRecyclerAdapter; +import me.grishka.appkit.utils.V; +import me.grishka.appkit.views.UsableRecyclerView; + +public class CustomEmojiPopupKeyboard extends PopupKeyboard{ + private List emojis; + private UsableRecyclerView list; + private ListImageLoaderWrapper imgLoader; + private MergeRecyclerAdapter adapter=new MergeRecyclerAdapter(); + private String domain; + private int gridGap; + private int spanCount=6; + private Consumer listener; + + public CustomEmojiPopupKeyboard(Activity activity, List emojis, String domain){ + super(activity); + this.emojis=emojis; + this.domain=domain; + } + + @Override + protected View onCreateView(){ + GridLayoutManager lm=new GridLayoutManager(activity, spanCount); + list=new UsableRecyclerView(activity){ + @Override + protected void onMeasure(int widthSpec, int heightSpec){ + // it's important to do this in onMeasure so the child views will be measured with correct paddings already set + spanCount=Math.round(MeasureSpec.getSize(widthSpec)/(float)V.dp(44+20)); + lm.setSpanCount(spanCount); + int pad=V.dp(16); + gridGap=(MeasureSpec.getSize(widthSpec)-pad*2-V.dp(44)*spanCount)/(spanCount-1); + setPadding(pad, 0, pad-gridGap, 0); + invalidateItemDecorations(); + super.onMeasure(widthSpec, heightSpec); + } + }; + lm.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup(){ + @Override + public int getSpanSize(int position){ + if(adapter.getItemViewType(position)==0) + return lm.getSpanCount(); + return 1; + } + }); + list.setLayoutManager(lm); + imgLoader=new ListImageLoaderWrapper(activity, list, new RecyclerViewDelegate(list), null); + + for(EmojiCategory category:emojis) + adapter.addAdapter(new SingleCategoryAdapter(category)); + list.setAdapter(adapter); + list.addItemDecoration(new RecyclerView.ItemDecoration(){ + @Override + public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state){ + outRect.right=gridGap; + if(view instanceof TextView){ // section header + if(parent.getChildAdapterPosition(view)>0) + outRect.top=-gridGap; // negate the margin added by the emojis above + }else{ + outRect.bottom=gridGap; + } + } + }); + list.setBackgroundResource(R.color.gray_100); + list.setSelector(null); + + return list; + } + + public void setListener(Consumer listener){ + this.listener=listener; + } + + private class SingleCategoryAdapter extends UsableRecyclerView.Adapter implements ImageLoaderRecyclerAdapter{ + private final EmojiCategory category; + private final List requests; + + public SingleCategoryAdapter(EmojiCategory category){ + super(imgLoader); + this.category=category; + requests=category.emojis.stream().map(e->new UrlImageLoaderRequest(e.url, V.dp(44), V.dp(44))).collect(Collectors.toList()); + } + + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){ + return viewType==0 ? new SectionHeaderViewHolder() : new EmojiViewHolder(); + } + + @Override + public void onBindViewHolder(RecyclerView.ViewHolder holder, int position){ + if(holder instanceof EmojiViewHolder){ + ((EmojiViewHolder) holder).bind(category.emojis.get(position-1)); + ((EmojiViewHolder) holder).positionWithinCategory=position-1; + }else if(holder instanceof SectionHeaderViewHolder){ + ((SectionHeaderViewHolder) holder).bind(TextUtils.isEmpty(category.title) ? domain : category.title); + } + super.onBindViewHolder(holder, position); + } + + @Override + public int getItemCount(){ + return category.emojis.size()+1; + } + + @Override + public int getItemViewType(int position){ + return position==0 ? 0 : 1; + } + + @Override + public int getImageCountForItem(int position){ + return position>0 ? 1 : 0; + } + + @Override + public ImageLoaderRequest getImageRequest(int position, int image){ + return requests.get(position-1); + } + } + + private class SectionHeaderViewHolder extends BindableViewHolder{ + public SectionHeaderViewHolder(){ + super(activity, R.layout.item_emoji_section, list); + } + + @Override + public void onBind(String item){ + ((TextView)itemView).setText(item); + } + } + + private class EmojiViewHolder extends BindableViewHolder implements ImageLoaderViewHolder, UsableRecyclerView.Clickable{ + public int positionWithinCategory; + public EmojiViewHolder(){ + super(new ImageView(activity)); + ImageView img=(ImageView) itemView; + img.setLayoutParams(new RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, V.dp(44))); + img.setScaleType(ImageView.ScaleType.FIT_CENTER); + } + + @Override + public void onBind(Emoji item){ + + } + + @Override + public void setImage(int index, Drawable image){ + ((ImageView)itemView).setImageDrawable(image); + if(image instanceof Animatable) + ((Animatable) image).start(); + } + + @Override + public void clearImage(int index){ + ((ImageView)itemView).setImageDrawable(null); + } + + @Override + public void onClick(){ + listener.accept(item); + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/PopupKeyboard.java b/mastodon/src/main/java/org/joinmastodon/android/ui/PopupKeyboard.java new file mode 100644 index 000000000..242193408 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/PopupKeyboard.java @@ -0,0 +1,170 @@ +package org.joinmastodon.android.ui; + +import android.app.Activity; +import android.content.Context; +import android.util.DisplayMetrics; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewTreeObserver; +import android.view.Window; +import android.view.inputmethod.InputMethodManager; +import android.widget.LinearLayout; + +import me.grishka.appkit.utils.V; + +/** + * Created by grishka on 17.08.15. + */ +public abstract class PopupKeyboard{ + + protected View keyboardPopupView; + protected Activity activity; + private int initialHeight; + private int prevWidth; + private int keyboardHeight; + private boolean needShowOnHide=false; + private boolean keyboardWasVisible=false; + private OnIconChangeListener iconListener; + + public static final int ICON_HIDDEN=0; + public static final int ICON_ARROW=1; + public static final int ICON_KEYBOARD=2; + + public PopupKeyboard(Activity activity){ + this.activity=activity; + } + + protected abstract View onCreateView(); + + private void ensureView(){ + if(keyboardPopupView==null){ + keyboardPopupView=onCreateView(); + keyboardPopupView.setVisibility(View.GONE); + } + } + + public View getView(){ + ensureView(); + return keyboardPopupView; + } + + public boolean isVisible(){ + ensureView(); + return keyboardPopupView.getVisibility()==View.VISIBLE; + } + + public void toggleKeyboardPopup(View textField){ + ensureView(); + if(keyboardPopupView.getVisibility()==View.VISIBLE){ + if(keyboardWasVisible){ + keyboardWasVisible=false; + InputMethodManager imm=(InputMethodManager)activity.getSystemService(Context.INPUT_METHOD_SERVICE); + imm.showSoftInput(textField, 0); + }else{ + keyboardPopupView.setVisibility(View.GONE); + } + if(iconListener!=null) + iconListener.onIconChanged(ICON_HIDDEN); + return; + } + if(keyboardHeight>0){ + needShowOnHide=true; + keyboardWasVisible=true; + InputMethodManager imm=(InputMethodManager)activity.getSystemService(Context.INPUT_METHOD_SERVICE); + imm.hideSoftInputFromWindow(getWindow().getDecorView().getWindowToken(), 0); + if(iconListener!=null) + iconListener.onIconChanged(ICON_KEYBOARD); + }else{ + doShowKeyboardPopup(); + if(iconListener!=null) + iconListener.onIconChanged(ICON_ARROW); + } + } + + protected Window getWindow(){ + return activity.getWindow(); + } + + public void setOnIconChangedListener(OnIconChangeListener l){ + iconListener=l; + } + + public void onContentViewSizeChanged(int w, int h, int oldw, int oldh){ + if(oldw==0 || w!=prevWidth){ + initialHeight=h; + prevWidth=w; + onWidthChanged(w); + } + if(h>initialHeight){ + initialHeight=h; + } + if(initialHeight!=0 && w==oldw){ + keyboardHeight=initialHeight-h; + if(keyboardHeight!=0){ + DisplayMetrics dm=activity.getResources().getDisplayMetrics(); + activity.getSharedPreferences("emoji", Context.MODE_PRIVATE).edit().putInt("kb_size"+dm.widthPixels+"_"+dm.heightPixels, keyboardHeight).commit(); + } + if(needShowOnHide && keyboardHeight==0){ + ((View)keyboardPopupView.getParent()).getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener(){ + @Override + public boolean onPreDraw(){ + ((View)keyboardPopupView.getParent()).getViewTreeObserver().removeOnPreDrawListener(this); + doShowKeyboardPopup(); + return false; + } + }); + needShowOnHide=false; + } + if(keyboardHeight>0 && keyboardPopupView.getVisibility()==View.VISIBLE){ + if(iconListener!=null) + iconListener.onIconChanged(ICON_HIDDEN); + ((View)keyboardPopupView.getParent()).getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener(){ + @Override + public boolean onPreDraw(){ + ((View)keyboardPopupView.getParent()).getViewTreeObserver().removeOnPreDrawListener(this); + keyboardPopupView.setVisibility(View.GONE); + return false; + } + }); + } + } + } + + public void hide(){ + ensureView(); + if(keyboardPopupView.getVisibility()==View.VISIBLE){ + keyboardPopupView.setVisibility(View.GONE); + keyboardWasVisible=false; + if(iconListener!=null) + iconListener.onIconChanged(ICON_HIDDEN); + } + } + + public void onConfigurationChanged(){ + + } + + protected void onWidthChanged(int w){ + + } + + protected boolean needWrapContent(){ + return false; + } + + private void doShowKeyboardPopup(){ + ensureView(); + DisplayMetrics dm=activity.getResources().getDisplayMetrics(); + int height=activity.getSharedPreferences("emoji", Context.MODE_PRIVATE).getInt("kb_size"+dm.widthPixels+"_"+dm.heightPixels, V.dp(200)); + if(needWrapContent()){ + keyboardPopupView.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.AT_MOST | height); + height=keyboardPopupView.getMeasuredHeight(); + } + keyboardPopupView.setLayoutParams(new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, height)); + keyboardPopupView.setVisibility(View.VISIBLE); + } + + public interface OnIconChangeListener{ + public void onIconChanged(int icon); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/views/SizeListenerLinearLayout.java b/mastodon/src/main/java/org/joinmastodon/android/ui/views/SizeListenerLinearLayout.java new file mode 100644 index 000000000..8a3a568ba --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/views/SizeListenerLinearLayout.java @@ -0,0 +1,47 @@ +package org.joinmastodon.android.ui.views; + +import android.content.Context; +import android.util.AttributeSet; +import android.util.Log; +import android.view.View; +import android.widget.LinearLayout; + +import androidx.annotation.Nullable; + +public class SizeListenerLinearLayout extends LinearLayout{ + private OnSizeChangedListener sizeListener; + + public SizeListenerLinearLayout(Context context){ + super(context); + } + + public SizeListenerLinearLayout(Context context, @Nullable AttributeSet attrs){ + super(context, attrs); + } + + public SizeListenerLinearLayout(Context context, @Nullable AttributeSet attrs, int defStyleAttr){ + super(context, attrs, defStyleAttr); + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh){ + if(sizeListener!=null) + sizeListener.onSizeChanged(w, h, oldw, oldh); + } + + public void setSizeListener(OnSizeChangedListener sizeListener){ + this.sizeListener=sizeListener; + } +// +// @Override +// public View findFocus(){ +// View v=super.findFocus(); +// Log.w("11", "findFocus() "+v); +// return v; +// } + + @FunctionalInterface + public interface OnSizeChangedListener{ + void onSizeChanged(int w, int h, int oldw, int oldh); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/utils/CharRegex.java b/mastodon/src/main/java/org/joinmastodon/android/utils/CharRegex.java new file mode 100644 index 000000000..b52ac3500 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/utils/CharRegex.java @@ -0,0 +1,41 @@ +package org.joinmastodon.android.utils; + +import java.util.regex.Pattern; + +/** + * from https://github.com/Richienb/char-regex/blob/master/index.js + */ +public class CharRegex{ + // Used to compose unicode character classes. + private static final String astralRange = "\\ud800-\\udfff"; + private static final String comboMarksRange = "\\u0300-\\u036f"; + private static final String comboHalfMarksRange = "\\ufe20-\\ufe2f"; + private static final String comboSymbolsRange = "\\u20d0-\\u20ff"; + private static final String comboMarksExtendedRange = "\\u1ab0-\\u1aff"; + private static final String comboMarksSupplementRange = "\\u1dc0-\\u1dff"; + private static final String comboRange = comboMarksRange + comboHalfMarksRange + comboSymbolsRange + comboMarksExtendedRange + comboMarksSupplementRange; + private static final String varRange = "\\ufe0e\\ufe0f"; + + + // Used to compose unicode capture groups. + private static final String astral = "["+astralRange+"]"; + private static final String combo = "["+comboRange+"]"; + private static final String fitz = "\\ud83c[\\udffb-\\udfff]"; + private static final String modifier = "(?:"+combo+"|"+fitz+")"; + private static final String nonAstral = "[^"+astralRange+"]"; + private static final String regional = "(?:\\ud83c[\\udde6-\\uddff]){2}"; + private static final String surrogatePair = "[\\ud800-\\udbff][\\udc00-\\udfff]"; + private static final String zeroWidthJoiner = "\\u200d"; + private static final String blackFlag = "(?:\\ud83c\\udff4\\udb40\\udc67\\udb40\\udc62\\udb40(?:\\udc65|\\udc73|\\udc77)\\udb40(?:\\udc6e|\\udc63|\\udc6c)\\udb40(?:\\udc67|\\udc74|\\udc73)\\udb40\\udc7f)"; + + // Used to compose unicode regexes. + private static final String optModifier = modifier+"?"; + private static final String optVar = "["+varRange+"]?"; + private static final String optJoin = "(?:"+zeroWidthJoiner+"(?:"+nonAstral+"|"+regional+"|"+surrogatePair+")"+optVar + optModifier+")*"; + private static final String seq = optVar + optModifier + optJoin; + private static final String nonAstralCombo = nonAstral+combo+"?"; + private static final String symbol = "(?:"+blackFlag+"|"+nonAstralCombo+"|"+combo+"|"+regional+"|"+surrogatePair+"|"+astral+")"; + + public static final Pattern REGEX=Pattern.compile(fitz+"(?="+fitz+")|"+symbol + seq); +// public static final Pattern REGEX=Pattern.compile("\\ud83c[\\udffb-\\udfff](?=\\ud83c[\\udffb-\\udfff])|(?:(?:\\ud83c\\udff4\\udb40\\udc67\\udb40\\udc62\\udb40(?:\\udc65|\\udc73|\\udc77)\\udb40(?:\\udc6e|\\udc63|\\udc6c)\\udb40(?:\\udc67|\\udc74|\\udc73)\\udb40\\udc7f)|[^\\ud800-\\udfff][\\u0300-\\u036f\\ufe20-\\ufe2f\\u20d0-\\u20ff\\u1ab0-\\u1aff\\u1dc0-\\u1dff]?|[\\u0300-\\u036f\\ufe20-\\ufe2f\\u20d0-\\u20ff\\u1ab0-\\u1aff\\u1dc0-\\u1dff]|(?:\\ud83c[\\udde6-\\uddff]){2}|[\\ud800-\\udbff][\\udc00-\\udfff]|[\\ud800-\\udfff])[\\ufe0e\\ufe0f]?(?:[\\u0300-\\u036f\\ufe20-\\ufe2f\\u20d0-\\u20ff\\u1ab0-\\u1aff\\u1dc0-\\u1dff]|\\ud83c[\\udffb-\\udfff])?(?:\\u200d(?:[^\\ud800-\\udfff]|(?:\\ud83c[\\udde6-\\uddff]){2}|[\\ud800-\\udbff][\\udc00-\\udfff])[\\ufe0e\\ufe0f]?(?:[\\u0300-\\u036f\\ufe20-\\ufe2f\\u20d0-\\u20ff\\u1ab0-\\u1aff\\u1dc0-\\u1dff]|\\ud83c[\\udffb-\\udfff])?)*"); +} diff --git a/mastodon/src/main/res/drawable/ic_fluent_emoji_24_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_emoji_24_filled.xml new file mode 100644 index 000000000..7849904d4 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_emoji_24_filled.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_emoji_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_emoji_24_regular.xml new file mode 100644 index 000000000..fa240d2cf --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_emoji_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_emoji_24_selector.xml b/mastodon/src/main/res/drawable/ic_fluent_emoji_24_selector.xml new file mode 100644 index 000000000..ee44e01d0 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_emoji_24_selector.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/mastodon/src/main/res/layout/display_item_header.xml b/mastodon/src/main/res/layout/display_item_header.xml index 878b63c6f..999800014 100644 --- a/mastodon/src/main/res/layout/display_item_header.xml +++ b/mastodon/src/main/res/layout/display_item_header.xml @@ -15,7 +15,7 @@ android:layout_alignParentEnd="true" android:background="?android:selectableItemBackgroundBorderless" android:scaleType="center" - android:src="@drawable/ic_post_more"/> + android:src="@drawable/ic_post_more" /> - + + + android:gravity="center_vertical" + android:background="@color/gray_25" + android:paddingLeft="16dp" + android:paddingRight="16dp"> + + + + - \ No newline at end of file + \ No newline at end of file diff --git a/mastodon/src/main/res/layout/item_emoji_section.xml b/mastodon/src/main/res/layout/item_emoji_section.xml new file mode 100644 index 000000000..167db782b --- /dev/null +++ b/mastodon/src/main/res/layout/item_emoji_section.xml @@ -0,0 +1,14 @@ + + \ No newline at end of file diff --git a/mastodon/src/main/res/values/colors.xml b/mastodon/src/main/res/values/colors.xml index de2144ddb..7b1cfb9ee 100644 --- a/mastodon/src/main/res/values/colors.xml +++ b/mastodon/src/main/res/values/colors.xml @@ -9,10 +9,12 @@ #FFFFFFFF @color/gray_800 - + + #FCFCFD + #CCF9FAFB + #F2F4F7 #282C37 #667085 - #CCF9FAFB @color/gray_800 @color/gray_500