diff --git a/.github/workflows/validate-gradle-wrapper.yml b/.github/workflows/validate-gradle-wrapper.yml new file mode 100644 index 000000000..b5f0b31ac --- /dev/null +++ b/.github/workflows/validate-gradle-wrapper.yml @@ -0,0 +1,11 @@ +name: Validate Gradle Wrapper + +on: [pull_request, push] + +jobs: + validation: + name: Validation + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: gradle/wrapper-validation-action@v1 diff --git a/README.md b/README.md index 902a0bea6..417609784 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ On the Fediverse, it’s quite common for people to pin posts they want others t [apt.izzysoft.de/fdroid/index/apk/org.joinmastodon.android.sk](https://apt.izzysoft.de/fdroid/index/apk/org.joinmastodon.android.sk) -Get it on IzzyOnDroid +Get it on IzzyOnDroid Note that you'll need to add Izzy's F-Droid repository to your F-Droid app first: diff --git a/_layouts/default.html b/_layouts/default.html index cd80d4a87..490f89903 100644 --- a/_layouts/default.html +++ b/_layouts/default.html @@ -6,7 +6,7 @@ Megalodon - + diff --git a/build.gradle b/build.gradle index d426d29b9..8357ab502 100644 --- a/build.gradle +++ b/build.gradle @@ -5,7 +5,7 @@ buildscript { mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:7.3.1' + classpath 'com.android.tools.build:gradle:8.0.0' // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files } diff --git a/crowdin.yml b/crowdin.yml deleted file mode 100644 index 99e5b7d99..000000000 --- a/crowdin.yml +++ /dev/null @@ -1,5 +0,0 @@ -files: - - source: /mastodon/src/main/res/values/strings.xml - translation: /mastodon/src/main/res/values-%android_code%/strings.xml - - source: /fastlane/metadata/android/en-US/*.txt - translation: /fastlane/metadata/android/%locale%/%original_file_name% diff --git a/gradle.properties b/gradle.properties index 52f5917cb..415e17f9f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,4 +16,7 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 # https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true # Automatically convert third-party libraries to use AndroidX -android.enableJetifier=true \ No newline at end of file +android.enableJetifier=false +android.defaults.buildfeatures.buildconfig=true +android.nonTransitiveRClass=true +android.nonFinalResIds=false \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index e708b1c02..c1962a79e 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 112a498f8..2c3425d49 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ -#Thu Jan 13 11:33:43 MSK 2022 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-bin.zip distributionPath=wrapper/dists -zipStorePath=wrapper/dists +distributionSha256Sum=e111cb9948407e26351227dabce49822fb88c37ee72f1d1582a69c68af2e702f +distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip +networkTimeout=10000 zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 4f906e0c8..aeb74cbb4 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ -#!/usr/bin/env sh +#!/bin/sh # -# Copyright 2015 the original author or authors. +# Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -17,67 +17,98 @@ # ############################################################################## -## -## Gradle start up script for UN*X -## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# ############################################################################## # Attempt to set APP_HOME + # Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum warn () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar @@ -87,9 +118,9 @@ CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -98,7 +129,7 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" + JAVACMD=java which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the @@ -106,80 +137,109 @@ location of your Java installation." fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi -fi - -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi - -# For Cygwin or MSYS, switch paths to Windows format before running java -if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi - # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" - fi - i=`expr $i + 1` - done - case $i in - 0) set -- ;; - 1) set -- "$args0" ;; - 2) set -- "$args0" "$args1" ;; - 3) set -- "$args0" "$args1" "$args2" ;; - 4) set -- "$args0" "$args1" "$args2" "$args3" ;; - 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=`save "$@"` +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index ac1b06f93..6689b85be 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -14,7 +14,7 @@ @rem limitations under the License. @rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,7 +25,8 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -40,7 +41,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 0 goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. @@ -75,13 +76,15 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal diff --git a/mastodon/build.gradle b/mastodon/build.gradle index 4e2700f2a..d43bc070a 100644 --- a/mastodon/build.gradle +++ b/mastodon/build.gradle @@ -2,6 +2,12 @@ plugins { id 'com.android.application' } +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + android { compileSdk 33 defaultConfig { @@ -9,11 +15,11 @@ android { applicationId "org.joinmastodon.android.sk" minSdk 23 targetSdk 33 - versionCode 73 - versionName "1.1.5+fork.73" + versionCode 91 + versionName "1.2.3+fork.91" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - resConfigs "ar-rSA", "be-rBY", "bn-rBD", "bs-rBA", "ca-rES", "cs-rCZ", "de-rDE", "el-rGR", "es-rES", "eu-rES", "fi-rFI", "fil-rPH", "fr-rFR", "ga-rIE", "gd-rGB", "gl-rES", "hi-rIN", "hr-rHR", "hu-rHU", "hy-rAM", "in-rID", "is-rIS", "it-rIT", "iw-rIL", "ja-rJP", "kab", "ko-rKR", "nl-rNL", "oc-rFR", "pl-rPL", "pt-rBR", "pt-rPT", "ro-rRO", "ru-rRU", "si-rLK", "sl-rSI", "sv-rSE", "th-rTH", "tr-rTR", "uk-rUA", "vi-rVN", "zh-rCN", "zh-rTW" - } + resourceConfigurations += ['ar-rSA', 'ar-rDZ', 'be-rBY', 'bn-rBD', 'bs-rBA', 'ca-rES', 'cs-rCZ', 'da-rDK', 'de-rDE', 'el-rGR', 'es-rES', 'eu-rES', 'fa-rIR', 'fi-rFI', 'fil-rPH', 'fr-rFR', 'ga-rIE', 'gd-rGB', 'gl-rES', 'hi-rIN', 'hr-rHR', 'hu-rHU', 'hy-rAM', 'ig-rNG', 'in-rID', 'is-rIS', 'it-rIT', 'iw-rIL', 'ja-rJP', 'kab', 'ko-rKR', 'my-rMM', 'nl-rNL', 'no-rNO', 'oc-rFR', 'pl-rPL', 'pt-rBR', 'pt-rPT', 'ro-rRO', 'ru-rRU', 'si-rLK', 'sl-rSI', 'sv-rSE', 'th-rTH', 'tr-rTR', 'uk-rUA', 'ur-rIN', 'vi-rVN', 'zh-rCN', 'zh-rTW'] + } buildTypes { release { @@ -49,14 +55,19 @@ android { setRoot "src/github" } } - lintOptions{ - checkReleaseBuilds false + namespace 'org.joinmastodon.android' + lint { abortOnError false + checkReleaseBuilds false + } + + buildFeatures { + buildConfig true } } dependencies { - api 'androidx.annotation:annotation:1.3.0' + api 'androidx.annotation:annotation:1.6.0' implementation 'com.squareup.okhttp3:okhttp:3.14.9' implementation 'me.grishka.litex:recyclerview:1.2.1.1' implementation 'me.grishka.litex:swiperefreshlayout:1.1.0.1' @@ -64,12 +75,13 @@ dependencies { implementation 'me.grishka.litex:dynamicanimation:1.1.0-alpha03' implementation 'me.grishka.litex:viewpager:1.0.0' implementation 'me.grishka.litex:viewpager2:1.0.0' - implementation 'me.grishka.appkit:appkit:1.2.7' - implementation 'com.google.code.gson:gson:2.8.9' + implementation 'me.grishka.appkit:appkit:1.2.8' + implementation 'com.google.code.gson:gson:2.9.0' implementation 'org.jsoup:jsoup:1.14.3' implementation 'com.squareup:otto:1.3.8' implementation 'de.psdev:async-otto:1.0.3' implementation 'org.parceler:parceler-api:1.1.12' + implementation 'com.github.bottom-software-foundation:bottom-java:2.1.0' annotationProcessor 'org.parceler:parceler:1.1.12' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5' diff --git a/mastodon/src/androidTest/java/org/joinmastodon/android/fragments/ThreadFragmentTest.java b/mastodon/src/androidTest/java/org/joinmastodon/android/fragments/ThreadFragmentTest.java new file mode 100644 index 000000000..38a2a489c --- /dev/null +++ b/mastodon/src/androidTest/java/org/joinmastodon/android/fragments/ThreadFragmentTest.java @@ -0,0 +1,113 @@ +package org.joinmastodon.android.fragments; + +import static org.junit.Assert.*; + +import org.joinmastodon.android.events.StatusCountersUpdatedEvent; +import org.joinmastodon.android.events.StatusUpdatedEvent; +import org.joinmastodon.android.model.Status; +import org.joinmastodon.android.model.StatusContext; +import org.junit.Test; + +import java.time.Instant; +import java.util.List; + +public class ThreadFragmentTest { + + private Status fakeStatus(String id, String inReplyTo) { + Status status = Status.ofFake(id, null, null); + status.inReplyToId = inReplyTo; + return status; + } + + private ThreadFragment.NeighborAncestryInfo fakeInfo(Status s, Status d, Status a) { + return new ThreadFragment.NeighborAncestryInfo(s, d, a); + } + + @Test + public void mapNeighborhoodAncestry() { + StatusContext context = new StatusContext(); + context.ancestors = List.of( + fakeStatus("oldest ancestor", null), + fakeStatus("younger ancestor", "oldest ancestor") + ); + Status mainStatus = fakeStatus("main status", "younger ancestor"); + context.descendants = List.of( + fakeStatus("first reply", "main status"), + fakeStatus("reply to first reply", "first reply"), + fakeStatus("third level reply", "reply to first reply"), + fakeStatus("another reply", "main status") + ); + + List neighbors = + ThreadFragment.mapNeighborhoodAncestry(mainStatus, context); + + assertEquals(List.of( + fakeInfo(context.ancestors.get(0), context.ancestors.get(1), null), + fakeInfo(context.ancestors.get(1), mainStatus, context.ancestors.get(0)), + fakeInfo(mainStatus, context.descendants.get(0), context.ancestors.get(1)), + fakeInfo(context.descendants.get(0), context.descendants.get(1), mainStatus), + fakeInfo(context.descendants.get(1), context.descendants.get(2), context.descendants.get(0)), + fakeInfo(context.descendants.get(2), null, context.descendants.get(1)), + fakeInfo(context.descendants.get(3), null, null) + ), neighbors); + } + + @Test + public void maybeApplyMainStatus() { + ThreadFragment fragment = new ThreadFragment(); + fragment.contextInitiallyRendered = true; + fragment.mainStatus = Status.ofFake("123456", "original text", Instant.EPOCH); + + Status update1 = Status.ofFake("123456", "updated text", Instant.EPOCH); + update1.editedAt = Instant.ofEpochSecond(1); + fragment.updatedStatus = update1; + StatusUpdatedEvent event1 = (StatusUpdatedEvent) fragment.maybeApplyMainStatus(); + assertEquals("fired update event", update1, event1.status); + assertEquals("updated main status", update1, fragment.mainStatus); + + Status update2 = Status.ofFake("123456", "updated text", Instant.EPOCH); + update2.favouritesCount = 123; + fragment.updatedStatus = update2; + StatusCountersUpdatedEvent event2 = (StatusCountersUpdatedEvent) fragment.maybeApplyMainStatus(); + assertEquals("only fired counter update event", update2.id, event2.id); + assertEquals("updated counter is correct", 123, event2.favorites); + assertEquals("updated main status", update2, fragment.mainStatus); + + Status update3 = Status.ofFake("123456", "whatever", Instant.EPOCH); + fragment.contextInitiallyRendered = false; + fragment.updatedStatus = update3; + assertNull("no update when context hasn't been rendered", fragment.maybeApplyMainStatus()); + } + + @Test + public void sortStatusContext() { + StatusContext context = new StatusContext(); + context.ancestors = List.of( + fakeStatus("younger ancestor", "oldest ancestor"), + fakeStatus("oldest ancestor", null) + ); + context.descendants = List.of( + fakeStatus("reply to first reply", "first reply"), + fakeStatus("third level reply", "reply to first reply"), + fakeStatus("first reply", "main status"), + fakeStatus("another reply", "main status") + ); + + ThreadFragment.sortStatusContext( + fakeStatus("main status", "younger ancestor"), + context + ); + List expectedAncestors = List.of( + fakeStatus("oldest ancestor", null), + fakeStatus("younger ancestor", "oldest ancestor") + ); + List expectedDescendants = List.of( + fakeStatus("first reply", "main status"), + fakeStatus("reply to first reply", "first reply"), + fakeStatus("third level reply", "reply to first reply"), + fakeStatus("another reply", "main status") + ); + + // TODO: ??? i have no idea how this code works. it certainly doesn't return what i'd expect + } +} \ No newline at end of file diff --git a/mastodon/src/androidTest/java/org/joinmastodon/android/ui/utils/UiUtilsTest.java b/mastodon/src/androidTest/java/org/joinmastodon/android/ui/utils/UiUtilsTest.java new file mode 100644 index 000000000..63c3fb866 --- /dev/null +++ b/mastodon/src/androidTest/java/org/joinmastodon/android/ui/utils/UiUtilsTest.java @@ -0,0 +1,106 @@ +package org.joinmastodon.android.ui.utils; + +import static org.junit.Assert.*; + +import android.util.Pair; + +import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.model.Instance; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.Optional; + +public class UiUtilsTest { + @BeforeClass + public static void createDummySession() { + Instance dummyInstance = new Instance(); + dummyInstance.uri = "test.tld"; + Account dummyAccount = new Account(); + dummyAccount.id = "123456"; + AccountSessionManager.getInstance().addAccount(dummyInstance, null, dummyAccount, null, null); + } + + @AfterClass + public static void cleanUp() { + AccountSessionManager.getInstance().removeAccount("test.tld_123456"); + } + + @Test + public void parseFediverseHandle() { + assertEquals( + Optional.of(Pair.create("megalodon", Optional.of("floss.social"))), + UiUtils.parseFediverseHandle("megalodon@floss.social") + ); + + assertEquals( + Optional.of(Pair.create("megalodon", Optional.of("floss.social"))), + UiUtils.parseFediverseHandle("@megalodon@floss.social") + ); + + assertEquals( + Optional.of(Pair.create("megalodon", Optional.empty())), + UiUtils.parseFediverseHandle("@megalodon") + ); + + assertEquals( + Optional.of(Pair.create("megalodon", Optional.of("floss.social"))), + UiUtils.parseFediverseHandle("mailto:megalodon@floss.social") + ); + + assertEquals( + Optional.empty(), + UiUtils.parseFediverseHandle("megalodon") + ); + + assertEquals( + Optional.empty(), + UiUtils.parseFediverseHandle("this is not a fedi handle") + ); + + assertEquals( + Optional.empty(), + UiUtils.parseFediverseHandle("not@a-domain") + ); + } + + @Test + public void acctMatches() { + assertTrue("local account, domain not specified", UiUtils.acctMatches( + "test.tld_123456", + "someone", + "someone", + null + )); + + assertTrue("domain not specified", UiUtils.acctMatches( + "test.tld_123456", + "someone@somewhere.social", + "someone", + null + )); + + assertTrue("local account, domain specified, different casing", UiUtils.acctMatches( + "test.tld_123456", + "SomeOne", + "someone", + "Test.TLD" + )); + + assertFalse("username doesn't match", UiUtils.acctMatches( + "test.tld_123456", + "someone-else@somewhere.social", + "someone", + "somewhere.social" + )); + + assertFalse("domain doesn't match", UiUtils.acctMatches( + "test.tld_123456", + "someone@somewhere.social", + "someone", + "somewhere.else" + )); + } +} \ No newline at end of file diff --git a/mastodon/src/androidTest/java/org/joinmastodon/android/utils/StatusFilterPredicateTest.java b/mastodon/src/androidTest/java/org/joinmastodon/android/utils/StatusFilterPredicateTest.java new file mode 100644 index 000000000..0a00fc665 --- /dev/null +++ b/mastodon/src/androidTest/java/org/joinmastodon/android/utils/StatusFilterPredicateTest.java @@ -0,0 +1,81 @@ +package org.joinmastodon.android.utils; + +import static org.joinmastodon.android.model.Filter.FilterAction.*; +import static org.joinmastodon.android.model.Filter.FilterContext.*; +import static org.junit.Assert.*; + +import org.joinmastodon.android.model.Filter; +import org.joinmastodon.android.model.Status; +import org.junit.Test; + +import java.time.Instant; +import java.util.EnumSet; +import java.util.List; + +public class StatusFilterPredicateTest { + + private static final Filter hideMeFilter = new Filter(), warnMeFilter = new Filter(); + private static final List allFilters = List.of(hideMeFilter, warnMeFilter); + + private static final Status + hideInHomePublic = Status.ofFake(null, "hide me, please", Instant.now()), + warnInHomePublic = Status.ofFake(null, "display me with a warning", Instant.now()); + + static { + hideMeFilter.phrase = "hide me"; + hideMeFilter.filterAction = HIDE; + hideMeFilter.context = EnumSet.of(PUBLIC, HOME); + + warnMeFilter.phrase = "warning"; + warnMeFilter.filterAction = WARN; + warnMeFilter.context = EnumSet.of(PUBLIC, HOME); + } + + @Test + public void testHide() { + assertFalse("should not pass because matching filter applies to given context", + new StatusFilterPredicate(allFilters, HOME).test(hideInHomePublic)); + } + + @Test + public void testHideRegardlessOfContext() { + assertTrue("filters without context should always pass", + new StatusFilterPredicate(allFilters, null).test(hideInHomePublic)); + } + + @Test + public void testHideInDifferentContext() { + assertTrue("should pass because matching filter does not apply to given context", + new StatusFilterPredicate(allFilters, THREAD).test(hideInHomePublic)); + } + + @Test + public void testHideWithWarningText() { + assertTrue("should pass because matching filter is for warnings", + new StatusFilterPredicate(allFilters, HOME).test(warnInHomePublic)); + } + + @Test + public void testWarn() { + assertFalse("should not pass because filter applies to given context", + new StatusFilterPredicate(allFilters, HOME, WARN).test(warnInHomePublic)); + } + + @Test + public void testWarnRegardlessOfContext() { + assertTrue("filters without context should always pass", + new StatusFilterPredicate(allFilters, null, WARN).test(warnInHomePublic)); + } + + @Test + public void testWarnInDifferentContext() { + assertTrue("should pass because filter does not apply to given context", + new StatusFilterPredicate(allFilters, THREAD, WARN).test(warnInHomePublic)); + } + + @Test + public void testWarnWithHideText() { + assertTrue("should pass because matching filter is for hiding", + new StatusFilterPredicate(allFilters, HOME, WARN).test(hideInHomePublic)); + } +} \ No newline at end of file diff --git a/mastodon/src/github/AndroidManifest.xml b/mastodon/src/github/AndroidManifest.xml index a75f12de6..5838d1c43 100644 --- a/mastodon/src/github/AndroidManifest.xml +++ b/mastodon/src/github/AndroidManifest.xml @@ -1,6 +1,5 @@ - + diff --git a/mastodon/src/main/AndroidManifest.xml b/mastodon/src/main/AndroidManifest.xml index 7d9c6f2e9..d7c114c33 100644 --- a/mastodon/src/main/AndroidManifest.xml +++ b/mastodon/src/main/AndroidManifest.xml @@ -1,6 +1,5 @@ - + @@ -17,6 +16,9 @@ + + + + + + + + + + + @@ -44,7 +62,8 @@ - + diff --git a/mastodon/src/main/assets/blocks.tsv b/mastodon/src/main/assets/blocks.tsv deleted file mode 100644 index ad6a59e93..000000000 --- a/mastodon/src/main/assets/blocks.tsv +++ /dev/null @@ -1,89 +0,0 @@ -# lists.d Mastodon Blocklist (c) 2022 Greyhat Academy LICENSED UNDER: CC-BY-NC-SA 4.0 -# https://raw.githubusercontent.com/greyhat-academy/lists.d/main/mastodon.domains.block.list.tsv -# This list contains domains of toxic mastodon instances -# Last-Modified: 1672044500 - -# gab - a neonazi social network -gab.ai -gab.com -gab.protohype.net - -# consequence-free speech -social.unzensiert.to -freeatlantis.com - -# reactionary bigotry and hatespeech against magrinalized groups -poa.st -freespeechextremist.com -rdrama.cc -outpoa.st -anime.website -gameliberty.club -social.byoblu.com -yggdrasil.social -smuglo.li -dogeposting.social -unsafe.space -freezepeach.xyz - -# + CSAM -rojogato.com - -# antivaxxer shitposting & fearmongering -shadowsocial.org - -# Kiwifarms -kiwifarms.net -kiwifarms.cc -kiwifarms.is -kiwifarms.pleroma.net - - -# https://mastodon.art/@Curator/109649354849593592 - -poa.st antisemitic racist homophobic -nicecrew.digital antisemitic -beefyboys.win antisemitic racist homophobic harassment -cawfee.club antisemitic racist homophobic -comfyboy.club antisemitic racist homophobic -freespeechextremist.com racist homophobic -cum.salon racist misogynist -bae.st racist -natehiggers.online racist -rapemeat.solutions misogynist -rapist.town misogynist -rapefeminists.network misogynist -kiwifarms.cc harassment -noagendasocial.com noagenda -posting.lolicon.rocks underage -urchan.org harassment homophobic racist -ryona.agency harassment -yggdrasil.social antisemitic homophobic racist -genderheretics.xyz transphobic -baraag.net underage -lolison.top underage -shota.house underage -shota.social underage -aethy.com underage -taullo.social underage -childpawn.shop underage -posting.lolicon.rocks underage -loli.best underage -gothloli.club underage -smuglo.li underage -youjo.love underage -pedo.school underage -lolison.network underage -freak.university underage -mirr0r.city underage -xhais.love underage -refusal.biz underage -refusal.llc underage -mirr0r.city underage -nnia.space underage -ignorelist.com malicious -repl.co malicious - -# custom - -pawoo.net csam diff --git a/mastodon/src/main/assets/blocks.txt b/mastodon/src/main/assets/blocks.txt new file mode 100644 index 000000000..e6af4a505 --- /dev/null +++ b/mastodon/src/main/assets/blocks.txt @@ -0,0 +1,171 @@ +13bells.com +4aem.com +aethy.com +anime.website +annihilation.social +anon-kenkai.com +asbestos.cafe +bae.st +bajax.us +banepo.st +baraag.net +beefyboys.win +beepboop.ga +berserker.town +bikeshed.party +boks.moe +brainsoap.net +breastmilk.club +brighteon.social +cawfee.club +clew.lol +clubcyberia.co +collapsitarian.io +comfyboy.club +contrapointsfan.club +cum.camp +cum.salon +cybercriminal.eu +darknight-coffee.org +dembased.xyz +desupost.soy +detroitriotcity.com +eatthebugs.social +eientei.org +elementality.org +eveningzoo.club +firedragonstudios.com +firefaithfellowship.com +fluf.club +foxfam.club +freak.university +freeatlantis.com +freecumextremist.com +freedomstrike.org +freesoftwareextremist.com +freespeech.group +freespeechextremist.com +freetalklive.com +froth.zone +fulltermprivacy.com +gameliberty.club +gearlandia.haus +genderheretics.xyz +geofront.rocks +gleasonator.com +glee.li +glindr.org +goyim.app +goyslop.cafe +haeder.net +handholding.io +hidamari.apartments +hitchhiker.social +hunk.city +iddqd.social +intkos.link +justicewarrior.social +kawa-kun.com +kitsunemimi.club +kiwifarms.cc +kompost.cz +kurosawa.moe +leafposter.club +leftychan.net +lewdieheaven.com +liberdon.com +ligma.pro +lizards.live +lolicon.rocks +lolison.top +lovingexpressions.net +lucasvl.nl +mahodou.moe +makemysarcophagus.com +maladaptive.art +masochi.st +mastinator.com +merovingian.club +midwaytrades.com +mirr0r.city +moa.st +mouse.services +mugicha.club +narrativerry.xyz +natehiggers.online +neckbeard.xyz +needs.vodka +neenster.org +nicecrew.digital +nnia.space +noagendasocial.com +noagendasocial.nl +noagendatube.com +nobodyhasthe.biz +nukem.biz +obo.sh +onionfarms.org +outpoa.st +pawlicker.com +pawoo.net +pedo.school +piazza.today +pibvt.net +pieville.net +pisskey.io +plagu.ee +pmth.us +poa.st +poast.org +poast.tv +poster.place +prospeech.space +quodverum.com +rakket.app +rapemeat.solutions +rdrama.cc +rebelbase.site +retardedniggers.forsale +rojogato.com +ryona.agency +schwartzwelt.xyz +seal.cafe +shigusegubu.club +shitpost.cloud +shitposter.club +shota.house +silliness.observer +skinheads.eu +skinheads.io +skinheads.social +skinheads.uk +skippers-bin.com +skyshanty.xyz +slash.cl +sleepy.cafe +smuglo.li +sneed.social +sonichu.com +spinster.xyz +springbo.cc +starnix.network +stereophonic.space +strelizia.net +syspxl.xyz +tastingtraffic.net +teci.world +theapex.social +thepostearthdestination.com +tkammer.de +trumpislovetrumpis.life +truthsocial.co.in +urchan.org +varishangout.net +whinge.house +whinge.town +wideboys.org +wolfgirl.bar +xn--p1abe3d.xn--80asehdb +yggdrasil.social +youjo.love +zztails.gay diff --git a/mastodon/src/main/java/org/joinmastodon/android/ExitActivity.java b/mastodon/src/main/java/org/joinmastodon/android/ExitActivity.java new file mode 100644 index 000000000..54a5ccbc4 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ExitActivity.java @@ -0,0 +1,24 @@ +package org.joinmastodon.android; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; + +public class ExitActivity extends Activity { + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + finishAndRemoveTask(); + } + + public static void exit(Context context) { + Intent intent = new Intent(context, ExitActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK); + context.startActivity(intent); + } + +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ExternalShareActivity.java b/mastodon/src/main/java/org/joinmastodon/android/ExternalShareActivity.java index 32d7c74cd..40418d5fe 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ExternalShareActivity.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ExternalShareActivity.java @@ -3,20 +3,25 @@ package org.joinmastodon.android; import android.app.Fragment; import android.content.ClipData; import android.content.Intent; -import android.graphics.drawable.ColorDrawable; import android.net.Uri; import android.os.Bundle; import android.text.TextUtils; +import android.util.Pair; import android.widget.Toast; +import org.joinmastodon.android.api.MastodonAPIRequest; import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.fragments.ComposeFragment; +import org.joinmastodon.android.ui.AccountSwitcherSheet; import org.joinmastodon.android.ui.utils.UiUtils; +import org.jsoup.internal.StringUtil; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Optional; +import java.util.function.BiConsumer; import androidx.annotation.Nullable; import me.grishka.appkit.FragmentStackActivity; @@ -27,18 +32,52 @@ public class ExternalShareActivity extends FragmentStackActivity{ UiUtils.setUserPreferredTheme(this); super.onCreate(savedInstanceState); if(savedInstanceState==null){ + + Optional text = Optional.ofNullable(getIntent().getStringExtra(Intent.EXTRA_TEXT)); + Optional>> fediHandle = text.flatMap(UiUtils::parseFediverseHandle); + boolean isFediUrl = text.map(UiUtils::looksLikeFediverseUrl).orElse(false); + boolean isOpenable = isFediUrl || fediHandle.isPresent(); + List sessions=AccountSessionManager.getInstance().getLoggedInAccounts(); - if(sessions.isEmpty()){ + if (sessions.isEmpty()){ Toast.makeText(this, R.string.err_not_logged_in, Toast.LENGTH_SHORT).show(); finish(); - }else if(sessions.size()==1){ + } else if (isOpenable || sessions.size() > 1) { + AccountSwitcherSheet sheet = new AccountSwitcherSheet(this, null, true, isOpenable); + sheet.setOnClick((accountId, open) -> { + if (open && text.isPresent()) { + BiConsumer, Bundle> callback = (clazz, args) -> { + if (clazz == null) { + Toast.makeText(this, R.string.sk_open_in_app_failed, Toast.LENGTH_SHORT).show(); + // TODO: do something about the window getting leaked + sheet.dismiss(); + finish(); + return; + } + args.putString("fromExternalShare", clazz.getSimpleName()); + Intent intent = new Intent(this, MainActivity.class); + intent.putExtras(args); + finish(); + startActivity(intent); + }; + + fediHandle + .>map(handle -> + UiUtils.lookupAccountHandle(this, accountId, handle, callback)) + .or(() -> Optional.ofNullable( + UiUtils.lookupURL(this, accountId, text.get(), false, callback))) + .ifPresent(req -> + req.wrapProgress(this, R.string.loading, true, d -> { + UiUtils.transformDialogForLookup(this, accountId, isFediUrl ? text.get() : null, d); + d.setOnDismissListener((ev) -> finish()); + })); + } else { + openComposeFragment(accountId); + } + }); + sheet.show(); + } else if (sessions.size() == 1) { openComposeFragment(sessions.get(0).getID()); - }else{ - getWindow().setBackgroundDrawable(new ColorDrawable(0xff000000)); - UiUtils.pickAccount(this, null, R.string.choose_account, 0, - session -> openComposeFragment(session.getID()), - b -> b.setOnCancelListener(d -> finish()) - ); } } } @@ -51,9 +90,15 @@ public class ExternalShareActivity extends FragmentStackActivity{ String subject = ""; if (intent.hasExtra(Intent.EXTRA_SUBJECT)) { subject = intent.getStringExtra(Intent.EXTRA_SUBJECT); - if (!subject.isBlank()) builder.append(subject).append("\n\n"); + if (!StringUtil.isBlank(subject)) builder.append(subject).append("\n\n"); + } + if (intent.hasExtra(Intent.EXTRA_TEXT)) { + String extra = intent.getStringExtra(Intent.EXTRA_TEXT); + if (!StringUtil.isBlank(extra)) { + if (extra.startsWith(subject)) extra = extra.substring(subject.length()).trim(); + builder.append(extra).append("\n\n"); + } } - if (intent.hasExtra(Intent.EXTRA_TEXT)) builder.append(intent.getStringExtra(Intent.EXTRA_TEXT)).append("\n"); String text=builder.toString(); List mediaUris; if(Intent.ACTION_SEND.equals(intent.getAction())){ @@ -80,8 +125,7 @@ public class ExternalShareActivity extends FragmentStackActivity{ args.putString("account", accountID); if(!TextUtils.isEmpty(text)) args.putString("prefilledText", text); - if(!subject.isBlank()) - args.putInt("selectionEnd", subject.length()); + args.putInt("selectionStart", StringUtil.isBlank(subject) ? 0 : subject.length()); if(mediaUris!=null && !mediaUris.isEmpty()) args.putParcelableArrayList("mediaAttachments", toArrayList(mediaUris)); Fragment fragment=new ComposeFragment(); diff --git a/mastodon/src/main/java/org/joinmastodon/android/GlobalUserPreferences.java b/mastodon/src/main/java/org/joinmastodon/android/GlobalUserPreferences.java index b857c9fbf..3366f213f 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/GlobalUserPreferences.java +++ b/mastodon/src/main/java/org/joinmastodon/android/GlobalUserPreferences.java @@ -8,6 +8,7 @@ import android.content.SharedPreferences; import com.google.gson.JsonSyntaxException; import com.google.gson.reflect.TypeToken; +import org.joinmastodon.android.model.ContentType; import org.joinmastodon.android.model.TimelineDefinition; import java.lang.reflect.Type; @@ -39,16 +40,33 @@ public class GlobalUserPreferences{ public static boolean showAltIndicator; public static boolean showNoAltIndicator; public static boolean enablePreReleases; + public static boolean prefixRepliesWithRe; + public static boolean bottomEncoding; + public static boolean collapseLongPosts; + public static boolean spectatorMode; + public static boolean autoHideFab; + public static boolean replyLineAboveHeader; + public static boolean compactReblogReplyLine; + public static boolean confirmBeforeReblog; + public static boolean allowRemoteLoading; public static String publishButtonText; public static ThemePreference theme; public static ColorPreference color; private final static Type recentLanguagesType = new TypeToken>>() {}.getType(); private final static Type pinnedTimelinesType = new TypeToken>>() {}.getType(); + private final static Type accountsDefaultContentTypesType = new TypeToken>() {}.getType(); public static Map> recentLanguages; public static Map> pinnedTimelines; public static Set accountsWithLocalOnlySupport; public static Set accountsInGlitchMode; + public static Set accountsWithContentTypesEnabled; + public static Map accountsDefaultContentTypes; + + /** + * Pleroma + */ + public static String replyVisibility; private static SharedPreferences getPrefs(){ return MastodonApp.context.getSharedPreferences("global", Context.MODE_PRIVATE); @@ -60,6 +78,16 @@ public class GlobalUserPreferences{ catch (JsonSyntaxException ignored) { return orElse; } } + public static void removeAccount(String accountId) { + recentLanguages.remove(accountId); + pinnedTimelines.remove(accountId); + accountsInGlitchMode.remove(accountId); + accountsWithLocalOnlySupport.remove(accountId); + accountsWithContentTypesEnabled.remove(accountId); + accountsDefaultContentTypes.remove(accountId); + save(); + } + public static void load(){ SharedPreferences prefs=getPrefs(); playGifs=prefs.getBoolean("playGifs", true); @@ -83,12 +111,24 @@ public class GlobalUserPreferences{ showAltIndicator=prefs.getBoolean("showAltIndicator", true); showNoAltIndicator=prefs.getBoolean("showNoAltIndicator", true); enablePreReleases=prefs.getBoolean("enablePreReleases", false); + prefixRepliesWithRe=prefs.getBoolean("prefixRepliesWithRe", false); + bottomEncoding=prefs.getBoolean("bottomEncoding", false); + collapseLongPosts=prefs.getBoolean("collapseLongPosts", true); + spectatorMode=prefs.getBoolean("spectatorMode", false); + autoHideFab=prefs.getBoolean("autoHideFab", true); + replyLineAboveHeader=prefs.getBoolean("replyLineAboveHeader", true); + compactReblogReplyLine=prefs.getBoolean("compactReblogReplyLine", true); + confirmBeforeReblog=prefs.getBoolean("confirmBeforeReblog", false); publishButtonText=prefs.getString("publishButtonText", ""); theme=ThemePreference.values()[prefs.getInt("theme", 0)]; recentLanguages=fromJson(prefs.getString("recentLanguages", null), recentLanguagesType, new HashMap<>()); pinnedTimelines=fromJson(prefs.getString("pinnedTimelines", null), pinnedTimelinesType, new HashMap<>()); accountsWithLocalOnlySupport=prefs.getStringSet("accountsWithLocalOnlySupport", new HashSet<>()); accountsInGlitchMode=prefs.getStringSet("accountsInGlitchMode", new HashSet<>()); + replyVisibility=prefs.getString("replyVisibility", null); + accountsWithContentTypesEnabled=prefs.getStringSet("accountsWithContentTypesEnabled", new HashSet<>()); + accountsDefaultContentTypes=fromJson(prefs.getString("accountsDefaultContentTypes", null), accountsDefaultContentTypesType, new HashMap<>()); + allowRemoteLoading=prefs.getBoolean("allowRemoteLoading", true); try { color=ColorPreference.valueOf(prefs.getString("color", ColorPreference.PINK.name())); @@ -120,13 +160,25 @@ public class GlobalUserPreferences{ .putBoolean("showAltIndicator", showAltIndicator) .putBoolean("showNoAltIndicator", showNoAltIndicator) .putBoolean("enablePreReleases", enablePreReleases) + .putBoolean("prefixRepliesWithRe", prefixRepliesWithRe) + .putBoolean("collapseLongPosts", collapseLongPosts) + .putBoolean("spectatorMode", spectatorMode) + .putBoolean("autoHideFab", autoHideFab) + .putBoolean("compactReblogReplyLine", compactReblogReplyLine) .putString("publishButtonText", publishButtonText) + .putBoolean("bottomEncoding", bottomEncoding) + .putBoolean("replyLineAboveHeader", replyLineAboveHeader) + .putBoolean("confirmBeforeReblog", confirmBeforeReblog) .putInt("theme", theme.ordinal()) .putString("color", color.name()) .putString("recentLanguages", gson.toJson(recentLanguages)) .putString("pinnedTimelines", gson.toJson(pinnedTimelines)) .putStringSet("accountsWithLocalOnlySupport", accountsWithLocalOnlySupport) .putStringSet("accountsInGlitchMode", accountsInGlitchMode) + .putString("replyVisibility", replyVisibility) + .putStringSet("accountsWithContentTypesEnabled", accountsWithContentTypesEnabled) + .putString("accountsDefaultContentTypes", gson.toJson(accountsDefaultContentTypes)) + .putBoolean("allowRemoteLoading", allowRemoteLoading) .apply(); } @@ -147,4 +199,3 @@ public class GlobalUserPreferences{ DARK } } - diff --git a/mastodon/src/main/java/org/joinmastodon/android/MainActivity.java b/mastodon/src/main/java/org/joinmastodon/android/MainActivity.java index 99ea1eb4a..67b29e14f 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/MainActivity.java +++ b/mastodon/src/main/java/org/joinmastodon/android/MainActivity.java @@ -2,11 +2,14 @@ package org.joinmastodon.android; import android.Manifest; import android.app.Fragment; +import android.app.assist.AssistContent; import android.content.Intent; import android.content.pm.PackageManager; import android.os.Build; import android.os.Bundle; import android.util.Log; +import android.view.View; +import android.widget.FrameLayout; import org.joinmastodon.android.api.ObjectValidationException; import org.joinmastodon.android.api.session.AccountSession; @@ -20,12 +23,13 @@ import org.joinmastodon.android.fragments.onboarding.CustomWelcomeFragment; import org.joinmastodon.android.model.Notification; import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.updater.GithubSelfUpdater; +import org.joinmastodon.android.utils.ProvidesAssistContent; import org.parceler.Parcels; import androidx.annotation.Nullable; import me.grishka.appkit.FragmentStackActivity; -public class MainActivity extends FragmentStackActivity{ +public class MainActivity extends FragmentStackActivity implements ProvidesAssistContent { @Override protected void onCreate(@Nullable Bundle savedInstanceState){ UiUtils.setUserPreferredTheme(this); @@ -35,10 +39,18 @@ public class MainActivity extends FragmentStackActivity{ if(AccountSessionManager.getInstance().getLoggedInAccounts().isEmpty()){ showFragmentClearingBackStack(new CustomWelcomeFragment()); }else{ - AccountSessionManager.getInstance().maybeUpdateLocalInfo(); AccountSession session; Bundle args=new Bundle(); Intent intent=getIntent(); + if(intent.hasExtra("fromExternalShare")) { + AccountSessionManager.getInstance() + .setLastActiveAccountID(intent.getStringExtra("account")); + AccountSessionManager.getInstance().maybeUpdateLocalInfo( + AccountSessionManager.getInstance().getLastActiveAccount()); + showFragmentForExternalShare(intent.getExtras()); + return; + } + boolean fromNotification = intent.getBooleanExtra("fromNotification", false); boolean hasNotification = intent.hasExtra("notification"); if(fromNotification){ @@ -52,6 +64,7 @@ public class MainActivity extends FragmentStackActivity{ }else{ session=AccountSessionManager.getInstance().getLastActiveAccount(); } + AccountSessionManager.getInstance().maybeUpdateLocalInfo(session); args.putString("account", session.getID()); Fragment fragment=session.activated ? new HomeFragment() : new AccountActivationFragment(); fragment.setArguments(args); @@ -75,11 +88,12 @@ public class MainActivity extends FragmentStackActivity{ @Override protected void onNewIntent(Intent intent){ super.onNewIntent(intent); - if(intent.getBooleanExtra("fromNotification", false)){ + AccountSessionManager.getInstance().maybeUpdateLocalInfo(); + if (intent.hasExtra("fromExternalShare")) showFragmentForExternalShare(intent.getExtras()); + else if (intent.getBooleanExtra("fromNotification", false)) { String accountID=intent.getStringExtra("accountID"); - AccountSession accountSession; try{ - accountSession=AccountSessionManager.getInstance().getAccount(accountID); + AccountSessionManager.getInstance().getAccount(accountID); }catch(IllegalStateException x){ return; } @@ -103,23 +117,24 @@ public class MainActivity extends FragmentStackActivity{ } private void showFragmentForNotification(Notification notification, String accountID){ - Fragment fragment; - Bundle args=new Bundle(); - args.putString("account", accountID); - args.putBoolean("_can_go_back", true); try{ notification.postprocess(); }catch(ObjectValidationException x){ Log.w("MainActivity", x); return; } - if(notification.status!=null){ - fragment=new ThreadFragment(); - args.putParcelable("status", Parcels.wrap(notification.status)); - }else{ - fragment=new ProfileFragment(); - args.putParcelable("profileAccount", Parcels.wrap(notification.account)); - } + UiUtils.showFragmentForNotification(this, notification, accountID, null); + } + + private void showFragmentForExternalShare(Bundle args) { + String className = args.getString("fromExternalShare"); + Fragment fragment = switch (className) { + case "ThreadFragment" -> new ThreadFragment(); + case "ProfileFragment" -> new ProfileFragment(); + default -> null; + }; + if (fragment == null) return; + args.putBoolean("_can_go_back", true); fragment.setArguments(args); showFragment(fragment); } @@ -153,17 +168,40 @@ public class MainActivity extends FragmentStackActivity{ (fragmentContainers.get(fragmentContainers.size() - 1)).getId() ); Bundle currentArgs = currentFragment.getArguments(); - if (this.fragmentContainers.size() == 1 - && currentArgs.getBoolean("_can_go_back", false) - && currentArgs.containsKey("account")) { + if (fragmentContainers.size() != 1 + || currentArgs == null + || !currentArgs.getBoolean("_can_go_back", false)) { + super.onBackPressed(); + return; + } + if (currentArgs.getBoolean("_finish_on_back", false)) { + finish(); + } else if (currentArgs.containsKey("account")) { Bundle args = new Bundle(); args.putString("account", currentArgs.getString("account")); - args.putString("tab", "notifications"); + if (getIntent().getBooleanExtra("fromNotification", false)) { + args.putString("tab", "notifications"); + } Fragment fragment=new HomeFragment(); fragment.setArguments(args); showFragmentClearingBackStack(fragment); - } else { - super.onBackPressed(); } } + + public Fragment getCurrentFragment() { + for (int i = fragmentContainers.size() - 1; i >= 0; i--) { + FrameLayout fl = fragmentContainers.get(i); + if (fl.getVisibility() == View.VISIBLE) { + return getFragmentManager().findFragmentById(fl.getId()); + } + } + return null; + } + + @Override + public void onProvideAssistContent(AssistContent assistContent) { + super.onProvideAssistContent(assistContent); + Fragment fragment = getCurrentFragment(); + if (fragment != null) callFragmentToProvideAssistContent(fragment, assistContent); + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/OAuthActivity.java b/mastodon/src/main/java/org/joinmastodon/android/OAuthActivity.java index 553074599..f239bf3f0 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/OAuthActivity.java +++ b/mastodon/src/main/java/org/joinmastodon/android/OAuthActivity.java @@ -61,6 +61,9 @@ public class OAuthActivity extends Activity{ @Override public void onSuccess(Token token){ new GetOwnAccount() + // in case the instance (looking at pixelfed) wants to redirect to a + // website, we need to pass a context so we can launch a browser + .setContext(OAuthActivity.this) .setCallback(new Callback<>(){ @Override public void onSuccess(Account account){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/PanicResponderActivity.java b/mastodon/src/main/java/org/joinmastodon/android/PanicResponderActivity.java new file mode 100644 index 000000000..716907a67 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/PanicResponderActivity.java @@ -0,0 +1,49 @@ +package org.joinmastodon.android; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; + +import org.joinmastodon.android.api.requests.oauth.RevokeOauthToken; +import org.joinmastodon.android.api.session.AccountSession; +import org.joinmastodon.android.api.session.AccountSessionManager; + +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; + + +public class PanicResponderActivity extends Activity { + public static final String PANIC_TRIGGER_ACTION = "info.guardianproject.panic.action.TRIGGER"; + + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + final Intent intent = getIntent(); + if (intent != null && PANIC_TRIGGER_ACTION.equals(intent.getAction())) { + AccountSessionManager.getInstance().getLoggedInAccounts().forEach(accountSession -> logOut(accountSession.getID())); + ExitActivity.exit(this); + } + finishAndRemoveTask(); + } + + private void logOut(String accountID){ + AccountSession session=AccountSessionManager.getInstance().getAccount(accountID); + new RevokeOauthToken(session.app.clientId, session.app.clientSecret, session.token.accessToken) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(Object result){ + onLoggedOut(accountID); + } + + @Override + public void onError(ErrorResponse error){ + onLoggedOut(accountID); + } + }) + .exec(accountID); + } + + private void onLoggedOut(String accountID){ + AccountSessionManager.getInstance().removeAccount(accountID); + } +} \ No newline at end of file diff --git a/mastodon/src/main/java/org/joinmastodon/android/PushNotificationReceiver.java b/mastodon/src/main/java/org/joinmastodon/android/PushNotificationReceiver.java index 5023e1996..372f39e02 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/PushNotificationReceiver.java +++ b/mastodon/src/main/java/org/joinmastodon/android/PushNotificationReceiver.java @@ -5,6 +5,7 @@ import android.app.NotificationChannel; import android.app.NotificationChannelGroup; import android.app.NotificationManager; import android.app.PendingIntent; +import android.app.RemoteInput; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; @@ -16,15 +17,28 @@ import android.util.Log; import org.joinmastodon.android.api.MastodonAPIController; import org.joinmastodon.android.api.requests.notifications.GetNotificationByID; +import org.joinmastodon.android.api.requests.statuses.CreateStatus; +import org.joinmastodon.android.api.requests.statuses.SetStatusBookmarked; +import org.joinmastodon.android.api.requests.statuses.SetStatusFavorited; +import org.joinmastodon.android.api.requests.statuses.SetStatusReblogged; import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.events.NotificationReceivedEvent; import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.model.Mention; +import org.joinmastodon.android.model.NotificationAction; +import org.joinmastodon.android.model.Preferences; import org.joinmastodon.android.model.PushNotification; +import org.joinmastodon.android.model.Status; +import org.joinmastodon.android.model.StatusPrivacy; import org.joinmastodon.android.ui.utils.UiUtils; import org.parceler.Parcels; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Random; +import java.util.UUID; import java.util.stream.Collectors; import me.grishka.appkit.api.Callback; @@ -37,10 +51,14 @@ public class PushNotificationReceiver extends BroadcastReceiver{ private static final String TAG="PushNotificationReceive"; public static final int NOTIFICATION_ID=178; + private static final String ACTION_KEY_TEXT_REPLY = "ACTION_KEY_TEXT_REPLY"; + + private static final int SUMMARY_ID = 791; private static int notificationId = 0; @Override public void onReceive(Context context, Intent intent){ + UiUtils.setUserPreferredTheme(context); if(BuildConfig.DEBUG){ Log.e(TAG, "received: "+intent); Bundle extras=intent.getExtras(); @@ -70,6 +88,7 @@ public class PushNotificationReceiver extends BroadcastReceiver{ } String accountID=account.getID(); PushNotification pn=AccountSessionManager.getInstance().getAccount(accountID).getPushSubscriptionManager().decryptNotification(k, p, s); + E.post(new NotificationReceivedEvent(accountID, pn.notificationId+"")); new GetNotificationByID(pn.notificationId+"") .setCallback(new Callback<>(){ @Override @@ -91,6 +110,35 @@ public class PushNotificationReceiver extends BroadcastReceiver{ Log.w(TAG, "onReceive: invalid push notification format"); } } + if(intent.getBooleanExtra("fromNotificationAction", false)){ + String accountID=intent.getStringExtra("accountID"); + int notificationId=intent.getIntExtra("notificationId", -1); + + if (notificationId >= 0){ + NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + notificationManager.cancel(accountID, notificationId); + } + + if(intent.hasExtra("notification")){ + org.joinmastodon.android.model.Notification notification=Parcels.unwrap(intent.getParcelableExtra("notification")); + String statusID=notification.status.id; + if (statusID != null) { + AccountSessionManager accountSessionManager = AccountSessionManager.getInstance(); + Preferences preferences = accountSessionManager.getAccount(accountID).preferences; + + switch (NotificationAction.values()[intent.getIntExtra("notificationAction", 0)]) { + case FAVORITE -> new SetStatusFavorited(statusID, true).exec(accountID); + case BOOKMARK -> new SetStatusBookmarked(statusID, true).exec(accountID); + case REBLOG -> new SetStatusReblogged(notification.status.id, true, preferences.postingDefaultVisibility).exec(accountID); + case UNDO_REBLOG -> new SetStatusReblogged(notification.status.id, false, preferences.postingDefaultVisibility).exec(accountID); + case REPLY -> handleReplyAction(context, accountID, intent, notification, notificationId, preferences); + default -> Log.w(TAG, "onReceive: Failed to get NotificationAction"); + } + } + }else{ + Log.e(TAG, "onReceive: Failed to load notification"); + } + } } private void notify(Context context, PushNotification pn, String accountID, org.joinmastodon.android.model.Notification notification){ @@ -142,7 +190,7 @@ public class PushNotificationReceiver extends BroadcastReceiver{ .setShowWhen(true) .setCategory(Notification.CATEGORY_SOCIAL) .setAutoCancel(true) - .setColor(context.getColor(R.color.primary_700)); + .setColor(UiUtils.getThemeColor(context, android.R.attr.colorAccent)); if (!GlobalUserPreferences.uniformNotificationIcon) { builder.setSmallIcon(switch (pn.notificationType) { @@ -164,6 +212,123 @@ public class PushNotificationReceiver extends BroadcastReceiver{ if(AccountSessionManager.getInstance().getLoggedInAccounts().size()>1){ builder.setSubText(accountName); } - nm.notify(accountID, GlobalUserPreferences.keepOnlyLatestNotification ? NOTIFICATION_ID : notificationId++, builder.build()); + + int id = GlobalUserPreferences.keepOnlyLatestNotification ? NOTIFICATION_ID : notificationId++; + + if (notification != null){ + switch (pn.notificationType){ + case MENTION, STATUS -> { + if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N){ + builder.addAction(buildReplyAction(context, id, accountID, notification)); + } + builder.addAction(buildNotificationAction(context, id, accountID, notification, context.getString(R.string.button_favorite), NotificationAction.FAVORITE)); + builder.addAction(buildNotificationAction(context, id, accountID, notification, context.getString(R.string.add_bookmark), NotificationAction.BOOKMARK)); + if(notification.status.visibility != StatusPrivacy.DIRECT) { + builder.addAction(buildNotificationAction(context, id, accountID, notification, context.getString(R.string.button_reblog), NotificationAction.REBLOG)); + } + } + case UPDATE -> { + if(notification.status.reblogged) + builder.addAction(buildNotificationAction(context, id, accountID, notification, context.getString(R.string.sk_undo_reblog), NotificationAction.UNDO_REBLOG)); + } + } + } + + nm.notify(accountID, id, builder.build()); + } + + private Notification.Action buildNotificationAction(Context context, int notificationId, String accountID, org.joinmastodon.android.model.Notification notification, String title, NotificationAction action){ + Intent notificationIntent=new Intent(context, PushNotificationReceiver.class); + notificationIntent.putExtra("notificationId", notificationId); + notificationIntent.putExtra("fromNotificationAction", true); + notificationIntent.putExtra("accountID", accountID); + notificationIntent.putExtra("notificationAction", action.ordinal()); + notificationIntent.putExtra("notification", Parcels.wrap(notification)); + PendingIntent actionPendingIntent = PendingIntent.getBroadcast(context, new Random().nextInt(), notificationIntent, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_ONE_SHOT); + + return new Notification.Action.Builder(null, title, actionPendingIntent).build(); + } + + private Notification.Action buildReplyAction(Context context, int notificationId, String accountID, org.joinmastodon.android.model.Notification notification){ + String replyLabel = context.getResources().getString(R.string.button_reply); + RemoteInput remoteInput = new RemoteInput.Builder(ACTION_KEY_TEXT_REPLY) + .setLabel(replyLabel) + .build(); + + Intent notificationIntent=new Intent(context, PushNotificationReceiver.class); + notificationIntent.putExtra("notificationId", notificationId); + notificationIntent.putExtra("fromNotificationAction", true); + notificationIntent.putExtra("accountID", accountID); + notificationIntent.putExtra("notificationAction", NotificationAction.REPLY.ordinal()); + notificationIntent.putExtra("notification", Parcels.wrap(notification)); + + int flags = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ? PendingIntent.FLAG_MUTABLE | PendingIntent.FLAG_UPDATE_CURRENT : PendingIntent.FLAG_UPDATE_CURRENT; + PendingIntent replyPendingIntent = PendingIntent.getBroadcast(context, new Random().nextInt(), notificationIntent,flags); + return new Notification.Action.Builder(null, replyLabel, replyPendingIntent).addRemoteInput(remoteInput).build(); + } + + private void handleReplyAction(Context context, String accountID, Intent intent, org.joinmastodon.android.model.Notification notification, int notificationId, Preferences preferences) { + Bundle remoteInput = RemoteInput.getResultsFromIntent(intent); + if (remoteInput == null) { + Log.e(TAG, "handleReplyAction: Could not get reply input"); + return; + } + CharSequence input = remoteInput.getCharSequence(ACTION_KEY_TEXT_REPLY); + + // copied from ComposeFragment - TODO: generalize? + ArrayList mentions=new ArrayList<>(); + Status status = notification.status; + String ownID=AccountSessionManager.getInstance().getAccount(accountID).self.id; + if(!status.account.id.equals(ownID)) + mentions.add('@'+status.account.acct); + for(Mention mention:status.mentions){ + if(mention.id.equals(ownID)) + continue; + String m='@'+mention.acct; + if(!mentions.contains(m)) + mentions.add(m); + } + String initialText=mentions.isEmpty() ? "" : TextUtils.join(" ", mentions)+" "; + + CreateStatus.Request req=new CreateStatus.Request(); + req.status = initialText + input.toString(); + req.language = preferences.postingDefaultLanguage; + req.visibility = preferences.postingDefaultVisibility; + req.inReplyToId = notification.status.id; + if(!notification.status.spoilerText.isEmpty() && GlobalUserPreferences.prefixRepliesWithRe && !notification.status.spoilerText.startsWith("re: ")){ + req.spoilerText = "re: " + notification.status.spoilerText; + } + + new CreateStatus(req, UUID.randomUUID().toString()).setCallback(new Callback<>() { + @Override + public void onSuccess(Status status) { + NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + Notification.Builder builder = android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O ? + new Notification.Builder(context, accountID+"_"+notification.type) : + new Notification.Builder(context) + .setPriority(Notification.PRIORITY_DEFAULT) + .setDefaults(Notification.DEFAULT_SOUND | Notification.DEFAULT_VIBRATE); + + notification.status = status; + Intent contentIntent=new Intent(context, MainActivity.class); + contentIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + contentIntent.putExtra("fromNotification", true); + contentIntent.putExtra("accountID", accountID); + contentIntent.putExtra("notification", Parcels.wrap(notification)); + + Notification repliedNotification = builder.setSmallIcon(R.drawable.ic_ntf_logo) + .setContentTitle(context.getString(R.string.sk_notification_action_replied, notification.status.account.displayName)) + .setContentText(status.getStrippedText()) + .setCategory(Notification.CATEGORY_SOCIAL) + .setContentIntent(PendingIntent.getActivity(context, notificationId, contentIntent, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT)) + .build(); + notificationManager.notify(accountID, notificationId, repliedNotification); + } + + @Override + public void onError(ErrorResponse errorResponse) { + + } + }).exec(accountID); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/ApiUtils.java b/mastodon/src/main/java/org/joinmastodon/android/api/ApiUtils.java index 8c588e468..a2cb79775 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/ApiUtils.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/ApiUtils.java @@ -11,7 +11,7 @@ public class ApiUtils{ //no instance } - public static > List enumSetToStrings(EnumSet e, Class cls){ +public static > List enumSetToStrings(EnumSet e, Class cls){ return e.stream().map(ev->{ try{ SerializedName annotation=cls.getField(ev.name()).getAnnotation(SerializedName.class); diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java b/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java index 3b58fac14..3cab9c71f 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java @@ -13,11 +13,12 @@ import org.joinmastodon.android.BuildConfig; import org.joinmastodon.android.MastodonApp; import org.joinmastodon.android.api.requests.notifications.GetNotifications; import org.joinmastodon.android.api.requests.timelines.GetHomeTimeline; +import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.model.CacheablePaginatedResponse; import org.joinmastodon.android.model.Filter; +import org.joinmastodon.android.model.Instance; import org.joinmastodon.android.model.Notification; -import org.joinmastodon.android.model.PaginatedResponse; import org.joinmastodon.android.model.SearchResult; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.utils.StatusFilterPredicate; @@ -35,7 +36,7 @@ import me.grishka.appkit.utils.WorkerThread; public class CacheController{ private static final String TAG="CacheController"; - private static final int DB_VERSION=3; + private static final int DB_VERSION=4; private static final WorkerThread databaseThread=new WorkerThread("databaseThread"); private static final Handler uiHandler=new Handler(Looper.getMainLooper()); @@ -60,7 +61,7 @@ public class CacheController{ List filters=AccountSessionManager.getInstance().getAccount(accountID).wordFilters.stream().filter(f->f.context.contains(Filter.FilterContext.HOME)).collect(Collectors.toList()); if(!forceReload){ SQLiteDatabase db=getOrOpenDatabase(); - try(Cursor cursor=db.query("home_timeline", new String[]{"json", "flags"}, maxID==null ? null : "`id` result=new ArrayList<>(); cursor.moveToFirst(); @@ -72,10 +73,8 @@ public class CacheController{ int flags=cursor.getInt(1); status.hasGapAfter=((flags & POST_FLAG_GAP_AFTER)!=0); newMaxID=status.id; - for(Filter filter:filters){ - if(filter.matches(status)) - continue outer; - } + if (!new StatusFilterPredicate(filters, Filter.FilterContext.HOME).test(status)) + continue outer; result.add(status); }while(cursor.moveToNext()); String _newMaxID=newMaxID; @@ -90,7 +89,7 @@ public class CacheController{ .setCallback(new Callback<>(){ @Override public void onSuccess(List result){ - callback.onSuccess(new CacheablePaginatedResponse<>(result.stream().filter(new StatusFilterPredicate(filters)).collect(Collectors.toList()), result.isEmpty() ? null : result.get(result.size()-1).id, false)); + callback.onSuccess(new CacheablePaginatedResponse<>(result.stream().filter(new StatusFilterPredicate(filters, Filter.FilterContext.HOME)).collect(Collectors.toList()), result.isEmpty() ? null : result.get(result.size()-1).id, false)); putHomeTimeline(result, maxID==null); } @@ -113,7 +112,7 @@ public class CacheController{ runOnDbThread((db)->{ if(clear) db.delete("home_timeline", null, null); - ContentValues values=new ContentValues(3); + ContentValues values=new ContentValues(4); for(Status s:posts){ values.put("id", s.id); values.put("json", MastodonAPIController.gson.toJson(s)); @@ -121,20 +120,22 @@ public class CacheController{ if(s.hasGapAfter) flags|=POST_FLAG_GAP_AFTER; values.put("flags", flags); + values.put("time", s.createdAt.getEpochSecond()); db.insertWithOnConflict("home_timeline", null, values, SQLiteDatabase.CONFLICT_REPLACE); } }); } - public void getNotifications(String maxID, int count, boolean onlyMentions, boolean onlyPosts, boolean forceReload, Callback>> callback){ + public void getNotifications(String maxID, int count, boolean onlyMentions, boolean onlyPosts, boolean forceReload, Callback>> callback){ cancelDelayedClose(); databaseThread.postRunnable(()->{ try{ - List filters=AccountSessionManager.getInstance().getAccount(accountID).wordFilters.stream().filter(f->f.context.contains(Filter.FilterContext.NOTIFICATIONS)).collect(Collectors.toList()); + AccountSession accountSession=AccountSessionManager.getInstance().getAccount(accountID); + List filters=accountSession.wordFilters.stream().filter(f->f.context.contains(Filter.FilterContext.NOTIFICATIONS)).collect(Collectors.toList()); if(!forceReload){ SQLiteDatabase db=getOrOpenDatabase(); String table=onlyPosts ? "notifications_posts" : onlyMentions ? "notifications_mentions" : "notifications_all"; - try(Cursor cursor=db.query(table, new String[]{"json"}, maxID==null ? null : "`id` result=new ArrayList<>(); cursor.moveToFirst(); @@ -145,35 +146,30 @@ public class CacheController{ ntf.postprocess(); newMaxID=ntf.id; if(ntf.status!=null){ - for(Filter filter:filters){ - if(filter.matches(ntf.status)) - continue outer; - } + if (!new StatusFilterPredicate(filters, Filter.FilterContext.NOTIFICATIONS).test(ntf.status)) + continue outer; } result.add(ntf); }while(cursor.moveToNext()); String _newMaxID=newMaxID; - uiHandler.post(()->callback.onSuccess(new PaginatedResponse<>(result, _newMaxID))); + uiHandler.post(()->callback.onSuccess(new CacheablePaginatedResponse<>(result, _newMaxID, true))); return; } }catch(IOException x){ Log.w(TAG, "getNotifications: corrupted notification object in database", x); } } - new GetNotifications(maxID, count, onlyPosts ? EnumSet.of(Notification.Type.STATUS) : onlyMentions ? EnumSet.of(Notification.Type.MENTION): EnumSet.allOf(Notification.Type.class)) + Instance instance=AccountSessionManager.getInstance().getInstanceInfo(accountSession.domain); + new GetNotifications(maxID, count, onlyPosts ? EnumSet.of(Notification.Type.STATUS) : onlyMentions ? EnumSet.of(Notification.Type.MENTION): EnumSet.allOf(Notification.Type.class), instance.isAkkoma()) .setCallback(new Callback<>(){ @Override public void onSuccess(List result){ - callback.onSuccess(new PaginatedResponse<>(result.stream().filter(ntf->{ + callback.onSuccess(new CacheablePaginatedResponse<>(result.stream().filter(ntf->{ if(ntf.status!=null){ - for(Filter filter:filters){ - if(filter.matches(ntf.status)){ - return false; - } - } + return new StatusFilterPredicate(filters, Filter.FilterContext.NOTIFICATIONS).test(ntf.status); } return true; - }).collect(Collectors.toList()), result.isEmpty() ? null : result.get(result.size()-1).id)); + }).collect(Collectors.toList()), result.isEmpty() ? null : result.get(result.size()-1).id, false)); putNotifications(result, onlyMentions, onlyPosts, maxID==null); } @@ -197,7 +193,7 @@ public class CacheController{ String table=onlyPosts ? "notifications_posts" : onlyMentions ? "notifications_mentions" : "notifications_all"; if(clear) db.delete(table, null, null); - ContentValues values=new ContentValues(3); + ContentValues values=new ContentValues(4); for(Notification n:notifications){ if(n.type==null){ continue; @@ -205,6 +201,7 @@ public class CacheController{ values.put("id", n.id); values.put("json", MastodonAPIController.gson.toJson(n)); values.put("type", n.type.ordinal()); + values.put("time", n.createdAt.getEpochSecond()); db.insertWithOnConflict(table, null, values, SQLiteDatabase.CONFLICT_REPLACE); } }); @@ -301,21 +298,24 @@ public class CacheController{ CREATE TABLE `home_timeline` ( `id` VARCHAR(25) NOT NULL PRIMARY KEY, `json` TEXT NOT NULL, - `flags` INTEGER NOT NULL DEFAULT 0 + `flags` INTEGER NOT NULL DEFAULT 0, + `time` INTEGER NOT NULL )"""); db.execSQL(""" CREATE TABLE `notifications_all` ( `id` VARCHAR(25) NOT NULL PRIMARY KEY, `json` TEXT NOT NULL, `flags` INTEGER NOT NULL DEFAULT 0, - `type` INTEGER NOT NULL + `type` INTEGER NOT NULL, + `time` INTEGER NOT NULL )"""); db.execSQL(""" CREATE TABLE `notifications_mentions` ( `id` VARCHAR(25) NOT NULL PRIMARY KEY, `json` TEXT NOT NULL, `flags` INTEGER NOT NULL DEFAULT 0, - `type` INTEGER NOT NULL + `type` INTEGER NOT NULL, + `time` INTEGER NOT NULL )"""); createRecentSearchesTable(db); createPostsNotificationsTable(db); @@ -323,12 +323,16 @@ public class CacheController{ @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion){ - if(oldVersion==1){ + if(oldVersion<2){ createRecentSearchesTable(db); } - if(oldVersion==2){ + if(oldVersion<3){ + // MEGALODON-SPECIFIC createPostsNotificationsTable(db); } + if(oldVersion<4){ + addTimeColumns(db); + } } private void createRecentSearchesTable(SQLiteDatabase db){ @@ -346,9 +350,21 @@ public class CacheController{ `id` VARCHAR(25) NOT NULL PRIMARY KEY, `json` TEXT NOT NULL, `flags` INTEGER NOT NULL DEFAULT 0, - `type` INTEGER NOT NULL + `type` INTEGER NOT NULL, + `time` INTEGER NOT NULL )"""); } + + private void addTimeColumns(SQLiteDatabase db){ + db.execSQL("DELETE FROM `home_timeline`"); + db.execSQL("DELETE FROM `notifications_all`"); + db.execSQL("DELETE FROM `notifications_mentions`"); + db.execSQL("DELETE FROM `notifications_posts`"); + db.execSQL("ALTER TABLE `home_timeline` ADD `time` INTEGER NOT NULL DEFAULT 0"); + db.execSQL("ALTER TABLE `notifications_all` ADD `time` INTEGER NOT NULL DEFAULT 0"); + db.execSQL("ALTER TABLE `notifications_mentions` ADD `time` INTEGER NOT NULL DEFAULT 0"); + db.execSQL("ALTER TABLE `notifications_posts` ADD `time` INTEGER NOT NULL DEFAULT 0"); + } } @FunctionalInterface 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 3f8696dd2..b941a485f 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java @@ -16,6 +16,8 @@ import org.joinmastodon.android.MastodonApp; import org.joinmastodon.android.api.gson.IsoInstantTypeAdapter; import org.joinmastodon.android.api.gson.IsoLocalDateTypeAdapter; import org.joinmastodon.android.api.session.AccountSession; +import org.joinmastodon.android.model.Status; +import org.joinmastodon.android.ui.utils.UiUtils; import java.io.BufferedReader; import java.io.IOException; @@ -27,6 +29,7 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.TimeUnit; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -40,14 +43,19 @@ import okhttp3.ResponseBody; public class MastodonAPIController{ private static final String TAG="MastodonAPIController"; - public static final Gson gson=new GsonBuilder() + public static final Gson gsonWithoutDeserializer = new GsonBuilder() .disableHtmlEscaping() .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) .registerTypeAdapter(Instant.class, new IsoInstantTypeAdapter()) .registerTypeAdapter(LocalDate.class, new IsoLocalDateTypeAdapter()) .create(); + public static final Gson gson = gsonWithoutDeserializer.newBuilder() + .registerTypeAdapter(Status.class, new Status.StatusDeserializer()) + .create(); private static WorkerThread thread=new WorkerThread("MastodonAPIController"); - private static OkHttpClient httpClient=new OkHttpClient.Builder().build(); + private static OkHttpClient httpClient=new OkHttpClient.Builder() + .readTimeout(5, TimeUnit.MINUTES) + .build(); private AccountSession session; private static List badDomains = new ArrayList<>(); @@ -56,7 +64,7 @@ public class MastodonAPIController{ thread.start(); try { final BufferedReader reader = new BufferedReader(new InputStreamReader( - MastodonApp.context.getAssets().open("blocks.tsv") + MastodonApp.context.getAssets().open("blocks.txt") )); String line; while ((line = reader.readLine()) != null) { @@ -87,7 +95,7 @@ public class MastodonAPIController{ Request.Builder builder=new Request.Builder() .url(req.getURL().toString()) .method(req.getMethod(), req.getRequestBody()) - .header("User-Agent", "MastodonAndroid/"+BuildConfig.VERSION_NAME); + .header("User-Agent", "MegalodonAndroid/"+BuildConfig.VERSION_NAME); String token=null; if(session!=null) @@ -154,6 +162,11 @@ public class MastodonAPIController{ respObj=gson.fromJson(reader, req.respClass); } }catch(JsonIOException|JsonSyntaxException x){ + if (req.context != null && response.body().contentType().subtype().equals("html")) { + UiUtils.launchWebBrowser(req.context, response.request().url().toString()); + req.cancel(); + return; + } if(BuildConfig.DEBUG) Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+response+" error parsing or reading body", x); req.onError(x.getLocalizedMessage(), response.code(), x); diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java index 44a740401..b6d624588 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java @@ -2,6 +2,7 @@ package org.joinmastodon.android.api; import android.app.Activity; import android.app.ProgressDialog; +import android.content.Context; import android.net.Uri; import android.util.Log; import android.util.Pair; @@ -20,9 +21,11 @@ import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.function.Consumer; import androidx.annotation.CallSuper; +import androidx.annotation.Nullable; import androidx.annotation.StringRes; import me.grishka.appkit.api.APIRequest; import me.grishka.appkit.api.Callback; @@ -44,10 +47,11 @@ public abstract class MastodonAPIRequest extends APIRequest{ TypeToken respTypeToken; Call okhttpCall; Token token; - boolean canceled; + boolean canceled, isRemote; Map headers; private ProgressDialog progressDialog; protected boolean removeUnsupportedItems; + @Nullable Context context; public MastodonAPIRequest(HttpMethod method, String path, Class respClass){ this.path=path; @@ -101,6 +105,21 @@ public abstract class MastodonAPIRequest extends APIRequest{ return this; } + public MastodonAPIRequest execRemote(String domain) { + return execRemote(domain, null); + } + + public MastodonAPIRequest execRemote(String domain, @Nullable AccountSession remoteSession) { + this.isRemote = true; + return Optional.ofNullable(remoteSession) + .or(() -> AccountSessionManager.getInstance().getLoggedInAccounts().stream() + .filter(acc -> acc.domain.equals(domain)) + .findAny()) + .map(AccountSession::getID) + .map(this::exec) + .orElse(this.execNoAuth(domain)); + } + public MastodonAPIRequest wrapProgress(Activity activity, @StringRes int message, boolean cancelable){ return wrapProgress(activity, message, cancelable, null); } @@ -164,9 +183,20 @@ public abstract class MastodonAPIRequest extends APIRequest{ return this; } + public MastodonAPIRequest setContext(Context context) { + this.context = context; + return this; + } + + @Nullable + public Context getContext() { + return context; + } + @CallSuper public void validateAndPostprocessResponse(T respObj, Response httpResponse) throws IOException{ if(respObj instanceof BaseModel){ + ((BaseModel) respObj).isRemote = isRemote; ((BaseModel) respObj).postprocess(); }else if(respObj instanceof List){ if(removeUnsupportedItems){ @@ -175,6 +205,7 @@ public abstract class MastodonAPIRequest extends APIRequest{ Object item=itr.next(); if(item instanceof BaseModel){ try{ + ((BaseModel) item).isRemote = isRemote; ((BaseModel) item).postprocess(); }catch(ObjectValidationException x){ Log.w(TAG, "Removing invalid object from list", x); @@ -182,15 +213,20 @@ public abstract class MastodonAPIRequest extends APIRequest{ } } } + // no idea why we're post-processing twice, but well, as long + // as upstream does it like this, i don't wanna break anything for(Object item:((List) respObj)){ if(item instanceof BaseModel){ + ((BaseModel) item).isRemote = isRemote; ((BaseModel) item).postprocess(); } } }else{ for(Object item:((List) respObj)){ - if(item instanceof BaseModel) + if(item instanceof BaseModel) { + ((BaseModel) item).isRemote = isRemote; ((BaseModel) item).postprocess(); + } } } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonErrorResponse.java b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonErrorResponse.java index 9dfbfdc83..7dedffd54 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonErrorResponse.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonErrorResponse.java @@ -5,8 +5,6 @@ import android.view.View; import android.widget.TextView; import android.widget.Toast; -import org.joinmastodon.android.R; - import me.grishka.appkit.api.ErrorResponse; public class MastodonErrorResponse extends ErrorResponse{ @@ -22,7 +20,7 @@ public class MastodonErrorResponse extends ErrorResponse{ @Override public void bindErrorView(View view){ - TextView text=view.findViewById(R.id.error_text); + TextView text=view.findViewById(me.grishka.appkit.R.id.error_text); text.setText(error); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/StatusInteractionController.java b/mastodon/src/main/java/org/joinmastodon/android/api/StatusInteractionController.java index ec45b6d90..8baabf20a 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/StatusInteractionController.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/StatusInteractionController.java @@ -46,7 +46,7 @@ public class StatusInteractionController{ @Override public void onSuccess(Status result){ runningFavoriteRequests.remove(status.id); - result.favouritesCount = Math.max(0, status.favouritesCount) + (favorited ? 1 : -1); + result.favouritesCount = Math.max(0, status.favouritesCount + (favorited ? 1 : -1)); cb.accept(result); if (updateCounters) E.post(new StatusCountersUpdatedEvent(result)); } @@ -80,7 +80,7 @@ public class StatusInteractionController{ public void onSuccess(Status reblog){ Status result = reblog.getContentStatus(); runningReblogRequests.remove(status.id); - result.reblogsCount = Math.max(0, status.reblogsCount) + (reblogged ? 1 : -1); + result.reblogsCount = Math.max(0, status.reblogsCount + (reblogged ? 1 : -1)); cb.accept(result); if (updateCounters) E.post(new StatusCountersUpdatedEvent(result)); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetAccountByHandle.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetAccountByHandle.java new file mode 100644 index 000000000..011a8bf91 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetAccountByHandle.java @@ -0,0 +1,15 @@ +package org.joinmastodon.android.api.requests.accounts; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.Account; + +public class GetAccountByHandle extends MastodonAPIRequest{ + /** + * note that this method usually only returns a result if the instance already knows about an + * account - so it makes sense for looking up local users, search might be preferred otherwise + */ + public GetAccountByHandle(String acct){ + super(HttpMethod.GET, "/accounts/lookup", Account.class); + addQueryParameter("acct", acct); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/SetAccountFollowed.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/SetAccountFollowed.java index 1799120a9..4d7ca8571 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/SetAccountFollowed.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/SetAccountFollowed.java @@ -4,6 +4,10 @@ import org.joinmastodon.android.api.MastodonAPIRequest; import org.joinmastodon.android.model.Relationship; public class SetAccountFollowed extends MastodonAPIRequest{ + public SetAccountFollowed(String id, boolean followed, boolean showReblogs){ + this(id, followed, showReblogs, false); + } + public SetAccountFollowed(String id, boolean followed, boolean showReblogs, boolean notify){ super(HttpMethod.POST, "/accounts/"+id+"/"+(followed ? "follow" : "unfollow"), Relationship.class); if(followed) diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/markers/GetMarkers.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/markers/GetMarkers.java new file mode 100644 index 000000000..b7dd6536b --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/markers/GetMarkers.java @@ -0,0 +1,17 @@ +package org.joinmastodon.android.api.requests.markers; + +import org.joinmastodon.android.api.ApiUtils; +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.Marker; +import org.joinmastodon.android.model.Markers; + +import java.util.EnumSet; + +public class GetMarkers extends MastodonAPIRequest { + public GetMarkers(EnumSet timelines) { + super(HttpMethod.GET, "/markers", Markers.class); + for (String type : ApiUtils.enumSetToStrings(timelines, Marker.Type.class)){ + addQueryParameter("timeline[]", type); + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/GetNotifications.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/GetNotifications.java index 3c5a162fb..33aefea52 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/GetNotifications.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/GetNotifications.java @@ -1,6 +1,5 @@ package org.joinmastodon.android.api.requests.notifications; -import com.google.gson.annotations.SerializedName; import com.google.gson.reflect.TypeToken; import org.joinmastodon.android.api.ApiUtils; @@ -11,18 +10,24 @@ import java.util.EnumSet; import java.util.List; public class GetNotifications extends MastodonAPIRequest>{ - public GetNotifications(String maxID, int limit, EnumSet includeTypes){ + public GetNotifications(String maxID, int limit, EnumSet includeTypes, boolean isPleromaInstance){ super(HttpMethod.GET, "/notifications", new TypeToken<>(){}); if(maxID!=null) addQueryParameter("max_id", maxID); if(limit>0) addQueryParameter("limit", ""+limit); if(includeTypes!=null){ - for(String type:ApiUtils.enumSetToStrings(includeTypes, Notification.Type.class)){ - addQueryParameter("types[]", type); - } - for(String type:ApiUtils.enumSetToStrings(EnumSet.complementOf(includeTypes), Notification.Type.class)){ - addQueryParameter("exclude_types[]", type); + if(!isPleromaInstance) { + for(String type:ApiUtils.enumSetToStrings(includeTypes, Notification.Type.class)){ + addQueryParameter("types[]", type); + } + for(String type:ApiUtils.enumSetToStrings(EnumSet.complementOf(includeTypes), Notification.Type.class)){ + addQueryParameter("exclude_types[]", type); + } + }else{ + for(String type:ApiUtils.enumSetToStrings(includeTypes, Notification.Type.class)){ + addQueryParameter("include_types[]", type); + } } } removeUnsupportedItems=true; diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/PleromaMarkNotificationsRead.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/PleromaMarkNotificationsRead.java new file mode 100644 index 000000000..6e9fe23e7 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/PleromaMarkNotificationsRead.java @@ -0,0 +1,30 @@ +package org.joinmastodon.android.api.requests.notifications; + +import android.text.TextUtils; + +import com.google.gson.reflect.TypeToken; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.Notification; + +import java.util.List; + +import okhttp3.MultipartBody; +import okhttp3.RequestBody; + +public class PleromaMarkNotificationsRead extends MastodonAPIRequest> { + private String maxID; + public PleromaMarkNotificationsRead(String maxID) { + super(HttpMethod.POST, "/pleroma/notifications/read", new TypeToken<>(){}); + this.maxID = maxID; + } + + @Override + public RequestBody getRequestBody() { + MultipartBody.Builder builder=new MultipartBody.Builder() + .setType(MultipartBody.FORM); + if(!TextUtils.isEmpty(maxID)) + builder.addFormDataPart("max_id", maxID); + return builder.build(); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/CreateStatus.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/CreateStatus.java index bd6b307b1..12a7935e3 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/CreateStatus.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/CreateStatus.java @@ -1,6 +1,7 @@ package org.joinmastodon.android.api.requests.statuses; import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.ContentType; import org.joinmastodon.android.model.ScheduledStatus; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.model.StatusPrivacy; @@ -45,6 +46,9 @@ public class CreateStatus extends MastodonAPIRequest{ public Instant scheduledAt; public String language; + public String quoteId; + public ContentType contentType; + public static class Poll{ public ArrayList options=new ArrayList<>(); public int expiresIn; diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/GetStatusSourceText.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/GetStatusSourceText.java index f1dd895e3..1a1df5a5f 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/GetStatusSourceText.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/GetStatusSourceText.java @@ -2,17 +2,22 @@ package org.joinmastodon.android.api.requests.statuses; import org.joinmastodon.android.api.AllFieldsAreRequired; import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.api.RequiredField; import org.joinmastodon.android.model.BaseModel; +import org.joinmastodon.android.model.ContentType; public class GetStatusSourceText extends MastodonAPIRequest{ public GetStatusSourceText(String id){ super(HttpMethod.GET, "/statuses/"+id+"/source", Response.class); } - @AllFieldsAreRequired public static class Response extends BaseModel{ + @RequiredField public String id; + @RequiredField public String text; + @RequiredField public String spoilerText; + public ContentType contentType; } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetBubbleTimeline.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetBubbleTimeline.java new file mode 100644 index 000000000..9b54d1895 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetBubbleTimeline.java @@ -0,0 +1,23 @@ +package org.joinmastodon.android.api.requests.timelines; + +import android.text.TextUtils; + +import com.google.gson.reflect.TypeToken; + +import org.joinmastodon.android.GlobalUserPreferences; +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.Status; + +import java.util.List; + +public class GetBubbleTimeline extends MastodonAPIRequest> { + public GetBubbleTimeline(String maxID, int limit) { + super(HttpMethod.GET, "/timelines/bubble", new TypeToken<>(){}); + if(!TextUtils.isEmpty(maxID)) + addQueryParameter("max_id", maxID); + if(limit>0) + addQueryParameter("limit", limit+""); + if(GlobalUserPreferences.replyVisibility != null) + addQueryParameter("reply_visibility", GlobalUserPreferences.replyVisibility); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetHashtagTimeline.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetHashtagTimeline.java index 4a3c831df..1670c38c2 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetHashtagTimeline.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetHashtagTimeline.java @@ -2,6 +2,7 @@ package org.joinmastodon.android.api.requests.timelines; import com.google.gson.reflect.TypeToken; +import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.api.MastodonAPIRequest; import org.joinmastodon.android.model.Status; @@ -16,5 +17,7 @@ public class GetHashtagTimeline extends MastodonAPIRequest>{ addQueryParameter("min_id", minID); if(limit>0) addQueryParameter("limit", ""+limit); + if(GlobalUserPreferences.replyVisibility != null) + addQueryParameter("reply_visibility", GlobalUserPreferences.replyVisibility); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetHomeTimeline.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetHomeTimeline.java index a84978d2a..3792c5a66 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetHomeTimeline.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetHomeTimeline.java @@ -2,6 +2,7 @@ package org.joinmastodon.android.api.requests.timelines; import com.google.gson.reflect.TypeToken; +import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.api.MastodonAPIRequest; import org.joinmastodon.android.model.Status; @@ -18,5 +19,7 @@ public class GetHomeTimeline extends MastodonAPIRequest>{ addQueryParameter("since_id", sinceID); if(limit>0) addQueryParameter("limit", ""+limit); + if(GlobalUserPreferences.replyVisibility != null) + addQueryParameter("reply_visibility", GlobalUserPreferences.replyVisibility); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetListTimeline.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetListTimeline.java index 145a740bc..82d537971 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetListTimeline.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetListTimeline.java @@ -2,6 +2,7 @@ package org.joinmastodon.android.api.requests.timelines; import com.google.gson.reflect.TypeToken; +import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.api.MastodonAPIRequest; import org.joinmastodon.android.model.Status; @@ -18,5 +19,7 @@ public class GetListTimeline extends MastodonAPIRequest> { addQueryParameter("limit", ""+limit); if(sinceID!=null) addQueryParameter("since_id", sinceID); + if(GlobalUserPreferences.replyVisibility != null) + addQueryParameter("reply_visibility", GlobalUserPreferences.replyVisibility); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetPublicTimeline.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetPublicTimeline.java index 6723c18b9..7ec562704 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetPublicTimeline.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetPublicTimeline.java @@ -4,6 +4,7 @@ import android.text.TextUtils; import com.google.gson.reflect.TypeToken; +import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.api.MastodonAPIRequest; import org.joinmastodon.android.model.Status; @@ -20,5 +21,7 @@ public class GetPublicTimeline extends MastodonAPIRequest>{ addQueryParameter("max_id", maxID); if(limit>0) addQueryParameter("limit", limit+""); + if(GlobalUserPreferences.replyVisibility != null) + addQueryParameter("reply_visibility", GlobalUserPreferences.replyVisibility); } } 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 e7fa19d9e..30acb30d6 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 @@ -1,5 +1,7 @@ package org.joinmastodon.android.api.session; +import android.net.Uri; + import org.joinmastodon.android.api.CacheController; import org.joinmastodon.android.api.MastodonAPIController; import org.joinmastodon.android.api.PushSubscriptionManager; @@ -7,12 +9,15 @@ import org.joinmastodon.android.api.StatusInteractionController; import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.Application; import org.joinmastodon.android.model.Filter; +import org.joinmastodon.android.model.Instance; +import org.joinmastodon.android.model.Markers; import org.joinmastodon.android.model.Preferences; import org.joinmastodon.android.model.PushSubscription; import org.joinmastodon.android.model.Token; import java.util.ArrayList; import java.util.List; +import java.util.Optional; public class AccountSession{ public Token token; @@ -31,6 +36,7 @@ public class AccountSession{ public String pushAccountID; public Preferences preferences; public AccountActivationInfo activationInfo; + public Markers markers; private transient MastodonAPIController apiController; private transient StatusInteractionController statusInteractionController, remoteStatusInteractionController; private transient CacheController cacheController; @@ -85,4 +91,15 @@ public class AccountSession{ pushSubscriptionManager=new PushSubscriptionManager(getID()); return pushSubscriptionManager; } + + public Optional getInstance() { + return Optional.ofNullable(AccountSessionManager.getInstance().getInstanceInfo(domain)); + } + + public Uri getInstanceUri() { + return new Uri.Builder() + .scheme("https") + .authority(getInstance().map(i -> i.normalizedUri).orElse(domain)) + .build(); + } } 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 4f3b57a39..b2631b39c 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 @@ -15,6 +15,7 @@ import android.util.Log; import org.joinmastodon.android.BuildConfig; import org.joinmastodon.android.E; +import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.MainActivity; import org.joinmastodon.android.MastodonApp; import org.joinmastodon.android.R; @@ -25,6 +26,7 @@ import org.joinmastodon.android.api.requests.accounts.GetWordFilters; import org.joinmastodon.android.api.requests.instance.GetCustomEmojis; import org.joinmastodon.android.api.requests.accounts.GetOwnAccount; import org.joinmastodon.android.api.requests.instance.GetInstance; +import org.joinmastodon.android.api.requests.markers.GetMarkers; import org.joinmastodon.android.api.requests.oauth.CreateOAuthApp; import org.joinmastodon.android.events.EmojiUpdatedEvent; import org.joinmastodon.android.model.Account; @@ -33,6 +35,8 @@ import org.joinmastodon.android.model.Emoji; import org.joinmastodon.android.model.EmojiCategory; import org.joinmastodon.android.model.Filter; import org.joinmastodon.android.model.Instance; +import org.joinmastodon.android.model.Marker; +import org.joinmastodon.android.model.Markers; import org.joinmastodon.android.model.Preferences; import org.joinmastodon.android.model.Token; @@ -46,6 +50,7 @@ import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; +import java.util.EnumSet; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -106,6 +111,12 @@ public class AccountSessionManager{ sessions.put(session.getID(), session); lastActiveAccountID=session.getID(); writeAccountsFile(); + + // write initial instance info to file immediately to avoid sessions without instance info + InstanceInfoStorageWrapper wrapper = new InstanceInfoStorageWrapper(); + wrapper.instance = instance; + MastodonAPIController.runInBackground(()->writeInstanceInfoFile(wrapper, instance.uri)); + updateMoreInstanceInfo(instance, instance.uri); if(PushSubscriptionManager.arePushNotificationsAvailable()){ session.getPushSubscriptionManager().registerAccountForPush(null); @@ -114,14 +125,16 @@ public class AccountSessionManager{ } public synchronized void writeAccountsFile(){ - File file=new File(MastodonApp.context.getFilesDir(), "accounts.json"); + File tmpFile = new File(MastodonApp.context.getFilesDir(), "accounts.json~"); + File file = new File(MastodonApp.context.getFilesDir(), "accounts.json"); try{ - try(FileOutputStream out=new FileOutputStream(file)){ + try(FileOutputStream out=new FileOutputStream(tmpFile)){ SessionsStorageWrapper w=new SessionsStorageWrapper(); w.accounts=new ArrayList<>(sessions.values()); OutputStreamWriter writer=new OutputStreamWriter(out, StandardCharsets.UTF_8); MastodonAPIController.gson.toJson(w, writer); writer.flush(); + if (!tmpFile.renameTo(file)) Log.e(TAG, "Error renaming " + tmpFile.getPath() + " to " + file.getPath()); } }catch(IOException x){ Log.e(TAG, "Error writing accounts file", x); @@ -147,6 +160,11 @@ public class AccountSessionManager{ return sessions.get(id); } + @Nullable + public AccountSession tryGetAccount(Account account) { + return sessions.get(account.getDomainFromURL() + "_" + account.id); + } + @Nullable public AccountSession getLastActiveAccount(){ if(sessions.isEmpty() || lastActiveAccountID==null) @@ -174,6 +192,7 @@ public class AccountSessionManager{ AccountSession session=getAccount(id); session.getCacheController().closeDatabase(); MastodonApp.context.deleteDatabase(id+".db"); + GlobalUserPreferences.removeAccount(id); sessions.remove(id); if(lastActiveAccountID.equals(id)){ if(sessions.isEmpty()) @@ -244,30 +263,44 @@ public class AccountSessionManager{ } public void maybeUpdateLocalInfo(){ + maybeUpdateLocalInfo(null); + } + + public void maybeUpdateLocalInfo(AccountSession activeSession){ 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){ - updateSessionPreferences(session); - updateSessionLocalInfo(session); -// } -// if(now-session.filtersLastUpdated>3600_000L){ - updateSessionWordFilters(session); -// } + if(now-session.infoLastUpdated>24L*3600_000L || session == activeSession){ + updateSessionPreferences(session); + updateSessionLocalInfo(session); + } + if(now-session.filtersLastUpdated>3600_000L || session == activeSession){ + updateSessionWordFilters(session); + } + updateSessionMarkers(session); } if(loadedInstances){ - maybeUpdateCustomEmojis(domains); + maybeUpdateCustomEmojis(domains, activeSession != null ? activeSession.domain : null); } } - private void maybeUpdateCustomEmojis(Set domains){ + private void maybeUpdateCustomEmojis(Set domains, String activeDomain){ long now=System.currentTimeMillis(); for(String domain:domains){ -// Long lastUpdated=instancesLastUpdated.get(domain); -// if(lastUpdated==null || now-lastUpdated>24L*3600_000L){ - updateInstanceInfo(domain); -// } + Long lastUpdated=instancesLastUpdated.get(domain); + if(lastUpdated==null || now-lastUpdated>24L*3600_000L || domain.equals(activeDomain)){ + updateInstanceInfo(domain); + } + } + } + + private void preferencesFromSource(AccountSession session, Account account) { + if (account != null && account.source != null && session.preferences != null) { + if (account.source.privacy != null) + session.preferences.postingDefaultVisibility = account.source.privacy; + if (account.source.language != null) + session.preferences.postingDefaultLanguage = account.source.language; } } @@ -278,13 +311,12 @@ public class AccountSessionManager{ public void onSuccess(Account result){ session.self=result; session.infoLastUpdated=System.currentTimeMillis(); + preferencesFromSource(session, result); writeAccountsFile(); } @Override - public void onError(ErrorResponse error){ - - } + public void onError(ErrorResponse error){} }) .exec(session.getID()); } @@ -294,10 +326,14 @@ public class AccountSessionManager{ @Override public void onSuccess(Preferences preferences) { session.preferences=preferences; + preferencesFromSource(session, session.self); } @Override - public void onError(ErrorResponse error) {} + public void onError(ErrorResponse error) { + session.preferences = new Preferences(); + preferencesFromSource(session, session.self); + } }).exec(session.getID()); } @@ -319,6 +355,21 @@ public class AccountSessionManager{ .exec(session.getID()); } + private void updateSessionMarkers(AccountSession session) { + new GetMarkers(EnumSet.allOf(Marker.Type.class)).setCallback(new Callback<>() { + @Override + public void onSuccess(Markers markers) { + session.markers = markers; + writeAccountsFile(); + } + + @Override + public void onError(ErrorResponse error) { + + } + }).exec(session.getID()); + } + public void updateInstanceInfo(String domain){ new GetInstance() .setCallback(new Callback<>(){ @@ -368,7 +419,9 @@ public class AccountSessionManager{ @Override public void onError(ErrorResponse error){ - + InstanceInfoStorageWrapper wrapper=new InstanceInfoStorageWrapper(); + wrapper.instance = instance; + MastodonAPIController.runInBackground(()->writeInstanceInfoFile(wrapper, domain)); } }) .execNoAuth(domain); @@ -379,10 +432,13 @@ public class AccountSessionManager{ } private void writeInstanceInfoFile(InstanceInfoStorageWrapper emojis, String domain){ - try(FileOutputStream out=new FileOutputStream(getInstanceInfoFile(domain))){ + File file = getInstanceInfoFile(domain); + File tmpFile = new File(file.getPath() + "~"); + try(FileOutputStream out=new FileOutputStream(tmpFile)){ OutputStreamWriter writer=new OutputStreamWriter(out, StandardCharsets.UTF_8); MastodonAPIController.gson.toJson(emojis, writer); writer.flush(); + if (!tmpFile.renameTo(file)) Log.e(TAG, "Error renaming " + tmpFile.getPath() + " to " + file.getPath()); }catch(IOException x){ Log.w(TAG, "Error writing instance info file for "+domain, x); } @@ -402,7 +458,7 @@ public class AccountSessionManager{ } if(!loadedInstances){ loadedInstances=true; - maybeUpdateCustomEmojis(domains); + maybeUpdateCustomEmojis(domains, null); } } @@ -426,10 +482,6 @@ public class AccountSessionManager{ return instances.get(domain); } - public Instance getInstanceInfoForAccount(String account) { - return AccountSessionManager.getInstance().getInstanceInfo(instance.getAccount(account).domain); - } - public void updateAccountInfo(String id, Account account){ AccountSession session=getAccount(id); session.self=account; diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/AllNotificationsSeenEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/AllNotificationsSeenEvent.java new file mode 100644 index 000000000..aded8546a --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/events/AllNotificationsSeenEvent.java @@ -0,0 +1,4 @@ +package org.joinmastodon.android.events; + +public class AllNotificationsSeenEvent { +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/NotificationReceivedEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/NotificationReceivedEvent.java new file mode 100644 index 000000000..7641a4bdb --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/events/NotificationReceivedEvent.java @@ -0,0 +1,9 @@ +package org.joinmastodon.android.events; + +public class NotificationReceivedEvent { + public String account, id; + public NotificationReceivedEvent(String account, String id) { + this.account = account; + this.id = id; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/AccountTimelineFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/AccountTimelineFragment.java index b2d32aef2..67cd4df72 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/AccountTimelineFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/AccountTimelineFragment.java @@ -1,6 +1,7 @@ package org.joinmastodon.android.fragments; import android.app.Activity; +import android.net.Uri; import android.os.Bundle; import android.view.View; @@ -11,12 +12,16 @@ import org.joinmastodon.android.events.RemoveAccountPostsEvent; import org.joinmastodon.android.events.StatusCreatedEvent; import org.joinmastodon.android.events.StatusUnpinnedEvent; import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.model.Filter; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.ui.displayitems.HeaderStatusDisplayItem; +import org.joinmastodon.android.utils.StatusFilterPredicate; import org.parceler.Parcels; import java.util.Collections; import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; import me.grishka.appkit.api.SimpleCallback; @@ -57,6 +62,12 @@ public class AccountTimelineFragment extends StatusListFragment{ @Override public void onSuccess(List result){ if(getActivity()==null) return; + AccountSessionManager asm = AccountSessionManager.getInstance(); + result=result.stream().filter(status -> { + // don't hide own posts in own profile + if (asm.isSelf(accountID, user) && asm.isSelf(accountID, status.account)) return true; + else return new StatusFilterPredicate(accountID, getFilterContext()).test(status); + }).collect(Collectors.toList()); onDataLoaded(result, !result.isEmpty()); } }) @@ -76,7 +87,8 @@ public class AccountTimelineFragment extends StatusListFragment{ } protected void onStatusCreated(StatusCreatedEvent ev){ - if(!AccountSessionManager.getInstance().isSelf(accountID, ev.status.account)) + AccountSessionManager asm = AccountSessionManager.getInstance(); + if(!asm.isSelf(accountID, ev.status.account) || !asm.isSelf(accountID, user)) return; if(filter==GetAccountStatuses.Filter.PINNED) return; if(filter==GetAccountStatuses.Filter.DEFAULT){ @@ -84,10 +96,11 @@ public class AccountTimelineFragment extends StatusListFragment{ if(ev.status.inReplyToAccountId!=null && !ev.status.inReplyToAccountId.equals(AccountSessionManager.getInstance().getAccount(accountID).self.id)) return; }else if(filter==GetAccountStatuses.Filter.MEDIA){ - if(ev.status.mediaAttachments.isEmpty()) + if(Optional.ofNullable(ev.status.mediaAttachments).map(List::isEmpty).orElse(true)) return; } prependItems(Collections.singletonList(ev.status), true); + if (isOnTop()) scrollToTop(); } protected void onStatusUnpinned(StatusUnpinnedEvent ev){ @@ -114,4 +127,19 @@ public class AccountTimelineFragment extends StatusListFragment{ protected void onRemoveAccountPostsEvent(RemoveAccountPostsEvent ev){ // no-op } + + + @Override + protected Filter.FilterContext getFilterContext() { + return Filter.FilterContext.ACCOUNT; + } + + @Override + public Uri getWebUri(Uri.Builder base) { + // could return different uris based on filter (e.g. media -> "/media"), but i want to + // return the remote url to the user, and i don't know whether i'd need to append + // '#media' (akkoma/pleroma) or '/media' (glitch/mastodon) since i don't know anything + // about the remote instance. so, just returning the base url to the user instead + return Uri.parse(user.url); + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/AnnouncementsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/AnnouncementsFragment.java index a87b8c465..1b6b8ce2a 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/AnnouncementsFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/AnnouncementsFragment.java @@ -3,6 +3,7 @@ package org.joinmastodon.android.fragments; import static java.util.stream.Collectors.toList; import android.app.Activity; +import android.net.Uri; import android.os.Bundle; import android.text.TextUtils; import android.view.View; @@ -103,4 +104,9 @@ public class AnnouncementsFragment extends BaseStatusListFragment }) .exec(accountID); } + + @Override + public Uri getWebUri(Uri.Builder base) { + return isInstanceAkkoma() ? base.path("/announcements").build() : null; + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseStatusListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseStatusListFragment.java index 00e577c4c..debbf06f2 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseStatusListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseStatusListFragment.java @@ -1,6 +1,7 @@ package org.joinmastodon.android.fragments; import android.app.Activity; +import android.app.assist.AssistContent; import android.content.res.Configuration; import android.graphics.Canvas; import android.graphics.Paint; @@ -16,6 +17,8 @@ import android.text.TextUtils; import android.view.View; import android.view.ViewGroup; import android.view.WindowInsets; +import android.view.animation.TranslateAnimation; +import android.widget.ImageButton; import android.widget.Toolbar; import org.joinmastodon.android.E; @@ -30,20 +33,22 @@ import org.joinmastodon.android.model.Poll; import org.joinmastodon.android.model.Relationship; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.ui.BetterItemAnimator; -import org.joinmastodon.android.ui.PhotoLayoutHelper; -import org.joinmastodon.android.ui.TileGridLayoutManager; import org.joinmastodon.android.ui.displayitems.ExtendedFooterStatusDisplayItem; +import org.joinmastodon.android.ui.displayitems.FooterStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.GapStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.HeaderStatusDisplayItem; -import org.joinmastodon.android.ui.displayitems.ImageStatusDisplayItem; +import org.joinmastodon.android.ui.displayitems.MediaGridStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.PollFooterStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.PollOptionStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.StatusDisplayItem; import org.joinmastodon.android.ui.displayitems.TextStatusDisplayItem; +import org.joinmastodon.android.ui.displayitems.WarningFilteredStatusDisplayItem; import org.joinmastodon.android.ui.photoviewer.PhotoViewer; import org.joinmastodon.android.ui.photoviewer.PhotoViewerHost; +import org.joinmastodon.android.ui.utils.MediaAttachmentViewController; import org.joinmastodon.android.ui.utils.UiUtils; -import org.joinmastodon.android.ui.views.ImageAttachmentFrameLayout; +import org.joinmastodon.android.utils.ProvidesAssistContent; +import org.joinmastodon.android.utils.TypedObjectPool; import java.util.ArrayList; import java.util.Collections; @@ -54,11 +59,11 @@ import java.util.stream.Collectors; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.RecyclerView; + +import me.grishka.appkit.Nav; import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.ErrorResponse; -import me.grishka.appkit.fragments.BaseRecyclerFragment; import me.grishka.appkit.imageloader.ImageLoaderRecyclerAdapter; import me.grishka.appkit.imageloader.ImageLoaderViewHolder; import me.grishka.appkit.imageloader.requests.ImageLoaderRequest; @@ -66,17 +71,26 @@ import me.grishka.appkit.utils.BindableViewHolder; import me.grishka.appkit.utils.V; import me.grishka.appkit.views.UsableRecyclerView; -public abstract class BaseStatusListFragment extends BaseRecyclerFragment implements PhotoViewerHost, ScrollableToTop{ +public abstract class BaseStatusListFragment extends RecyclerFragment implements PhotoViewerHost, ScrollableToTop, IsOnTop, HasFab, ProvidesAssistContent.ProvidesWebUri { protected ArrayList displayItems=new ArrayList<>(); protected DisplayItemsAdapter adapter; protected String accountID; protected PhotoViewer currentPhotoViewer; + protected ImageButton fab; + protected int scrollDiff = 0; protected HashMap knownAccounts=new HashMap<>(); protected HashMap relationships=new HashMap<>(); protected Rect tmpRect=new Rect(); + protected TypedObjectPool attachmentViewsPool=new TypedObjectPool<>(this::makeNewMediaAttachmentView); + protected boolean currentlyScrolling; public BaseStatusListFragment(){ super(20); + if (wantsComposeButton()) setListLayoutId(R.layout.recycler_fragment_with_fab); + } + + protected boolean wantsComposeButton() { + return false; } @Override @@ -118,7 +132,7 @@ public abstract class BaseStatusListFragment exten displayItems.clear(); } - protected void prependItems(List items, boolean notify){ + protected int prependItems(List items, boolean notify){ data.addAll(0, items); int offset=0; for(T s:items){ @@ -131,6 +145,7 @@ public abstract class BaseStatusListFragment exten } if(notify) adapter.notifyItemRangeInserted(0, offset); + return offset; } protected String getMaxID(){ @@ -176,29 +191,30 @@ public abstract class BaseStatusListFragment exten } @Override - public void openPhotoViewer(String parentID, Status _status, int attachmentIndex){ - final Status status=_status.reblog!=null ? _status.reblog : _status; + public void openPhotoViewer(String parentID, Status _status, int attachmentIndex, MediaGridStatusDisplayItem.Holder gridHolder){ + final Status status=_status.getContentStatus(); currentPhotoViewer=new PhotoViewer(getActivity(), status.mediaAttachments, attachmentIndex, new PhotoViewer.Listener(){ - private ImageStatusDisplayItem.Holder transitioningHolder; + private MediaAttachmentViewController transitioningHolder; @Override public void setPhotoViewVisibility(int index, boolean visible){ - ImageStatusDisplayItem.Holder holder=findPhotoViewHolder(index); + MediaAttachmentViewController holder=findPhotoViewHolder(index); if(holder!=null) holder.photo.setAlpha(visible ? 1f : 0f); } @Override public boolean startPhotoViewTransition(int index, @NonNull Rect outRect, @NonNull int[] outCornerRadius){ - ImageStatusDisplayItem.Holder holder=findPhotoViewHolder(index); - if(holder!=null){ + MediaAttachmentViewController holder=findPhotoViewHolder(index); + if(holder!=null && list!=null){ transitioningHolder=holder; View view=transitioningHolder.photo; int[] pos={0, 0}; view.getLocationOnScreen(pos); outRect.set(pos[0], pos[1], pos[0]+view.getWidth(), pos[1]+view.getHeight()); list.setClipChildren(false); - transitioningHolder.itemView.setElevation(1f); + gridHolder.setClipChildren(false); + transitioningHolder.view.setElevation(1f); return true; } return false; @@ -225,15 +241,16 @@ public abstract class BaseStatusListFragment exten view.setTranslationY(0f); view.setScaleX(1f); view.setScaleY(1f); - transitioningHolder.itemView.setElevation(0f); + transitioningHolder.view.setElevation(0f); if(list!=null) list.setClipChildren(true); + gridHolder.setClipChildren(true); transitioningHolder=null; } @Override public Drawable getPhotoViewCurrentDrawable(int index){ - ImageStatusDisplayItem.Holder holder=findPhotoViewHolder(index); + MediaAttachmentViewController holder=findPhotoViewHolder(index); if(holder!=null) return holder.photo.getDrawable(); return null; @@ -249,35 +266,81 @@ public abstract class BaseStatusListFragment exten requestPermissions(permissions, PhotoViewer.PERMISSION_REQUEST); } - private ImageStatusDisplayItem.Holder findPhotoViewHolder(int index){ - if(list==null) - return null; - int offset=0; - for(StatusDisplayItem item:displayItems){ - if(item.parentID.equals(parentID)){ - if(item instanceof ImageStatusDisplayItem){ - RecyclerView.ViewHolder holder=list.findViewHolderForAdapterPosition(getMainAdapterOffset()+offset+index); - if(holder instanceof ImageStatusDisplayItem.Holder imgHolder){ - return imgHolder; - } - return null; - } - } - offset++; - } - return null; + private MediaAttachmentViewController findPhotoViewHolder(int index){ + return gridHolder.getViewController(index); } }); } + @Override + public @Nullable View getFab() { + if (getParentFragment() instanceof HasFab l) return l.getFab(); + else return fab; + } + + @Override + public void showFab() { + View fab = getFab(); + if (fab == null || fab.getVisibility() == View.VISIBLE) return; + fab.setVisibility(View.VISIBLE); + TranslateAnimation animate = new TranslateAnimation( + 0, + 0, + fab.getHeight() * 2, + 0); + animate.setDuration(300); + fab.startAnimation(animate); + } + + public boolean isScrolling() { + return currentlyScrolling; + } + + @Override + public void hideFab() { + View fab = getFab(); + if (fab == null || fab.getVisibility() != View.VISIBLE) return; + TranslateAnimation animate = new TranslateAnimation( + 0, + 0, + 0, + fab.getHeight() * 2); + animate.setDuration(300); + fab.startAnimation(animate); + fab.setVisibility(View.INVISIBLE); + scrollDiff = 0; + } + @Override public void onViewCreated(View view, Bundle savedInstanceState){ super.onViewCreated(view, savedInstanceState); + fab=view.findViewById(R.id.fab); + list.addOnScrollListener(new RecyclerView.OnScrollListener(){ @Override public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy){ if(currentPhotoViewer!=null) currentPhotoViewer.offsetView(-dx, -dy); + + View fab = getFab(); + if (fab!=null && GlobalUserPreferences.autoHideFab && dy != UiUtils.SCROLL_TO_TOP_DELTA) { + if (dy > 0 && fab.getVisibility() == View.VISIBLE) { + hideFab(); + } else if (dy < 0 && fab.getVisibility() != View.VISIBLE) { + if (list.getChildAt(0).getTop() == 0 || scrollDiff > 400) { + showFab(); + scrollDiff = 0; + } else { + scrollDiff += Math.abs(dy); + } + } + } + } + + @Override + public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) { + super.onScrollStateChanged(recyclerView, newState); + currentlyScrolling = newState != RecyclerView.SCROLL_STATE_IDLE; } }); list.addItemDecoration(new StatusListItemDecoration()); @@ -285,6 +348,8 @@ public abstract class BaseStatusListFragment exten private Rect tmpRect=new Rect(); @Override public void getSelectorBounds(View view, Rect outRect){ + boolean hasDescendant = false, hasAncestor = false, isWarning = false; + int lastIndex = -1, firstIndex = -1; list.getDecoratedBoundsWithMargins(view, outRect); RecyclerView.ViewHolder holder=list.getChildViewHolder(view); if(holder instanceof StatusDisplayItem.Holder){ @@ -296,48 +361,55 @@ public abstract class BaseStatusListFragment exten for(int i=0;i h){ String otherID=((StatusDisplayItem.Holder) holder).getItemID(); if(otherID.equals(id)){ + if (firstIndex < 0) firstIndex = i; + lastIndex = i; + StatusDisplayItem item = h.getItem(); + hasDescendant = item.hasDescendantNeighbor; + // no for direct descendants because main status (right above) is + // being displayed with an extended footer - no connected layout + hasAncestor = item.hasAncestoringNeighbor && !item.isDirectDescendant; list.getDecoratedBoundsWithMargins(child, tmpRect); outRect.left=Math.min(outRect.left, tmpRect.left); outRect.top=Math.min(outRect.top, tmpRect.top); outRect.right=Math.max(outRect.right, tmpRect.right); outRect.bottom=Math.max(outRect.bottom, tmpRect.bottom); + if (holder instanceof WarningFilteredStatusDisplayItem.Holder) { + isWarning = true; + } } } } } + // shifting the selection box down + // see also: FooterStatusDisplayItem#onBind (setMargins) + if (isWarning || firstIndex < 0 || lastIndex < 0 || + !(list.getChildViewHolder(list.getChildAt(lastIndex)) + instanceof FooterStatusDisplayItem.Holder)) return; + int prevIndex = firstIndex - 1, nextIndex = lastIndex + 1; + boolean prevIsWarning = prevIndex > 0 && prevIndex < list.getChildCount() && + list.getChildViewHolder(list.getChildAt(prevIndex)) + instanceof WarningFilteredStatusDisplayItem.Holder; + boolean nextIsWarning = nextIndex > 0 && nextIndex < list.getChildCount() && + list.getChildViewHolder(list.getChildAt(nextIndex)) + instanceof WarningFilteredStatusDisplayItem.Holder; + if (!prevIsWarning && hasAncestor) outRect.top += V.dp(4); + if (!nextIsWarning && hasDescendant) outRect.bottom += V.dp(4); } }); list.setItemAnimator(new BetterItemAnimator()); ((UsableRecyclerView) list).setIncludeMarginsInItemHitbox(true); updateToolbar(); - } - @Override - protected RecyclerView.LayoutManager onCreateLayoutManager(){ - GridLayoutManager lm=new TileGridLayoutManager(getActivity(), 1000); - lm.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup(){ - @Override - public int getSpanSize(int position){ - position-=getMainAdapterOffset(); - if(position>=0 && position exten revealSpoiler(status, holder.getItemID()); } - public void onRevealSpoilerClick(ImageStatusDisplayItem.Holder holder){ + public void onRevealSpoilerClick(MediaGridStatusDisplayItem.Holder holder){ Status status=holder.getItem().status; revealSpoiler(status, holder.getItemID()); } @@ -478,7 +550,7 @@ public abstract class BaseStatusListFragment exten Status status=holder.getItem().status; status.spoilerRevealed=!status.spoilerRevealed; if(!TextUtils.isEmpty(status.spoilerText)){ - TextStatusDisplayItem.Holder text=findHolderOfType(holder.getItemID(), TextStatusDisplayItem.Holder.class); + TextStatusDisplayItem.Holder text = findHolderOfType(holder.getItemID(), TextStatusDisplayItem.Holder.class); if(text!=null){ adapter.notifyItemChanged(text.getAbsoluteAdapterPosition()); } @@ -487,23 +559,58 @@ public abstract class BaseStatusListFragment exten updateImagesSpoilerState(status, holder.getItemID()); } + public void onEnableExpandable(TextStatusDisplayItem.Holder holder, boolean expandable) { + if (holder.getItem().status.textExpandable != expandable && list != null) { + holder.getItem().status.textExpandable = expandable; + HeaderStatusDisplayItem.Holder header = findHolderOfType(holder.getItemID(), HeaderStatusDisplayItem.Holder.class); + if (header != null) header.rebind(); + } + } + + public void onToggleExpanded(Status status, String itemID) { + status.textExpanded = !status.textExpanded; + TextStatusDisplayItem.Holder text=findHolderOfType(itemID, TextStatusDisplayItem.Holder.class); + HeaderStatusDisplayItem.Holder header=findHolderOfType(itemID, HeaderStatusDisplayItem.Holder.class); + if (text != null) text.rebind(); + if (header != null) header.rebind(); + } + protected void updateImagesSpoilerState(Status status, String itemID){ ArrayList updatedPositions=new ArrayList<>(); - for(ImageStatusDisplayItem.Holder photo:(List)findAllHoldersOfType(itemID, ImageStatusDisplayItem.Holder.class)){ - photo.setRevealed(status.spoilerRevealed); - updatedPositions.add(photo.getAbsoluteAdapterPosition()-getMainAdapterOffset()); + MediaGridStatusDisplayItem.Holder mediaGrid=findHolderOfType(itemID, MediaGridStatusDisplayItem.Holder.class); + if(mediaGrid!=null){ + mediaGrid.setRevealed(status.spoilerRevealed); + updatedPositions.add(mediaGrid.getAbsoluteAdapterPosition()-getMainAdapterOffset()); } int i=0; for(StatusDisplayItem item:displayItems){ - if(itemID.equals(item.parentID) && item instanceof ImageStatusDisplayItem && !updatedPositions.contains(i)){ + if(itemID.equals(item.parentID) && item instanceof MediaGridStatusDisplayItem && !updatedPositions.contains(i)){ adapter.notifyItemChanged(i); } i++; } } + public void onImageUpdated(MediaGridStatusDisplayItem.Holder holder, int index) { + holder.rebind(); + MediaGridStatusDisplayItem.Holder mediaGrid = findHolderOfType(holder.getItemID(), MediaGridStatusDisplayItem.Holder.class); + if(mediaGrid!=null){ + adapter.notifyItemChanged(mediaGrid.getAbsoluteAdapterPosition()); + } + } + public void onGapClick(GapStatusDisplayItem.Holder item){} + public void onWarningClick(WarningFilteredStatusDisplayItem.Holder warning){ + int startPos = warning.getAbsoluteAdapterPosition(); + displayItems.remove(startPos); + displayItems.addAll(startPos, warning.filteredItems); + adapter.notifyItemRangeInserted(startPos, warning.filteredItems.size() - 1); + if (startPos == 0) scrollToTop(); + warning.getItem().status.filterRevealed = true; + } + + @Override public String getAccountID(){ return accountID; } @@ -573,6 +680,11 @@ public abstract class BaseStatusListFragment exten smoothScrollRecyclerViewToTop(list); } + @Override + public boolean isOnTop() { + return isRecyclerViewOnTop(list); + } + protected int getListWidthForMediaLayout(){ return list.getWidth(); } @@ -619,6 +731,36 @@ public abstract class BaseStatusListFragment exten currentPhotoViewer.onPause(); } + public void onFabClick(View v){ + Bundle args=new Bundle(); + args.putString("account", accountID); + Nav.go(getActivity(), ComposeFragment.class, args); + } + + public boolean onFabLongClick(View v) { + return UiUtils.pickAccountForCompose(getActivity(), accountID); + } + + private MediaAttachmentViewController makeNewMediaAttachmentView(MediaGridStatusDisplayItem.GridItemType type){ + return new MediaAttachmentViewController(getActivity(), type); + } + + public TypedObjectPool getAttachmentViewsPool(){ + return attachmentViewsPool; + } + + @Override + public void onProvideAssistContent(AssistContent assistContent) { + assistContent.setWebUri(getWebUri(getSession().getInstanceUri().buildUpon())); + } + + @Override + protected void onDataLoaded(List d, boolean more) { + super.onDataLoaded(d, more); + // more available, but the page isn't even full yet? seems wrong, let's load some more + if (more && d.size() < itemsPerPage) preloader.onScrolledToLastItem(); + } + protected class DisplayItemsAdapter extends UsableRecyclerView.Adapter> implements ImageLoaderRecyclerAdapter{ public DisplayItemsAdapter(){ @@ -656,16 +798,6 @@ public abstract class BaseStatusListFragment exten public ImageLoaderRequest getImageRequest(int position, int image){ return displayItems.get(position).getImageRequest(image); } - -// @Override -// public void onViewDetachedFromWindow(@NonNull BindableViewHolder holder){ -// if(holder instanceof ImageLoaderViewHolder){ -// int count=holder.getItem().getImageCount(); -// for(int i=0;i exten RecyclerView.ViewHolder siblingHolder=parent.getChildViewHolder(bottomSibling); if(holder instanceof StatusDisplayItem.Holder ih && siblingHolder instanceof StatusDisplayItem.Holder sh && (!ih.getItemID().equals(sh.getItemID()) || sh instanceof ExtendedFooterStatusDisplayItem.Holder) && ih.getItem().getType()!=StatusDisplayItem.Type.GAP){ + if (!ih.getItem().isMainStatus && ih.getItem().hasDescendantNeighbor) continue; drawDivider(child, bottomSibling, holder, siblingHolder, parent, c, dividerPaint); } } @@ -699,25 +832,21 @@ public abstract class BaseStatusListFragment exten for(int i=0;i imgHolder){ + if(holder instanceof MediaGridStatusDisplayItem.Holder imgHolder){ if(!imgHolder.getItem().status.spoilerRevealed && TextUtils.isEmpty(imgHolder.getItem().status.spoilerText)){ hiddenMediaPaint.setColor(0x80000000); - PhotoLayoutHelper.TiledLayoutResult.Tile tile=imgHolder.getItem().thisTile; - float hGap=tile.startCol>0 ? V.dp(1) : 0; - float vGap=tile.startRow>0 ? V.dp(1) : 0; - c.drawRect(child.getX()-hGap, child.getY()-vGap, child.getX()+child.getWidth(), child.getY()+child.getHeight(), hiddenMediaPaint); + c.drawRect(child.getX(), child.getY(), child.getX()+child.getWidth(), child.getY()+child.getHeight(), hiddenMediaPaint); } } } for(int i=0;i imgHolder){ + if(holder instanceof MediaGridStatusDisplayItem.Holder imgHolder){ if(!imgHolder.getItem().status.spoilerRevealed){ - PhotoLayoutHelper.TiledLayoutResult.Tile tile=imgHolder.getItem().thisTile; - if(tile.startCol==0 && tile.startRow==0 && TextUtils.isEmpty(imgHolder.getItem().status.spoilerText)){ + if(TextUtils.isEmpty(imgHolder.getItem().status.spoilerText)){ int listWidth=getListWidthForMediaLayout(); - int width=Math.min(listWidth, V.dp(ImageAttachmentFrameLayout.MAX_WIDTH)); + int width=Math.min(listWidth, UiUtils.MAX_WIDTH); if(currentMediaHiddenLayoutsWidth!=width) rebuildMediaHiddenLayouts(width-V.dp(32)); c.save(); @@ -742,47 +871,6 @@ public abstract class BaseStatusListFragment exten } } - @Override - public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state){ - RecyclerView.ViewHolder holder=parent.getChildViewHolder(view); - if(holder instanceof ImageStatusDisplayItem.Holder){ - int listWidth=getListWidthForMediaLayout(); - int width=Math.min(listWidth, V.dp(ImageAttachmentFrameLayout.MAX_WIDTH)); - PhotoLayoutHelper.TiledLayoutResult layout=((ImageStatusDisplayItem.Holder) holder).getItem().tiledLayout; - PhotoLayoutHelper.TiledLayoutResult.Tile tile=((ImageStatusDisplayItem.Holder) holder).getItem().thisTile; - if(tile.startCol+tile.colSpan1){ - outRect.bottom=-(Math.round(tile.height/1000f*width)-Math.round(layout.rowSizes[tile.startRow]/1000f*width)); - } - // ...and for its siblings, offset those on rows below first to the right where they belong - if(tile.startCol>0 && layout.tiles[0].rowSpan>1 && tile.startRow>layout.tiles[0].startRow){ - int xOffset=Math.round(layout.tiles[0].width/1000f*listWidth); - outRect.left=xOffset; - outRect.right=-xOffset; - } - - // If the width of the media block is smaller than that of the RecyclerView, offset the views horizontally to center them - if(listWidth>width){ - outRect.left+=(listWidth-V.dp(ImageAttachmentFrameLayout.MAX_WIDTH))/2; - if(tile.startCol>0){ - int spanOffset=0; - for(int i=0;i customEmojis; private CustomEmojiPopupKeyboard emojiKeyboard; private Status replyTo; + private Status quote; private String initialText; private String uuid; private int pollDuration=24*3600; @@ -229,7 +234,8 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr private boolean ignoreSelectionChanges=false; private Runnable updateUploadEtaRunnable; - private String language; + private String language, encoding; + private ContentType contentType; private MastodonLanguage.LanguageResolver languageResolver; @Override @@ -238,6 +244,8 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr setRetainInstance(true); accountID=getArguments().getString("account"); + contentType = GlobalUserPreferences.accountsDefaultContentTypes.get(accountID); + AccountSession session=AccountSessionManager.getInstance().getAccount(accountID); self=session.self; instanceDomain=session.domain; @@ -249,13 +257,12 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr editingStatus=Parcels.unwrap(getArguments().getParcelable("editStatus")); if(getArguments().containsKey("replyTo")) replyTo=Parcels.unwrap(getArguments().getParcelable("replyTo")); + if(getArguments().containsKey("quote")) + quote=Parcels.unwrap(getArguments().getParcelable("quote")); if(instance==null){ Nav.finish(this); return; } - if(customEmojis.isEmpty()){ - AccountSessionManager.getInstance().updateInstanceInfo(instanceDomain); - } Bundle bundle = savedInstanceState != null ? savedInstanceState : getArguments(); if (bundle.containsKey("scheduledStatus")) scheduledStatus=Parcels.unwrap(bundle.getParcelable("scheduledStatus")); @@ -324,13 +331,16 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr emojiBtn=view.findViewById(R.id.btn_emoji); spoilerBtn=view.findViewById(R.id.btn_spoiler); visibilityBtn=view.findViewById(R.id.btn_visibility); + contentTypeBtn=view.findViewById(R.id.btn_content_type); scheduleDraftView=view.findViewById(R.id.schedule_draft_view); scheduleDraftText=view.findViewById(R.id.schedule_draft_text); scheduleDraftDismiss=view.findViewById(R.id.schedule_draft_dismiss); scheduleTimeBtn=view.findViewById(R.id.scheduled_time_btn); sensitiveIcon=view.findViewById(R.id.sensitive_icon); sensitiveItem=view.findViewById(R.id.sensitive_item); - replyText=view.findViewById(R.id.reply_text); + replyText=view.findViewById(GlobalUserPreferences.replyLineAboveHeader ? R.id.reply_text : R.id.reply_text_below); + view.findViewById(GlobalUserPreferences.replyLineAboveHeader ? R.id.reply_text_below : R.id.reply_text) + .setVisibility(View.GONE); if (isPhotoPickerAvailable()) { PopupMenu attachPopup = new PopupMenu(getContext(), mediaBtn); @@ -345,6 +355,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr } else { mediaBtn.setOnClickListener(v -> openFilePicker(false)); } + if (isInstancePixelfed()) pollBtn.setVisibility(View.GONE); pollBtn.setOnClickListener(v->togglePoll()); emojiBtn.setOnClickListener(v->emojiKeyboard.toggleKeyboardPopup(mainEditText)); spoilerBtn.setOnClickListener(v->toggleSpoiler()); @@ -356,6 +367,10 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr visibilityBtn.setOnClickListener(v->visibilityPopup.show()); visibilityBtn.setOnTouchListener(visibilityPopup.getDragToOpenListener()); + buildContentTypePopup(contentTypeBtn); + contentTypeBtn.setOnClickListener(v->contentTypePopup.show()); + contentTypeBtn.setOnTouchListener(contentTypePopup.getDragToOpenListener()); + scheduleDraftDismiss.setOnClickListener(v->updateScheduledAt(null)); scheduleTimeBtn.setOnClickListener(v->pickScheduledDateTime()); @@ -458,8 +473,10 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr } } - if(editingStatus!=null && editingStatus.visibility!=null) { - statusVisibility=editingStatus.visibility; + if (savedInstanceState != null) { + statusVisibility = (StatusPrivacy) savedInstanceState.getSerializable("visibility"); + } else if (editingStatus != null && editingStatus.visibility != null) { + statusVisibility = editingStatus.visibility; } else { loadDefaultStatusVisibility(savedInstanceState); } @@ -474,6 +491,20 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr }).setChecked(true); visibilityPopup.getMenu().findItem(R.id.local_only).setChecked(localOnly); + + if (savedInstanceState != null && savedInstanceState.containsKey("contentType")) { + contentType = (ContentType) savedInstanceState.getSerializable("contentType"); + } else if (getArguments().containsKey("sourceContentType")) { + try { + String val = getArguments().getString("sourceContentType"); + contentType = val == null ? null : ContentType.valueOf(val); + } catch (IllegalArgumentException ignored) {} + } + + int contentTypeId = ContentType.getContentTypeRes(contentType); + contentTypePopup.getMenu().findItem(contentTypeId).setChecked(true); + contentTypeBtn.setSelected(contentTypeId != R.id.content_type_null && contentTypeId != R.id.content_type_plain); + autocompleteViewController=new ComposeAutocompleteViewController(getActivity(), accountID); autocompleteViewController.setCompletionSelectedListener(this::onAutocompleteOptionSelected); View autocompleteView=autocompleteViewController.getView(); @@ -510,6 +541,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr outState.putParcelableArrayList("attachments", serializedAttachments); } outState.putSerializable("visibility", statusVisibility); + outState.putSerializable("contentType", contentType); if (scheduledAt != null) outState.putSerializable("scheduledAt", scheduledAt); if (scheduledStatus != null) outState.putParcelable("scheduledStatus", Parcels.wrap(scheduledStatus)); } @@ -548,8 +580,10 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr @Override public void afterTextChanged(Editable s){ - if(s.length()==0) + if(s.length()==0){ + updateCharCounter(); return; + } int start=lastChangeStart; int count=lastChangeCount; // offset one char back to catch an already typed '@' or '#' or ':' @@ -602,7 +636,8 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr } }); spoilerEdit.addTextChangedListener(new SimpleTextWatcher(e->updateCharCounter())); - if(replyTo!=null){ + if(replyTo!=null || quote!=null){ + Status status = quote!=null ? quote : replyTo; View replyWrap = view.findViewById(R.id.reply_wrap); scrollView.getViewTreeObserver().addOnGlobalLayoutListener(() -> { int scrollHeight = scrollView.getHeight(); @@ -628,13 +663,13 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr originalPost.setOnClickListener(v->{ Bundle args=new Bundle(); args.putString("account", accountID); - args.putParcelable("status", Parcels.wrap(replyTo)); + args.putParcelable("status", Parcels.wrap(status)); imm.hideSoftInputFromWindow(view.getWindowToken(), 0); Nav.go(getActivity(), ThreadFragment.class, args); }); ImageView avatar = view.findViewById(R.id.avatar); - ViewImageLoader.load(avatar, null, new UrlImageLoaderRequest(replyTo.account.avatar)); + ViewImageLoader.load(avatar, null, new UrlImageLoaderRequest(status.account.avatar)); ViewOutlineProvider roundCornersOutline=new ViewOutlineProvider(){ @Override public void getOutline(View view, Outline outline){ @@ -646,15 +681,15 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr avatar.setOnClickListener(v->{ Bundle args=new Bundle(); args.putString("account", accountID); - args.putParcelable("profileAccount", Parcels.wrap(replyTo.account)); + args.putParcelable("profileAccount", Parcels.wrap(status.account)); imm.hideSoftInputFromWindow(view.getWindowToken(), 0); Nav.go(getActivity(), ProfileFragment.class, args); }); - ((TextView) view.findViewById(R.id.name)).setText(replyTo.account.displayName); - ((TextView) view.findViewById(R.id.username)).setText(replyTo.account.getDisplayUsername()); + ((TextView) view.findViewById(R.id.name)).setText(status.account.displayName); + ((TextView) view.findViewById(R.id.username)).setText(status.account.getDisplayUsername()); view.findViewById(R.id.visibility).setVisibility(View.GONE); - Drawable visibilityIcon = getActivity().getDrawable(switch(replyTo.visibility){ + Drawable visibilityIcon = getActivity().getDrawable(switch(status.visibility){ case PUBLIC -> R.drawable.ic_fluent_earth_20_regular; case UNLISTED -> R.drawable.ic_fluent_lock_open_20_regular; case PRIVATE -> R.drawable.ic_fluent_lock_closed_20_filled; @@ -665,36 +700,37 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr moreBtn.setImageDrawable(visibilityIcon); moreBtn.setBackground(null); TextView timestamp = view.findViewById(R.id.timestamp); - if (replyTo.editedAt==null) timestamp.setText(UiUtils.formatRelativeTimestamp(getContext(), replyTo.createdAt)); - else timestamp.setText(getString(R.string.edited_timestamp, UiUtils.formatRelativeTimestamp(getContext(), replyTo.editedAt))); - if (replyTo.spoilerText != null && !replyTo.spoilerText.isBlank()) { + if (status.editedAt!=null) timestamp.setText(getString(R.string.edited_timestamp, UiUtils.formatRelativeTimestamp(getContext(), status.editedAt))); + else if (status.createdAt!=null) timestamp.setText(UiUtils.formatRelativeTimestamp(getContext(), status.createdAt)); + else timestamp.setText(""); + if (status.spoilerText != null && !status.spoilerText.isBlank()) { view.findViewById(R.id.spoiler_header).setVisibility(View.VISIBLE); - ((TextView) view.findViewById(R.id.spoiler_title_inline)).setText(replyTo.spoilerText); + ((TextView) view.findViewById(R.id.spoiler_title_inline)).setText(status.spoilerText); } - SpannableStringBuilder content = HtmlParser.parse(replyTo.content, replyTo.emojis, replyTo.mentions, replyTo.tags, accountID); + SpannableStringBuilder content = HtmlParser.parse(status.content, status.emojis, status.mentions, status.tags, accountID); LinkedTextView text = view.findViewById(R.id.text); if (content.length() > 0) text.setText(content); else view.findViewById(R.id.display_item_text).setLayoutParams(new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, V.dp(16))); - replyText.setText(getString(R.string.in_reply_to, replyTo.account.displayName)); - int visibilityNameRes = switch (replyTo.visibility) { + replyText.setText(getString(quote!=null? R.string.sk_quoting_user : R.string.in_reply_to, status.account.displayName)); + int visibilityNameRes = switch (status.visibility) { case PUBLIC -> R.string.visibility_public; case UNLISTED -> R.string.sk_visibility_unlisted; case PRIVATE -> R.string.visibility_followers_only; case DIRECT -> R.string.visibility_private; case LOCAL -> R.string.sk_local_only; }; - replyText.setContentDescription(getString(R.string.in_reply_to, replyTo.account.displayName) + ". " + getString(R.string.post_visibility) + ": " + getString(visibilityNameRes)); + replyText.setContentDescription(getString(R.string.in_reply_to, status.account.displayName) + ". " + getString(R.string.post_visibility) + ": " + getString(visibilityNameRes)); replyText.setOnClickListener(v->{ scrollView.smoothScrollTo(0, 0); }); ArrayList mentions=new ArrayList<>(); String ownID=AccountSessionManager.getInstance().getAccount(accountID).self.id; - if(!replyTo.account.id.equals(ownID)) - mentions.add('@'+replyTo.account.acct); - for(Mention mention:replyTo.mentions){ + if(!status.account.id.equals(ownID)) + mentions.add('@'+status.account.acct); + for(Mention mention:status.mentions){ if(mention.id.equals(ownID)) continue; String m='@'+mention.acct; @@ -707,13 +743,17 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr ignoreSelectionChanges=true; mainEditText.setSelection(mainEditText.length()); ignoreSelectionChanges=false; - if(!TextUtils.isEmpty(replyTo.spoilerText)){ + if(!TextUtils.isEmpty(status.spoilerText)){ hasSpoiler=true; spoilerEdit.setVisibility(View.VISIBLE); - spoilerEdit.setText(replyTo.spoilerText); + if(GlobalUserPreferences.prefixRepliesWithRe && !status.spoilerText.startsWith("re: ")){ + spoilerEdit.setText("re: " + status.spoilerText); + }else{ + spoilerEdit.setText(status.spoilerText); + } spoilerBtn.setSelected(true); } - if (replyTo.language != null && !replyTo.language.isEmpty()) updateLanguage(replyTo.language); + if (status.language != null && !status.language.isEmpty()) updateLanguage(status.language); } }else if (editingStatus==null || editingStatus.inReplyToId==null){ // TODO: remove workaround after https://github.com/mastodon/mastodon-android/issues/341 gets fixed @@ -808,12 +848,18 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr updateScheduledAt(scheduledAt != null ? scheduledAt : scheduledStatus != null ? scheduledStatus.scheduledAt : null); buildLanguageSelector(languageButton); - if (editingStatus != null && scheduledStatus == null) { + if (isInstancePixelfed() || (editingStatus != null && scheduledStatus == null)) { // editing an already published post draftsBtn.setVisibility(View.GONE); + spoilerBtn.setVisibility(View.GONE); } } + @Override + public String getAccountID() { + return accountID; + } + private void navigateToUnsentPosts() { Bundle args=new Bundle(); args.putString("account", accountID); @@ -835,9 +881,13 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr } private void updateLanguage(MastodonLanguage loc) { - language = loc.getLanguage(); - languageButton.setText(loc.getLanguageName()); - languageButton.setContentDescription(getActivity().getString(R.string.sk_post_language, loc.getDefaultName())); + updateLanguage(loc.getLanguage(), loc.getLanguageName(), loc.getDefaultName()); + } + + private void updateLanguage(String languageTag, String languageName, String defaultName) { + language = languageTag; + languageButton.setText(languageName); + languageButton.setContentDescription(getActivity().getString(R.string.sk_post_language, defaultName)); } @SuppressLint("ClickableViewAccessibility") @@ -854,8 +904,12 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr Menu languageMenu = languagePopup.getMenu(); for (String recentLanguage : Optional.ofNullable(recentLanguages.get(accountID)).orElse(defaultRecentLanguages)) { - MastodonLanguage l = languageResolver.from(recentLanguage); - languageMenu.add(0, allLanguages.indexOf(l), Menu.NONE, getActivity().getString(R.string.sk_language_name, l.getDefaultName(), l.getLanguageName())); + if (recentLanguage.equals("bottom")) { + addBottomLanguage(languageMenu); + } else { + MastodonLanguage l = languageResolver.from(recentLanguage); + languageMenu.add(0, allLanguages.indexOf(l), Menu.NONE, getActivity().getString(R.string.sk_language_name, l.getDefaultName(), l.getLanguageName())); + } } SubMenu allLanguagesMenu = languageMenu.addSubMenu(R.string.sk_available_languages); @@ -864,13 +918,44 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr allLanguagesMenu.add(0, i, Menu.NONE, getActivity().getString(R.string.sk_language_name, l.getDefaultName(), l.getLanguageName())); } + if (GlobalUserPreferences.bottomEncoding) addBottomLanguage(allLanguagesMenu); + + btn.setOnLongClickListener(v->{ + btn.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS); + if (!GlobalUserPreferences.bottomEncoding) addBottomLanguage(allLanguagesMenu); + return false; + }); + languagePopup.setOnMenuItemClickListener(i->{ if (i.hasSubMenu()) return false; - updateLanguage(allLanguages.get(i.getItemId())); + if (i.getItemId() == allLanguages.size()) { + updateLanguage(language, "\uD83E\uDD7A\uD83D\uDC49\uD83D\uDC48", "bottom"); + encoding = "bottom"; + } else { + updateLanguage(allLanguages.get(i.getItemId())); + encoding = null; + } return true; }); } + private int getContentTypeName(String id) { + return switch (id) { + case "text/plain" -> R.string.sk_content_type_plain; + case "text/html" -> R.string.sk_content_type_html; + case "text/markdown" -> R.string.sk_content_type_markdown; + case "text/bbcode" -> R.string.sk_content_type_bbcode; + case "text/x.misskeymarkdown" -> R.string.sk_content_type_mfm; + default -> throw new IllegalArgumentException("Invalid content type"); + }; + } + + private void addBottomLanguage(Menu menu) { + if (menu.findItem(allLanguages.size()) == null) { + menu.add(0, allLanguages.size(), Menu.NONE, "bottom (\uD83E\uDD7A\uD83D\uDC49\uD83D\uDC48)"); + } + } + @Override public boolean onOptionsItemSelected(MenuItem item){ return true; @@ -931,14 +1016,16 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr if(att.state!=AttachmentUploadState.DONE) nonDoneAttachmentCount++; } - publishButton.setEnabled((trimmedCharCount>0 || !attachments.isEmpty()) && charCount<=charLimit && nonDoneAttachmentCount==0 && (pollOptions.isEmpty() || nonEmptyPollOptionsCount>1)); + publishButton.setEnabled((!isInstancePixelfed() || attachments.size() > 0) && (trimmedCharCount>0 || !attachments.isEmpty()) && charCount<=charLimit && nonDoneAttachmentCount==0 && (pollOptions.isEmpty() || nonEmptyPollOptionsCount>1)); sendError.setVisibility(View.GONE); } private void onCustomEmojiClick(Emoji emoji){ - int start=mainEditText.getSelectionStart(); - String prefix=start>0 && !Character.isWhitespace(mainEditText.getText().charAt(start-1)) ? " :" : ":"; - mainEditText.getText().replace(start, mainEditText.getSelectionEnd(), prefix+emoji.shortcode+':'); + if(getActivity().getCurrentFocus() instanceof EditText edit){ + int start=edit.getSelectionStart(); + String prefix=start>0 && !Character.isWhitespace(edit.getText().charAt(start-1)) ? " :" : ":"; + edit.getText().replace(start, edit.getSelectionEnd(), prefix+emoji.shortcode+':'); + } } @Override @@ -995,6 +1082,10 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr private void publish(boolean force){ String text=mainEditText.getText().toString(); CreateStatus.Request req=new CreateStatus.Request(); + if ("bottom".equals(encoding)) { + text = new StatusTextEncoder(Bottom::encode).encode(text); + req.spoilerText = "bottom-encoded emoji spam"; + } if (localOnly && GlobalUserPreferences.accountsInGlitchMode.contains(accountID) && !GLITCH_LOCAL_ONLY_PATTERN.matcher(text).matches()) { @@ -1002,9 +1093,10 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr } req.status=text; req.localOnly=localOnly; - req.visibility=localOnly && instance.pleroma != null ? StatusPrivacy.LOCAL : statusVisibility; + req.visibility=localOnly && instance.isAkkoma() ? StatusPrivacy.LOCAL : statusVisibility; req.sensitive=sensitive; req.language=language; + req.contentType=contentType; req.scheduledAt = scheduledAt; if(!attachments.isEmpty()){ req.mediaIds=attachments.stream().map(a->a.serverAttachment.id).collect(Collectors.toList()); @@ -1046,6 +1138,9 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr if(hasSpoiler && spoilerEdit.length()>0){ req.spoilerText=spoilerEdit.getText().toString(); } + if(quote != null){ + req.quoteId=quote.id; + } if(uuid==null) uuid=UUID.randomUUID().toString(); @@ -1063,7 +1158,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr sendProgress.setVisibility(View.VISIBLE); sendError.setVisibility(View.GONE); - Callback resCallback=new Callback<>(){ + Callback resCallback = new Callback<>(){ @Override public void onSuccess(Status result){ maybeDeleteScheduledPost(() -> { @@ -1076,9 +1171,21 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr E.post(new StatusCountersUpdatedEvent(replyTo)); } }else{ - E.post(new StatusUpdatedEvent(result)); + // pixelfed doesn't return the edited status :/ + Status editedStatus = result == null ? editingStatus : result; + if (result == null) { + editedStatus.text = req.status; + editedStatus.spoilerText = req.spoilerText; + editedStatus.sensitive = req.sensitive; + editedStatus.language = req.language; + // user will have to reload to see html + editedStatus.content = req.status; + } + E.post(new StatusUpdatedEvent(editedStatus)); + } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || !isStateSaved()) { + Nav.finish(ComposeFragment.this); } - Nav.finish(ComposeFragment.this); if (getArguments().getBoolean("navigateToStatus", false)) { Bundle args=new Bundle(); args.putString("account", accountID); @@ -1136,6 +1243,14 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr List newRecentLanguages = new ArrayList<>(Optional.ofNullable(recentLanguages.get(accountID)).orElse(defaultRecentLanguages)); newRecentLanguages.remove(language); newRecentLanguages.add(0, language); + if (encoding != null) { + newRecentLanguages.remove(encoding); + newRecentLanguages.add(0, encoding); + } + if ("bottom".equals(encoding) && !GlobalUserPreferences.bottomEncoding) { + GlobalUserPreferences.bottomEncoding = true; + GlobalUserPreferences.save(); + } recentLanguages.put(accountID, newRecentLanguages.stream().limit(4).collect(Collectors.toList())); GlobalUserPreferences.save(); } @@ -1201,7 +1316,14 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr } private void confirmDiscardDraftAndFinish(){ - new M3AlertDialogBuilder(getActivity()) + boolean attachmentsPending = attachments.stream().anyMatch(att -> att.state != AttachmentUploadState.DONE); + if (attachmentsPending) new M3AlertDialogBuilder(getActivity()) + .setTitle(R.string.sk_unfinished_attachments) + .setMessage(R.string.sk_unfinished_attachments_message) + .setPositiveButton(R.string.edit, (d, w) -> {}) + .setNegativeButton(R.string.discard, (d, w) -> Nav.finish(this)) + .show(); + else new M3AlertDialogBuilder(getActivity()) .setTitle(editingStatus != null ? R.string.sk_confirm_save_changes : R.string.sk_confirm_save_draft) .setPositiveButton(R.string.save, (d, w) -> { updateScheduledAt(scheduledAt == null ? getDraftInstant() : scheduledAt); @@ -1211,18 +1333,6 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr .show(); } - /** - * Check to see if Android platform photopicker is available on the device\ - * @return whether the device supports photopicker intents. - */ - private boolean isPhotoPickerAvailable() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - return true; - } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - return getExtensionVersion(Build.VERSION_CODES.R) >= 2; - } else - return false; - } /** * Builds the correct intent for the device version to select media. @@ -1234,24 +1344,24 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr */ private void openFilePicker(boolean photoPicker){ Intent intent; - boolean usePhotoPicker = photoPicker && isPhotoPickerAvailable(); - if (usePhotoPicker) { - intent = new Intent(MediaStore.ACTION_PICK_IMAGES); - intent.putExtra(MediaStore.EXTRA_PICK_IMAGES_MAX, MediaStore.getPickImagesMaxLimit()); - } else { - intent = new Intent(Intent.ACTION_GET_CONTENT); + boolean usePhotoPicker=photoPicker && isPhotoPickerAvailable(); + if(usePhotoPicker){ + intent=new Intent(MediaStore.ACTION_PICK_IMAGES); + intent.putExtra(MediaStore.EXTRA_PICK_IMAGES_MAX, MAX_ATTACHMENTS-getMediaAttachmentsCount()); + }else{ + intent=new Intent(Intent.ACTION_GET_CONTENT); intent.addCategory(Intent.CATEGORY_OPENABLE); intent.setType("*/*"); } - if (!usePhotoPicker && instance.configuration != null && - instance.configuration.mediaAttachments != null && - instance.configuration.mediaAttachments.supportedMimeTypes != null && - !instance.configuration.mediaAttachments.supportedMimeTypes.isEmpty()) { + if(!usePhotoPicker && instance.configuration!=null && + instance.configuration.mediaAttachments!=null && + instance.configuration.mediaAttachments.supportedMimeTypes!=null && + !instance.configuration.mediaAttachments.supportedMimeTypes.isEmpty()){ intent.putExtra(Intent.EXTRA_MIME_TYPES, instance.configuration.mediaAttachments.supportedMimeTypes.toArray( new String[0])); - } else { - if (!usePhotoPicker) { + }else{ + if(!usePhotoPicker){ // If photo picker is being used these are the default mimetypes. intent.putExtra(Intent.EXTRA_MIME_TYPES, new String[]{"image/*", "video/*"}); } @@ -1491,7 +1601,8 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr if(att.isUploadingOrProcessing()) att.cancelUpload(); attachments.remove(att); - uploadNextQueuedAttachment(); + if(!areThereAnyUploadingAttachments()) + uploadNextQueuedAttachment(); attachmentsView.removeView(att.view); if(getMediaAttachmentsCount()==0) attachmentsView.setVisibility(View.GONE); @@ -1648,11 +1759,24 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr pollChanged=true; updatePublishButtonState(); })); - option.edit.setFilters(new InputFilter[]{new InputFilter.LengthFilter(instance.configuration!=null && instance.configuration.polls!=null && instance.configuration.polls.maxCharactersPerOption>0 ? instance.configuration.polls.maxCharactersPerOption : 50)}); + + int maxCharactersPerOption = 50; + if(instance.configuration!=null && instance.configuration.polls!=null && instance.configuration.polls.maxCharactersPerOption>0) + maxCharactersPerOption = instance.configuration.polls.maxCharactersPerOption; + else if(instance.pollLimits!=null && instance.pollLimits.maxOptionChars>0) + maxCharactersPerOption = instance.pollLimits.maxOptionChars; + option.edit.setFilters(new InputFilter[]{new InputFilter.LengthFilter(maxCharactersPerOption)}); pollOptionsView.addView(option.view); pollOptions.add(option); - if(pollOptions.size()==(instance.configuration!=null && instance.configuration.polls!=null && instance.configuration.polls.maxOptions>0 ? instance.configuration.polls.maxOptions : 4)) + + int maxPollOptions = 4; + if(instance.configuration!=null && instance.configuration.polls!=null && instance.configuration.polls.maxOptions>0) + maxPollOptions = instance.configuration.polls.maxOptions; + else if (instance.pollLimits!=null && instance.pollLimits.maxOptions>0) + maxPollOptions = instance.pollLimits.maxOptions; + + if(pollOptions.size()==maxPollOptions) addPollOptionBtn.setVisibility(View.GONE); return option; } @@ -1792,9 +1916,12 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr visibilityPopup=new PopupMenu(getActivity(), v); visibilityPopup.inflate(R.menu.compose_visibility); Menu m=visibilityPopup.getMenu(); + if (isInstancePixelfed()) { + m.findItem(R.id.vis_private).setVisible(false); + } MenuItem localOnlyItem = visibilityPopup.getMenu().findItem(R.id.local_only); boolean prefsSaysSupported = GlobalUserPreferences.accountsWithLocalOnlySupport.contains(accountID); - if (instance.pleroma != null) { + if (isInstanceAkkoma()) { m.findItem(R.id.vis_local).setVisible(true); } else if (localOnly || prefsSaysSupported) { localOnlyItem.setVisible(true); @@ -1838,14 +1965,36 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr }); } + @SuppressLint("ClickableViewAccessibility") + private void buildContentTypePopup(View btn) { + contentTypePopup=new PopupMenu(getActivity(), btn); + contentTypePopup.inflate(R.menu.compose_content_type); + Menu m = contentTypePopup.getMenu(); + ContentType.adaptMenuToInstance(m, instance); + if (contentType != null) m.findItem(R.id.content_type_null).setVisible(false); + + contentTypePopup.setOnMenuItemClickListener(i->{ + int id=i.getItemId(); + if (id == R.id.content_type_null) contentType = null; + else if (id == R.id.content_type_plain) contentType = ContentType.PLAIN; + else if (id == R.id.content_type_html) contentType = ContentType.HTML; + else if (id == R.id.content_type_markdown) contentType = ContentType.MARKDOWN; + else if (id == R.id.content_type_bbcode) contentType = ContentType.BBCODE; + else if (id == R.id.content_type_misskey_markdown) contentType = ContentType.MISSKEY_MARKDOWN; + else return false; + btn.setSelected(id != R.id.content_type_null && id != R.id.content_type_plain); + i.setChecked(true); + return true; + }); + + if (!GlobalUserPreferences.accountsWithContentTypesEnabled.contains(accountID)) { + btn.setVisibility(View.GONE); + } + } + private void loadDefaultStatusVisibility(Bundle savedInstanceState) { if(replyTo != null) statusVisibility = replyTo.visibility; - // A saved privacy setting from a previous compose session wins over the reply visibility - if(savedInstanceState !=null){ - statusVisibility = (StatusPrivacy) savedInstanceState.getSerializable("visibility"); - } - AccountSessionManager asm = AccountSessionManager.getInstance(); Preferences prefs = asm.getAccount(accountID).preferences; if (prefs != null) { diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/EditTimelinesFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/EditTimelinesFragment.java index c947b0fa9..784d7920a 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/EditTimelinesFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/EditTimelinesFragment.java @@ -30,8 +30,11 @@ import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.R; import org.joinmastodon.android.api.requests.lists.GetLists; import org.joinmastodon.android.api.requests.tags.GetFollowedHashtags; +import org.joinmastodon.android.api.session.AccountSession; +import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.model.Hashtag; import org.joinmastodon.android.model.HeaderPaginationList; +import org.joinmastodon.android.model.Instance; import org.joinmastodon.android.model.ListTimeline; import org.joinmastodon.android.model.TimelineDefinition; import org.joinmastodon.android.ui.DividerItemDecoration; @@ -47,11 +50,10 @@ import java.util.Map; import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.ErrorResponse; -import me.grishka.appkit.fragments.BaseRecyclerFragment; import me.grishka.appkit.utils.BindableViewHolder; import me.grishka.appkit.views.UsableRecyclerView; -public class EditTimelinesFragment extends BaseRecyclerFragment implements ScrollableToTop { +public class EditTimelinesFragment extends RecyclerFragment implements ScrollableToTop { private String accountID; private TimelinesAdapter adapter; private final ItemTouchHelper itemTouchHelper; @@ -165,7 +167,7 @@ public class EditTimelinesFragment extends BaseRecyclerFragment addTimelineToOptions(tl, timelinesMenu)); + TimelineDefinition.getAllTimelines(accountID).forEach(tl -> addTimelineToOptions(tl, timelinesMenu)); listTimelines.stream().map(TimelineDefinition::ofList).forEach(tl -> addTimelineToOptions(tl, listsMenu)); hashtags.stream().map(TimelineDefinition::ofHashtag).forEach(tl -> addTimelineToOptions(tl, hashtagsMenu)); @@ -191,7 +193,7 @@ public class EditTimelinesFragment extends BaseRecyclerFragment implements ScrollableToTop{ +public class FollowRequestsListFragment extends RecyclerFragment implements ScrollableToTop, ProvidesAssistContent.ProvidesWebUri { private String accountID; private Map relationships=Collections.emptyMap(); private GetAccountRelationships relationshipsRequest; @@ -149,6 +150,16 @@ public class FollowRequestsListFragment extends BaseRecyclerFragment implements ImageLoaderRecyclerAdapter{ public AccountsAdapter(){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/FollowedHashtagsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/FollowedHashtagsFragment.java index cdb729841..e2e6ae59c 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/FollowedHashtagsFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/FollowedHashtagsFragment.java @@ -1,5 +1,6 @@ package org.joinmastodon.android.fragments; +import android.net.Uri; import android.os.Bundle; import android.view.View; import android.view.ViewGroup; @@ -14,15 +15,15 @@ import org.joinmastodon.android.model.Hashtag; import org.joinmastodon.android.model.HeaderPaginationList; import org.joinmastodon.android.ui.DividerItemDecoration; import org.joinmastodon.android.ui.utils.UiUtils; +import org.joinmastodon.android.utils.ProvidesAssistContent; import me.grishka.appkit.api.SimpleCallback; -import me.grishka.appkit.fragments.BaseRecyclerFragment; import me.grishka.appkit.utils.BindableViewHolder; import me.grishka.appkit.views.UsableRecyclerView; -public class FollowedHashtagsFragment extends BaseRecyclerFragment implements ScrollableToTop { +public class FollowedHashtagsFragment extends RecyclerFragment implements ScrollableToTop, ProvidesAssistContent.ProvidesWebUri { private String nextMaxID; - private String accountId; + private String accountID; public FollowedHashtagsFragment() { super(20); @@ -32,7 +33,7 @@ public class FollowedHashtagsFragment extends BaseRecyclerFragment impl public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); Bundle args=getArguments(); - accountId=args.getString("account"); + accountID=args.getString("account"); setTitle(R.string.sk_hashtags_you_follow); } @@ -63,7 +64,7 @@ public class FollowedHashtagsFragment extends BaseRecyclerFragment impl onDataLoaded(result, nextMaxID!=null); } }) - .exec(accountId); + .exec(accountID); } @Override @@ -76,6 +77,16 @@ public class FollowedHashtagsFragment extends BaseRecyclerFragment impl smoothScrollRecyclerViewToTop(list); } + @Override + public String getAccountID() { + return accountID; + } + + @Override + public Uri getWebUri(Uri.Builder base) { + return isInstanceAkkoma() ? null : base.path("/followed_tags").build(); + } + private class HashtagsAdapter extends RecyclerView.Adapter{ @NonNull @Override @@ -110,7 +121,7 @@ public class FollowedHashtagsFragment extends BaseRecyclerFragment impl @Override public void onClick() { - UiUtils.openHashtagTimeline(getActivity(), accountId, item.name, item.following); + UiUtils.openHashtagTimeline(getActivity(), accountID, item.name, item.following); } } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/HasAccountID.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/HasAccountID.java new file mode 100644 index 000000000..5e7f01fed --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HasAccountID.java @@ -0,0 +1,27 @@ +package org.joinmastodon.android.fragments; + +import org.joinmastodon.android.api.session.AccountSession; +import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.model.Instance; + +import java.util.Optional; + +public interface HasAccountID { + String getAccountID(); + + default AccountSession getSession() { + return AccountSessionManager.getInstance().getAccount(getAccountID()); + } + + default boolean isInstanceAkkoma() { + return getInstance().map(Instance::isAkkoma).orElse(false); + } + + default boolean isInstancePixelfed() { + return getInstance().map(Instance::isPixelfed).orElse(false); + } + + default Optional getInstance() { + return getSession().getInstance(); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/HasFab.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/HasFab.java new file mode 100644 index 000000000..937e64757 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HasFab.java @@ -0,0 +1,10 @@ +package org.joinmastodon.android.fragments; + +import android.view.View; + +public interface HasFab { + View getFab(); + void showFab(); + void hideFab(); + boolean isScrolling(); +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/HashtagTimelineFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/HashtagTimelineFragment.java index e0510e5db..a69963dac 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/HashtagTimelineFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HashtagTimelineFragment.java @@ -1,6 +1,7 @@ package org.joinmastodon.android.fragments; import android.app.Activity; +import android.net.Uri; import android.os.Bundle; import android.view.HapticFeedbackConstants; import android.view.Menu; @@ -8,7 +9,6 @@ import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; -import android.widget.ImageButton; import android.widget.Toast; import org.joinmastodon.android.E; @@ -17,12 +17,15 @@ import org.joinmastodon.android.api.requests.tags.GetHashtag; import org.joinmastodon.android.api.requests.tags.SetHashtagFollowed; import org.joinmastodon.android.api.requests.timelines.GetHashtagTimeline; import org.joinmastodon.android.events.HashtagUpdatedEvent; +import org.joinmastodon.android.model.Filter; import org.joinmastodon.android.model.Hashtag; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.model.TimelineDefinition; import org.joinmastodon.android.ui.utils.UiUtils; +import org.joinmastodon.android.utils.StatusFilterPredicate; import java.util.List; +import java.util.stream.Collectors; import me.grishka.appkit.Nav; import me.grishka.appkit.api.Callback; @@ -33,11 +36,11 @@ import me.grishka.appkit.utils.V; public class HashtagTimelineFragment extends PinnableStatusListFragment { private String hashtag; private boolean following; - private ImageButton fab; private MenuItem followButton; - public HashtagTimelineFragment(){ - setListLayoutId(R.layout.recycler_fragment_with_fab); + @Override + protected boolean wantsComposeButton() { + return true; } @Override @@ -120,6 +123,7 @@ public class HashtagTimelineFragment extends PinnableStatusListFragment { @Override public void onSuccess(List result){ if (getActivity() == null) return; + result=result.stream().filter(new StatusFilterPredicate(accountID, getFilterContext())).collect(Collectors.toList()); onDataLoaded(result, !result.isEmpty()); } }) @@ -134,14 +138,12 @@ public class HashtagTimelineFragment extends PinnableStatusListFragment { } @Override - public void onViewCreated(View view, Bundle savedInstanceState){ - super.onViewCreated(view, savedInstanceState); - fab=view.findViewById(R.id.fab); - fab.setOnClickListener(this::onFabClick); - fab.setOnLongClickListener(v -> UiUtils.pickAccountForCompose(getActivity(), accountID, '#'+hashtag+' ')); + public boolean onFabLongClick(View v) { + return UiUtils.pickAccountForCompose(getActivity(), accountID, '#'+hashtag+' '); } - private void onFabClick(View v){ + @Override + public void onFabClick(View v){ Bundle args=new Bundle(); args.putString("account", accountID); args.putString("prefilledText", '#'+hashtag+' '); @@ -152,4 +154,14 @@ public class HashtagTimelineFragment extends PinnableStatusListFragment { protected void onSetFabBottomInset(int inset){ ((ViewGroup.MarginLayoutParams) fab.getLayoutParams()).bottomMargin=V.dp(24)+inset; } + + @Override + protected Filter.FilterContext getFilterContext() { + return Filter.FilterContext.PUBLIC; + } + + @Override + public Uri getWebUri(Uri.Builder base) { + return base.path((isInstanceAkkoma() ? "/tag/" : "/tags") + hashtag).build(); + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeFragment.java index 7ff7b536b..fbbfb26f3 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeFragment.java @@ -2,6 +2,7 @@ package org.joinmastodon.android.fragments; import android.app.Fragment; import android.app.NotificationManager; +import android.app.assist.AssistContent; import android.graphics.Outline; import android.os.Build; import android.os.Bundle; @@ -16,23 +17,36 @@ import android.widget.FrameLayout; import android.widget.ImageView; import android.widget.LinearLayout; -import org.joinmastodon.android.PushNotificationReceiver; +import androidx.annotation.IdRes; +import androidx.annotation.Nullable; + +import com.squareup.otto.Subscribe; + +import org.joinmastodon.android.E; import org.joinmastodon.android.R; +import org.joinmastodon.android.api.requests.notifications.GetNotifications; import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.events.AllNotificationsSeenEvent; +import org.joinmastodon.android.events.NotificationReceivedEvent; import org.joinmastodon.android.fragments.discover.DiscoverFragment; -import org.joinmastodon.android.fragments.discover.SearchFragment; import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.model.Instance; +import org.joinmastodon.android.model.Notification; import org.joinmastodon.android.ui.AccountSwitcherSheet; import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.ui.views.TabBar; +import org.joinmastodon.android.utils.ProvidesAssistContent; import org.parceler.Parcels; import java.util.ArrayList; +import java.util.EnumSet; +import java.util.List; +import java.util.Optional; -import androidx.annotation.IdRes; -import androidx.annotation.Nullable; import me.grishka.appkit.FragmentStackActivity; +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; import me.grishka.appkit.fragments.AppKitFragment; import me.grishka.appkit.fragments.LoaderFragment; import me.grishka.appkit.fragments.OnBackPressedListener; @@ -41,7 +55,7 @@ import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest; import me.grishka.appkit.utils.V; import me.grishka.appkit.views.FragmentRootLinearLayout; -public class HomeFragment extends AppKitFragment implements OnBackPressedListener{ +public class HomeFragment extends AppKitFragment implements OnBackPressedListener, ProvidesAssistContent, HasAccountID { private FragmentRootLinearLayout content; private HomeTabFragment homeTabFragment; private NotificationsFragment notificationsFragment; @@ -50,26 +64,34 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene private TabBar tabBar; private View tabBarWrap; private ImageView tabBarAvatar; + private ImageView notificationTabIcon; @IdRes private int currentTab=R.id.tab_home; private String accountID; + private boolean isPleroma; @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); + E.register(this); accountID=getArguments().getString("account"); setTitle(R.string.sk_app_name); + isPleroma = AccountSessionManager.getInstance().getAccount(accountID).getInstance() + .map(Instance::isAkkoma) + .orElse(false); if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N) setRetainInstance(true); + // TODO: clean up if(savedInstanceState==null){ Bundle args=new Bundle(); args.putString("account", accountID); homeTabFragment=new HomeTabFragment(); homeTabFragment.setArguments(args); args=new Bundle(args); + args.putBoolean("disableDiscover", isPleroma); args.putBoolean("noAutoLoad", true); searchFragment=new DiscoverFragment(); searchFragment.setArguments(args); @@ -91,7 +113,7 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene content.setOrientation(LinearLayout.VERTICAL); FrameLayout fragmentContainer=new FrameLayout(getActivity()); - fragmentContainer.setId(R.id.fragment_wrap); + fragmentContainer.setId(me.grishka.appkit.R.id.fragment_wrap); content.addView(fragmentContainer, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)); inflater.inflate(R.layout.tab_bar, content); @@ -110,12 +132,15 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene Account self=AccountSessionManager.getInstance().getAccount(accountID).self; ViewImageLoader.load(tabBarAvatar, null, new UrlImageLoaderRequest(self.avatar, V.dp(28), V.dp(28))); + notificationTabIcon=content.findViewById(R.id.tab_notifications); + updateNotificationBadge(); + if(savedInstanceState==null){ getChildFragmentManager().beginTransaction() - .add(R.id.fragment_wrap, homeTabFragment) - .add(R.id.fragment_wrap, searchFragment).hide(searchFragment) - .add(R.id.fragment_wrap, notificationsFragment).hide(notificationsFragment) - .add(R.id.fragment_wrap, profileFragment).hide(profileFragment) + .add(me.grishka.appkit.R.id.fragment_wrap, homeTabFragment) + .add(me.grishka.appkit.R.id.fragment_wrap, searchFragment).hide(searchFragment) + .add(me.grishka.appkit.R.id.fragment_wrap, notificationsFragment).hide(notificationsFragment) + .add(me.grishka.appkit.R.id.fragment_wrap, profileFragment).hide(profileFragment) .commit(); String defaultTab=getArguments().getString("tab"); @@ -144,6 +169,7 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene notificationsFragment=(NotificationsFragment) getChildFragmentManager().getFragment(savedInstanceState, "notificationsFragment"); profileFragment=(ProfileFragment) getChildFragmentManager().getFragment(savedInstanceState, "profileFragment"); currentTab=savedInstanceState.getInt("selectedTab"); + tabBar.selectTab(currentTab); Fragment current=fragmentForTab(currentTab); getChildFragmentManager().beginTransaction() .hide(homeTabFragment) @@ -200,6 +226,13 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene throw new IllegalArgumentException(); } + public void setCurrentTab(@IdRes int tab){ + if(tab==currentTab) + return; + tabBar.selectTab(tab); + onTabSelected(tab); + } + private void onTabSelected(@IdRes int tab){ Fragment newFragment=fragmentForTab(tab); if(tab==currentTab){ @@ -211,8 +244,10 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene } getChildFragmentManager().beginTransaction().hide(fragmentForTab(currentTab)).show(newFragment).commit(); maybeTriggerLoading(newFragment); + if (newFragment instanceof HasFab fabulous && !fabulous.isScrolling()) fabulous.showFab(); currentTab=tab; ((FragmentStackActivity)getActivity()).invalidateSystemBarColors(this); + if (tab == R.id.tab_search && isPleroma) searchFragment.selectSearch(); } private void maybeTriggerLoading(Fragment newFragment){ @@ -239,7 +274,7 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene for(AccountSession session:AccountSessionManager.getInstance().getLoggedInAccounts()){ options.add(session.self.displayName+"\n("+session.self.username+"@"+session.domain+")"); } - new AccountSwitcherSheet(getActivity()).show(); + new AccountSwitcherSheet(getActivity(), this).show(); return true; } return false; @@ -269,4 +304,57 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene if (notificationsFragment.isAdded()) getChildFragmentManager().putFragment(outState, "notificationsFragment", notificationsFragment); if (profileFragment.isAdded()) getChildFragmentManager().putFragment(outState, "profileFragment", profileFragment); } + + public void updateNotificationBadge() { + AccountSession session = AccountSessionManager.getInstance().getAccount(accountID); + Optional instance = session.getInstance(); + if (instance.isEmpty()) return; // avoiding incompatibility with akkoma + + new GetNotifications(null, 1, EnumSet.allOf(Notification.Type.class), instance.get().isAkkoma()) + .setCallback(new Callback<>() { + @Override + public void onSuccess(List notifications) { + if (notifications.size() > 0) { + try { + long newestId = Long.parseLong(notifications.get(0).id); + long lastSeenId = Long.parseLong(session.markers.notifications.lastReadId); + setNotificationBadge(newestId > lastSeenId); + } catch (Exception ignored) { + setNotificationBadge(false); + } + } + } + + @Override + public void onError(ErrorResponse error) { + setNotificationBadge(false); + } + }).exec(accountID); + } + + public void setNotificationBadge(boolean badge) { + notificationTabIcon.setImageResource(badge + ? R.drawable.ic_fluent_alert_28_selector_badged + : R.drawable.ic_fluent_alert_28_selector); + } + + @Subscribe + public void onNotificationReceived(NotificationReceivedEvent notificationReceivedEvent) { + if (notificationReceivedEvent.account.equals(accountID)) setNotificationBadge(true); + } + + @Subscribe + public void onAllNotificationsSeen(AllNotificationsSeenEvent allNotificationsSeenEvent) { + setNotificationBadge(false); + } + + @Override + public String getAccountID() { + return accountID; + } + + @Override + public void onProvideAssistContent(AssistContent assistContent) { + callFragmentToProvideAssistContent(fragmentForTab(currentTab), assistContent); + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTabFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTabFragment.java index 669f3d36d..98d0516cc 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTabFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTabFragment.java @@ -10,6 +10,7 @@ import android.annotation.SuppressLint; import android.app.Activity; import android.app.Fragment; import android.app.FragmentTransaction; +import android.app.assist.AssistContent; import android.content.Context; import android.os.Build; import android.os.Bundle; @@ -24,6 +25,7 @@ import android.view.ViewParent; import android.view.ViewTreeObserver; import android.widget.Button; import android.widget.FrameLayout; +import android.widget.ImageButton; import android.widget.ImageView; import android.widget.PopupMenu; import android.widget.TextView; @@ -53,6 +55,7 @@ import org.joinmastodon.android.model.TimelineDefinition; import org.joinmastodon.android.ui.SimpleViewHolder; import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.updater.GithubSelfUpdater; +import org.joinmastodon.android.utils.ProvidesAssistContent; import java.util.Collection; import java.util.HashMap; @@ -70,7 +73,7 @@ import me.grishka.appkit.fragments.OnBackPressedListener; import me.grishka.appkit.utils.CubicBezierInterpolator; import me.grishka.appkit.utils.V; -public class HomeTabFragment extends MastodonToolbarFragment implements ScrollableToTop, OnBackPressedListener { +public class HomeTabFragment extends MastodonToolbarFragment implements ScrollableToTop, OnBackPressedListener, HasFab, ProvidesAssistContent { private static final int ANNOUNCEMENTS_RESULT = 654; private String accountID; @@ -98,13 +101,14 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab private PopupMenu overflowPopup; private View overflowActionView = null; private boolean announcementsBadged, settingsBadged; + private ImageButton fab; @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); E.register(this); accountID = getArguments().getString("account"); - timelineDefinitions = GlobalUserPreferences.pinnedTimelines.getOrDefault(accountID, TimelineDefinition.DEFAULT_TIMELINES); + timelineDefinitions = GlobalUserPreferences.pinnedTimelines.getOrDefault(accountID, TimelineDefinition.getDefaultTimelines(accountID)); assert timelineDefinitions != null; if (timelineDefinitions.size() == 0) timelineDefinitions = List.of(TimelineDefinition.HOME_TIMELINE); count = timelineDefinitions.size(); @@ -122,6 +126,10 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab @Override public View onCreateContentView(LayoutInflater inflater, ViewGroup container, Bundle bundle) { FrameLayout view = new FrameLayout(getContext()); + inflater.inflate(R.layout.compose_fab, view); + fab = view.findViewById(R.id.fab); + fab.setOnClickListener(this::onFabClick); + fab.setOnLongClickListener(this::onFabLongClick); pager = new ViewPager2(getContext()); toolbarFrame = (FrameLayout) LayoutInflater.from(getContext()).inflate(R.layout.home_toolbar, getToolbar(), false); @@ -129,6 +137,7 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab Bundle args = new Bundle(); args.putString("account", accountID); args.putBoolean("__is_tab", true); + args.putBoolean("__disable_fab", true); args.putBoolean("onlyPosts", true); for (int i = 0; i < timelineDefinitions.size(); i++) { @@ -280,6 +289,20 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab }).exec(accountID); } + private void onFabClick(View v){ + if (fragments[pager.getCurrentItem()] instanceof BaseStatusListFragment l) { + l.onFabClick(v); + } + } + + private boolean onFabLongClick(View v) { + if (fragments[pager.getCurrentItem()] instanceof BaseStatusListFragment l) { + return l.onFabLongClick(v); + } else { + return false; + } + } + private void addListsToOverflowMenu() { Context ctx = getContext(); listsMenu.clear(); @@ -358,7 +381,7 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab addListsToOverflowMenu(); addHashtagsToOverflowMenu(); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && !UiUtils.isEMUI()) { m.setGroupDividerEnabled(true); } } @@ -375,7 +398,7 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab } private void updateList(List addItems, Map items) { - if (addItems.size() == 0) return; + if (addItems.size() == 0 || getActivity() == null) return; for (int i = 0; i < addItems.size(); i++) items.put(View.generateViewId(), addItems.get(i)); updateOverflowMenu(); } @@ -427,9 +450,26 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab updateSwitcherIcon(i); } + @Override + public void showFab() { + if (fragments[pager.getCurrentItem()] instanceof BaseStatusListFragment l) l.showFab(); + } + + @Override + public void hideFab() { + if (fragments[pager.getCurrentItem()] instanceof BaseStatusListFragment l) l.hideFab(); + } + + @Override + public boolean isScrolling() { + return (fragments[pager.getCurrentItem()] instanceof HasFab fabulous) + && fabulous.isScrolling(); + } + private void updateSwitcherIcon(int i) { timelineIcon.setImageResource(timelines[i].getIcon().iconRes); timelineTitle.setText(timelines[i].getTitle(getContext())); + showFab(); } @Override @@ -657,12 +697,22 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab return hashtagsItems.values(); } + public ImageButton getFab() { + return fab; + } + + @Override + public void onProvideAssistContent(AssistContent assistContent) { + callFragmentToProvideAssistContent(fragments[pager.getCurrentItem()], assistContent); + } + private class HomePagerAdapter extends RecyclerView.Adapter { @NonNull @Override public SimpleViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { FrameLayout tabView = tabViews[viewType % getItemCount()]; - ((ViewGroup)tabView.getParent()).removeView(tabView); + ViewGroup tabParent = (ViewGroup) tabView.getParent(); + if (tabParent != null) tabParent.removeView(tabView); tabView.setVisibility(View.VISIBLE); return new SimpleViewHolder(tabView); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTimelineFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTimelineFragment.java index 4f3fd7c6c..35495f5ab 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTimelineFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTimelineFragment.java @@ -1,6 +1,7 @@ package org.joinmastodon.android.fragments; import android.app.Activity; +import android.net.Uri; import android.os.Bundle; import android.view.View; @@ -8,8 +9,6 @@ import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; import org.joinmastodon.android.GlobalUserPreferences; -import org.joinmastodon.android.E; -import org.joinmastodon.android.R; import org.joinmastodon.android.api.requests.markers.SaveMarkers; import org.joinmastodon.android.api.requests.timelines.GetHomeTimeline; import org.joinmastodon.android.api.session.AccountSessionManager; @@ -32,11 +31,16 @@ import me.grishka.appkit.api.ErrorResponse; import me.grishka.appkit.api.SimpleCallback; import me.grishka.appkit.utils.V; -public class HomeTimelineFragment extends FabStatusListFragment { +public class HomeTimelineFragment extends StatusListFragment { private HomeTabFragment parent; private String maxID; private String lastSavedMarkerID; + @Override + protected boolean wantsComposeButton() { + return true; + } + @Override public void onAttach(Activity activity){ super.onAttach(activity); @@ -145,7 +149,7 @@ public class HomeTimelineFragment extends FabStatusListFragment { result.get(result.size()-1).hasGapAfter=true; toAdd=result; } - StatusFilterPredicate filterPredicate=new StatusFilterPredicate(accountID, Filter.FilterContext.HOME); + StatusFilterPredicate filterPredicate=new StatusFilterPredicate(accountID, getFilterContext()); toAdd=toAdd.stream().filter(filterPredicate).collect(Collectors.toList()); if(!toAdd.isEmpty()){ prependItems(toAdd, true); @@ -161,6 +165,10 @@ public class HomeTimelineFragment extends FabStatusListFragment { } }) .exec(accountID); + + if (parent.getParentFragment() instanceof HomeFragment homeFragment) { + homeFragment.updateNotificationBadge(); + } } @Override @@ -220,7 +228,7 @@ public class HomeTimelineFragment extends FabStatusListFragment { List targetList=displayItems.subList(gapPos, gapPos+1); targetList.clear(); List insertedPosts=data.subList(gapPostIndex+1, gapPostIndex+1); - StatusFilterPredicate filterPredicate=new StatusFilterPredicate(accountID, Filter.FilterContext.HOME); + StatusFilterPredicate filterPredicate=new StatusFilterPredicate(accountID, getFilterContext()); for(Status s:result){ if(idsBelowGap.contains(s.id)) break; @@ -273,4 +281,14 @@ public class HomeTimelineFragment extends FabStatusListFragment { protected boolean shouldRemoveAccountPostsWhenUnfollowing(){ return true; } + + @Override + protected Filter.FilterContext getFilterContext() { + return Filter.FilterContext.HOME; + } + + @Override + public Uri getWebUri(Uri.Builder base) { + return base.path("/").build(); + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ListTimelineFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ListTimelineFragment.java index 3198a300d..0e03a19e6 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ListTimelineFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ListTimelineFragment.java @@ -1,13 +1,13 @@ package org.joinmastodon.android.fragments; import android.app.Activity; +import android.net.Uri; import android.os.Bundle; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; -import android.widget.ImageButton; import androidx.annotation.Nullable; @@ -18,14 +18,17 @@ import org.joinmastodon.android.api.requests.lists.UpdateList; import org.joinmastodon.android.api.requests.timelines.GetListTimeline; import org.joinmastodon.android.events.ListDeletedEvent; import org.joinmastodon.android.events.ListUpdatedCreatedEvent; +import org.joinmastodon.android.model.Filter; import org.joinmastodon.android.model.ListTimeline; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.model.TimelineDefinition; import org.joinmastodon.android.ui.M3AlertDialogBuilder; import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.ui.views.ListTimelineEditor; +import org.joinmastodon.android.utils.StatusFilterPredicate; import java.util.List; +import java.util.stream.Collectors; import me.grishka.appkit.Nav; import me.grishka.appkit.api.Callback; @@ -39,10 +42,10 @@ public class ListTimelineFragment extends PinnableStatusListFragment { private String listTitle; @Nullable private ListTimeline.RepliesPolicy repliesPolicy; - private ImageButton fab; - public ListTimelineFragment() { - setListLayoutId(R.layout.recycler_fragment_with_fab); + @Override + protected boolean wantsComposeButton() { + return true; } @Override @@ -134,10 +137,11 @@ public class ListTimelineFragment extends PinnableStatusListFragment { @Override public void onSuccess(List result) { if (getActivity() == null) return; + result=result.stream().filter(new StatusFilterPredicate(accountID, getFilterContext())).collect(Collectors.toList()); onDataLoaded(result, !result.isEmpty()); } }) - .exec(accountID); + .exec(accountID); } @Override @@ -148,14 +152,7 @@ public class ListTimelineFragment extends PinnableStatusListFragment { } @Override - public void onViewCreated(View view, Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - fab=view.findViewById(R.id.fab); - fab.setOnClickListener(this::onFabClick); - fab.setOnLongClickListener(v -> UiUtils.pickAccountForCompose(getActivity(), accountID)); - } - - private void onFabClick(View v){ + public void onFabClick(View v){ Bundle args=new Bundle(); args.putString("account", accountID); Nav.go(getActivity(), ComposeFragment.class, args); @@ -165,4 +162,15 @@ public class ListTimelineFragment extends PinnableStatusListFragment { protected void onSetFabBottomInset(int inset) { ((ViewGroup.MarginLayoutParams) fab.getLayoutParams()).bottomMargin=V.dp(24)+inset; } + + + @Override + protected Filter.FilterContext getFilterContext() { + return Filter.FilterContext.HOME; + } + + @Override + public Uri getWebUri(Uri.Builder base) { + return base.path("/lists/" + listID).build(); + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ListTimelinesFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ListTimelinesFragment.java deleted file mode 100644 index 2e70881f2..000000000 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ListTimelinesFragment.java +++ /dev/null @@ -1,260 +0,0 @@ -package org.joinmastodon.android.fragments; - -import android.os.Bundle; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; -import android.widget.CheckBox; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.recyclerview.widget.RecyclerView; - -import com.squareup.otto.Subscribe; - -import org.joinmastodon.android.E; -import org.joinmastodon.android.R; -import org.joinmastodon.android.api.MastodonAPIRequest; -import org.joinmastodon.android.api.requests.lists.AddAccountsToList; -import org.joinmastodon.android.api.requests.lists.CreateList; -import org.joinmastodon.android.api.requests.lists.GetLists; -import org.joinmastodon.android.api.requests.lists.RemoveAccountsFromList; -import org.joinmastodon.android.events.ListDeletedEvent; -import org.joinmastodon.android.events.ListUpdatedCreatedEvent; -import org.joinmastodon.android.model.ListTimeline; -import org.joinmastodon.android.ui.DividerItemDecoration; -import org.joinmastodon.android.ui.M3AlertDialogBuilder; -import org.joinmastodon.android.ui.views.ListTimelineEditor; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; - -import me.grishka.appkit.Nav; -import me.grishka.appkit.api.Callback; -import me.grishka.appkit.api.ErrorResponse; -import me.grishka.appkit.api.SimpleCallback; -import me.grishka.appkit.fragments.BaseRecyclerFragment; -import me.grishka.appkit.utils.BindableViewHolder; -import me.grishka.appkit.views.UsableRecyclerView; - -public class ListTimelinesFragment extends BaseRecyclerFragment implements ScrollableToTop { - private String accountId; - private String profileAccountId; - private final HashMap userInListBefore = new HashMap<>(); - private final HashMap userInList = new HashMap<>(); - private ListsAdapter adapter; - - public ListTimelinesFragment() { - super(10); - } - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - Bundle args=getArguments(); - accountId=args.getString("account"); - setHasOptionsMenu(true); - E.register(this); - - if(args.containsKey("profileAccount")){ - profileAccountId=args.getString("profileAccount"); - String profileDisplayUsername = args.getString("profileDisplayUsername"); - setTitle(getString(R.string.sk_lists_with_user, profileDisplayUsername)); - } else { - setTitle(R.string.sk_your_lists); - } - } - - @Override - protected void onShown(){ - super.onShown(); - if(!getArguments().getBoolean("noAutoLoad") && !loaded && !dataLoading) - loadData(); - } - - @Override - public void onViewCreated(View view, Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - list.addItemDecoration(new DividerItemDecoration(getActivity(), R.attr.colorPollVoted, 0.5f, 56, 16)); - } - - @Override - public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { - inflater.inflate(R.menu.menu_list, menu); - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - if (item.getItemId() == R.id.create) { - ListTimelineEditor editor = new ListTimelineEditor(getContext()); - new M3AlertDialogBuilder(getActivity()) - .setTitle(R.string.sk_create_list_title) - .setIcon(R.drawable.ic_fluent_people_add_28_regular) - .setView(editor) - .setPositiveButton(R.string.sk_create, (d, which) -> - new CreateList(editor.getTitle(), editor.getRepliesPolicy()).setCallback(new Callback<>() { - @Override - public void onSuccess(ListTimeline list) { - saveListMembership(list.id, true); - data.add(0, list); - adapter.notifyItemRangeInserted(0, 1); - E.post(new ListUpdatedCreatedEvent(list.id, list.title, list.repliesPolicy)); - } - - @Override - public void onError(ErrorResponse error) { - error.showToast(getContext()); - } - }).exec(accountId) - ) - .setNegativeButton(R.string.cancel, (d, which) -> {}) - .show(); - } - return true; - } - - private void saveListMembership(String listId, boolean isMember) { - userInList.put(listId, isMember); - List accountIdList = Collections.singletonList(profileAccountId); - MastodonAPIRequest req = isMember ? new AddAccountsToList(listId, accountIdList) : new RemoveAccountsFromList(listId, accountIdList); - req.setCallback(new Callback<>() { - @Override - public void onSuccess(Object o) {} - - @Override - public void onError(ErrorResponse error) { - error.showToast(getContext()); - } - }).exec(accountId); - } - - @Override - protected void doLoadData(int offset, int count){ - userInListBefore.clear(); - userInList.clear(); - currentRequest=(profileAccountId != null ? new GetLists(profileAccountId) : new GetLists()) - .setCallback(new SimpleCallback<>(this) { - @Override - public void onSuccess(List lists) { - if (getActivity() == null) return; - for (ListTimeline l : lists) userInListBefore.put(l.id, true); - userInList.putAll(userInListBefore); - if (profileAccountId == null || !lists.isEmpty()) onDataLoaded(lists, false); - if (profileAccountId == null) return; - - currentRequest=new GetLists().setCallback(new SimpleCallback<>(ListTimelinesFragment.this) { - @Override - public void onSuccess(List allLists) { - if (getActivity() == null) return; - List newLists = new ArrayList<>(); - for (ListTimeline l : allLists) { - if (lists.stream().noneMatch(e -> e.id.equals(l.id))) newLists.add(l); - if (!userInListBefore.containsKey(l.id)) { - userInListBefore.put(l.id, false); - } - } - userInList.putAll(userInListBefore); - onDataLoaded(newLists, false); - } - }).exec(accountId); - } - }) - .exec(accountId); - } - - @Subscribe - public void onListDeletedEvent(ListDeletedEvent event) { - for (int i = 0; i < data.size(); i++) { - ListTimeline item = data.get(i); - if (item.id.equals(event.id)) { - data.remove(i); - adapter.notifyItemRemoved(i); - break; - } - } - } - - @Subscribe - public void onListUpdatedCreatedEvent(ListUpdatedCreatedEvent event) { - for (int i = 0; i < data.size(); i++) { - ListTimeline item = data.get(i); - if (item.id.equals(event.id)) { - item.title = event.title; - item.repliesPolicy = event.repliesPolicy; - adapter.notifyItemChanged(i); - break; - } - } - } - - @Override - protected RecyclerView.Adapter getAdapter() { - return adapter = new ListsAdapter(); - } - - @Override - public void scrollToTop() { - smoothScrollRecyclerViewToTop(list); - } - - private class ListsAdapter extends RecyclerView.Adapter{ - @NonNull - @Override - public ListViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){ - return new ListViewHolder(); - } - - @Override - public void onBindViewHolder(@NonNull ListViewHolder holder, int position) { - holder.bind(data.get(position)); - } - - @Override - public int getItemCount() { - return data.size(); - } - } - - private class ListViewHolder extends BindableViewHolder implements UsableRecyclerView.Clickable{ - private final TextView title; - private final CheckBox listToggle; - - public ListViewHolder(){ - super(getActivity(), R.layout.item_text, list); - title=findViewById(R.id.title); - listToggle=findViewById(R.id.list_toggle); - } - - @Override - public void onBind(ListTimeline item) { - title.setText(item.title); - title.setCompoundDrawablesRelativeWithIntrinsicBounds(itemView.getContext().getDrawable(R.drawable.ic_fluent_people_24_regular), null, null, null); - if (profileAccountId != null) { - Boolean checked = userInList.get(item.id); - listToggle.setVisibility(View.VISIBLE); - listToggle.setChecked(userInList.containsKey(item.id) && checked != null && checked); - listToggle.setOnClickListener(this::onClickToggle); - } else { - listToggle.setVisibility(View.GONE); - } - } - - private void onClickToggle(View view) { - saveListMembership(item.id, listToggle.isChecked()); - } - - @Override - public void onClick() { - Bundle args=new Bundle(); - args.putString("account", accountId); - args.putString("listID", item.id); - args.putString("listTitle", item.title); - if (item.repliesPolicy != null) args.putInt("repliesPolicy", item.repliesPolicy.ordinal()); - Nav.go(getActivity(), ListTimelineFragment.class, args); - } - } -} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ListsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ListsFragment.java new file mode 100644 index 000000000..243a104c6 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ListsFragment.java @@ -0,0 +1,270 @@ +package org.joinmastodon.android.fragments; + +import android.net.Uri; +import android.os.Bundle; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.CheckBox; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.squareup.otto.Subscribe; + +import org.joinmastodon.android.E; +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.api.requests.lists.AddAccountsToList; +import org.joinmastodon.android.api.requests.lists.CreateList; +import org.joinmastodon.android.api.requests.lists.GetLists; +import org.joinmastodon.android.api.requests.lists.RemoveAccountsFromList; +import org.joinmastodon.android.events.ListDeletedEvent; +import org.joinmastodon.android.events.ListUpdatedCreatedEvent; +import org.joinmastodon.android.model.ListTimeline; +import org.joinmastodon.android.ui.DividerItemDecoration; +import org.joinmastodon.android.ui.M3AlertDialogBuilder; +import org.joinmastodon.android.ui.views.ListTimelineEditor; +import org.joinmastodon.android.utils.ProvidesAssistContent; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; + +import me.grishka.appkit.Nav; +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; +import me.grishka.appkit.api.SimpleCallback; +import me.grishka.appkit.utils.BindableViewHolder; +import me.grishka.appkit.views.UsableRecyclerView; + +public class ListsFragment extends RecyclerFragment implements ScrollableToTop, ProvidesAssistContent.ProvidesWebUri { + private String accountID; + private String profileAccountId; + private final HashMap userInListBefore = new HashMap<>(); + private final HashMap userInList = new HashMap<>(); + private ListsAdapter adapter; + + public ListsFragment() { + super(10); + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + Bundle args = getArguments(); + accountID = args.getString("account"); + setHasOptionsMenu(true); + E.register(this); + + if(args.containsKey("profileAccount")){ + profileAccountId=args.getString("profileAccount"); + String profileDisplayUsername = args.getString("profileDisplayUsername"); + setTitle(getString(R.string.sk_lists_with_user, profileDisplayUsername)); + } else { + setTitle(R.string.sk_your_lists); + } + } + + @Override + protected void onShown(){ + super.onShown(); + if(!getArguments().getBoolean("noAutoLoad") && !loaded && !dataLoading) + loadData(); + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + list.addItemDecoration(new DividerItemDecoration(getActivity(), R.attr.colorPollVoted, 0.5f, 56, 16)); + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + inflater.inflate(R.menu.menu_list, menu); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == R.id.create) { + ListTimelineEditor editor = new ListTimelineEditor(getContext()); + new M3AlertDialogBuilder(getActivity()) + .setTitle(R.string.sk_create_list_title) + .setIcon(R.drawable.ic_fluent_people_add_28_regular) + .setView(editor) + .setPositiveButton(R.string.sk_create, (d, which) -> + new CreateList(editor.getTitle(), editor.getRepliesPolicy()).setCallback(new Callback<>() { + @Override + public void onSuccess(ListTimeline list) { + data.add(0, list); + adapter.notifyItemRangeInserted(0, 1); + E.post(new ListUpdatedCreatedEvent(list.id, list.title, list.repliesPolicy)); + } + + @Override + public void onError(ErrorResponse error) { + error.showToast(getContext()); + } + }).exec(accountID) + ) + .setNegativeButton(R.string.cancel, (d, which) -> {}) + .show(); + } + return true; + } + + private void saveListMembership(String listId, boolean isMember) { + userInList.put(listId, isMember); + List accountIdList = Collections.singletonList(profileAccountId); + MastodonAPIRequest req = isMember ? new AddAccountsToList(listId, accountIdList) : new RemoveAccountsFromList(listId, accountIdList); + req.setCallback(new Callback<>() { + @Override + public void onSuccess(Object o) {} + + @Override + public void onError(ErrorResponse error) { + error.showToast(getContext()); + } + }).exec(accountID); + } + + @Override + protected void doLoadData(int offset, int count){ + userInListBefore.clear(); + userInList.clear(); + currentRequest=(profileAccountId != null ? new GetLists(profileAccountId) : new GetLists()) + .setCallback(new SimpleCallback<>(this) { + @Override + public void onSuccess(List lists) { + if (getActivity() == null) return; + for (ListTimeline l : lists) userInListBefore.put(l.id, true); + userInList.putAll(userInListBefore); + if (profileAccountId == null || !lists.isEmpty()) onDataLoaded(lists, false); + if (profileAccountId == null) return; + + currentRequest=new GetLists().setCallback(new SimpleCallback<>(ListsFragment.this) { + @Override + public void onSuccess(List allLists) { + if (getActivity() == null) return; + List newLists = new ArrayList<>(); + for (ListTimeline l : allLists) { + if (lists.stream().noneMatch(e -> e.id.equals(l.id))) newLists.add(l); + if (!userInListBefore.containsKey(l.id)) { + userInListBefore.put(l.id, false); + } + } + userInList.putAll(userInListBefore); + onDataLoaded(newLists, false); + } + }).exec(accountID); + } + }) + .exec(accountID); + } + + @Subscribe + public void onListDeletedEvent(ListDeletedEvent event) { + for (int i = 0; i < data.size(); i++) { + ListTimeline item = data.get(i); + if (item.id.equals(event.id)) { + data.remove(i); + adapter.notifyItemRemoved(i); + break; + } + } + } + + @Subscribe + public void onListUpdatedCreatedEvent(ListUpdatedCreatedEvent event) { + for (int i = 0; i < data.size(); i++) { + ListTimeline item = data.get(i); + if (item.id.equals(event.id)) { + item.title = event.title; + item.repliesPolicy = event.repliesPolicy; + adapter.notifyItemChanged(i); + break; + } + } + } + + @Override + protected RecyclerView.Adapter getAdapter() { + return adapter = new ListsAdapter(); + } + + @Override + public void scrollToTop() { + smoothScrollRecyclerViewToTop(list); + } + + @Override + public String getAccountID() { + return accountID; + } + + @Override + public Uri getWebUri(Uri.Builder base) { + return base.path("/lists").build(); + } + + private class ListsAdapter extends RecyclerView.Adapter{ + @NonNull + @Override + public ListViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){ + return new ListViewHolder(); + } + + @Override + public void onBindViewHolder(@NonNull ListViewHolder holder, int position) { + holder.bind(data.get(position)); + } + + @Override + public int getItemCount() { + return data.size(); + } + } + + private class ListViewHolder extends BindableViewHolder implements UsableRecyclerView.Clickable{ + private final TextView title; + private final CheckBox listToggle; + + public ListViewHolder(){ + super(getActivity(), R.layout.item_text, list); + title=findViewById(R.id.title); + listToggle=findViewById(R.id.list_toggle); + } + + @Override + public void onBind(ListTimeline item) { + title.setText(item.title); + title.setCompoundDrawablesRelativeWithIntrinsicBounds(itemView.getContext().getDrawable(R.drawable.ic_fluent_people_24_regular), null, null, null); + if (profileAccountId != null) { + Boolean checked = userInList.get(item.id); + listToggle.setVisibility(View.VISIBLE); + listToggle.setChecked(userInList.containsKey(item.id) && checked != null && checked); + listToggle.setOnClickListener(this::onClickToggle); + } else { + listToggle.setVisibility(View.GONE); + } + } + + private void onClickToggle(View view) { + saveListMembership(item.id, listToggle.isChecked()); + } + + @Override + public void onClick() { + Bundle args=new Bundle(); + args.putString("account", accountID); + args.putString("listID", item.id); + args.putString("listTitle", item.title); + if (item.repliesPolicy != null) args.putInt("repliesPolicy", item.repliesPolicy.ordinal()); + Nav.go(getActivity(), ListTimelineFragment.class, args); + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/MastodonToolbarFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/MastodonToolbarFragment.java index 93cb88fdf..f690f1a39 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/MastodonToolbarFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/MastodonToolbarFragment.java @@ -30,4 +30,9 @@ public abstract class MastodonToolbarFragment extends ToolbarFragment{ toolbar.setNavigationContentDescription(R.string.back); } } + + @Override + protected boolean wantsToolbarMenuIconsTinted() { + return false; // else, badged icons don't work :( + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsFragment.java index 38b4d9ae6..fb589f119 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsFragment.java @@ -2,6 +2,7 @@ package org.joinmastodon.android.fragments; import android.app.Activity; import android.app.Fragment; +import android.app.assist.AssistContent; import android.os.Build; import android.os.Bundle; import android.view.LayoutInflater; @@ -13,6 +14,12 @@ import android.view.ViewGroup; import android.widget.FrameLayout; import android.widget.LinearLayout; +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; +import androidx.viewpager2.widget.ViewPager2; + +import com.squareup.otto.Subscribe; + import org.joinmastodon.android.E; import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.R; @@ -24,12 +31,7 @@ import org.joinmastodon.android.ui.SimpleViewHolder; import org.joinmastodon.android.ui.tabs.TabLayout; import org.joinmastodon.android.ui.tabs.TabLayoutMediator; import org.joinmastodon.android.ui.utils.UiUtils; - -import androidx.annotation.NonNull; -import androidx.recyclerview.widget.RecyclerView; -import androidx.viewpager2.widget.ViewPager2; - -import com.squareup.otto.Subscribe; +import org.joinmastodon.android.utils.ProvidesAssistContent; import me.grishka.appkit.Nav; import me.grishka.appkit.api.Callback; @@ -37,7 +39,7 @@ import me.grishka.appkit.api.ErrorResponse; import me.grishka.appkit.fragments.BaseRecyclerFragment; import me.grishka.appkit.utils.V; -public class NotificationsFragment extends MastodonToolbarFragment implements ScrollableToTop{ +public class NotificationsFragment extends MastodonToolbarFragment implements ScrollableToTop, ProvidesAssistContent { private TabLayout tabLayout; private ViewPager2 pager; @@ -47,7 +49,6 @@ public class NotificationsFragment extends MastodonToolbarFragment implements Sc private NotificationsListFragment allNotificationsFragment, mentionsFragment; private String accountID; - @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); @@ -227,12 +228,17 @@ public class NotificationsFragment extends MastodonToolbarFragment implements Sc }; } + @Override + public void onProvideAssistContent(AssistContent assistContent) { + callFragmentToProvideAssistContent(getFragmentForPage(pager.getCurrentItem()), assistContent); + } + private class DiscoverPagerAdapter extends RecyclerView.Adapter{ @NonNull @Override public SimpleViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){ FrameLayout view=tabViews[viewType]; - ((ViewGroup)view.getParent()).removeView(view); + if (view.getParent() != null) ((ViewGroup)view.getParent()).removeView(view); view.setVisibility(View.VISIBLE); view.setLayoutParams(new RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); return new SimpleViewHolder(view); diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsListFragment.java index a7a47cb4e..c01d88f15 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsListFragment.java @@ -1,6 +1,7 @@ package org.joinmastodon.android.fragments; import android.app.Activity; +import android.net.Uri; import android.os.Bundle; import android.text.TextUtils; import android.view.View; @@ -10,19 +11,27 @@ import com.squareup.otto.Subscribe; import org.joinmastodon.android.E; import org.joinmastodon.android.R; import org.joinmastodon.android.api.requests.markers.SaveMarkers; +import org.joinmastodon.android.api.requests.notifications.PleromaMarkNotificationsRead; import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.events.AllNotificationsSeenEvent; import org.joinmastodon.android.events.PollUpdatedEvent; import org.joinmastodon.android.events.RemoveAccountPostsEvent; +import org.joinmastodon.android.events.StatusCountersUpdatedEvent; import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.model.CacheablePaginatedResponse; +import org.joinmastodon.android.model.Emoji; +import org.joinmastodon.android.model.Filter; +import org.joinmastodon.android.model.Instance; +import org.joinmastodon.android.model.Markers; import org.joinmastodon.android.model.Notification; -import org.joinmastodon.android.model.PaginatedResponse; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.ui.displayitems.AccountCardStatusDisplayItem; -import org.joinmastodon.android.ui.displayitems.AccountStatusDisplayItem; +import org.joinmastodon.android.ui.displayitems.ExtendedFooterStatusDisplayItem; +import org.joinmastodon.android.ui.displayitems.FooterStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.HeaderStatusDisplayItem; -import org.joinmastodon.android.ui.displayitems.ImageStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.StatusDisplayItem; import org.joinmastodon.android.ui.displayitems.TextStatusDisplayItem; +import org.joinmastodon.android.ui.text.HtmlParser; import org.joinmastodon.android.ui.utils.DiscoverInfoBannerHelper; import org.joinmastodon.android.ui.utils.InsetStatusItemDecoration; import org.joinmastodon.android.ui.utils.UiUtils; @@ -39,7 +48,6 @@ import java.util.stream.Stream; import androidx.recyclerview.widget.RecyclerView; import me.grishka.appkit.Nav; import me.grishka.appkit.api.SimpleCallback; -import me.grishka.appkit.utils.V; public class NotificationsListFragment extends BaseStatusListFragment{ private boolean onlyMentions; @@ -47,6 +55,11 @@ public class NotificationsListFragment extends BaseStatusListFragment buildDisplayItems(Notification n){ Account reportTarget = n.report == null ? null : n.report.targetAccount == null ? null : n.report.targetAccount; + Emoji emoji = new Emoji(); + if(n.emojiUrl!=null){ + emoji.shortcode=n.emoji.substring(1,n.emoji.length()-1); + emoji.url=n.emojiUrl; + emoji.staticUrl=n.emojiUrl; + emoji.visibleInPicker=false; + } String extraText=switch(n.type){ case FOLLOW -> getString(R.string.user_followed_you); case FOLLOW_REQUEST -> getString(R.string.user_sent_follow_request); @@ -88,17 +108,12 @@ public class NotificationsListFragment extends BaseStatusListFragment getString(R.string.sk_post_edited); case SIGN_UP -> getString(R.string.sk_signed_up); case REPORT -> getString(R.string.sk_reported); + case REACTION, PLEROMA_EMOJI_REACTION -> + n.emoji != null ? getString(R.string.sk_reacted_with, n.emoji) : getString(R.string.sk_reacted); }; - HeaderStatusDisplayItem titleItem=extraText!=null ? new HeaderStatusDisplayItem(n.id, n.account, n.createdAt, this, accountID, n.status, extraText, n, null) : null; + HeaderStatusDisplayItem titleItem=extraText!=null ? new HeaderStatusDisplayItem(n.id, n.account, n.createdAt, this, accountID, n.status, n.emojiUrl!=null ? HtmlParser.parseCustomEmoji(extraText, Collections.singletonList(emoji)) : extraText, n, null) : null; if(n.status!=null){ - ArrayList items=StatusDisplayItem.buildItems(this, n.status, accountID, n, knownAccounts, titleItem!=null, titleItem==null, n); - if(titleItem!=null){ - for(StatusDisplayItem item:items){ - if(item instanceof ImageStatusDisplayItem imgItem){ - imgItem.horizontalInset=V.dp(32); - } - } - } + ArrayList items=StatusDisplayItem.buildItems(this, n.status, accountID, n, knownAccounts, titleItem!=null, titleItem==null, n, false, Filter.FilterContext.NOTIFICATIONS); if(titleItem!=null) items.add(0, titleItem); return items; @@ -121,6 +136,8 @@ public class NotificationsListFragment extends BaseStatusListFragment0 ? maxID : null, count, onlyMentions, onlyPosts, refreshing, new SimpleCallback<>(this){ @Override - public void onSuccess(PaginatedResponse> result){ + public void onSuccess(CacheablePaginatedResponse> result){ if (getActivity() == null) return; if(refreshing) relationships.clear(); @@ -141,8 +158,17 @@ public class NotificationsListFragment extends BaseStatusListFragment(GlobalUserPreferences.pinnedTimelines.getOrDefault(accountID, TimelineDefinition.DEFAULT_TIMELINES)); + pinnedTimelines = new ArrayList<>(GlobalUserPreferences.pinnedTimelines.getOrDefault(accountID, TimelineDefinition.getDefaultTimelines(accountID))); } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileAboutFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileAboutFragment.java index 15b11215c..90751bded 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileAboutFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileAboutFragment.java @@ -337,7 +337,7 @@ public class ProfileAboutFragment extends Fragment implements WindowInsetsAwareF public void onSelectedChanged(@Nullable RecyclerView.ViewHolder viewHolder, int actionState){ super.onSelectedChanged(viewHolder, actionState); if(actionState==ItemTouchHelper.ACTION_STATE_DRAG){ - viewHolder.itemView.setTag(R.id.item_touch_helper_previous_elevation, viewHolder.itemView.getElevation()); // prevents the default behavior of changing elevation in onDraw() + viewHolder.itemView.setTag(me.grishka.appkit.R.id.item_touch_helper_previous_elevation, viewHolder.itemView.getElevation()); // prevents the default behavior of changing elevation in onDraw() viewHolder.itemView.animate().translationZ(V.dp(1)).setDuration(200).setInterpolator(CubicBezierInterpolator.DEFAULT).start(); draggedViewHolder=viewHolder; } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java index 2f69fb24e..e40588491 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java @@ -1,26 +1,22 @@ package org.joinmastodon.android.fragments; -import static android.content.Context.CLIPBOARD_SERVICE; - import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.AnimatorSet; import android.animation.ObjectAnimator; import android.app.Activity; import android.app.Fragment; -import android.content.ClipData; -import android.content.ClipboardManager; -import android.content.Context; +import android.app.assist.AssistContent; import android.content.Intent; import android.content.res.Configuration; +import android.graphics.Color; import android.graphics.Outline; import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; +import android.graphics.drawable.GradientDrawable; import android.net.Uri; import android.os.Build; import android.os.Bundle; -import android.os.VibrationEffect; -import android.os.Vibrator; import android.text.SpannableStringBuilder; import android.text.TextUtils; import android.text.style.ImageSpan; @@ -29,16 +25,18 @@ import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; +import android.view.SubMenu; import android.view.View; import android.view.ViewGroup; import android.view.ViewOutlineProvider; import android.view.ViewTreeObserver; import android.view.WindowInsets; +import android.view.inputmethod.InputMethodManager; import android.widget.Button; import android.widget.EditText; import android.widget.FrameLayout; +import android.widget.ImageButton; import android.widget.ImageView; -import android.widget.LinearLayout; import android.widget.ProgressBar; import android.widget.RelativeLayout; import android.widget.TextView; @@ -47,6 +45,7 @@ import android.widget.Toolbar; import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.R; +import org.joinmastodon.android.api.MastodonErrorResponse; import org.joinmastodon.android.api.requests.accounts.GetAccountByID; import org.joinmastodon.android.api.requests.accounts.GetAccountRelationships; import org.joinmastodon.android.api.requests.accounts.GetAccountStatuses; @@ -61,6 +60,7 @@ import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.AccountField; import org.joinmastodon.android.model.Attachment; import org.joinmastodon.android.model.Relationship; +import org.joinmastodon.android.ui.BetterItemAnimator; import org.joinmastodon.android.ui.SimpleViewHolder; import org.joinmastodon.android.ui.SingleImagePhotoViewerListener; import org.joinmastodon.android.ui.drawables.CoverOverlayGradientDrawable; @@ -69,10 +69,13 @@ import org.joinmastodon.android.ui.tabs.TabLayout; import org.joinmastodon.android.ui.tabs.TabLayoutMediator; import org.joinmastodon.android.ui.text.CustomEmojiSpan; import org.joinmastodon.android.ui.text.HtmlParser; +import org.joinmastodon.android.ui.utils.SimpleTextWatcher; import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.ui.views.CoverImageView; +import org.joinmastodon.android.ui.views.LinkedTextView; import org.joinmastodon.android.ui.views.NestedRecyclerScrollView; import org.joinmastodon.android.ui.views.ProgressBarButton; +import org.joinmastodon.android.utils.ProvidesAssistContent; import org.parceler.Parcels; import java.time.LocalDateTime; @@ -84,9 +87,13 @@ import java.util.Collections; import java.util.List; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.ItemTouchHelper; +import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; import androidx.viewpager2.widget.ViewPager2; + import me.grishka.appkit.Nav; import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.ErrorResponse; @@ -94,37 +101,46 @@ import me.grishka.appkit.api.SimpleCallback; import me.grishka.appkit.fragments.BaseRecyclerFragment; import me.grishka.appkit.fragments.LoaderFragment; import me.grishka.appkit.fragments.OnBackPressedListener; +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.ViewImageLoader; +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.CubicBezierInterpolator; import me.grishka.appkit.utils.V; +import me.grishka.appkit.views.UsableRecyclerView; -public class ProfileFragment extends LoaderFragment implements OnBackPressedListener, ScrollableToTop{ +public class ProfileFragment extends LoaderFragment implements OnBackPressedListener, ScrollableToTop, HasFab, ProvidesAssistContent.ProvidesWebUri { private static final int AVATAR_RESULT=722; private static final int COVER_RESULT=343; private ImageView avatar; private CoverImageView cover; - private View avatarBorder; + private View avatarBorder, nameWrap; private TextView name, username, bio, followersCount, followersLabel, followingCount, followingLabel, postsCount, postsLabel; private ProgressBarButton actionButton, notifyButton; private ViewPager2 pager; private NestedRecyclerScrollView scrollView; private AccountTimelineFragment postsFragment, postsWithRepliesFragment, pinnedPostsFragment, mediaFragment; - private ProfileAboutFragment aboutFragment; +// private ProfileAboutFragment aboutFragment; private TabLayout tabbar; private SwipeRefreshLayout refreshLayout; private CoverOverlayGradientDrawable coverGradient=new CoverOverlayGradientDrawable(); private float titleTransY; - private View postsBtn, followersBtn, followingBtn; + private View postsBtn, followersBtn, followingBtn, profileCounters; private EditText nameEdit, bioEdit; private ProgressBar actionProgress, notifyProgress; private FrameLayout[] tabViews; private TabLayoutMediator tabLayoutMediator; private TextView followsYouView; + private ViewGroup rolesView; - private Account account; + private Account account, remoteAccount; private String accountID; + private String domain; private Relationship relationship; private int statusBarHeight; private boolean isOwnProfile; @@ -134,11 +150,21 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList private Uri editNewAvatar, editNewCover; private String profileAccountID; private boolean refreshing; - private View fab; + private ImageButton fab; private WindowInsets childInsets; private PhotoViewer currentPhotoViewer; private boolean editModeLoading; + private int maxFields = 4; + + // from ProfileAboutFragment + public UsableRecyclerView list; + private List metadataListData=Collections.emptyList(); + private MetadataAdapter adapter; + private ItemTouchHelper dragHelper=new ItemTouchHelper(new ReorderCallback()); + private RecyclerView.ViewHolder draggedViewHolder; + private ListImageLoaderWrapper imgLoader; + public ProfileFragment(){ super(R.layout.loader_fragment_overlay_toolbar); } @@ -150,13 +176,20 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList setRetainInstance(true); accountID=getArguments().getString("account"); - if(getArguments().containsKey("profileAccount")){ + domain=AccountSessionManager.getInstance().getAccount(accountID).domain; + if (getArguments().containsKey("remoteAccount")) { + remoteAccount = Parcels.unwrap(getArguments().getParcelable("remoteAccount")); + if(!getArguments().getBoolean("noAutoLoad", false)) + loadData(); + } else if(getArguments().containsKey("profileAccount")){ account=Parcels.unwrap(getArguments().getParcelable("profileAccount")); profileAccountID=account.id; isOwnProfile=AccountSessionManager.getInstance().isSelf(accountID, account); loaded=true; if(!isOwnProfile) loadRelationship(); + else if (isInstanceAkkoma() && getInstance().isPresent()) + maxFields = getInstance().get().pleroma.metadata.fieldsLimits.maxFields; }else{ profileAccountID=getArguments().getString("profileAccountID"); if(!getArguments().getBoolean("noAutoLoad", false)) @@ -183,8 +216,10 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList cover=content.findViewById(R.id.cover); avatarBorder=content.findViewById(R.id.avatar_border); name=content.findViewById(R.id.name); + nameWrap=content.findViewById(R.id.name_wrap); username=content.findViewById(R.id.username); bio=content.findViewById(R.id.bio); + profileCounters=content.findViewById(R.id.profile_counters); followersCount=content.findViewById(R.id.followers_count); followersLabel=content.findViewById(R.id.followers_label); followersBtn=content.findViewById(R.id.followers_btn); @@ -206,6 +241,8 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList notifyProgress=content.findViewById(R.id.notify_progress); fab=content.findViewById(R.id.fab); followsYouView=content.findViewById(R.id.follows_you); + list=content.findViewById(R.id.metadata); + rolesView=content.findViewById(R.id.roles); avatar.setOutlineProvider(new ViewOutlineProvider(){ @Override @@ -225,7 +262,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList } }; - tabViews=new FrameLayout[5]; + tabViews=new FrameLayout[4]; for(int i=0;i{ String usernameString=account.acct; if(!usernameString.contains("@")){ - usernameString+="@"+AccountSessionManager.getInstance().getAccount(accountID).domain; + usernameString+="@"+domain; } UiUtils.copyText(username, '@'+usernameString); return true; }); + // from ProfileAboutFragment + list.setItemAnimator(new BetterItemAnimator()); + list.setDrawSelectorOnTop(true); + list.setLayoutManager(new LinearLayoutManager(getActivity())); + imgLoader=new ListImageLoaderWrapper(getActivity(), list, new RecyclerViewDelegate(list), null); + list.setAdapter(adapter=new MetadataAdapter()); + list.setClipToPadding(false); + return sizeWrapper; } + private void onAccountLoaded(Account result) { + account=result; + isOwnProfile=AccountSessionManager.getInstance().isSelf(accountID, account); + bindHeaderView(); + dataLoaded(); + if(!tabLayoutMediator.isAttached()) + tabLayoutMediator.attach(); + if(!isOwnProfile) + loadRelationship(); + else + AccountSessionManager.getInstance().updateAccountInfo(accountID, account); + if(refreshing){ + refreshing=false; + refreshLayout.setRefreshing(false); + if(postsFragment.loaded) + postsFragment.onRefresh(); + if(postsWithRepliesFragment.loaded) + postsWithRepliesFragment.onRefresh(); + if(pinnedPostsFragment.loaded) + pinnedPostsFragment.onRefresh(); + if(mediaFragment.loaded) + mediaFragment.onRefresh(); + } + V.setVisibilityAnimated(fab, View.VISIBLE); + } + @Override protected void doLoadData(){ + if (remoteAccount != null) { + UiUtils.lookupAccountHandle(getContext(), accountID, remoteAccount.getFullyQualifiedName(), (c, args) -> { + if (getContext() == null) return; + if (args == null || !args.containsKey("profileAccount")) { + Toast.makeText(getContext(), getContext().getString( + R.string.sk_error_loading_profile, domain + ), Toast.LENGTH_SHORT).show(); + Nav.finish(this); + return; + } + onAccountLoaded(Parcels.unwrap(args.getParcelable("profileAccount"))); + }); + return; + } + currentRequest=new GetAccountByID(profileAccountID) .setCallback(new SimpleCallback<>(this){ @Override public void onSuccess(Account result){ if (getActivity() == null) return; - account=result; - isOwnProfile=AccountSessionManager.getInstance().isSelf(accountID, account); - bindHeaderView(); - dataLoaded(); - if(!tabLayoutMediator.isAttached()) - tabLayoutMediator.attach(); - if(!isOwnProfile) - loadRelationship(); - else - AccountSessionManager.getInstance().updateAccountInfo(accountID, account); - if(refreshing){ - refreshing=false; - refreshLayout.setRefreshing(false); - if(postsFragment.loaded) - postsFragment.onRefresh(); - if(postsWithRepliesFragment.loaded) - postsWithRepliesFragment.onRefresh(); - if(pinnedPostsFragment.loaded) - pinnedPostsFragment.onRefresh(); - if(mediaFragment.loaded) - mediaFragment.onRefresh(); - } - V.setVisibilityAnimated(fab, View.VISIBLE); + onAccountLoaded(result); } }) .exec(accountID); @@ -358,8 +423,8 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList postsWithRepliesFragment=AccountTimelineFragment.newInstance(accountID, account, GetAccountStatuses.Filter.INCLUDE_REPLIES, false); pinnedPostsFragment=AccountTimelineFragment.newInstance(accountID, account, GetAccountStatuses.Filter.PINNED, false); mediaFragment=AccountTimelineFragment.newInstance(accountID, account, GetAccountStatuses.Filter.MEDIA, false); - aboutFragment=new ProfileAboutFragment(); - aboutFragment.setFields(fields); +// aboutFragment=new ProfileAboutFragment(); + setFields(fields); } pager.getAdapter().notifyDataSetChanged(); super.dataLoaded(); @@ -401,6 +466,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList toolbarTitleView.setTranslationY(titleTransY); toolbarSubtitleView.setTranslationY(titleTransY); } + RecyclerFragment.setRefreshLayoutColors(refreshLayout); } @Override @@ -448,19 +514,33 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList ViewImageLoader.load(avatar, null, new UrlImageLoaderRequest(GlobalUserPreferences.playGifs ? account.avatar : account.avatarStatic, V.dp(100), V.dp(100))); ViewImageLoader.load(cover, null, new UrlImageLoaderRequest(GlobalUserPreferences.playGifs ? account.header : account.headerStatic, 1000, 1000)); SpannableStringBuilder ssb=new SpannableStringBuilder(account.displayName); - HtmlParser.parseCustomEmoji(ssb, account.emojis); - name.setText(ssb); - setTitle(ssb); + HtmlParser.parseCustomEmoji(ssb, account.emojis); + name.setText(ssb); + setTitle(ssb); + + if (account.roles != null && !account.roles.isEmpty()) { + rolesView.setVisibility(View.VISIBLE); + rolesView.removeAllViews(); + name.setPadding(0, 0, V.dp(12), 0); + for (Account.Role role : account.roles) { + TextView roleText = new TextView(getActivity(), null, 0, R.style.role_label); + roleText.setText(role.name); + if (!TextUtils.isEmpty(role.color) && role.color.startsWith("#")) try { + GradientDrawable bg = (GradientDrawable) roleText.getBackground().mutate(); + bg.setStroke(V.dp(2), Color.parseColor(role.color)); + } catch (Exception ignored) {} + rolesView.addView(roleText); + } + } boolean isSelf=AccountSessionManager.getInstance().isSelf(accountID, account); + String acct = ((isSelf || account.isRemote) + ? account.getFullyQualifiedName() + : account.acct); if(account.locked){ ssb=new SpannableStringBuilder("@"); - ssb.append(account.acct); - if(isSelf){ - ssb.append('@'); - ssb.append(AccountSessionManager.getInstance().getAccount(accountID).domain); - } + ssb.append(acct); ssb.append(" "); Drawable lock=username.getResources().getDrawable(R.drawable.ic_lock, getActivity().getTheme()).mutate(); lock.setBounds(0, 0, lock.getIntrinsicWidth(), lock.getIntrinsicHeight()); @@ -469,7 +549,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList username.setText(ssb); }else{ // noinspection SetTextI18n - username.setText('@'+account.acct+(isSelf ? ('@'+AccountSessionManager.getInstance().getAccount(accountID).domain) : "")); + username.setText('@'+acct); } CharSequence parsedBio=HtmlParser.parse(account.note, account.emojis, Collections.emptyList(), Collections.emptyList(), accountID); if(TextUtils.isEmpty(parsedBio)){ @@ -497,10 +577,12 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList fields.clear(); - AccountField joined=new AccountField(); - joined.parsedName=joined.name=getString(R.string.profile_joined); - joined.parsedValue=joined.value=DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).format(LocalDateTime.ofInstant(account.createdAt, ZoneId.systemDefault())); - fields.add(joined); + if (account.createdAt != null) { + AccountField joined=new AccountField(); + joined.parsedName=joined.name=getString(R.string.profile_joined); + joined.parsedValue=joined.value=DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).format(LocalDateTime.ofInstant(account.createdAt, ZoneId.systemDefault())); + fields.add(joined); + } for(AccountField field:account.fields){ field.parsedValue=ssb=HtmlParser.parse(field.value, account.emojis, Collections.emptyList(), Collections.emptyList(), accountID); @@ -519,9 +601,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList fields.add(field); } - if(aboutFragment!=null){ - aboutFragment.setFields(fields); - } + setFields(fields); } private void updateToolbar(){ @@ -539,6 +619,11 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList return false; } + @Override + protected boolean wantsToolbarMenuIconsTinted() { + return false; + } + @Override public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){ if(isOwnProfile && isInEditMode){ @@ -558,9 +643,21 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList return; inflater.inflate(isOwnProfile ? R.menu.profile_own : R.menu.profile, menu); UiUtils.enableOptionsMenuIcons(getActivity(), menu, R.id.bookmarks, R.id.followed_hashtags); + boolean hasMultipleAccounts = AccountSessionManager.getInstance().getLoggedInAccounts().size() > 1; + MenuItem openWithAccounts = menu.findItem(R.id.open_with_account); + openWithAccounts.setVisible(hasMultipleAccounts); + SubMenu accountsMenu = openWithAccounts.getSubMenu(); + if (hasMultipleAccounts) { + accountsMenu.clear(); + UiUtils.populateAccountsMenu(accountID, accountsMenu, s-> UiUtils.openURL( + getActivity(), s.getID(), account.url, false + )); + } menu.findItem(R.id.share).setTitle(getString(R.string.share_user, account.getShortUsername())); - if(isOwnProfile) + if(isOwnProfile) { + if (isInstancePixelfed()) menu.findItem(R.id.scheduled).setVisible(false); return; + } MenuItem mute = menu.findItem(R.id.mute); mute.setTitle(getString(relationship.muting ? R.string.unmute_user : R.string.mute_user, account.getShortUsername())); @@ -642,7 +739,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList args.putString("profileAccount", profileAccountID); args.putString("profileDisplayUsername", account.getDisplayUsername()); } - Nav.go(getActivity(), ListTimelinesFragment.class, args); + Nav.go(getActivity(), ListsFragment.class, args); }else if(id==R.id.followed_hashtags){ Bundle args=new Bundle(); args.putString("account", accountID); @@ -693,6 +790,26 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList notifyButton.setContentDescription(getString(relationship.notifying ? R.string.sk_user_post_notifications_on : R.string.sk_user_post_notifications_off, '@'+account.username)); } + public ImageButton getFab() { + return fab; + } + + @Override + public void showFab() { + if (getFragmentForPage(pager.getCurrentItem()) instanceof HasFab fabulous) fabulous.showFab(); + } + + @Override + public void hideFab() { + if (getFragmentForPage(pager.getCurrentItem()) instanceof HasFab fabulous) fabulous.hideFab(); + } + + @Override + public boolean isScrolling() { + return getFragmentForPage(pager.getCurrentItem()) instanceof HasFab fabulous + && fabulous.isScrolling(); + } + private void onScrollChanged(View v, int scrollX, int scrollY, int oldScrollX, int oldScrollY){ int topBarsH=getToolbar().getHeight()+statusBarHeight; if(scrollY>avatarBorder.getTop()-topBarsH){ @@ -713,8 +830,8 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList coverGradient.setTopOffset(scrollY); cover.invalidate(); titleTransY=getToolbar().getHeight(); - if(scrollY>name.getTop()-topBarsH){ - titleTransY=Math.max(0f, titleTransY-(scrollY-(name.getTop()-topBarsH))); + if(scrollY>nameWrap.getTop()-topBarsH){ + titleTransY=Math.max(0f, titleTransY-(scrollY-(nameWrap.getTop()-topBarsH))); } if(toolbarTitleView!=null){ toolbarTitleView.setTranslationY(titleTransY); @@ -731,7 +848,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList case 1 -> postsWithRepliesFragment; case 2 -> pinnedPostsFragment; case 3 -> mediaFragment; - case 4 -> aboutFragment; +// case 4 -> aboutFragment; default -> throw new IllegalStateException(); }; } @@ -751,6 +868,31 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList } } + private boolean onActionButtonLongClick(View v) { + if (isOwnProfile || AccountSessionManager.getInstance().getLoggedInAccounts().size() < 2) return false; + UiUtils.pickAccount(getActivity(), accountID, R.string.sk_follow_as, R.drawable.ic_fluent_person_add_28_regular, session -> { + UiUtils.lookupAccount(getActivity(), account, session.getID(), accountID, acc -> { + if (acc == null) return; + new SetAccountFollowed(acc.id, true, true).setCallback(new Callback<>() { + @Override + public void onSuccess(Relationship relationship) { + Toast.makeText( + getActivity(), + getString(R.string.sk_followed_as, session.self.getShortUsername()), + Toast.LENGTH_SHORT + ).show(); + } + + @Override + public void onError(ErrorResponse error) { + error.showToast(getActivity()); + } + }).exec(session.getID()); + }); + }, null); + return true; + } + private void setActionProgressVisible(boolean visible){ actionButton.setTextVisible(!visible); actionProgress.setVisibility(visible ? View.VISIBLE : View.GONE); @@ -796,16 +938,12 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList invalidateOptionsMenu(); pager.setUserInputEnabled(false); actionButton.setText(R.string.done); - pager.setCurrentItem(4); ArrayList animators=new ArrayList<>(); - for(int i=0;i animators=new ArrayList<>(); actionButton.setText(R.string.edit_profile); - for(int i=0;i(){ @Override public void onSuccess(Account result){ @@ -1015,7 +1156,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList @Override public SimpleViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){ FrameLayout view=tabViews[viewType]; - ((ViewGroup)view.getParent()).removeView(view); + if (view.getParent() != null) ((ViewGroup)view.getParent()).removeView(view); view.setVisibility(View.VISIBLE); view.setLayoutParams(new RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); return new SimpleViewHolder(view); @@ -1049,4 +1190,242 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList return position; } } + + // from ProfileAboutFragment + public void setFields(ArrayList fields){ + metadataListData=fields; + if (isInEditMode) { + isInEditMode=false; + dragHelper.attachToRecyclerView(null); + } + if (adapter != null) adapter.notifyDataSetChanged(); + } + + @Override + public void onProvideAssistContent(AssistContent assistContent) { + callFragmentToProvideAssistContent(getFragmentForPage(pager.getCurrentItem()), assistContent); + } + + @Override + public String getAccountID() { + return accountID; + } + + @Override + public Uri getWebUri(Uri.Builder base) { + return Uri.parse(account.url); + } + + private class MetadataAdapter extends UsableRecyclerView.Adapter implements ImageLoaderRecyclerAdapter { + public MetadataAdapter(){ + super(imgLoader); + } + + @NonNull + @Override + public BaseViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){ + return switch(viewType){ + case 0 -> new AboutViewHolder(); + case 1 -> new EditableAboutViewHolder(); + case 2 -> new AddRowViewHolder(); + default -> throw new IllegalStateException("Unexpected value: "+viewType); + }; + } + + @Override + public void onBindViewHolder(BaseViewHolder holder, int position){ + if(position { + public BaseViewHolder(int layout){ + super(getActivity(), layout, list); + } + } + + private class AboutViewHolder extends BaseViewHolder implements ImageLoaderViewHolder { + private TextView title; + private LinkedTextView value; + + public AboutViewHolder(){ + super(R.layout.item_profile_about); + title=findViewById(R.id.title); + value=findViewById(R.id.value); + } + + @Override + public void onBind(AccountField item){ + title.setText(item.parsedName); + value.setText(item.parsedValue); + if(item.verifiedAt!=null){ + int textColor=UiUtils.isDarkTheme() ? 0xFF89bb9c : 0xFF5b8e63; + value.setTextColor(textColor); + value.setLinkTextColor(textColor); + Drawable check=getResources().getDrawable(R.drawable.ic_fluent_checkmark_24_regular, getActivity().getTheme()).mutate(); + check.setTint(textColor); + value.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, check, null); + }else{ + value.setTextColor(UiUtils.getThemeColor(getActivity(), android.R.attr.textColorPrimary)); + value.setLinkTextColor(UiUtils.getThemeColor(getActivity(), android.R.attr.colorAccent)); + value.setCompoundDrawables(null, null, null, null); + } + } + + @Override + public void setImage(int index, Drawable image){ + CustomEmojiSpan span=index>=item.nameEmojis.length ? item.valueEmojis[index-item.nameEmojis.length] : item.nameEmojis[index]; + span.setDrawable(image); + title.invalidate(); + value.invalidate(); + } + + @Override + public void clearImage(int index){ + setImage(index, null); + } + } + + private class EditableAboutViewHolder extends BaseViewHolder { + private EditText title; + private EditText value; + + public EditableAboutViewHolder(){ + super(R.layout.item_profile_about_editable); + title=findViewById(R.id.title); + value=findViewById(R.id.value); + findViewById(R.id.dragger_thingy).setOnLongClickListener(v->{ + dragHelper.startDrag(this); + return true; + }); + title.addTextChangedListener(new SimpleTextWatcher(e->item.name=e.toString())); + value.addTextChangedListener(new SimpleTextWatcher(e->item.value=e.toString())); + findViewById(R.id.remove_row_btn).setOnClickListener(this::onRemoveRowClick); + } + + @Override + public void onBind(AccountField item){ + title.setText(item.name); + value.setText(item.value); + } + + private void onRemoveRowClick(View v){ + int pos=getAbsoluteAdapterPosition(); + metadataListData.remove(pos); + adapter.notifyItemRemoved(pos); + for(int i=0;itoPosition;i--) { + Collections.swap(metadataListData, i, i-1); + } + } + adapter.notifyItemMoved(fromPosition, toPosition); + ((BindableViewHolder)viewHolder).rebind(); + ((BindableViewHolder)target).rebind(); + return true; + } + + @Override + public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction){ + + } + + @Override + public void onSelectedChanged(@Nullable RecyclerView.ViewHolder viewHolder, int actionState){ + super.onSelectedChanged(viewHolder, actionState); + if(actionState==ItemTouchHelper.ACTION_STATE_DRAG){ + viewHolder.itemView.setTag(me.grishka.appkit.R.id.item_touch_helper_previous_elevation, viewHolder.itemView.getElevation()); // prevents the default behavior of changing elevation in onDraw() + viewHolder.itemView.animate().translationZ(V.dp(1)).setDuration(200).setInterpolator(CubicBezierInterpolator.DEFAULT).start(); + draggedViewHolder=viewHolder; + } + } + + @Override + public void clearView(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder){ + super.clearView(recyclerView, viewHolder); + viewHolder.itemView.animate().translationZ(0).setDuration(100).setInterpolator(CubicBezierInterpolator.DEFAULT).start(); + draggedViewHolder=null; + } + + @Override + public boolean isLongPressDragEnabled(){ + return false; + } + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/RecyclerFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/RecyclerFragment.java new file mode 100644 index 000000000..2dbc9650b --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/RecyclerFragment.java @@ -0,0 +1,50 @@ +package org.joinmastodon.android.fragments; + +import android.content.Context; +import android.os.Bundle; +import android.view.View; + +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.ui.utils.UiUtils; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import me.grishka.appkit.fragments.BaseRecyclerFragment; + + +public abstract class RecyclerFragment extends BaseRecyclerFragment { + public RecyclerFragment(int perPage) { + super(perPage); + } + + public RecyclerFragment(int layout, int perPage) { + super(layout, perPage); + } + + public void onViewCreated(View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + if (refreshLayout != null) setRefreshLayoutColors(refreshLayout); + } + + public static void setRefreshLayoutColors(SwipeRefreshLayout l) { + List colors = new ArrayList<>(Arrays.asList( + R.color.primary_600, + R.color.red_primary_600, + R.color.green_primary_600, + R.color.blue_primary_600, + R.color.purple_600 + )); + int primary = UiUtils.getThemeColorRes(l.getContext(), R.attr.colorPrimary600); + if (!colors.contains(primary)) colors.add(0, primary); + int offset = colors.indexOf(primary); + int[] sorted = new int[colors.size()]; + for (int i = 0; i < colors.size(); i++) { + sorted[i] = colors.get((i + offset) % colors.size()); + } + l.setColorSchemeResources(sorted); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ScheduledStatusListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ScheduledStatusListFragment.java index ea9fc0677..d882bb16f 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ScheduledStatusListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ScheduledStatusListFragment.java @@ -1,6 +1,7 @@ package org.joinmastodon.android.fragments; import android.app.Activity; +import android.net.Uri; import android.os.Bundle; import android.view.View; import android.widget.ImageButton; @@ -28,11 +29,11 @@ import me.grishka.appkit.api.SimpleCallback; public class ScheduledStatusListFragment extends BaseStatusListFragment { private String nextMaxID; - private ImageButton fab; private static final int SCHEDULED_STATUS_LIST_OPENED = 161; - public ScheduledStatusListFragment() { - setListLayoutId(R.layout.recycler_fragment_with_fab); + @Override + protected boolean wantsComposeButton() { + return true; } @Override @@ -56,20 +57,30 @@ public class ScheduledStatusListFragment extends BaseStatusListFragment Nav.go(getActivity(), ComposeFragment.class, args)); - fab.setOnLongClickListener(v -> UiUtils.pickAccountForCompose(getActivity(), accountID, args)); + Nav.go(getActivity(), ComposeFragment.class, args); + } + + @Override + public boolean onFabLongClick(View v) { + Bundle args=new Bundle(); + args.putString("account", accountID); + args.putSerializable("scheduledAt", CreateStatus.getDraftInstant()); + return UiUtils.pickAccountForCompose(getActivity(), accountID, args); + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); if (getArguments().getBoolean("hide_fab", false)) fab.setVisibility(View.GONE); } @Override protected List buildDisplayItems(ScheduledStatus s) { - return StatusDisplayItem.buildItems(this, s.toStatus(), accountID, s, knownAccounts, false, false, null, true); + return StatusDisplayItem.buildItems(this, s.toStatus(), accountID, s, knownAccounts, false, false, null, true, null); } @Override @@ -86,6 +97,8 @@ public class ScheduledStatusListFragment extends BaseStatusListFragment items=new ArrayList<>(); private ThemeItem themeItem; private NotificationPolicyItem notificationPolicyItem; - private SwitchItem showNewPostsButtonItem, glitchModeItem; + private SwitchItem showNewPostsItem, glitchModeItem, compactReblogReplyLineItem; + private ButtonItem defaultContentTypeButtonItem; private String accountID; private boolean needUpdateNotificationSettings; private boolean needAppRestart; @@ -88,7 +95,9 @@ public class SettingsFragment extends MastodonToolbarFragment{ private ImageView themeTransitionWindowView; private TextItem checkForUpdateItem, clearImageCacheItem; private ImageCache imageCache; + private Menu contentTypeMenu; + @SuppressLint("ClickableViewAccessibility") @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); @@ -98,7 +107,7 @@ public class SettingsFragment extends MastodonToolbarFragment{ imageCache = ImageCache.getInstance(getActivity()); accountID=getArguments().getString("account"); AccountSession session=AccountSessionManager.getInstance().getAccount(accountID); - Instance instance = AccountSessionManager.getInstance().getInstanceInfo(session.domain); + Optional instance = session.getInstance(); String instanceName = UiUtils.getInstanceName(accountID); if(GithubSelfUpdater.needSelfUpdating()){ @@ -202,27 +211,56 @@ public class SettingsFragment extends MastodonToolbarFragment{ GlobalUserPreferences.keepOnlyLatestNotification=i.checked; GlobalUserPreferences.save(); })); + items.add(new SwitchItem(R.string.sk_settings_prefix_reply_cw_with_re, R.drawable.ic_fluent_arrow_reply_24_regular, GlobalUserPreferences.prefixRepliesWithRe, i->{ + GlobalUserPreferences.prefixRepliesWithRe=i.checked; + GlobalUserPreferences.save(); + })); + items.add(new SwitchItem(R.string.sk_settings_confirm_before_reblog, R.drawable.ic_fluent_checkmark_circle_24_regular, GlobalUserPreferences.confirmBeforeReblog, i->{ + GlobalUserPreferences.confirmBeforeReblog=i.checked; + GlobalUserPreferences.save(); + })); + items.add(new SwitchItem(R.string.sk_settings_allow_remote_loading, R.drawable.ic_fluent_communication_24_regular, GlobalUserPreferences.allowRemoteLoading, i->{ + GlobalUserPreferences.allowRemoteLoading=i.checked; + GlobalUserPreferences.save(); + })); + items.add(new SmallTextItem(R.string.sk_settings_allow_remote_loading_explanation)); items.add(new HeaderItem(R.string.sk_timelines)); items.add(new SwitchItem(R.string.sk_settings_show_replies, R.drawable.ic_fluent_chat_multiple_24_regular, GlobalUserPreferences.showReplies, i->{ GlobalUserPreferences.showReplies=i.checked; GlobalUserPreferences.save(); })); + if (isInstanceAkkoma()) { + items.add(new ButtonItem(R.string.sk_settings_reply_visibility, R.drawable.ic_fluent_chat_24_regular, b->{ + PopupMenu popupMenu=new PopupMenu(getActivity(), b, Gravity.CENTER_HORIZONTAL); + popupMenu.inflate(R.menu.reply_visibility); + popupMenu.setOnMenuItemClickListener(item -> this.onReplyVisibilityChanged(item, b)); + b.setOnTouchListener(popupMenu.getDragToOpenListener()); + b.setOnClickListener(v->popupMenu.show()); + b.setText(GlobalUserPreferences.replyVisibility == null ? + R.string.sk_settings_reply_visibility_all : + switch(GlobalUserPreferences.replyVisibility){ + case "following" -> R.string.sk_settings_reply_visibility_following; + case "self" -> R.string.sk_settings_reply_visibility_self; + default -> R.string.sk_settings_reply_visibility_all; + }); + })); + } items.add(new SwitchItem(R.string.sk_settings_show_boosts, R.drawable.ic_fluent_arrow_repeat_all_24_regular, GlobalUserPreferences.showBoosts, i->{ GlobalUserPreferences.showBoosts=i.checked; GlobalUserPreferences.save(); })); items.add(new SwitchItem(R.string.sk_settings_load_new_posts, R.drawable.ic_fluent_arrow_sync_24_regular, GlobalUserPreferences.loadNewPosts, i->{ GlobalUserPreferences.loadNewPosts=i.checked; - showNewPostsButtonItem.enabled = i.checked; + showNewPostsItem.enabled = i.checked; if (!i.checked) { GlobalUserPreferences.showNewPostsButton = false; - showNewPostsButtonItem.checked = false; + showNewPostsItem.checked = false; } - if (list.findViewHolderForAdapterPosition(items.indexOf(showNewPostsButtonItem)) instanceof SwitchViewHolder svh) svh.rebind(); + if (list.findViewHolderForAdapterPosition(items.indexOf(showNewPostsItem)) instanceof SwitchViewHolder svh) svh.rebind(); GlobalUserPreferences.save(); })); - items.add(showNewPostsButtonItem = new SwitchItem(R.string.sk_settings_see_new_posts_button, R.drawable.ic_fluent_arrow_up_24_regular, GlobalUserPreferences.showNewPostsButton, i->{ + items.add(showNewPostsItem = new SwitchItem(R.string.sk_settings_see_new_posts_button, R.drawable.ic_fluent_arrow_up_24_regular, GlobalUserPreferences.showNewPostsButton, i->{ GlobalUserPreferences.showNewPostsButton=i.checked; GlobalUserPreferences.save(); })); @@ -234,12 +272,43 @@ public class SettingsFragment extends MastodonToolbarFragment{ GlobalUserPreferences.showNoAltIndicator=i.checked; GlobalUserPreferences.save(); })); + items.add(new SwitchItem(R.string.sk_settings_collapse_long_posts, R.drawable.ic_fluent_chevron_down_24_regular, GlobalUserPreferences.collapseLongPosts, i->{ + GlobalUserPreferences.collapseLongPosts=i.checked; + GlobalUserPreferences.save(); + })); + items.add(new SwitchItem(R.string.sk_settings_hide_interaction, R.drawable.ic_fluent_eye_24_regular, GlobalUserPreferences.spectatorMode, i->{ + GlobalUserPreferences.spectatorMode=i.checked; + GlobalUserPreferences.save(); + needAppRestart=true; + })); + items.add(new SwitchItem(R.string.sk_settings_hide_fab, R.drawable.ic_fluent_edit_24_regular, GlobalUserPreferences.autoHideFab, i->{ + GlobalUserPreferences.autoHideFab=i.checked; + GlobalUserPreferences.save(); + needAppRestart=true; + })); + items.add(new SwitchItem(R.string.sk_reply_line_above_avatar, R.drawable.ic_fluent_arrow_reply_24_regular, GlobalUserPreferences.replyLineAboveHeader, i->{ + GlobalUserPreferences.replyLineAboveHeader=i.checked; + GlobalUserPreferences.compactReblogReplyLine=i.checked; + compactReblogReplyLineItem.enabled=i.checked; + compactReblogReplyLineItem.checked= GlobalUserPreferences.replyLineAboveHeader; + if (list.findViewHolderForAdapterPosition(items.indexOf(compactReblogReplyLineItem)) instanceof SwitchViewHolder svh) svh.rebind(); + GlobalUserPreferences.save(); + needAppRestart=true; + })); + items.add(compactReblogReplyLineItem=new SwitchItem(R.string.sk_compact_reblog_reply_line, R.drawable.ic_fluent_re_order_24_regular, GlobalUserPreferences.compactReblogReplyLine, i->{ + GlobalUserPreferences.compactReblogReplyLine=i.checked; + GlobalUserPreferences.save(); + needAppRestart=true; + })); + compactReblogReplyLineItem.enabled=GlobalUserPreferences.replyLineAboveHeader; items.add(new SwitchItem(R.string.sk_settings_translate_only_opened, R.drawable.ic_fluent_translate_24_regular, GlobalUserPreferences.translateButtonOpenedOnly, i->{ GlobalUserPreferences.translateButtonOpenedOnly=i.checked; GlobalUserPreferences.save(); needAppRestart=true; })); - boolean translationAvailable = instance.v2 != null && instance.v2.configuration.translation != null && instance.v2.configuration.translation.enabled; + boolean translationAvailable = instance + .map(i -> i.v2 != null && i.v2.configuration.translation != null && i.v2.configuration.translation.enabled) + .orElse(false); items.add(new SmallTextItem(getString(translationAvailable ? R.string.sk_settings_translation_availability_note_available : R.string.sk_settings_translation_availability_note_unavailable, instanceName))); @@ -264,23 +333,55 @@ public class SettingsFragment extends MastodonToolbarFragment{ items.add(new TextItem(R.string.sk_settings_auth, ()->UiUtils.launchWebBrowser(getActivity(), "https://"+session.domain+"/auth/edit"), R.drawable.ic_fluent_open_24_regular)); items.add(new HeaderItem(instanceName)); - items.add(new TextItem(R.string.sk_settings_rules, ()->{ - Bundle args=new Bundle(); - args.putParcelable("instance", Parcels.wrap(instance)); + items.add(new TextItem(R.string.sk_settings_rules, instance.map(i -> () -> { + Bundle args = new Bundle(); + args.putParcelable("instance", Parcels.wrap(i)); Nav.go(getActivity(), InstanceRulesFragment.class, args); - }, R.drawable.ic_fluent_task_list_ltr_24_regular)); + }).orElse(null), R.drawable.ic_fluent_task_list_ltr_24_regular)); items.add(new TextItem(R.string.sk_settings_about_instance , ()->UiUtils.launchWebBrowser(getActivity(), "https://"+session.domain+"/about"), R.drawable.ic_fluent_info_24_regular)); items.add(new TextItem(R.string.settings_tos, ()->UiUtils.launchWebBrowser(getActivity(), "https://"+session.domain+"/terms"), R.drawable.ic_fluent_open_24_regular)); items.add(new TextItem(R.string.settings_privacy_policy, ()->UiUtils.launchWebBrowser(getActivity(), "https://"+session.domain+"/terms"), R.drawable.ic_fluent_open_24_regular)); items.add(new TextItem(R.string.log_out, this::confirmLogOut, R.drawable.ic_fluent_sign_out_24_regular)); - if (!TextUtils.isEmpty(instance.version)) items.add(new SmallTextItem(getString(R.string.sk_settings_server_version, instance.version))); + items.add(new SmallTextItem(instance + .map(i -> getString(R.string.sk_settings_server_version, i.version)) + .orElse(getString(R.string.sk_instance_info_unavailable)))); items.add(new HeaderItem(R.string.sk_instance_features)); + items.add(new SwitchItem(R.string.sk_settings_content_types, 0, GlobalUserPreferences.accountsWithContentTypesEnabled.contains(accountID), (i)->{ + if (i.checked) { + GlobalUserPreferences.accountsWithContentTypesEnabled.add(accountID); + if (GlobalUserPreferences.accountsDefaultContentTypes.get(accountID) == null) { + GlobalUserPreferences.accountsDefaultContentTypes.put(accountID, ContentType.PLAIN); + } + } else { + GlobalUserPreferences.accountsWithContentTypesEnabled.remove(accountID); + GlobalUserPreferences.accountsDefaultContentTypes.remove(accountID); + } + if (list.findViewHolderForAdapterPosition(items.indexOf(defaultContentTypeButtonItem)) + instanceof ButtonViewHolder bvh) bvh.rebind(); + GlobalUserPreferences.save(); + })); + items.add(new SmallTextItem(getString(R.string.sk_settings_content_types_explanation))); + items.add(defaultContentTypeButtonItem = new ButtonItem(R.string.sk_settings_default_content_type, 0, b->{ + PopupMenu popupMenu=new PopupMenu(getActivity(), b, Gravity.CENTER_HORIZONTAL); + popupMenu.inflate(R.menu.compose_content_type); + popupMenu.setOnMenuItemClickListener(item -> this.onContentTypeChanged(item, b)); + b.setOnTouchListener(popupMenu.getDragToOpenListener()); + b.setOnClickListener(v->popupMenu.show()); + ContentType contentType = GlobalUserPreferences.accountsDefaultContentTypes.get(accountID); + b.setText(getContentTypeString(contentType)); + contentTypeMenu = popupMenu.getMenu(); + contentTypeMenu.findItem(ContentType.getContentTypeRes(contentType)).setChecked(true); + instance.ifPresent(i -> ContentType.adaptMenuToInstance(contentTypeMenu, i)); + })); + items.add(new SmallTextItem(getString(R.string.sk_settings_default_content_type_explanation))); items.add(new SwitchItem(R.string.sk_settings_support_local_only, 0, GlobalUserPreferences.accountsWithLocalOnlySupport.contains(accountID), i->{ glitchModeItem.enabled = i.checked; if (i.checked) { GlobalUserPreferences.accountsWithLocalOnlySupport.add(accountID); - if (instance.pleroma == null) GlobalUserPreferences.accountsInGlitchMode.add(accountID); + if (!isInstanceAkkoma()) { + GlobalUserPreferences.accountsInGlitchMode.add(accountID); + } } else { GlobalUserPreferences.accountsWithLocalOnlySupport.remove(accountID); GlobalUserPreferences.accountsInGlitchMode.remove(accountID); @@ -320,6 +421,19 @@ public class SettingsFragment extends MastodonToolbarFragment{ items.add(checkForUpdateItem); } + if(BuildConfig.DEBUG){ + items.add(new RedHeaderItem("Debug options")); + items.add(new TextItem("Test e-mail confirmation flow", ()->{ + AccountSession sess=AccountSessionManager.getInstance().getAccount(accountID); + sess.activated=false; + sess.activationInfo=new AccountActivationInfo("test@email", System.currentTimeMillis()); + Bundle args=new Bundle(); + args.putString("account", accountID); + args.putBoolean("debug", true); + Nav.goClearingStack(getActivity(), AccountActivationFragment.class, args); + })); + } + items.add(new FooterItem(getString(R.string.sk_settings_app_version, BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE))); } @@ -433,6 +547,53 @@ public class SettingsFragment extends MastodonToolbarFragment{ } } + private @StringRes int getContentTypeString(@Nullable ContentType contentType) { + if (contentType == null) return R.string.sk_content_type_unspecified; + return switch (contentType) { + case PLAIN -> R.string.sk_content_type_plain; + case HTML -> R.string.sk_content_type_html; + case MARKDOWN -> R.string.sk_content_type_markdown; + case BBCODE -> R.string.sk_content_type_bbcode; + case MISSKEY_MARKDOWN -> R.string.sk_content_type_mfm; + }; + } + + private boolean onContentTypeChanged(MenuItem item, Button btn){ + int id = item.getItemId(); + ContentType contentType = switch (id) { + case R.id.content_type_plain -> ContentType.PLAIN; + case R.id.content_type_html -> ContentType.HTML; + case R.id.content_type_markdown -> ContentType.MARKDOWN; + case R.id.content_type_bbcode -> ContentType.BBCODE; + case R.id.content_type_misskey_markdown -> ContentType.MISSKEY_MARKDOWN; + default -> null; + }; + GlobalUserPreferences.accountsDefaultContentTypes.put(accountID, contentType); + GlobalUserPreferences.save(); + btn.setText(getContentTypeString(contentType)); + item.setChecked(true); + return true; + } + + private boolean onReplyVisibilityChanged(MenuItem item, Button btn){ + String pref = null; + int id = item.getItemId(); + + if (id == R.id.reply_visibility_following) pref = "following"; + else if (id == R.id.reply_visibility_self) pref = "self"; + + GlobalUserPreferences.replyVisibility=pref; + GlobalUserPreferences.save(); + btn.setText(GlobalUserPreferences.replyVisibility == null ? + R.string.sk_settings_reply_visibility_all : + switch(GlobalUserPreferences.replyVisibility){ + case "following" -> R.string.sk_settings_reply_visibility_following; + case "self" -> R.string.sk_settings_reply_visibility_self; + default -> R.string.sk_settings_reply_visibility_all; + }); + return true; + } + private void restartActivityToApplyNewTheme(){ // Calling activity.recreate() causes a black screen for like half a second. // So, let's take a screenshot and overlay it on top to create the illusion of a smoother transition. @@ -586,6 +747,16 @@ public class SettingsFragment extends MastodonToolbarFragment{ } } + @Override + public Uri getWebUri(Uri.Builder base) { + return base.path(isInstanceAkkoma() ? "/about" : "/settings").build(); + } + + @Override + public String getAccountID() { + return accountID; + } + private static abstract class Item{ public abstract int getViewType(); } @@ -597,7 +768,7 @@ public class SettingsFragment extends MastodonToolbarFragment{ this.text=getString(text); } - public HeaderItem(String text) { + public HeaderItem(String text){ this.text=text; } @@ -669,7 +840,11 @@ public class SettingsFragment extends MastodonToolbarFragment{ } private class SmallTextItem extends Item { - private String text; + private final String text; + + public SmallTextItem(@StringRes int text) { + this.text = getString(text); + } public SmallTextItem(String text) { this.text = text; @@ -708,6 +883,11 @@ public class SettingsFragment extends MastodonToolbarFragment{ this.secondaryText = secondaryText; } + public TextItem(String text, Runnable onClick){ + this.text=text; + this.onClick=onClick; + } + @Override public int getViewType(){ return 4; @@ -720,6 +900,10 @@ public class SettingsFragment extends MastodonToolbarFragment{ super(text); } + public RedHeaderItem(String text){ + super(text); + } + @Override public int getViewType(){ return 5; @@ -901,7 +1085,6 @@ public class SettingsFragment extends MastodonToolbarFragment{ private final ImageView icon; private final TextView text; - @SuppressLint("ClickableViewAccessibility") public ButtonViewHolder(){ super(getActivity(), R.layout.item_settings_button, list); text=findViewById(R.id.text); @@ -909,10 +1092,17 @@ public class SettingsFragment extends MastodonToolbarFragment{ button=findViewById(R.id.button); } + @SuppressLint("ClickableViewAccessibility") @Override public void onBind(ButtonItem item){ text.setText(item.text); - icon.setImageResource(item.icon); + icon.setVisibility(item.icon == 0 ? View.GONE : View.VISIBLE); + icon.setImageResource(item.icon == 0 ? 0 : item.icon); + // reset listeners before letting the button consumer consume the button + // (and potentially set some listeners, but not others) + button.setOnTouchListener(null); + button.setOnClickListener(null); + button.setOnLongClickListener(null); item.buttonConsumer.accept(button); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/SplashFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/SplashFragment.java index b11047400..6b6097458 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/SplashFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/SplashFragment.java @@ -1,43 +1,47 @@ package org.joinmastodon.android.fragments; -import android.graphics.Canvas; -import android.graphics.Paint; -import android.graphics.drawable.Drawable; +import android.graphics.drawable.ColorDrawable; import android.os.Bundle; -import android.text.SpannableString; -import android.text.style.ReplacementSpan; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.view.ViewTreeObserver; import android.view.WindowInsets; -import android.widget.LinearLayout; -import android.widget.TextView; +import android.widget.Button; +import org.joinmastodon.android.MastodonApp; import org.joinmastodon.android.R; +import org.joinmastodon.android.api.requests.instance.GetInstance; import org.joinmastodon.android.fragments.onboarding.InstanceCatalogSignupFragment; import org.joinmastodon.android.fragments.onboarding.InstanceChooserLoginFragment; +import org.joinmastodon.android.fragments.onboarding.InstanceRulesFragment; +import org.joinmastodon.android.model.Instance; +import org.joinmastodon.android.ui.InterpolatingMotionEffect; +import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.ui.views.SizeListenerFrameLayout; +import org.parceler.Parcels; -import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.recyclerview.widget.RecyclerView; -import androidx.viewpager2.widget.ViewPager2; import me.grishka.appkit.Nav; +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; import me.grishka.appkit.fragments.AppKitFragment; import me.grishka.appkit.utils.V; +import me.grishka.appkit.views.BottomSheet; public class SplashFragment extends AppKitFragment{ + private static final String DEFAULT_SERVER="mastodon.social"; + private SizeListenerFrameLayout contentView; private View artContainer, blueFill, greenFill; - private ViewPager2 pager; - private ViewGroup pagerDots; + private InterpolatingMotionEffect motionEffect; private View artClouds, artPlaneElephant, artRightHill, artLeftHill, artCenterHill; @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); + motionEffect=new InterpolatingMotionEffect(MastodonApp.context); } @Nullable @@ -46,44 +50,26 @@ public class SplashFragment extends AppKitFragment{ contentView=(SizeListenerFrameLayout) inflater.inflate(R.layout.fragment_splash, container, false); contentView.findViewById(R.id.btn_get_started).setOnClickListener(this::onButtonClick); contentView.findViewById(R.id.btn_log_in).setOnClickListener(this::onButtonClick); + Button joinDefault=contentView.findViewById(R.id.btn_join_default_server); + joinDefault.setText(getString(R.string.join_default_server, DEFAULT_SERVER)); + joinDefault.setOnClickListener(this::onJoinDefaultServerClick); + contentView.findViewById(R.id.btn_learn_more).setOnClickListener(this::onLearnMoreClick); + artClouds=contentView.findViewById(R.id.art_clouds); artPlaneElephant=contentView.findViewById(R.id.art_plane_elephant); artRightHill=contentView.findViewById(R.id.art_right_hill); artLeftHill=contentView.findViewById(R.id.art_left_hill); artCenterHill=contentView.findViewById(R.id.art_center_hill); - pager=contentView.findViewById(R.id.pager); - pagerDots=contentView.findViewById(R.id.pager_dots); - pager.setAdapter(new PagerAdapter()); - pager.setOffscreenPageLimit(3); - pager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback(){ - @Override - public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels){ - for(int i=0;i=1 ? 1f : positionOffset)); - artPlaneElephant.setTranslationX(V.dp(101.55f)*parallaxProgress); - artLeftHill.setTranslationX(V.dp(-88)*parallaxProgress); - artLeftHill.setTranslationY(V.dp(24)*parallaxProgress); - artRightHill.setTranslationX(V.dp(-88)*parallaxProgress); - artRightHill.setTranslationY(V.dp(-24)*parallaxProgress); - artCenterHill.setTranslationX(V.dp(-40)*parallaxProgress); - } - }); artContainer=contentView.findViewById(R.id.art_container); blueFill=contentView.findViewById(R.id.blue_fill); greenFill=contentView.findViewById(R.id.green_fill); + motionEffect.addViewEffect(new InterpolatingMotionEffect.ViewEffect(artClouds, V.dp(-5), V.dp(5), V.dp(-5), V.dp(5))); + motionEffect.addViewEffect(new InterpolatingMotionEffect.ViewEffect(artRightHill, V.dp(-15), V.dp(25), V.dp(-10), V.dp(10))); + motionEffect.addViewEffect(new InterpolatingMotionEffect.ViewEffect(artLeftHill, V.dp(-25), V.dp(15), V.dp(-15), V.dp(15))); + motionEffect.addViewEffect(new InterpolatingMotionEffect.ViewEffect(artCenterHill, V.dp(-14), V.dp(14), V.dp(-5), V.dp(25))); + motionEffect.addViewEffect(new InterpolatingMotionEffect.ViewEffect(artPlaneElephant, V.dp(-20), V.dp(12), V.dp(-20), V.dp(12))); + artContainer.setOnTouchListener(motionEffect); contentView.setSizeListener(new SizeListenerFrameLayout.OnSizeChangedListener(){ @Override @@ -109,6 +95,38 @@ public class SplashFragment extends AppKitFragment{ Nav.go(getActivity(), isSignup ? InstanceCatalogSignupFragment.class : InstanceChooserLoginFragment.class, extras); } + private void onJoinDefaultServerClick(View v){ + new GetInstance() + .setCallback(new Callback<>(){ + @Override + public void onSuccess(Instance result){ + if(getActivity()==null) + return; + Bundle args=new Bundle(); + args.putParcelable("instance", Parcels.wrap(result)); + Nav.go(getActivity(), InstanceRulesFragment.class, args); + } + + @Override + public void onError(ErrorResponse error){ + if(getActivity()==null) + return; + error.showToast(getActivity()); + } + }) + .wrapProgress(getActivity(), R.string.loading_instance, true) + .execNoAuth(DEFAULT_SERVER); + } + + private void onLearnMoreClick(View v){ + View sheetView=getActivity().getLayoutInflater().inflate(R.layout.intro_bottom_sheet, null); + BottomSheet sheet=new BottomSheet(getActivity()); + sheet.setContentView(sheetView); + sheet.setNavigationBarBackground(new ColorDrawable(UiUtils.alphaBlendColors(UiUtils.getThemeColor(getActivity(), R.attr.colorM3Surface), + UiUtils.getThemeColor(getActivity(), R.attr.colorM3Primary), 0.05f)), !UiUtils.isDarkTheme()); + sheet.show(); + } + private void updateArtSize(int w, int h){ float scale=w/(float)V.dp(360); artContainer.setScaleX(scale); @@ -139,60 +157,15 @@ public class SplashFragment extends AppKitFragment{ return true; } - private class PagerAdapter extends RecyclerView.Adapter{ - - @NonNull - @Override - public PagerViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){ - return new PagerViewHolder(viewType); - } - - @Override - public void onBindViewHolder(@NonNull PagerViewHolder holder, int position){} - - @Override - public int getItemCount(){ - return 3; - } - - @Override - public int getItemViewType(int position){ - return position; - } + @Override + protected void onShown(){ + super.onShown(); + motionEffect.activate(); } - private class PagerViewHolder extends RecyclerView.ViewHolder{ - public PagerViewHolder(int page){ - super(new LinearLayout(getActivity())); - LinearLayout ll=(LinearLayout) itemView; - ll.setOrientation(LinearLayout.VERTICAL); - int pad=V.dp(16); - ll.setPadding(pad, pad, pad, pad); - ll.setLayoutParams(new RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); - - TextView title=new TextView(getActivity()); - title.setTextAppearance(R.style.m3_headline_medium); - title.setText(switch(page){ - case 0 -> getString(R.string.welcome_page1_title); - case 1 -> getString(R.string.welcome_page2_title); - case 2 -> getString(R.string.welcome_page3_title); - default -> throw new IllegalStateException("Unexpected value: "+page); - }); - title.setTextColor(0xFF17063B); - LinearLayout.LayoutParams lp=new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, V.dp(page==0 ? 46 : 36)); - lp.bottomMargin=V.dp(page==0 ? 4 : 14); - ll.addView(title, lp); - - TextView text=new TextView(getActivity()); - text.setTextAppearance(R.style.m3_body_medium); - text.setText(switch(page){ - case 0 -> R.string.welcome_page1_text; - case 1 -> R.string.welcome_page2_text; - case 2 -> R.string.welcome_page3_text; - default -> throw new IllegalStateException("Unexpected value: "+page); - }); - text.setTextColor(0xFF17063B); - ll.addView(text, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); - } + @Override + protected void onHidden(){ + super.onHidden(); + motionEffect.deactivate(); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/StatusEditHistoryFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/StatusEditHistoryFragment.java index aa2a59c46..5d592f7fd 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/StatusEditHistoryFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/StatusEditHistoryFragment.java @@ -1,11 +1,13 @@ package org.joinmastodon.android.fragments; import android.app.Activity; +import android.net.Uri; import android.os.Bundle; import android.view.View; import org.joinmastodon.android.R; import org.joinmastodon.android.api.requests.statuses.GetStatusEditHistory; +import org.joinmastodon.android.model.Filter; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.ui.displayitems.ReblogOrReplyLineStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.StatusDisplayItem; @@ -23,13 +25,13 @@ import java.util.stream.Collectors; import me.grishka.appkit.api.SimpleCallback; public class StatusEditHistoryFragment extends StatusListFragment{ - private String id; - + private String id, url; @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); id=getArguments().getString("id"); + url=getArguments().getString("url"); loadData(); } @@ -55,7 +57,7 @@ public class StatusEditHistoryFragment extends StatusListFragment{ @Override protected List buildDisplayItems(Status s){ - List items=StatusDisplayItem.buildItems(this, s, accountID, s, knownAccounts, true, false, null); + List items=StatusDisplayItem.buildItems(this, s, accountID, s, knownAccounts, true, false, null, null); int idx=data.indexOf(s); if(idx>=0){ String date=UiUtils.DATE_TIME_FORMATTER.format(s.createdAt.atZone(ZoneId.systemDefault())); @@ -156,4 +158,14 @@ public class StatusEditHistoryFragment extends StatusListFragment{ public boolean isItemEnabled(String id){ return false; } + + @Override + protected Filter.FilterContext getFilterContext() { + return null; + } + + @Override + public Uri getWebUri(Uri.Builder base) { + return Uri.parse(url); + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/StatusListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/StatusListFragment.java index a9ce3120e..838ace548 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/StatusListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/StatusListFragment.java @@ -1,17 +1,20 @@ package org.joinmastodon.android.fragments; +import android.app.assist.AssistContent; import android.content.res.Configuration; import android.os.Bundle; import com.squareup.otto.Subscribe; import org.joinmastodon.android.E; +import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.events.PollUpdatedEvent; import org.joinmastodon.android.events.RemoveAccountPostsEvent; import org.joinmastodon.android.events.StatusCountersUpdatedEvent; import org.joinmastodon.android.events.StatusCreatedEvent; import org.joinmastodon.android.events.StatusDeletedEvent; import org.joinmastodon.android.events.StatusUpdatedEvent; +import org.joinmastodon.android.model.Filter; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.ui.displayitems.ExtendedFooterStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.FooterStatusDisplayItem; @@ -26,17 +29,23 @@ import java.util.stream.Stream; import androidx.recyclerview.widget.RecyclerView; import me.grishka.appkit.Nav; -public abstract class StatusListFragment extends BaseStatusListFragment{ +public abstract class StatusListFragment extends BaseStatusListFragment { protected EventListener eventListener=new EventListener(); protected List buildDisplayItems(Status s){ - return StatusDisplayItem.buildItems(this, s, accountID, s, knownAccounts, false, true, null); + boolean addFooter = !GlobalUserPreferences.spectatorMode || + (this instanceof ThreadFragment t && s.id.equals(t.mainStatus.id)); + return StatusDisplayItem.buildItems(this, s, accountID, s, knownAccounts, false, addFooter, null, getFilterContext()); } + protected abstract Filter.FilterContext getFilterContext(); + @Override protected void addAccountToKnown(Status s){ if(!knownAccounts.containsKey(s.account.id)) knownAccounts.put(s.account.id, s.account); + if(s.reblog!=null && !knownAccounts.containsKey(s.reblog.account.id)) + knownAccounts.put(s.reblog.account.id, s.reblog.account); } @Override @@ -56,9 +65,10 @@ public abstract class StatusListFragment extends BaseStatusListFragment{ Status status=getContentStatusByID(id); if(status==null) return; + status.filterRevealed = true; Bundle args=new Bundle(); args.putString("account", accountID); - args.putParcelable("status", Parcels.wrap(status)); + args.putParcelable("status", Parcels.wrap(status.clone())); if(status.inReplyToAccountId!=null && knownAccounts.containsKey(status.inReplyToAccountId)) args.putParcelable("inReplyToAccount", Parcels.wrap(knownAccounts.get(status.inReplyToAccountId))); Nav.go(getActivity(), ThreadFragment.class, args); @@ -144,7 +154,7 @@ public abstract class StatusListFragment extends BaseStatusListFragment{ protected void onRemoveAccountPostsEvent(RemoveAccountPostsEvent ev){ List toRemove=Stream.concat(data.stream(), preloadedData.stream()) - .filter(s->s.account.id.equals(ev.postsByAccountID) || (s.reblog!=null && s.reblog.account.id.equals(ev.postsByAccountID))) + .filter(s->s.account.id.equals(ev.postsByAccountID) || (!ev.isUnfollow && s.reblog!=null && s.reblog.account.id.equals(ev.postsByAccountID))) .collect(Collectors.toList()); for(Status s:toRemove){ removeStatus(s); @@ -173,7 +183,7 @@ public abstract class StatusListFragment extends BaseStatusListFragment{ } @Override - public void onConfigurationChanged(Configuration newConfig){ + public void onConfigurationChanged(Configuration newConfig) { super.onConfigurationChanged(newConfig); if (getParentFragment() instanceof HomeTabFragment home) home.updateToolbarLogo(); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ThreadFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ThreadFragment.java index baacdcc13..a8d79b9b2 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ThreadFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ThreadFragment.java @@ -1,32 +1,56 @@ package org.joinmastodon.android.fragments; +import android.net.Uri; import android.os.Bundle; import android.view.View; +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import org.joinmastodon.android.E; +import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.R; +import org.joinmastodon.android.api.requests.statuses.GetStatusByID; import org.joinmastodon.android.api.requests.statuses.GetStatusContext; +import org.joinmastodon.android.events.StatusCountersUpdatedEvent; import org.joinmastodon.android.events.StatusCreatedEvent; +import org.joinmastodon.android.events.StatusUpdatedEvent; import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.Filter; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.model.StatusContext; +import org.joinmastodon.android.ui.BetterItemAnimator; import org.joinmastodon.android.ui.displayitems.ExtendedFooterStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.FooterStatusDisplayItem; +import org.joinmastodon.android.ui.displayitems.ReblogOrReplyLineStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.StatusDisplayItem; import org.joinmastodon.android.ui.displayitems.TextStatusDisplayItem; +import org.joinmastodon.android.ui.displayitems.WarningFilteredStatusDisplayItem; import org.joinmastodon.android.ui.text.HtmlParser; import org.joinmastodon.android.ui.utils.UiUtils; +import org.joinmastodon.android.utils.ProvidesAssistContent; import org.joinmastodon.android.utils.StatusFilterPredicate; import org.parceler.Parcels; +import java.util.ArrayDeque; +import java.util.ArrayList; import java.util.Collections; +import java.util.Deque; +import java.util.HashMap; import java.util.List; +import java.util.Objects; +import java.util.Optional; import java.util.stream.Collectors; +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; import me.grishka.appkit.api.SimpleCallback; +import me.grishka.appkit.utils.V; -public class ThreadFragment extends StatusListFragment{ - private Status mainStatus; +public class ThreadFragment extends StatusListFragment implements ProvidesAssistContent { + protected Status mainStatus, updatedStatus; + private final HashMap ancestryMap = new HashMap<>(); + protected boolean contextInitiallyRendered; @Override public void onCreate(Bundle savedInstanceState){ @@ -43,13 +67,45 @@ public class ThreadFragment extends StatusListFragment{ @Override protected List buildDisplayItems(Status s){ List items=super.buildDisplayItems(s); - if(s.id.equals(mainStatus.id)){ - for(StatusDisplayItem item:items){ + // "what the fuck is a deque"? yes + // (it's just so the last-added item automatically comes first when looping over it) + Deque deleteTheseItems = new ArrayDeque<>(); + + // modifying hidden filtered items if status is displayed as a warning + List itemsToModify = + (items.get(0) instanceof WarningFilteredStatusDisplayItem warning) + ? warning.filteredItems + : items; + + for(int i = 0; i < itemsToModify.size(); i++){ + StatusDisplayItem item = itemsToModify.get(i); + NeighborAncestryInfo ancestryInfo = ancestryMap.get(s.id); + if (ancestryInfo != null) { + item.setAncestryInfo( + ancestryInfo.descendantNeighbor != null, + ancestryInfo.ancestoringNeighbor != null, + s.id.equals(mainStatus.id), + Optional.ofNullable(ancestryInfo.ancestoringNeighbor) + .map(ancestor -> ancestor.id.equals(mainStatus.id)) + .orElse(false) + ); + } + + if (item instanceof ReblogOrReplyLineStatusDisplayItem && + (!item.isDirectDescendant && item.hasAncestoringNeighbor)) { + deleteTheseItems.add(i); + } + + if(s.id.equals(mainStatus.id)){ if(item instanceof TextStatusDisplayItem text) text.textSelectable=true; else if(item instanceof FooterStatusDisplayItem footer) footer.hideCounts=true; } + } + + for (int deleteThisItem : deleteTheseItems) itemsToModify.remove(deleteThisItem); + if(s.id.equals(mainStatus.id)) { items.add(new ExtendedFooterStatusDisplayItem(s.id, this, accountID, s.getContentStatus())); } return items; @@ -57,19 +113,30 @@ public class ThreadFragment extends StatusListFragment{ @Override protected void doLoadData(int offset, int count){ + if (refreshing) loadMainStatus(); currentRequest=new GetStatusContext(mainStatus.id) .setCallback(new SimpleCallback<>(this){ @Override public void onSuccess(StatusContext result){ - if (getActivity() == null) return; + if (getContext() == null) return; if(refreshing){ data.clear(); + ancestryMap.clear(); displayItems.clear(); data.add(mainStatus); onAppendItems(Collections.singletonList(mainStatus)); } + + // TODO: figure out how this code works + if(isInstanceAkkoma()) sortStatusContext(mainStatus, result); + result.descendants=filterStatuses(result.descendants); result.ancestors=filterStatuses(result.ancestors); + + for (NeighborAncestryInfo i : mapNeighborhoodAncestry(mainStatus, result)) { + ancestryMap.put(i.status.id, i); + } + if(footerProgress!=null) footerProgress.setVisibility(View.GONE); data.addAll(result.descendants); @@ -78,20 +145,139 @@ public class ThreadFragment extends StatusListFragment{ int count=displayItems.size(); if(!refreshing) adapter.notifyItemRangeInserted(prevCount, count-prevCount); - prependItems(result.ancestors, !refreshing); + int prependedCount = prependItems(result.ancestors, !refreshing); + if (prependedCount > 0 && displayItems.get(prependedCount) instanceof ReblogOrReplyLineStatusDisplayItem) { + displayItems.remove(prependedCount); + adapter.notifyItemRemoved(prependedCount); + count--; + } dataLoaded(); if(refreshing){ refreshDone(); adapter.notifyDataSetChanged(); } list.scrollToPosition(displayItems.size()-count); + + // no animation is going to happen, so proceeding to apply right now + if (data.size() == 1) { + contextInitiallyRendered = true; + // for the case that the main status has already finished loading + maybeApplyMainStatus(); + } } }) .exec(accountID); } + private void loadMainStatus() { + new GetStatusByID(mainStatus.id) + .setCallback(new Callback<>() { + @Override + public void onSuccess(Status status) { + if (getContext() == null || status == null) return; + updatedStatus = status; + // for the case that the context has already loaded (and the animation has + // already finished), falling back to applying it ourselves: + maybeApplyMainStatus(); + } + + @Override + public void onError(ErrorResponse error) {} + }).exec(accountID); + } + + protected Object maybeApplyMainStatus() { + if (updatedStatus == null || !contextInitiallyRendered) return null; + + // returning fired event object to facilitate testing + Object event; + if (updatedStatus.editedAt != null && + (mainStatus.editedAt == null || + updatedStatus.editedAt.isAfter(mainStatus.editedAt))) { + event = new StatusUpdatedEvent(updatedStatus); + } else { + event = new StatusCountersUpdatedEvent(updatedStatus); + } + + mainStatus = updatedStatus; + updatedStatus = null; + E.post(event); + return event; + } + + public static List mapNeighborhoodAncestry(Status mainStatus, StatusContext context) { + List ancestry = new ArrayList<>(); + + List statuses = new ArrayList<>(context.ancestors); + statuses.add(mainStatus); + statuses.addAll(context.descendants); + + int count = statuses.size(); + for (int index = 0; index < count; index++) { + Status current = statuses.get(index); + ancestry.add(new NeighborAncestryInfo( + current, + // descendant neighbor + Optional + .ofNullable(count > index + 1 ? statuses.get(index + 1) : null) + .filter(s -> s.inReplyToId.equals(current.id)) + .orElse(null), + // ancestoring neighbor + Optional.ofNullable(index > 0 ? ancestry.get(index - 1) : null) + .filter(ancestor -> Optional.ofNullable(ancestor.descendantNeighbor) + .map(ancestorsDescendant -> ancestorsDescendant.id.equals(current.id)) + .orElse(false)) + .map(a -> a.status) + .orElse(null) + )); + } + + return ancestry; + } + + public static void sortStatusContext(Status mainStatus, StatusContext context) { + List threadIds=new ArrayList<>(); + threadIds.add(mainStatus.id); + for(Status s:context.descendants){ + if(threadIds.contains(s.inReplyToId)){ + threadIds.add(s.id); + } + } + threadIds.add(mainStatus.inReplyToId); + for(int i=context.ancestors.size()-1; i >= 0; i--){ + Status s=context.ancestors.get(i); + if(s.inReplyToId != null && threadIds.contains(s.id)){ + threadIds.add(s.inReplyToId); + } + } + + context.ancestors=context.ancestors.stream().filter(s -> threadIds.contains(s.id)).collect(Collectors.toList()); + context.descendants=getDescendantsOrdered(mainStatus.id, + context.descendants.stream() + .filter(s -> threadIds.contains(s.id)) + .collect(Collectors.toList())); + } + + private static List getDescendantsOrdered(String id, List statuses){ + List out=new ArrayList<>(); + for(Status s:getDirectDescendants(id, statuses)){ + out.add(s); + getDirectDescendants(s.id, statuses).forEach(d ->{ + out.add(d); + out.addAll(getDescendantsOrdered(d.id, statuses)); + }); + } + return out; + } + + private static List getDirectDescendants(String id, List statuses){ + return statuses.stream() + .filter(s -> s.inReplyToId.equals(id)) + .collect(Collectors.toList()); + } + private List filterStatuses(List statuses){ - StatusFilterPredicate statusFilterPredicate=new StatusFilterPredicate(accountID,Filter.FilterContext.THREAD); + StatusFilterPredicate statusFilterPredicate=new StatusFilterPredicate(accountID,getFilterContext()); return statuses.stream() .filter(statusFilterPredicate) .collect(Collectors.toList()); @@ -113,10 +299,21 @@ public class ThreadFragment extends StatusListFragment{ showContent(); if(!loaded) footerProgress.setVisibility(View.VISIBLE); + + list.setItemAnimator(new BetterItemAnimator() { + @Override + public void onAnimationFinished(@NonNull RecyclerView.ViewHolder viewHolder) { + super.onAnimationFinished(viewHolder); + contextInitiallyRendered = true; + // for the case that both requests are already done (and thus won't apply it) + maybeApplyMainStatus(); + } + }); } protected void onStatusCreated(StatusCreatedEvent ev){ if(ev.status.inReplyToId!=null && getStatusByID(ev.status.inReplyToId)!=null){ + data.add(ev.status); onAppendItems(Collections.singletonList(ev.status)); } } @@ -135,4 +332,52 @@ public class ThreadFragment extends StatusListFragment{ public boolean wantsLightNavigationBar(){ return !UiUtils.isDarkTheme(); } + + + @Override + protected Filter.FilterContext getFilterContext() { + return Filter.FilterContext.THREAD; + } + + @Override + public Uri getWebUri(Uri.Builder base) { + return Uri.parse(mainStatus.url); + } + + protected static class NeighborAncestryInfo { + protected Status status, descendantNeighbor, ancestoringNeighbor; + + protected NeighborAncestryInfo(@NonNull Status status, Status descendantNeighbor, Status ancestoringNeighbor) { + this.status = status; + this.descendantNeighbor = descendantNeighbor; + this.ancestoringNeighbor = ancestoringNeighbor; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + NeighborAncestryInfo that = (NeighborAncestryInfo) o; + return status.equals(that.status) + && Objects.equals(descendantNeighbor, that.descendantNeighbor) + && Objects.equals(ancestoringNeighbor, that.ancestoringNeighbor); + } + + @Override + public int hashCode() { + return Objects.hash(status, descendantNeighbor, ancestoringNeighbor); + } + } + + @Override + protected void onErrorRetryClick(){ + if(preloadingFailed){ + preloadingFailed=false; + V.setVisibilityAnimated(footerProgress, View.VISIBLE); + V.setVisibilityAnimated(footerError, View.GONE); + doLoadData(); + return; + } + super.onErrorRetryClick(); + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/AccountRelatedAccountListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/AccountRelatedAccountListFragment.java index 8277a5b41..43ad6ffa4 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/AccountRelatedAccountListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/AccountRelatedAccountListFragment.java @@ -1,17 +1,68 @@ package org.joinmastodon.android.fragments.account_list; +import android.net.Uri; import android.os.Bundle; +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.api.requests.accounts.GetAccountByHandle; +import org.joinmastodon.android.api.session.AccountSession; +import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.model.Account; import org.parceler.Parcels; -public abstract class AccountRelatedAccountListFragment extends PaginatedAccountListFragment{ +import java.util.Optional; + +public abstract class AccountRelatedAccountListFragment extends PaginatedAccountListFragment { protected Account account; + protected String initialSubtitle = ""; @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); account=Parcels.unwrap(getArguments().getParcelable("targetAccount")); + if (getArguments().containsKey("remoteAccount")) { + remoteInfo = Parcels.unwrap(getArguments().getParcelable("remoteAccount")); + } setTitle("@"+account.acct); } + + @Override + public Uri getWebUri(Uri.Builder base) { + return base.path(isInstanceAkkoma() + ? "/users/" + account.id + : '@' + account.acct).build(); + } + + @Override + public String getRemoteDomain() { + return account.getDomainFromURL(); + } + + @Override + public Account getCurrentInfo() { + return doneWithHomeInstance && remoteInfo != null ? remoteInfo : account; + } + + @Override + protected MastodonAPIRequest loadRemoteInfo() { + return new GetAccountByHandle(account.acct); + } + + @Override + protected AccountSession getRemoteSession() { + return Optional.ofNullable(remoteInfo) + .map(AccountSessionManager.getInstance()::tryGetAccount) + .orElse(null); + } + + @Override + protected void onRemoteLoadingFailed() { + super.onRemoteLoadingFailed(); + String prefix = initialSubtitle == null ? "" : + initialSubtitle + " " + getContext().getString(R.string.sk_separator) + " "; + String str = prefix + + getContext().getString(R.string.sk_no_remote_info_hint, getSession().domain); + setSubtitle(str); + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/BaseAccountListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/BaseAccountListFragment.java index aea419590..fb4b95556 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/BaseAccountListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/BaseAccountListFragment.java @@ -1,6 +1,8 @@ package org.joinmastodon.android.fragments.account_list; +import android.annotation.SuppressLint; import android.app.ProgressDialog; +import android.app.assist.AssistContent; import android.content.Intent; import android.content.res.Configuration; import android.graphics.drawable.Animatable; @@ -23,8 +25,10 @@ import org.joinmastodon.android.R; import org.joinmastodon.android.api.requests.accounts.GetAccountRelationships; import org.joinmastodon.android.api.requests.accounts.SetAccountFollowed; import org.joinmastodon.android.api.session.AccountSessionManager; -import org.joinmastodon.android.fragments.ListTimelinesFragment; +import org.joinmastodon.android.fragments.HasAccountID; +import org.joinmastodon.android.fragments.ListsFragment; import org.joinmastodon.android.fragments.ProfileFragment; +import org.joinmastodon.android.fragments.RecyclerFragment; import org.joinmastodon.android.fragments.report.ReportReasonChoiceFragment; import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.Relationship; @@ -33,6 +37,7 @@ import org.joinmastodon.android.ui.OutlineProviders; import org.joinmastodon.android.ui.text.HtmlParser; import org.joinmastodon.android.ui.utils.CustomEmojiHelper; import org.joinmastodon.android.ui.utils.UiUtils; +import org.joinmastodon.android.utils.ProvidesAssistContent; import org.parceler.Parcels; import java.util.ArrayList; @@ -43,12 +48,12 @@ import java.util.stream.Collectors; import androidx.annotation.CallSuper; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.recyclerview.widget.RecyclerView; import me.grishka.appkit.Nav; import me.grishka.appkit.api.APIRequest; import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.ErrorResponse; -import me.grishka.appkit.fragments.BaseRecyclerFragment; import me.grishka.appkit.imageloader.ImageLoaderRecyclerAdapter; import me.grishka.appkit.imageloader.ImageLoaderViewHolder; import me.grishka.appkit.imageloader.requests.ImageLoaderRequest; @@ -57,7 +62,7 @@ import me.grishka.appkit.utils.BindableViewHolder; import me.grishka.appkit.utils.V; import me.grishka.appkit.views.UsableRecyclerView; -public abstract class BaseAccountListFragment extends BaseRecyclerFragment{ +public abstract class BaseAccountListFragment extends RecyclerFragment implements ProvidesAssistContent.ProvidesWebUri { protected HashMap relationships=new HashMap<>(); protected String accountID; protected ArrayList> relationshipsRequests=new ArrayList<>(); @@ -129,7 +134,8 @@ public abstract class BaseAccountListFragment extends BaseRecyclerFragment implements ImageLoaderRecyclerAdapter{ public AccountsAdapter(){ super(imgLoader); @@ -229,16 +245,19 @@ public abstract class BaseAccountListFragment extends BaseRecyclerFragment onCreateRequest(String maxID, int count){ - return new GetAccountFollowers(account.id, maxID, count); + return new GetAccountFollowers(getCurrentInfo().id, maxID, count); + } + + @Override + public Uri getWebUri(Uri.Builder base) { + return super.getWebUri(base).buildUpon() + .appendPath(isInstanceAkkoma() ? "#followers" : "/followers").build(); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/FollowingListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/FollowingListFragment.java index 83351e751..0e1c2bacb 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/FollowingListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/FollowingListFragment.java @@ -1,5 +1,6 @@ package org.joinmastodon.android.fragments.account_list; +import android.net.Uri; import android.os.Bundle; import org.joinmastodon.android.R; @@ -12,11 +13,17 @@ public class FollowingListFragment extends AccountRelatedAccountListFragment{ @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); - setSubtitle(getResources().getQuantityString(R.plurals.x_following, (int)(account.followingCount%1000), account.followingCount)); + setSubtitle(initialSubtitle = getResources().getQuantityString(R.plurals.x_following, (int)(account.followingCount%1000), account.followingCount)); } @Override public HeaderPaginationRequest onCreateRequest(String maxID, int count){ - return new GetAccountFollowing(account.id, maxID, count); + return new GetAccountFollowing(getCurrentInfo().id, maxID, count); + } + + @Override + public Uri getWebUri(Uri.Builder base) { + return super.getWebUri(base).buildUpon() + .appendPath(isInstanceAkkoma() ? "#followees" : "/following").build(); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/PaginatedAccountListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/PaginatedAccountListFragment.java index 5b34019f1..f6e77efe8 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/PaginatedAccountListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/PaginatedAccountListFragment.java @@ -1,33 +1,173 @@ package org.joinmastodon.android.fragments.account_list; +import android.os.Bundle; +import android.view.View; + +import org.joinmastodon.android.GlobalUserPreferences; +import org.joinmastodon.android.api.MastodonAPIRequest; import org.joinmastodon.android.api.requests.HeaderPaginationRequest; +import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.HeaderPaginationList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; import java.util.stream.Collectors; +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; import me.grishka.appkit.api.SimpleCallback; -public abstract class PaginatedAccountListFragment extends BaseAccountListFragment{ +public abstract class PaginatedAccountListFragment extends BaseAccountListFragment{ private String nextMaxID; + private MastodonAPIRequest remoteInfoRequest; + protected boolean doneWithHomeInstance, remoteRequestFailed, startedRemoteLoading, remoteDisabled; + protected int localOffset; + protected T remoteInfo; public abstract HeaderPaginationRequest onCreateRequest(String maxID, int count); + protected abstract MastodonAPIRequest loadRemoteInfo(); + public abstract T getCurrentInfo(); + public abstract String getRemoteDomain(); + + @Override + public void onViewCreated(View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + // already have remote info (e.g. from arguments), so no need to fetch it again + if (remoteInfo != null) { + onRemoteInfoLoaded(remoteInfo); + return; + } + + remoteDisabled = !GlobalUserPreferences.allowRemoteLoading + || getSession().domain.equals(getRemoteDomain()); + if (!remoteDisabled) { + remoteInfoRequest = loadRemoteInfo().setCallback(new Callback<>() { + @Override + public void onSuccess(T result) { + if (getContext() == null) return; + onRemoteInfoLoaded(result); + } + + @Override + public void onError(ErrorResponse error) { + if (getContext() == null) return; + onRemoteLoadingFailed(); + } + }); + remoteInfoRequest.execRemote(getRemoteDomain(), getRemoteSession()); + } + } + + /** + * override to provide an ideal account session (e.g. if you're logged into the author's remote + * account) to make the remote request from. if null is provided, will try to get any session + * on the remote domain, or tries the request without authentication. + */ + protected AccountSession getRemoteSession() { + return null; + } + + protected void onRemoteInfoLoaded(T info) { + this.remoteInfo = info; + this.remoteInfoRequest = null; + maybeStartLoadingRemote(); + } + + protected void onRemoteLoadingFailed() { + this.remoteRequestFailed = true; + this.remoteInfo = null; + this.remoteInfoRequest = null; + if (doneWithHomeInstance) dataLoaded(); + } + + @Override + public void dataLoaded() { + super.dataLoaded(); + footerProgress.setVisibility(View.GONE); + } + + private void maybeStartLoadingRemote() { + if (startedRemoteLoading || remoteDisabled) return; + if (!remoteRequestFailed) { + if (data.size() == 0) showProgress(); + else footerProgress.setVisibility(View.VISIBLE); + } + if (doneWithHomeInstance && remoteInfo != null) { + startedRemoteLoading = true; + loadData(localOffset, itemsPerPage * 2); + } + } + + @Override + public void onRefresh() { + localOffset = 0; + doneWithHomeInstance = false; + startedRemoteLoading = false; + super.onRefresh(); + } + + @Override + public void loadData(int offset, int count) { + // always subtract the amount loaded through the home instance once loading from remote + // since loadData gets called with data.size() (data includes both local and remote) + if (doneWithHomeInstance) offset -= localOffset; + super.loadData(offset, count); + } + @Override protected void doLoadData(int offset, int count){ - currentRequest=onCreateRequest(offset==0 ? null : nextMaxID, count) + MastodonAPIRequest request = onCreateRequest(offset==0 ? null : nextMaxID, count) .setCallback(new SimpleCallback<>(this){ @Override public void onSuccess(HeaderPaginationList result){ + boolean justRefreshed = !doneWithHomeInstance && offset == 0; + Collection d = justRefreshed ? List.of() : data; + if(result.nextPageUri!=null) nextMaxID=result.nextPageUri.getQueryParameter("max_id"); else nextMaxID=null; if (getActivity() == null) return; - onDataLoaded(result.stream().map(AccountItem::new).collect(Collectors.toList()), nextMaxID!=null); + List items = result.stream() + .filter(a -> d.size() > 1000 || d.stream() + .noneMatch(i -> i.account.url.equals(a.url))) + .map(AccountItem::new) + .collect(Collectors.toList()); + + boolean hasMore = nextMaxID != null; + + if (!hasMore && !doneWithHomeInstance) { + // only runs last time data was fetched from the home instance + localOffset = d.size() + items.size(); + doneWithHomeInstance = true; + } + + onDataLoaded(items, hasMore); + if (doneWithHomeInstance) maybeStartLoadingRemote(); } - }) - .exec(accountID); + + @Override + public void onError(ErrorResponse error) { + if (doneWithHomeInstance) { + onRemoteLoadingFailed(); + onDataLoaded(Collections.emptyList(), false); + return; + } + super.onError(error); + } + }); + + if (doneWithHomeInstance && remoteInfo == null) return; // we are waiting + if (doneWithHomeInstance && remoteInfo != null) { + request.execRemote(getRemoteDomain(), getRemoteSession()); + } else { + request.exec(accountID); + } + currentRequest = request; } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/StatusFavoritesListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/StatusFavoritesListFragment.java index f62e40ac5..20d0c2acf 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/StatusFavoritesListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/StatusFavoritesListFragment.java @@ -1,21 +1,36 @@ package org.joinmastodon.android.fragments.account_list; +import android.net.Uri; import android.os.Bundle; import org.joinmastodon.android.R; import org.joinmastodon.android.api.requests.HeaderPaginationRequest; import org.joinmastodon.android.api.requests.statuses.GetStatusFavorites; import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.model.Status; public class StatusFavoritesListFragment extends StatusRelatedAccountListFragment{ @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); + updateTitle(status); + } + + @Override + protected void updateTitle(Status status) { setTitle(getResources().getQuantityString(R.plurals.x_favorites, (int)(status.favouritesCount%1000), status.favouritesCount)); } @Override public HeaderPaginationRequest onCreateRequest(String maxID, int count){ - return new GetStatusFavorites(status.id, maxID, count); + return new GetStatusFavorites(getCurrentInfo().id, maxID, count); + } + + @Override + public Uri getWebUri(Uri.Builder base) { + Uri statusUri = super.getWebUri(base); + return isInstanceAkkoma() + ? statusUri + : statusUri.buildUpon().appendPath("favourites").build(); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/StatusReblogsListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/StatusReblogsListFragment.java index 6d494e198..3c5a5e228 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/StatusReblogsListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/StatusReblogsListFragment.java @@ -1,21 +1,36 @@ package org.joinmastodon.android.fragments.account_list; +import android.net.Uri; import android.os.Bundle; import org.joinmastodon.android.R; import org.joinmastodon.android.api.requests.HeaderPaginationRequest; import org.joinmastodon.android.api.requests.statuses.GetStatusReblogs; import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.model.Status; public class StatusReblogsListFragment extends StatusRelatedAccountListFragment{ @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); + updateTitle(status); + } + + @Override + protected void updateTitle(Status status) { setTitle(getResources().getQuantityString(R.plurals.x_reblogs, (int)(status.reblogsCount%1000), status.reblogsCount)); } @Override public HeaderPaginationRequest onCreateRequest(String maxID, int count){ - return new GetStatusReblogs(status.id, maxID, count); + return new GetStatusReblogs(getCurrentInfo().id, maxID, count); + } + + @Override + public Uri getWebUri(Uri.Builder base) { + Uri statusUri = super.getWebUri(base); + return isInstanceAkkoma() + ? statusUri + : statusUri.buildUpon().appendPath("reblogs").build(); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/StatusRelatedAccountListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/StatusRelatedAccountListFragment.java index db54c4850..aeee1fdc9 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/StatusRelatedAccountListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/StatusRelatedAccountListFragment.java @@ -1,13 +1,29 @@ package org.joinmastodon.android.fragments.account_list; +import android.net.Uri; import android.os.Bundle; +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.api.requests.statuses.GetStatusByID; +import org.joinmastodon.android.api.session.AccountSession; +import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.model.Status; import org.parceler.Parcels; -public abstract class StatusRelatedAccountListFragment extends PaginatedAccountListFragment{ +import java.util.Optional; + +public abstract class StatusRelatedAccountListFragment extends PaginatedAccountListFragment { protected Status status; + protected abstract void updateTitle(Status status); + + protected MastodonAPIRequest loadRemoteInfo() { + String[] parts = status.url.split("/"); + if (parts.length == 0) return null; + return new GetStatusByID(parts[parts.length - 1]); + } + @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); @@ -16,6 +32,46 @@ public abstract class StatusRelatedAccountListFragment extends PaginatedAccountL @Override protected boolean hasSubtitle(){ - return false; + return remoteRequestFailed; + } + + @Override + public Uri getWebUri(Uri.Builder base) { + return base + .encodedPath(isInstanceAkkoma() + ? "/notice/" + status.id + : '@' + status.account.acct + '/' + status.id) + .build(); + } + + @Override + public String getRemoteDomain() { + return Uri.parse(status.url).getHost(); + } + + @Override + public Status getCurrentInfo() { + return doneWithHomeInstance && remoteInfo != null ? remoteInfo : status; + } + + @Override + protected AccountSession getRemoteSession() { + return Optional.ofNullable(remoteInfo) + .map(s -> s.account) + .map(AccountSessionManager.getInstance()::tryGetAccount) + .orElse(null); + } + + @Override + protected void onRemoteInfoLoaded(Status info) { + super.onRemoteInfoLoaded(info); + updateTitle(remoteInfo); + } + + @Override + protected void onRemoteLoadingFailed() { + super.onRemoteLoadingFailed(); + setSubtitle(getContext().getString(R.string.sk_no_remote_info_hint, getSession().domain)); + updateToolbar(); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/BubbleTimelineFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/BubbleTimelineFragment.java new file mode 100644 index 000000000..3dee85c25 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/BubbleTimelineFragment.java @@ -0,0 +1,61 @@ +package org.joinmastodon.android.fragments.discover; + +import android.net.Uri; +import android.os.Bundle; +import android.view.View; + +import org.joinmastodon.android.api.requests.timelines.GetBubbleTimeline; +import org.joinmastodon.android.api.requests.timelines.GetPublicTimeline; +import org.joinmastodon.android.fragments.StatusListFragment; +import org.joinmastodon.android.model.Filter; +import org.joinmastodon.android.model.Status; +import org.joinmastodon.android.ui.utils.DiscoverInfoBannerHelper; +import org.joinmastodon.android.utils.StatusFilterPredicate; + +import java.util.List; +import java.util.stream.Collectors; + +import me.grishka.appkit.api.SimpleCallback; + +public class BubbleTimelineFragment extends StatusListFragment { + private DiscoverInfoBannerHelper bannerHelper=new DiscoverInfoBannerHelper(DiscoverInfoBannerHelper.BannerType.BUBBLE_TIMELINE); + private String maxID; + + @Override + protected boolean wantsComposeButton() { + return true; + } + + + @Override + protected void doLoadData(int offset, int count){ + currentRequest=new GetBubbleTimeline(refreshing ? null : maxID, count) + .setCallback(new SimpleCallback<>(this){ + @Override + public void onSuccess(List result){ + if(!result.isEmpty()) + maxID=result.get(result.size()-1).id; + if (getActivity() == null) return; + result=result.stream().filter(new StatusFilterPredicate(accountID, getFilterContext())).collect(Collectors.toList()); + onDataLoaded(result, !result.isEmpty()); + } + }) + .exec(accountID); + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState){ + super.onViewCreated(view, savedInstanceState); + bannerHelper.maybeAddBanner(contentWrap); + } + + @Override + protected Filter.FilterContext getFilterContext() { + return Filter.FilterContext.PUBLIC; + } + + @Override + public Uri getWebUri(Uri.Builder base) { + return isInstanceAkkoma() ? base.path("/main/bubble").build() : null; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverAccountsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverAccountsFragment.java index c89c5c0b1..524dce2a5 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverAccountsFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverAccountsFragment.java @@ -3,6 +3,7 @@ package org.joinmastodon.android.fragments.discover; import android.graphics.Rect; import android.graphics.drawable.Animatable; import android.graphics.drawable.Drawable; +import android.net.Uri; import android.os.Bundle; import android.text.SpannableStringBuilder; import android.text.TextUtils; @@ -17,6 +18,7 @@ import org.joinmastodon.android.api.requests.accounts.GetAccountRelationships; import org.joinmastodon.android.api.requests.accounts.GetFollowSuggestions; import org.joinmastodon.android.fragments.IsOnTop; import org.joinmastodon.android.fragments.ProfileFragment; +import org.joinmastodon.android.fragments.RecyclerFragment; import org.joinmastodon.android.fragments.ScrollableToTop; import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.FollowSuggestion; @@ -26,6 +28,7 @@ import org.joinmastodon.android.ui.text.HtmlParser; import org.joinmastodon.android.ui.utils.CustomEmojiHelper; import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.ui.views.ProgressBarButton; +import org.joinmastodon.android.utils.ProvidesAssistContent; import org.parceler.Parcels; import java.util.Collections; @@ -40,7 +43,6 @@ import me.grishka.appkit.Nav; import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.ErrorResponse; import me.grishka.appkit.api.SimpleCallback; -import me.grishka.appkit.fragments.BaseRecyclerFragment; import me.grishka.appkit.imageloader.ImageLoaderRecyclerAdapter; import me.grishka.appkit.imageloader.ImageLoaderViewHolder; import me.grishka.appkit.imageloader.requests.ImageLoaderRequest; @@ -49,7 +51,7 @@ import me.grishka.appkit.utils.BindableViewHolder; import me.grishka.appkit.utils.V; import me.grishka.appkit.views.UsableRecyclerView; -public class DiscoverAccountsFragment extends BaseRecyclerFragment implements ScrollableToTop, IsOnTop { +public class DiscoverAccountsFragment extends RecyclerFragment implements ScrollableToTop, IsOnTop, ProvidesAssistContent.ProvidesWebUri { private String accountID; private Map relationships=Collections.emptyMap(); private GetAccountRelationships relationshipsRequest; @@ -145,6 +147,16 @@ public class DiscoverAccountsFragment extends BaseRecyclerFragment implements ImageLoaderRecyclerAdapter{ public AccountsAdapter(){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverFragment.java index 67f7b7a69..35717e7fa 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverFragment.java @@ -1,6 +1,7 @@ package org.joinmastodon.android.fragments.discover; import android.app.Fragment; +import android.app.assist.AssistContent; import android.os.Build; import android.os.Bundle; import android.text.Editable; @@ -19,12 +20,14 @@ import android.widget.TextView; import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.R; +import org.joinmastodon.android.fragments.HomeFragment; import org.joinmastodon.android.fragments.IsOnTop; import org.joinmastodon.android.fragments.ScrollableToTop; import org.joinmastodon.android.ui.SimpleViewHolder; import org.joinmastodon.android.ui.tabs.TabLayout; import org.joinmastodon.android.ui.tabs.TabLayoutMediator; import org.joinmastodon.android.ui.utils.UiUtils; +import org.joinmastodon.android.utils.ProvidesAssistContent; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -36,7 +39,7 @@ import me.grishka.appkit.fragments.BaseRecyclerFragment; import me.grishka.appkit.fragments.OnBackPressedListener; import me.grishka.appkit.utils.V; -public class DiscoverFragment extends AppKitFragment implements ScrollableToTop, OnBackPressedListener, IsOnTop { +public class DiscoverFragment extends AppKitFragment implements ScrollableToTop, OnBackPressedListener, IsOnTop, ProvidesAssistContent { private TabLayout tabLayout; private ViewPager2 pager; @@ -49,7 +52,7 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop, private ProgressBar searchProgress; private DiscoverPostsFragment postsFragment; - private TrendingHashtagsFragment hashtagsFragment; + private DiscoverHashtagsFragment hashtagsFragment; private DiscoverNewsFragment newsFragment; private DiscoverAccountsFragment accountsFragment; private SearchFragment searchFragment; @@ -117,7 +120,7 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop, postsFragment=new DiscoverPostsFragment(); postsFragment.setArguments(args); - hashtagsFragment=new TrendingHashtagsFragment(); + hashtagsFragment=new DiscoverHashtagsFragment(); hashtagsFragment.setArguments(args); newsFragment=new DiscoverNewsFragment(); @@ -238,7 +241,7 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop, else scrollToTop(); } - private void selectSearch() { + public void selectSearch() { searchEdit.requestFocus(); onSearchEditFocusChanged(searchEdit, true); getActivity().getSystemService(InputMethodManager.class).showSoftInput(searchEdit, 0); @@ -272,6 +275,8 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop, searchBack.setEnabled(false); searchBack.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO); getActivity().getSystemService(InputMethodManager.class).hideSoftInputFromWindow(searchEdit.getWindowToken(), 0); + if (getArguments().getBoolean("disableDiscover")) + ((HomeFragment) getParentFragment()).onBackPressed(); } @Override @@ -318,6 +323,13 @@ public class DiscoverFragment extends AppKitFragment implements ScrollableToTop, V.setVisibilityAnimated(searchClear, visible ? View.INVISIBLE : View.VISIBLE); } + @Override + public void onProvideAssistContent(AssistContent assistContent) { + callFragmentToProvideAssistContent(searchActive + ? searchFragment + : getFragmentForPage(pager.getCurrentItem()), assistContent); + } + private class DiscoverPagerAdapter extends RecyclerView.Adapter{ @NonNull @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/TrendingHashtagsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverHashtagsFragment.java similarity index 77% rename from mastodon/src/main/java/org/joinmastodon/android/fragments/discover/TrendingHashtagsFragment.java rename to mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverHashtagsFragment.java index 01c98c615..aedd41e6e 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/TrendingHashtagsFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverHashtagsFragment.java @@ -1,5 +1,9 @@ package org.joinmastodon.android.fragments.discover; +import static org.joinmastodon.android.ui.displayitems.HashtagStatusDisplayItem.Holder.withHistoryParams; +import static org.joinmastodon.android.ui.displayitems.HashtagStatusDisplayItem.Holder.withoutHistoryParams; + +import android.net.Uri; import android.os.Bundle; import android.view.View; import android.view.ViewGroup; @@ -8,27 +12,28 @@ import android.widget.TextView; import org.joinmastodon.android.R; import org.joinmastodon.android.api.requests.trends.GetTrendingHashtags; import org.joinmastodon.android.fragments.IsOnTop; +import org.joinmastodon.android.fragments.RecyclerFragment; import org.joinmastodon.android.fragments.ScrollableToTop; import org.joinmastodon.android.model.Hashtag; import org.joinmastodon.android.ui.DividerItemDecoration; import org.joinmastodon.android.ui.utils.DiscoverInfoBannerHelper; import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.ui.views.HashtagChartView; +import org.joinmastodon.android.utils.ProvidesAssistContent; import java.util.List; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; import me.grishka.appkit.api.SimpleCallback; -import me.grishka.appkit.fragments.BaseRecyclerFragment; import me.grishka.appkit.utils.BindableViewHolder; import me.grishka.appkit.views.UsableRecyclerView; -public class TrendingHashtagsFragment extends BaseRecyclerFragment implements ScrollableToTop, IsOnTop { +public class DiscoverHashtagsFragment extends RecyclerFragment implements ScrollableToTop, IsOnTop, ProvidesAssistContent.ProvidesWebUri { private String accountID; private DiscoverInfoBannerHelper bannerHelper=new DiscoverInfoBannerHelper(DiscoverInfoBannerHelper.BannerType.TRENDING_HASHTAGS); - public TrendingHashtagsFragment(){ + public DiscoverHashtagsFragment(){ super(10); } @@ -73,6 +78,16 @@ public class TrendingHashtagsFragment extends BaseRecyclerFragment impl return isRecyclerViewOnTop(list); } + @Override + public String getAccountID() { + return accountID; + } + + @Override + public Uri getWebUri(Uri.Builder base) { + return isInstanceAkkoma() ? null : base.path("/explore/tags").build(); + } + private class HashtagsAdapter extends RecyclerView.Adapter{ @NonNull @Override @@ -105,6 +120,14 @@ public class TrendingHashtagsFragment extends BaseRecyclerFragment impl @Override public void onBind(Hashtag item){ title.setText('#'+item.name); + if (item.history == null || item.history.isEmpty()) { + subtitle.setText(null); + chart.setVisibility(View.GONE); + title.setLayoutParams(withoutHistoryParams); + return; + } + chart.setVisibility(View.VISIBLE); + title.setLayoutParams(withHistoryParams); int numPeople=item.history.get(0).accounts; if(item.history.size()>1) numPeople+=item.history.get(1).accounts; diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverNewsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverNewsFragment.java index 10a3a77dd..67e030514 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverNewsFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverNewsFragment.java @@ -1,6 +1,7 @@ package org.joinmastodon.android.fragments.discover; import android.graphics.drawable.Drawable; +import android.net.Uri; import android.os.Bundle; import android.text.TextUtils; import android.view.View; @@ -11,6 +12,7 @@ import android.widget.TextView; import org.joinmastodon.android.R; import org.joinmastodon.android.api.requests.trends.GetTrendingLinks; import org.joinmastodon.android.fragments.IsOnTop; +import org.joinmastodon.android.fragments.RecyclerFragment; import org.joinmastodon.android.fragments.ScrollableToTop; import org.joinmastodon.android.model.Card; import org.joinmastodon.android.ui.DividerItemDecoration; @@ -18,6 +20,7 @@ import org.joinmastodon.android.ui.OutlineProviders; import org.joinmastodon.android.ui.drawables.BlurhashCrossfadeDrawable; import org.joinmastodon.android.ui.utils.DiscoverInfoBannerHelper; import org.joinmastodon.android.ui.utils.UiUtils; +import org.joinmastodon.android.utils.ProvidesAssistContent; import java.util.Collections; import java.util.List; @@ -26,7 +29,6 @@ import java.util.stream.Collectors; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; import me.grishka.appkit.api.SimpleCallback; -import me.grishka.appkit.fragments.BaseRecyclerFragment; import me.grishka.appkit.imageloader.ImageLoaderRecyclerAdapter; import me.grishka.appkit.imageloader.ImageLoaderViewHolder; import me.grishka.appkit.imageloader.requests.ImageLoaderRequest; @@ -35,7 +37,7 @@ import me.grishka.appkit.utils.BindableViewHolder; import me.grishka.appkit.utils.V; import me.grishka.appkit.views.UsableRecyclerView; -public class DiscoverNewsFragment extends BaseRecyclerFragment implements ScrollableToTop, IsOnTop { +public class DiscoverNewsFragment extends RecyclerFragment implements ScrollableToTop, IsOnTop, ProvidesAssistContent.ProvidesWebUri { private String accountID; private List imageRequests=Collections.emptyList(); private DiscoverInfoBannerHelper bannerHelper=new DiscoverInfoBannerHelper(DiscoverInfoBannerHelper.BannerType.TRENDING_LINKS); @@ -88,6 +90,16 @@ public class DiscoverNewsFragment extends BaseRecyclerFragment implements return isRecyclerViewOnTop(list); } + @Override + public String getAccountID() { + return accountID; + } + + @Override + public Uri getWebUri(Uri.Builder base) { + return isInstanceAkkoma() ? null : base.path("/explore/links").build(); + } + private class LinksAdapter extends UsableRecyclerView.Adapter implements ImageLoaderRecyclerAdapter{ public LinksAdapter(){ super(imgLoader); diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverPostsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverPostsFragment.java index 8bfb49ce0..4130b16a3 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverPostsFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/DiscoverPostsFragment.java @@ -1,19 +1,22 @@ package org.joinmastodon.android.fragments.discover; +import android.net.Uri; import android.os.Bundle; import android.view.View; import org.joinmastodon.android.api.requests.trends.GetTrendingStatuses; -import org.joinmastodon.android.fragments.IsOnTop; import org.joinmastodon.android.fragments.StatusListFragment; +import org.joinmastodon.android.model.Filter; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.ui.utils.DiscoverInfoBannerHelper; +import org.joinmastodon.android.utils.StatusFilterPredicate; import java.util.List; +import java.util.stream.Collectors; import me.grishka.appkit.api.SimpleCallback; -public class DiscoverPostsFragment extends StatusListFragment implements IsOnTop { +public class DiscoverPostsFragment extends StatusListFragment { private DiscoverInfoBannerHelper bannerHelper=new DiscoverInfoBannerHelper(DiscoverInfoBannerHelper.BannerType.TRENDING_POSTS); @Override @@ -23,6 +26,7 @@ public class DiscoverPostsFragment extends StatusListFragment implements IsOnTop @Override public void onSuccess(List result){ if (getActivity() == null) return; + result=result.stream().filter(new StatusFilterPredicate(accountID, getFilterContext())).collect(Collectors.toList()); onDataLoaded(result, !result.isEmpty()); } }).exec(accountID); @@ -35,7 +39,12 @@ public class DiscoverPostsFragment extends StatusListFragment implements IsOnTop } @Override - public boolean isOnTop() { - return isRecyclerViewOnTop(list); + protected Filter.FilterContext getFilterContext() { + return Filter.FilterContext.PUBLIC; + } + + @Override + public Uri getWebUri(Uri.Builder base) { + return isInstanceAkkoma() ? null : base.path("/explore/posts").build(); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/FederatedTimelineFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/FederatedTimelineFragment.java index 9231af423..5fb7da53f 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/FederatedTimelineFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/FederatedTimelineFragment.java @@ -1,11 +1,11 @@ package org.joinmastodon.android.fragments.discover; +import android.net.Uri; import android.os.Bundle; import android.view.View; import org.joinmastodon.android.R; import org.joinmastodon.android.api.requests.timelines.GetPublicTimeline; -import org.joinmastodon.android.fragments.FabStatusListFragment; import org.joinmastodon.android.fragments.StatusListFragment; import org.joinmastodon.android.model.Filter; import org.joinmastodon.android.model.Status; @@ -17,10 +17,15 @@ import java.util.stream.Collectors; import me.grishka.appkit.api.SimpleCallback; -public class FederatedTimelineFragment extends FabStatusListFragment { +public class FederatedTimelineFragment extends StatusListFragment { private DiscoverInfoBannerHelper bannerHelper=new DiscoverInfoBannerHelper(DiscoverInfoBannerHelper.BannerType.FEDERATED_TIMELINE); private String maxID; + @Override + protected boolean wantsComposeButton() { + return true; + } + @Override protected void doLoadData(int offset, int count){ currentRequest=new GetPublicTimeline(false, false, refreshing ? null : maxID, count) @@ -30,7 +35,8 @@ public class FederatedTimelineFragment extends FabStatusListFragment { if(!result.isEmpty()) maxID=result.get(result.size()-1).id; if (getActivity() == null) return; - onDataLoaded(result.stream().filter(new StatusFilterPredicate(accountID, Filter.FilterContext.PUBLIC)).collect(Collectors.toList()), !result.isEmpty()); + result=result.stream().filter(new StatusFilterPredicate(accountID, getFilterContext())).collect(Collectors.toList()); + onDataLoaded(result, !result.isEmpty()); } }) .exec(accountID); @@ -41,4 +47,14 @@ public class FederatedTimelineFragment extends FabStatusListFragment { super.onViewCreated(view, savedInstanceState); bannerHelper.maybeAddBanner(contentWrap); } + + @Override + protected Filter.FilterContext getFilterContext() { + return Filter.FilterContext.PUBLIC; + } + + @Override + public Uri getWebUri(Uri.Builder base) { + return base.path(isInstanceAkkoma() ? "/main/all" : "/public").build(); + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/LocalTimelineFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/LocalTimelineFragment.java index 999a362b4..8cde7b7df 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/LocalTimelineFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/LocalTimelineFragment.java @@ -1,11 +1,11 @@ package org.joinmastodon.android.fragments.discover; +import android.net.Uri; import android.os.Bundle; import android.view.View; -import org.joinmastodon.android.R; import org.joinmastodon.android.api.requests.timelines.GetPublicTimeline; -import org.joinmastodon.android.fragments.FabStatusListFragment; +import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.fragments.StatusListFragment; import org.joinmastodon.android.model.Filter; import org.joinmastodon.android.model.Status; @@ -17,10 +17,15 @@ import java.util.stream.Collectors; import me.grishka.appkit.api.SimpleCallback; -public class LocalTimelineFragment extends FabStatusListFragment { +public class LocalTimelineFragment extends StatusListFragment { private DiscoverInfoBannerHelper bannerHelper=new DiscoverInfoBannerHelper(DiscoverInfoBannerHelper.BannerType.LOCAL_TIMELINE); private String maxID; + @Override + protected boolean wantsComposeButton() { + return true; + } + @Override protected void doLoadData(int offset, int count){ currentRequest=new GetPublicTimeline(true, false, refreshing ? null : maxID, count) @@ -30,7 +35,8 @@ public class LocalTimelineFragment extends FabStatusListFragment { if(!result.isEmpty()) maxID=result.get(result.size()-1).id; if (getActivity() == null) return; - onDataLoaded(result.stream().filter(new StatusFilterPredicate(accountID, Filter.FilterContext.PUBLIC)).collect(Collectors.toList()), !result.isEmpty()); + result=result.stream().filter(new StatusFilterPredicate(accountID, getFilterContext())).collect(Collectors.toList()); + onDataLoaded(result, !result.isEmpty()); } }) .exec(accountID); @@ -41,4 +47,14 @@ public class LocalTimelineFragment extends FabStatusListFragment { super.onViewCreated(view, savedInstanceState); bannerHelper.maybeAddBanner(contentWrap); } + + @Override + protected Filter.FilterContext getFilterContext() { + return Filter.FilterContext.PUBLIC; + } + + @Override + public Uri getWebUri(Uri.Builder base) { + return base.path(isInstanceAkkoma() ? "/main/public" : "/public/local").build(); + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/SearchFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/SearchFragment.java index 50a8e71b5..741149c35 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/SearchFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/SearchFragment.java @@ -1,6 +1,7 @@ package org.joinmastodon.android.fragments.discover; import android.app.Activity; +import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.text.TextUtils; @@ -11,10 +12,10 @@ import org.joinmastodon.android.R; import org.joinmastodon.android.api.requests.search.GetSearchResults; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.fragments.BaseStatusListFragment; -import org.joinmastodon.android.fragments.IsOnTop; import org.joinmastodon.android.fragments.ProfileFragment; import org.joinmastodon.android.fragments.ThreadFragment; import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.model.Filter; import org.joinmastodon.android.model.Hashtag; import org.joinmastodon.android.model.SearchResult; import org.joinmastodon.android.model.SearchResults; @@ -41,7 +42,7 @@ import me.grishka.appkit.api.ErrorResponse; import me.grishka.appkit.utils.MergeRecyclerAdapter; import me.grishka.appkit.utils.V; -public class SearchFragment extends BaseStatusListFragment implements IsOnTop { +public class SearchFragment extends BaseStatusListFragment { private String currentQuery; private List prevDisplayItems; private EnumSet currentFilter=EnumSet.allOf(SearchResult.Type.class); @@ -80,7 +81,7 @@ public class SearchFragment extends BaseStatusListFragment impleme return switch(s.type){ case ACCOUNT -> Collections.singletonList(new AccountStatusDisplayItem(s.id, this, s.account)); case HASHTAG -> Collections.singletonList(new HashtagStatusDisplayItem(s.id, this, s.hashtag)); - case STATUS -> StatusDisplayItem.buildItems(this, s.status, accountID, s, knownAccounts, false, true, null); + case STATUS -> StatusDisplayItem.buildItems(this, s.status, accountID, s, knownAccounts, false, true, null, Filter.FilterContext.PUBLIC); }; } @@ -247,7 +248,7 @@ public class SearchFragment extends BaseStatusListFragment impleme } public void setQuery(String q){ - if(Objects.equals(q, currentQuery)) + if(Objects.equals(q, currentQuery) || q.isBlank()) return; if(currentRequest!=null){ currentRequest.cancel(); @@ -311,8 +312,11 @@ public class SearchFragment extends BaseStatusListFragment impleme } @Override - public boolean isOnTop() { - return isRecyclerViewOnTop(list); + public Uri getWebUri(Uri.Builder base) { + Uri.Builder searchUri = base.path("/search"); + return isInstanceAkkoma() + ? searchUri.appendQueryParameter("query", currentQuery).build() + : searchUri.build(); } @FunctionalInterface diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/AccountActivationFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/AccountActivationFragment.java index dd86fdae6..b1a77e9c0 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/AccountActivationFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/AccountActivationFragment.java @@ -89,13 +89,6 @@ public class AccountActivationFragment extends ToolbarFragment{ return !UiUtils.isDarkTheme(); } - @Override - public void onViewCreated(View view, Bundle savedInstanceState){ - super.onViewCreated(view, savedInstanceState); - setStatusBarColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3Background)); - view.setBackgroundColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3Background)); - } - @Override protected void onUpdateToolbar(){ super.onUpdateToolbar(); @@ -110,7 +103,7 @@ public class AccountActivationFragment extends ToolbarFragment{ @Override public void onToolbarNavigationClick(){ - new AccountSwitcherSheet(getActivity()).show(); + new AccountSwitcherSheet(getActivity(), null).show(); } @Override @@ -193,30 +186,24 @@ public class AccountActivationFragment extends ToolbarFragment{ mgr.removeAccount(accountID); mgr.addAccount(mgr.getInstanceInfo(session.domain), session.token, result, session.app, null); String newID=mgr.getLastActiveAccountID(); - Bundle args=new Bundle(); - args.putString("account", newID); - if(session.self.avatar!=null || session.self.displayName!=null){ - File avaFile=session.self.avatar!=null ? new File(session.self.avatar) : null; - new UpdateAccountCredentials(session.self.displayName, "", avaFile, null, Collections.emptyList()) + accountID=newID; + if((session.self.avatar!=null || session.self.displayName!=null) && !getArguments().getBoolean("debug")){ + new UpdateAccountCredentials(session.self.displayName, "", (File)null, null, Collections.emptyList()) .setCallback(new Callback<>(){ @Override public void onSuccess(Account result){ - if(avaFile!=null) - avaFile.delete(); mgr.updateAccountInfo(newID, result); - Nav.goClearingStack(getActivity(), HomeFragment.class, args); + proceed(); } @Override public void onError(ErrorResponse error){ - if(avaFile!=null) - avaFile.delete(); - Nav.goClearingStack(getActivity(), HomeFragment.class, args); + proceed(); } }) .exec(newID); }else{ - Nav.goClearingStack(getActivity(), HomeFragment.class, args); + proceed(); } } @@ -249,4 +236,11 @@ public class AccountActivationFragment extends ToolbarFragment{ super.onDestroyView(); resendBtn.removeCallbacks(resendTimer); } + + private void proceed(){ + Bundle args=new Bundle(); + args.putString("account", accountID); +// Nav.goClearingStack(getActivity(), HomeFragment.class, args); + Nav.goClearingStack(getActivity(), OnboardingFollowSuggestionsFragment.class, args); + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/CustomWelcomeFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/CustomWelcomeFragment.java index 32f721c3a..4132cfaf2 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/CustomWelcomeFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/CustomWelcomeFragment.java @@ -124,6 +124,7 @@ public class CustomWelcomeFragment extends InstanceCatalogFragment { super.onViewCreated(view, savedInstanceState); view.setBackgroundColor(UiUtils.getThemeColor(getActivity(), R.attr.colorWindowBackground)); list.setItemAnimator(new BetterItemAnimator()); + ((UsableRecyclerView) list).setSelector(null); } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/GoogleMadeMeAddThisFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/GoogleMadeMeAddThisFragment.java index f4793b8c1..5f0404ac2 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/GoogleMadeMeAddThisFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/GoogleMadeMeAddThisFragment.java @@ -63,6 +63,8 @@ public class GoogleMadeMeAddThisFragment extends ToolbarFragment{ private ItemsAdapter itemsAdapter; private ElevationOnScrollListener onScrollListener; + private static final int SIGNUP_REQUEST=722; + @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); @@ -139,7 +141,16 @@ public class GoogleMadeMeAddThisFragment extends ToolbarFragment{ protected void onButtonClick(){ Bundle args=new Bundle(); args.putParcelable("instance", Parcels.wrap(instance)); - Nav.go(getActivity(), SignupFragment.class, args); + Nav.goForResult(getActivity(), SignupFragment.class, args, SIGNUP_REQUEST, this); + } + + @Override + public void onFragmentResult(int reqCode, boolean success, Bundle result){ + super.onFragmentResult(reqCode, success, result); + if(reqCode==SIGNUP_REQUEST && !success){ + setResult(false, null); + Nav.finish(this); + } } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceCatalogFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceCatalogFragment.java index 2d17d6272..ed7981c15 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceCatalogFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceCatalogFragment.java @@ -5,25 +5,22 @@ import android.app.ProgressDialog; import android.net.Uri; import android.os.Build; import android.os.Bundle; -import android.os.LocaleList; import android.text.TextUtils; import android.view.KeyEvent; import android.view.View; -import android.view.ViewGroup; import android.view.WindowInsets; import android.widget.Button; import android.widget.EditText; -import android.widget.RadioButton; import android.widget.TextView; import org.joinmastodon.android.R; import org.joinmastodon.android.api.MastodonAPIController; import org.joinmastodon.android.api.MastodonErrorResponse; import org.joinmastodon.android.api.requests.instance.GetInstance; +import org.joinmastodon.android.fragments.RecyclerFragment; import org.joinmastodon.android.model.Instance; import org.joinmastodon.android.model.catalog.CatalogInstance; import org.joinmastodon.android.ui.M3AlertDialogBuilder; -import org.joinmastodon.android.ui.utils.UiUtils; import org.w3c.dom.Document; import org.w3c.dom.Element; import org.w3c.dom.NodeList; @@ -31,6 +28,8 @@ import org.xml.sax.InputSource; import java.io.IOException; import java.net.IDN; +import java.net.URI; +import java.net.URISyntaxException; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; @@ -46,16 +45,14 @@ import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.ErrorResponse; -import me.grishka.appkit.fragments.BaseRecyclerFragment; import me.grishka.appkit.utils.BindableViewHolder; import me.grishka.appkit.utils.MergeRecyclerAdapter; import me.grishka.appkit.utils.V; -import me.grishka.appkit.views.UsableRecyclerView; import okhttp3.Call; import okhttp3.Request; import okhttp3.Response; -abstract class InstanceCatalogFragment extends BaseRecyclerFragment{ +abstract class InstanceCatalogFragment extends RecyclerFragment { protected RecyclerView.Adapter adapter; protected MergeRecyclerAdapter mergeAdapter; protected CatalogInstance chosenInstance; @@ -78,7 +75,7 @@ abstract class InstanceCatalogFragment extends BaseRecyclerFragment sortInstances(List result){ - Map> byLang=result.stream().collect(Collectors.groupingBy(ci->ci.language)); - for(List group:byLang.values()){ - Collections.sort(group, (a, b)->{ - double aa=Math.abs(DUNBAR-Math.log(a.lastWeekUsers)); - double bb=Math.abs(DUNBAR-Math.log(b.lastWeekUsers)); - return Double.compare(aa, bb); - }); - } - // get the list of user-configured system languages - List userLangs; - if(Build.VERSION.SDK_INT<24){ - userLangs=Collections.singletonList(getResources().getConfiguration().locale.getLanguage()); - }else{ - LocaleList ll=getResources().getConfiguration().getLocales(); - userLangs=new ArrayList<>(ll.size()); - for(int i=0;i> byLang=result.stream().sorted(Comparator.comparingInt((CatalogInstance ci)->ci.lastWeekUsers).reversed()).collect(Collectors.groupingBy(ci->ci.approvalRequired)); ArrayList sortedList=new ArrayList<>(); - for(String lang:userLangs){ - List langInstances=byLang.remove(lang); - if(langInstances!=null){ - sortedList.addAll(langInstances); - } - } - // sort the remaining language groups by aggregate lastWeekUsers - class InstanceGroup{ - public int activeUsers; - public List instances; - } - byLang.values().stream().map(il->{ - InstanceGroup group=new InstanceGroup(); - group.instances=il; - for(CatalogInstance instance:il){ - group.activeUsers+=instance.lastWeekUsers; - } - return group; - }).sorted(Comparator.comparingInt((InstanceGroup g)->g.activeUsers).reversed()).forEachOrdered(ig->sortedList.addAll(ig.instances)); + sortedList.addAll(byLang.getOrDefault(false, Collections.emptyList())); + sortedList.addAll(byLang.getOrDefault(true, Collections.emptyList())); return sortedList; } @@ -208,6 +169,20 @@ abstract class InstanceCatalogFragment extends BaseRecyclerFragment0 && filteredData.get(0)==fakeInstance){ + if(list.findViewHolderForAdapterPosition(1) instanceof BindableViewHolder ivh){ + ivh.rebind(); + } + } + } + return; + } loadingInstanceDomain=domain; loadingInstanceRequest=new GetInstance(); loadingInstanceRequest.setCallback(new Callback<>(){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceCatalogSignupFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceCatalogSignupFragment.java index e147ceac6..798b18ac5 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceCatalogSignupFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceCatalogSignupFragment.java @@ -64,7 +64,7 @@ public class InstanceCatalogSignupFragment extends InstanceCatalogFragment imple private List languages=Collections.emptyList(); private PopupMenu langFilterMenu, speedFilterMenu; - private SignupSpeedFilter currentSignupSpeedFilter=SignupSpeedFilter.INSTANT; + private SignupSpeedFilter currentSignupSpeedFilter=SignupSpeedFilter.ANY; private String currentLanguage=null; private boolean searchQueryMode; private LinearLayout filtersWrap; @@ -75,7 +75,7 @@ public class InstanceCatalogSignupFragment extends InstanceCatalogFragment imple private FilterChipView categoryGeneral, categorySpecialInterests; private List regionalFilters; private CatalogInstance.Region chosenRegion; - private CategoryChoice categoryChoice; + private CategoryChoice categoryChoice=CategoryChoice.GENERAL; public InstanceCatalogSignupFragment(){ super(R.layout.fragment_onboarding_common, 10); @@ -371,6 +371,9 @@ public class InstanceCatalogSignupFragment extends InstanceCatalogFragment imple if(instances.isEmpty()){ instances=data.stream().filter(ci->!ci.approvalRequired && ("general".equals(ci.category) || (ci.categories!=null && ci.categories.contains("general")))).collect(Collectors.toList()); } + if(instances.isEmpty()){ + instances=data.stream().filter(ci->("general".equals(ci.category) || (ci.categories!=null && ci.categories.contains("general")))).collect(Collectors.toList()); + } if(instances.isEmpty()){ return; } @@ -527,6 +530,15 @@ public class InstanceCatalogSignupFragment extends InstanceCatalogFragment imple updateFilteredList(); } + @Override + protected void onShown(){ + super.onShown(); + if(!searchQueryMode){ + // Prevent search view automatically getting focused when the user returns to this fragment + focusThing.requestFocus(); + } + } + private class InstancesAdapter extends UsableRecyclerView.Adapter{ public InstancesAdapter(){ super(imgLoader); diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceRulesFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceRulesFragment.java index 6dd47f224..c8efd7f80 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceRulesFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceRulesFragment.java @@ -2,8 +2,16 @@ package org.joinmastodon.android.fragments.onboarding; import android.annotation.SuppressLint; import android.app.Activity; +import android.app.assist.AssistContent; +import android.graphics.Typeface; +import android.net.Uri; import android.os.Build; import android.os.Bundle; +import android.text.Html; +import android.text.Spannable; +import android.text.SpannableStringBuilder; +import android.text.style.StyleSpan; +import android.text.style.TypefaceSpan; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -18,6 +26,7 @@ import org.joinmastodon.android.ui.DividerItemDecoration; import org.joinmastodon.android.ui.text.HtmlParser; import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.utils.ElevationOnScrollListener; +import org.joinmastodon.android.utils.ProvidesAssistContent; import org.parceler.Parcels; import androidx.annotation.NonNull; @@ -32,7 +41,7 @@ import me.grishka.appkit.utils.V; import me.grishka.appkit.views.FragmentRootLinearLayout; import me.grishka.appkit.views.UsableRecyclerView; -public class InstanceRulesFragment extends ToolbarFragment{ +public class InstanceRulesFragment extends ToolbarFragment implements ProvidesAssistContent { private UsableRecyclerView list; private MergeRecyclerAdapter adapter; private Button btn; @@ -64,7 +73,7 @@ public class InstanceRulesFragment extends ToolbarFragment{ list.setLayoutManager(new LinearLayoutManager(getActivity())); View headerView=inflater.inflate(R.layout.item_list_header_simple, list, false); TextView text=headerView.findViewById(R.id.text); - text.setText(getString(R.string.instance_rules_subtitle, instance.uri)); + text.setText(Html.fromHtml(getString(R.string.instance_rules_subtitle, ""+Html.escapeHtml(instance.uri)+""))); adapter=new MergeRecyclerAdapter(); adapter.addAdapter(new SingleViewRecyclerAdapter(headerView)); @@ -124,6 +133,15 @@ public class InstanceRulesFragment extends ToolbarFragment{ } } + @Override + public void onProvideAssistContent(AssistContent assistContent) { + assistContent.setWebUri(new Uri.Builder() + .scheme("https") + .authority(instance.normalizedUri) + .path("/about") + .build()); + } + private class ItemsAdapter extends RecyclerView.Adapter{ @NonNull diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/OnboardingFollowSuggestionsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/OnboardingFollowSuggestionsFragment.java new file mode 100644 index 000000000..56e91ac9b --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/OnboardingFollowSuggestionsFragment.java @@ -0,0 +1,344 @@ +package org.joinmastodon.android.fragments.onboarding; + +import android.app.ProgressDialog; +import android.graphics.drawable.Animatable; +import android.graphics.drawable.Drawable; +import android.os.Build; +import android.os.Bundle; +import android.text.TextUtils; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowInsets; +import android.widget.Button; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.ProgressBar; +import android.widget.TextView; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.requests.accounts.GetAccountRelationships; +import org.joinmastodon.android.api.requests.accounts.GetFollowSuggestions; +import org.joinmastodon.android.api.requests.accounts.SetAccountFollowed; +import org.joinmastodon.android.fragments.HomeFragment; +import org.joinmastodon.android.fragments.ProfileFragment; +import org.joinmastodon.android.fragments.RecyclerFragment; +import org.joinmastodon.android.model.FollowSuggestion; +import org.joinmastodon.android.model.ParsedAccount; +import org.joinmastodon.android.model.Relationship; +import org.joinmastodon.android.ui.OutlineProviders; +import org.joinmastodon.android.ui.utils.UiUtils; +import org.joinmastodon.android.ui.views.ProgressBarButton; +import org.joinmastodon.android.utils.ElevationOnScrollListener; +import org.parceler.Parcels; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; +import me.grishka.appkit.Nav; +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; +import me.grishka.appkit.api.SimpleCallback; +import me.grishka.appkit.imageloader.ImageLoaderRecyclerAdapter; +import me.grishka.appkit.imageloader.ImageLoaderViewHolder; +import me.grishka.appkit.imageloader.requests.ImageLoaderRequest; +import me.grishka.appkit.utils.BindableViewHolder; +import me.grishka.appkit.utils.V; +import me.grishka.appkit.views.FragmentRootLinearLayout; +import me.grishka.appkit.views.UsableRecyclerView; + +public class OnboardingFollowSuggestionsFragment extends RecyclerFragment { + private String accountID; + private Map relationships=Collections.emptyMap(); + private GetAccountRelationships relationshipsRequest; + private View buttonBar; + private ElevationOnScrollListener onScrollListener; + private int numRunningFollowRequests=0; + + public OnboardingFollowSuggestionsFragment(){ + super(R.layout.fragment_onboarding_follow_suggestions, 40); + } + + @Override + public void onCreate(Bundle savedInstanceState){ + super.onCreate(savedInstanceState); + setRetainInstance(true); + setTitle(R.string.popular_on_mastodon); + accountID=getArguments().getString("account"); + loadData(); + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState){ + super.onViewCreated(view, savedInstanceState); + buttonBar=view.findViewById(R.id.button_bar); + setStatusBarColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3Background)); + view.setBackgroundColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3Background)); + list.addOnScrollListener(onScrollListener=new ElevationOnScrollListener((FragmentRootLinearLayout) view, buttonBar, getToolbar())); + + view.findViewById(R.id.btn_next).setOnClickListener(UiUtils.rateLimitedClickListener(this::onFollowAllClick)); + view.findViewById(R.id.btn_skip).setOnClickListener(UiUtils.rateLimitedClickListener(v->proceed())); + } + + @Override + protected void onUpdateToolbar(){ + super.onUpdateToolbar(); + getToolbar().setBackgroundResource(R.drawable.bg_onboarding_panel); + getToolbar().setElevation(0); + if(onScrollListener!=null){ + onScrollListener.setViews(buttonBar, getToolbar()); + } + } + + @Override + protected void doLoadData(int offset, int count){ + new GetFollowSuggestions(40) + .setCallback(new SimpleCallback<>(this){ + @Override + public void onSuccess(List result){ + onDataLoaded(result.stream().map(fs->new ParsedAccount(fs.account, accountID)).collect(Collectors.toList()), false); + loadRelationships(); + } + }) + .exec(accountID); + } + + private void loadRelationships(){ + relationships=Collections.emptyMap(); + relationshipsRequest=new GetAccountRelationships(data.stream().map(fs->fs.account.id).collect(Collectors.toList())); + relationshipsRequest.setCallback(new Callback<>(){ + @Override + public void onSuccess(List result){ + relationshipsRequest=null; + relationships=result.stream().collect(Collectors.toMap(rel->rel.id, Function.identity())); + if(list==null) + return; + for(int i=0;i=27){ + int inset=insets.getSystemWindowInsetBottom(); + buttonBar.setPadding(0, 0, 0, inset>0 ? Math.max(inset, V.dp(36)) : 0); + super.onApplyWindowInsets(insets.replaceSystemWindowInsets(insets.getSystemWindowInsetLeft(), insets.getSystemWindowInsetTop(), insets.getSystemWindowInsetRight(), 0)); + }else{ + super.onApplyWindowInsets(insets.replaceSystemWindowInsets(insets.getSystemWindowInsetLeft(), insets.getSystemWindowInsetTop(), insets.getSystemWindowInsetRight(), insets.getSystemWindowInsetBottom())); + } + } + + @Override + protected RecyclerView.Adapter getAdapter(){ + return new SuggestionsAdapter(); + } + + private void onFollowAllClick(View v){ + if(!loaded || relationships.isEmpty()) + return; + if(data.isEmpty()){ + proceed(); + return; + } + ArrayList accountIdsToFollow=new ArrayList<>(); + for(ParsedAccount acc:data){ + Relationship rel=relationships.get(acc.account.id); + if(rel==null) + continue; + if(rel.canFollow()) + accountIdsToFollow.add(acc.account.id); + } + + final ProgressDialog progress=new ProgressDialog(getActivity()); + progress.setIndeterminate(false); + progress.setProgressStyle(ProgressDialog.STYLE_HORIZONTAL); + progress.setMax(accountIdsToFollow.size()); + progress.setCancelable(false); + progress.setMessage(getString(R.string.sending_follows)); + progress.show(); + + for(int i=0;i accountIdsToFollow, ProgressDialog progress){ + if(accountIdsToFollow.isEmpty()){ + if(numRunningFollowRequests==0){ + progress.dismiss(); + proceed(); + } + return; + } + numRunningFollowRequests++; + String id=accountIdsToFollow.remove(0); + new SetAccountFollowed(id, true, true) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(Relationship result){ + relationships.put(id, result); + for(int i=0;i implements ImageLoaderRecyclerAdapter{ + + public SuggestionsAdapter(){ + super(imgLoader); + } + + @NonNull + @Override + public SuggestionViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){ + return new SuggestionViewHolder(); + } + + @Override + public int getItemCount(){ + return data.size(); + } + + @Override + public void onBindViewHolder(SuggestionViewHolder holder, int position){ + holder.bind(data.get(position)); + super.onBindViewHolder(holder, position); + } + + @Override + public int getImageCountForItem(int position){ + return data.get(position).emojiHelper.getImageCount()+1; + } + + @Override + public ImageLoaderRequest getImageRequest(int position, int image){ + ParsedAccount account=data.get(position); + if(image==0) + return account.avatarRequest; + return account.emojiHelper.getImageRequest(image-1); + } + } + + private class SuggestionViewHolder extends BindableViewHolder implements ImageLoaderViewHolder, UsableRecyclerView.Clickable{ + private final TextView name, username, bio; + private final ImageView avatar; + private final ProgressBarButton actionButton; + private final ProgressBar actionProgress; + private final View actionWrap; + + private Relationship relationship; + + public SuggestionViewHolder(){ + super(getActivity(), R.layout.item_user_row_m3, list); + name=findViewById(R.id.name); + username=findViewById(R.id.username); + bio=findViewById(R.id.bio); + avatar=findViewById(R.id.avatar); + actionButton=findViewById(R.id.action_btn); + actionProgress=findViewById(R.id.action_progress); + actionWrap=findViewById(R.id.action_btn_wrap); + + avatar.setOutlineProvider(OutlineProviders.roundedRect(10)); + avatar.setClipToOutline(true); + actionButton.setOnClickListener(UiUtils.rateLimitedClickListener(this::onActionButtonClick)); + } + + @Override + public void onBind(ParsedAccount item){ + name.setText(item.parsedName); + username.setText(item.account.getDisplayUsername()); + if(TextUtils.isEmpty(item.parsedBio)){ + bio.setVisibility(View.GONE); + }else{ + bio.setVisibility(View.VISIBLE); + bio.setText(item.parsedBio); + } + + relationship=relationships.get(item.account.id); + if(relationship==null){ + actionWrap.setVisibility(View.GONE); + }else{ + actionWrap.setVisibility(View.VISIBLE); + UiUtils.setRelationshipToActionButtonM3(relationship, actionButton); + } + } + + @Override + public void setImage(int index, Drawable image){ + if(index==0){ + avatar.setImageDrawable(image); + }else{ + item.emojiHelper.setImageDrawable(index-1, image); + name.invalidate(); + bio.invalidate(); + } + if(image instanceof Animatable a && !a.isRunning()) + a.start(); + } + + @Override + public void clearImage(int index){ + setImage(index, null); + } + + @Override + public void onClick(){ + Bundle args=new Bundle(); + args.putString("account", accountID); + args.putParcelable("profileAccount", Parcels.wrap(item.account)); + Nav.go(getActivity(), ProfileFragment.class, args); + } + + private void onActionButtonClick(View v){ + itemView.setHasTransientState(true); + UiUtils.performAccountAction(getActivity(), item.account, accountID, relationship, actionButton, this::setActionProgressVisible, rel->{ + itemView.setHasTransientState(false); + relationships.put(item.account.id, rel); + rebind(); + }); + } + + private void setActionProgressVisible(boolean visible){ + actionButton.setTextVisible(!visible); + actionProgress.setVisibility(visible ? View.VISIBLE : View.GONE); + actionButton.setClickable(!visible); + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/OnboardingProfileSetupFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/OnboardingProfileSetupFragment.java new file mode 100644 index 000000000..4814fd990 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/OnboardingProfileSetupFragment.java @@ -0,0 +1,229 @@ +package org.joinmastodon.android.fragments.onboarding; + +import android.app.Activity; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowInsets; +import android.widget.Button; +import android.widget.EditText; +import android.widget.ImageView; +import android.widget.ScrollView; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.requests.accounts.UpdateAccountCredentials; +import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.fragments.HomeFragment; +import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.model.AccountField; +import org.joinmastodon.android.ui.OutlineProviders; +import org.joinmastodon.android.ui.utils.UiUtils; +import org.joinmastodon.android.ui.views.ReorderableLinearLayout; +import org.joinmastodon.android.utils.ElevationOnScrollListener; + +import java.util.ArrayList; + +import me.grishka.appkit.Nav; +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; +import me.grishka.appkit.fragments.ToolbarFragment; +import me.grishka.appkit.imageloader.ViewImageLoader; +import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest; +import me.grishka.appkit.utils.V; +import me.grishka.appkit.views.FragmentRootLinearLayout; + +public class OnboardingProfileSetupFragment extends ToolbarFragment implements ReorderableLinearLayout.OnDragListener{ + private Button btn; + private View buttonBar; + private String accountID; + private ElevationOnScrollListener onScrollListener; + private ScrollView scroller; + private EditText nameEdit, bioEdit; + private ImageView avaImage, coverImage; + private Button addRow; + private ReorderableLinearLayout profileFieldsLayout; + private Uri avatarUri, coverUri; + + private static final int AVATAR_RESULT=348; + private static final int COVER_RESULT=183; + + @Override + public void onCreate(Bundle savedInstanceState){ + super.onCreate(savedInstanceState); + setRetainInstance(true); + } + + @Override + public void onAttach(Activity activity){ + super.onAttach(activity); + setNavigationBarColor(UiUtils.getThemeColor(activity, R.attr.colorWindowBackground)); + accountID=getArguments().getString("account"); + setTitle(R.string.profile_setup); + } + + @Override + public View onCreateContentView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState){ + View view=inflater.inflate(R.layout.fragment_onboarding_profile_setup, container, false); + + scroller=view.findViewById(R.id.scroller); + nameEdit=view.findViewById(R.id.display_name); + bioEdit=view.findViewById(R.id.bio); + avaImage=view.findViewById(R.id.avatar); + coverImage=view.findViewById(R.id.header); + addRow=view.findViewById(R.id.add_row); + profileFieldsLayout=view.findViewById(R.id.profile_fields); + + btn=view.findViewById(R.id.btn_next); + btn.setOnClickListener(v->onButtonClick()); + buttonBar=view.findViewById(R.id.button_bar); + + avaImage.setOutlineProvider(OutlineProviders.roundedRect(24)); + avaImage.setClipToOutline(true); + + Account account=AccountSessionManager.getInstance().getAccount(accountID).self; + if(savedInstanceState==null){ + nameEdit.setText(account.displayName); + makeFieldsRow(); + }else{ + ArrayList fieldTitles=savedInstanceState.getStringArrayList("fieldTitles"); + ArrayList fieldValues=savedInstanceState.getStringArrayList("fieldValues"); + for(int i=0;i{ + makeFieldsRow(); + if(profileFieldsLayout.getChildCount()==4){ + addRow.setVisibility(View.GONE); + } + }); + profileFieldsLayout.setDragListener(this); + avaImage.setOnClickListener(v->startActivityForResult(UiUtils.getMediaPickerIntent(new String[]{"image/*"}, 1), AVATAR_RESULT)); + coverImage.setOnClickListener(v->startActivityForResult(UiUtils.getMediaPickerIntent(new String[]{"image/*"}, 1), COVER_RESULT)); + + return view; + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState){ + super.onViewCreated(view, savedInstanceState); + setStatusBarColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3Background)); + view.setBackgroundColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3Background)); + scroller.setOnScrollChangeListener(onScrollListener=new ElevationOnScrollListener((FragmentRootLinearLayout) view, buttonBar, getToolbar())); + } + + @Override + protected void onUpdateToolbar(){ + super.onUpdateToolbar(); + getToolbar().setBackgroundResource(R.drawable.bg_onboarding_panel); + getToolbar().setElevation(0); + if(onScrollListener!=null){ + onScrollListener.setViews(buttonBar, getToolbar()); + } + } + + protected void onButtonClick(){ + ArrayList fields=new ArrayList<>(); + for(int i=0;i(){ + @Override + public void onSuccess(Account result){ + AccountSessionManager.getInstance().updateAccountInfo(accountID, result); + Bundle args=new Bundle(); + args.putString("account", accountID); + Nav.goClearingStack(getActivity(), HomeFragment.class, args); + } + + @Override + public void onError(ErrorResponse error){ + error.showToast(getActivity()); + } + }) + .wrapProgress(getActivity(), R.string.saving, true) + .exec(accountID); + } + + @Override + public void onApplyWindowInsets(WindowInsets insets){ + if(Build.VERSION.SDK_INT>=27){ + int inset=insets.getSystemWindowInsetBottom(); + buttonBar.setPadding(0, 0, 0, inset>0 ? Math.max(inset, V.dp(36)) : 0); + super.onApplyWindowInsets(insets.replaceSystemWindowInsets(insets.getSystemWindowInsetLeft(), insets.getSystemWindowInsetTop(), insets.getSystemWindowInsetRight(), 0)); + }else{ + super.onApplyWindowInsets(insets.replaceSystemWindowInsets(insets.getSystemWindowInsetLeft(), insets.getSystemWindowInsetTop(), insets.getSystemWindowInsetRight(), insets.getSystemWindowInsetBottom())); + } + } + + private View makeFieldsRow(){ + View view=LayoutInflater.from(getActivity()).inflate(R.layout.onboarding_profile_field, profileFieldsLayout, false); + profileFieldsLayout.addView(view); + view.findViewById(R.id.dragger_thingy).setOnLongClickListener(v->{ + profileFieldsLayout.startDragging(view); + return true; + }); + view.findViewById(R.id.delete).setOnClickListener(v->{ + profileFieldsLayout.removeView(view); + if(addRow.getVisibility()==View.GONE) + addRow.setVisibility(View.VISIBLE); + }); + return view; + } + + @Override + public void onSwapItems(int oldIndex, int newIndex){} + + @Override + public void onSaveInstanceState(Bundle outState){ + super.onSaveInstanceState(outState); + ArrayList fieldTitles=new ArrayList<>(), fieldValues=new ArrayList<>(); + for(int i=0;i errorFields=new HashSet<>(); + private ElevationOnScrollListener onScrollListener; @Override public void onCreate(Bundle savedInstanceState){ @@ -145,19 +150,22 @@ public class SignupFragment extends ToolbarFragment{ super.onViewCreated(view, savedInstanceState); setStatusBarColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3Background)); view.setBackgroundColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3Background)); + view.findViewById(R.id.scroller).setOnScrollChangeListener(onScrollListener=new ElevationOnScrollListener((FragmentRootLinearLayout) view, buttonBar, getToolbar())); } @Override protected void onUpdateToolbar(){ super.onUpdateToolbar(); - getToolbar().setBackground(null); + getToolbar().setBackgroundResource(R.drawable.bg_onboarding_panel); getToolbar().setElevation(0); + if(onScrollListener!=null){ + onScrollListener.setViews(buttonBar, getToolbar()); + } } private void onButtonClick(){ if(!password.getText().toString().equals(passwordConfirm.getText().toString())){ - passwordConfirm.setError(getString(R.string.signup_passwords_dont_match)); - passwordConfirmWrap.setErrorState(); + passwordConfirmWrap.setErrorState(getString(R.string.signup_passwords_dont_match)); return; } showProgressDialog(); @@ -212,8 +220,22 @@ public class SignupFragment extends ToolbarFragment{ anyFieldsSkipped=true; continue; } - field.setError(fieldErrors.get(fieldName).stream().map(err->err.description).collect(Collectors.joining("\n"))); - getFieldWrapByName(fieldName).setErrorState(); + List errors=Objects.requireNonNull(fieldErrors.get(fieldName)); + if(errors.size()==1){ + getFieldWrapByName(fieldName).setErrorState(getErrorDescription(errors.get(0), fieldName)); + }else{ + SpannableStringBuilder ssb=new SpannableStringBuilder(); + boolean firstErr=true; + for(MastodonDetailedErrorResponse.FieldError err:errors){ + if(firstErr){ + firstErr=false; + }else{ + ssb.append('\n'); + } + ssb.append(getErrorDescription(err, fieldName)); + } + getFieldWrapByName(fieldName).setErrorState(getErrorDescription(errors.get(0), fieldName)); + } errorFields.add(field); if(first){ first=false; @@ -231,6 +253,40 @@ public class SignupFragment extends ToolbarFragment{ .exec(instance.uri, apiToken); } + private CharSequence getErrorDescription(MastodonDetailedErrorResponse.FieldError error, String fieldName){ + return switch(fieldName){ + case "email" -> switch(error.error){ + case "ERR_BLOCKED" -> { + String emailAddr=email.getText().toString(); + String s=getResources().getString(R.string.signup_email_domain_blocked, TextUtils.htmlEncode(instance.uri), TextUtils.htmlEncode(emailAddr.substring(emailAddr.lastIndexOf('@')+1))); + SpannableStringBuilder ssb=new SpannableStringBuilder(); + Jsoup.parseBodyFragment(s).body().traverse(new NodeVisitor(){ + private int spanStart; + @Override + public void head(Node node, int depth){ + if(node instanceof TextNode tn){ + ssb.append(tn.text()); + }else if(node instanceof Element){ + spanStart=ssb.length(); + } + } + + @Override + public void tail(Node node, int depth){ + if(node instanceof Element){ + ssb.setSpan(new LinkSpan("", SignupFragment.this::onGoBackLinkClick, LinkSpan.Type.CUSTOM, null), spanStart, ssb.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + ssb.setSpan(new TypefaceSpan("sans-serif-medium"), spanStart, ssb.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } + }); + yield ssb; + } + default -> error.description; + }; + default -> error.description; + }; + } + private EditText getFieldByName(String name){ return switch(name){ case "email" -> email; @@ -323,6 +379,11 @@ public class SignupFragment extends ToolbarFragment{ } } + private void onGoBackLinkClick(LinkSpan span){ + setResult(false, null); + Nav.finish(this); + } + private class ErrorClearingListener implements TextWatcher{ public final EditText editText; diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/report/ReportAddPostsChoiceFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/report/ReportAddPostsChoiceFragment.java index f8c2fc050..1a9ff0605 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/report/ReportAddPostsChoiceFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/report/ReportAddPostsChoiceFragment.java @@ -5,6 +5,7 @@ import android.graphics.Canvas; import android.graphics.Paint; import android.graphics.Rect; import android.graphics.drawable.Drawable; +import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.util.SparseIntArray; @@ -21,11 +22,10 @@ import org.joinmastodon.android.api.requests.accounts.GetAccountStatuses; import org.joinmastodon.android.events.FinishReportFragmentsEvent; import org.joinmastodon.android.fragments.StatusListFragment; import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.model.Filter; import org.joinmastodon.android.model.Status; -import org.joinmastodon.android.ui.PhotoLayoutHelper; import org.joinmastodon.android.ui.displayitems.AudioStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.HeaderStatusDisplayItem; -import org.joinmastodon.android.ui.displayitems.ImageStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.LinkCardStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.ReblogOrReplyLineStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.StatusDisplayItem; @@ -132,22 +132,7 @@ public class ReportAddPostsChoiceFragment extends StatusListFragment{ if(holder.getAbsoluteAdapterPosition()==0) return; outRect.left=V.dp(40); - if(holder instanceof ImageStatusDisplayItem.Holder imgHolder){ - PhotoLayoutHelper.TiledLayoutResult layout=imgHolder.getItem().tiledLayout; - PhotoLayoutHelper.TiledLayoutResult.Tile tile=imgHolder.getItem().thisTile; - String siblingID; - if(holder.getAbsoluteAdapterPosition()0) - outRect.left=0; - outRect.left+=V.dp(16); - outRect.right=V.dp(16); - if(!imgHolder.getItemID().equals(siblingID) || tile.startRow+tile.rowSpan==layout.rowSizes.length) - outRect.bottom=V.dp(16); - }else if(holder instanceof AudioStatusDisplayItem.Holder){ + if(holder instanceof AudioStatusDisplayItem.Holder){ outRect.bottom=V.dp(16); }else if(holder instanceof LinkCardStatusDisplayItem.Holder){ outRect.bottom=V.dp(16); @@ -166,10 +151,6 @@ public class ReportAddPostsChoiceFragment extends StatusListFragment{ parent.getDecoratedBoundsWithMargins(child, tmpRect); String id=sdiHolder.getItemID(); int height=tmpRect.height(); - if(holder instanceof ImageStatusDisplayItem.Holder imgHolder){ - if(imgHolder.getItem().thisTile.startCol+imgHolder.getItem().thisTile.colSpan buildDisplayItems(Status s){ - List items=StatusDisplayItem.buildItems(this, s, accountID, s, knownAccounts, true, false, null); - for(StatusDisplayItem item:items){ - if(item instanceof ImageStatusDisplayItem isdi){ - isdi.horizontalInset=V.dp(40+32); - } - } - return items; - } - protected void drawDivider(View child, View bottomSibling, RecyclerView.ViewHolder holder, RecyclerView.ViewHolder siblingHolder, RecyclerView parent, Canvas c, Paint paint){ parent.getDecoratedBoundsWithMargins(child, tmpRect); tmpRect.offset(0, Math.round(child.getTranslationY())); @@ -293,4 +263,16 @@ public class ReportAddPostsChoiceFragment extends StatusListFragment{ protected boolean wantsOverlaySystemNavigation(){ return false; } + + @Override + protected Filter.FilterContext getFilterContext() { + return null; + } + + @Override + public Uri getWebUri(Uri.Builder base) { + if (reportStatus != null) return Uri.parse(reportStatus.url); + if (reportAccount != null) return Uri.parse(reportAccount.url); + return null; + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/Account.java b/mastodon/src/main/java/org/joinmastodon/android/model/Account.java index 18e077c63..99be28f88 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/Account.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Account.java @@ -1,7 +1,10 @@ package org.joinmastodon.android.model; +import android.net.Uri; import android.text.TextUtils; +import androidx.annotation.Nullable; + import org.joinmastodon.android.api.ObjectValidationException; import org.joinmastodon.android.api.RequiredField; import org.parceler.Parcel; @@ -14,7 +17,7 @@ import java.util.List; * Represents a user of Mastodon and their associated profile. */ @Parcel -public class Account extends BaseModel{ +public class Account extends BaseModel implements Searchable{ // Base attributes /** @@ -43,7 +46,7 @@ public class Account extends BaseModel{ /** * The profile's display name. */ - @RequiredField +// @RequiredField public String displayName; /** * The profile's bio / description. @@ -62,7 +65,6 @@ public class Account extends BaseModel{ /** * An image banner that is shown above the profile and in profile cards. */ - @RequiredField public String header; /** * A static version of the header. Equal to header if its value is a static image; different if header is an animated GIF. @@ -86,7 +88,7 @@ public class Account extends BaseModel{ /** * When the account was created. */ - @RequiredField +// @RequiredField public Instant createdAt; /** * When the most recent status was posted. @@ -133,6 +135,21 @@ public class Account extends BaseModel{ */ public Instant muteExpiresAt; + public List roles; + + public @Nullable String fqn; // akkoma has this, mastodon't + + @Override + public String getQuery() { + return url; + } + + @Parcel + public static class Role { + public String name; + /** #rrggbb */ + public String color; + } @Override public void postprocess() throws ObjectValidationException{ @@ -149,6 +166,7 @@ public class Account extends BaseModel{ moved.postprocess(); if(TextUtils.isEmpty(displayName)) displayName=username; + if(fqn == null) fqn = getFullyQualifiedName(); } public boolean isLocal(){ @@ -160,6 +178,10 @@ public class Account extends BaseModel{ return parts.length==1 ? null : parts[1]; } + public String getDomainFromURL() { + return Uri.parse(url).getHost(); + } + public String getDisplayUsername(){ return '@'+acct; } @@ -168,6 +190,10 @@ public class Account extends BaseModel{ return '@'+acct.split("@")[0]; } + public String getFullyQualifiedName() { + return fqn != null ? fqn : acct.split("@")[0] + "@" + getDomainFromURL(); + } + @Override public String toString(){ return "Account{"+ diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/BaseModel.java b/mastodon/src/main/java/org/joinmastodon/android/model/BaseModel.java index e68026c9e..051be6240 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/BaseModel.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/BaseModel.java @@ -8,8 +8,17 @@ import java.lang.reflect.Field; import java.lang.reflect.Modifier; import androidx.annotation.CallSuper; +import androidx.annotation.NonNull; + +public abstract class BaseModel implements Cloneable{ + + /** + * indicates the profile has been fetched from a foreign instance. + * + * @see MastodonAPIRequest#execRemote + */ + public transient boolean isRemote; -public abstract class BaseModel{ @CallSuper public void postprocess() throws ObjectValidationException{ try{ @@ -23,4 +32,14 @@ public abstract class BaseModel{ } }catch(IllegalAccessException ignore){} } + + @NonNull + @Override + public Object clone(){ + try{ + return super.clone(); + }catch(CloneNotSupportedException x){ + throw new RuntimeException(x); + } + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/ContentType.java b/mastodon/src/main/java/org/joinmastodon/android/model/ContentType.java new file mode 100644 index 000000000..55331b1f2 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/ContentType.java @@ -0,0 +1,40 @@ +package org.joinmastodon.android.model; + +import android.view.Menu; + +import androidx.annotation.Nullable; + +import com.google.gson.annotations.SerializedName; + +import org.joinmastodon.android.R; + +public enum ContentType { + @SerializedName("text/plain") + PLAIN, + @SerializedName("text/html") + HTML, + @SerializedName("text/markdown") + MARKDOWN, + @SerializedName("text/bbcode") + BBCODE, // akkoma + @SerializedName("text/x.misskeymarkdown") + MISSKEY_MARKDOWN; // akkoma/*key + + public static int getContentTypeRes(@Nullable ContentType contentType) { + return contentType == null ? R.id.content_type_null : switch(contentType) { + case PLAIN -> R.id.content_type_plain; + case HTML -> R.id.content_type_html; + case MARKDOWN -> R.id.content_type_markdown; + case BBCODE -> R.id.content_type_bbcode; + case MISSKEY_MARKDOWN -> R.id.content_type_misskey_markdown; + }; + } + + public static void adaptMenuToInstance(Menu m, Instance i) { + if (i.pleroma == null) { + // memo: change this if glitch or another mastodon fork supports bbcode or mfm + m.findItem(R.id.content_type_bbcode).setVisible(false); + m.findItem(R.id.content_type_misskey_markdown).setVisible(false); + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/Filter.java b/mastodon/src/main/java/org/joinmastodon/android/model/Filter.java index f7b394765..019d249a6 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/Filter.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Filter.java @@ -2,23 +2,24 @@ package org.joinmastodon.android.model; import android.text.TextUtils; +import androidx.annotation.Nullable; + import com.google.gson.annotations.SerializedName; import org.joinmastodon.android.api.ObjectValidationException; -import org.joinmastodon.android.api.RequiredField; import org.parceler.Parcel; import java.time.Instant; import java.util.EnumSet; import java.util.List; import java.util.regex.Pattern; +import java.util.stream.Stream; @Parcel public class Filter extends BaseModel{ - @RequiredField public String id; - @RequiredField public String phrase; + public String title; public transient EnumSet context=EnumSet.noneOf(FilterContext.class); public Instant expiresAt; public boolean irreversible; @@ -50,6 +51,7 @@ public class Filter extends BaseModel{ else pattern=Pattern.compile(Pattern.quote(phrase), Pattern.CASE_INSENSITIVE); } + if (title == null) title = phrase; return pattern.matcher(text).find(); } @@ -61,6 +63,7 @@ public class Filter extends BaseModel{ public String toString(){ return "Filter{"+ "id='"+id+'\''+ + ", title='"+title+'\''+ ", phrase='"+phrase+'\''+ ", context="+context+ ", expiresAt="+expiresAt+ @@ -77,7 +80,9 @@ public class Filter extends BaseModel{ @SerializedName("public") PUBLIC, @SerializedName("thread") - THREAD + THREAD, + @SerializedName("account") + ACCOUNT } public enum FilterAction{ diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/FilterResult.java b/mastodon/src/main/java/org/joinmastodon/android/model/FilterResult.java index 2b67ef4bf..361a5fac5 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/FilterResult.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/FilterResult.java @@ -1,8 +1,15 @@ package org.joinmastodon.android.model; +import org.joinmastodon.android.api.ObjectValidationException; import org.parceler.Parcel; @Parcel public class FilterResult extends BaseModel { public Filter filter; + + @Override + public void postprocess() throws ObjectValidationException { + super.postprocess(); + if (filter != null) filter.postprocess(); + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/Instance.java b/mastodon/src/main/java/org/joinmastodon/android/model/Instance.java index 3db9d1433..e5b6f8719 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/Instance.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Instance.java @@ -11,6 +11,7 @@ import java.net.IDN; import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Optional; @Parcel public class Instance extends BaseModel{ @@ -45,7 +46,7 @@ public class Instance extends BaseModel{ @RequiredField public String version; /** - * Primary langauges of the website and its staff. + * Primary languages of the website and its staff. */ // @RequiredField public List languages; @@ -86,6 +87,11 @@ public class Instance extends BaseModel{ public Pleroma pleroma; + public PleromaPollLimits pollLimits; + + /** like uri, but always without scheme and trailing slash */ + public transient String normalizedUri; + @Override public void postprocess() throws ObjectValidationException{ super.postprocess(); @@ -95,6 +101,10 @@ public class Instance extends BaseModel{ rules=Collections.emptyList(); if(shortDescription==null) shortDescription=""; + // akkoma says uri is "https://example.social" while just "example.social" on mastodon + normalizedUri = uri + .replaceFirst("^https://", "") + .replaceFirst("/$", ""); } @Override @@ -121,7 +131,7 @@ public class Instance extends BaseModel{ ci.domain=uri; ci.normalizedDomain=IDN.toUnicode(uri); ci.description=Html.fromHtml(shortDescription).toString().trim(); - if(languages!=null){ + if(languages!=null && languages.size() > 0){ ci.language=languages.get(0); ci.languages=languages; }else{ @@ -134,6 +144,30 @@ public class Instance extends BaseModel{ return ci; } + public boolean isAkkoma() { + return pleroma != null; + } + + public boolean isPixelfed() { + return version.contains("compatible; Pixelfed"); + } + + public boolean hasFeature(Feature feature) { + Optional> pleromaFeatures = Optional.ofNullable(pleroma) + .map(p -> p.metadata) + .map(m -> m.features); + + return switch (feature) { + case BUBBLE_TIMELINE -> pleromaFeatures + .map(f -> f.contains("bubble_timeline")) + .orElse(false); + }; + } + + public enum Feature { + BUBBLE_TIMELINE + } + @Parcel public static class Rule{ public String id; @@ -198,6 +232,28 @@ public class Instance extends BaseModel{ @Parcel public static class Pleroma extends BaseModel { - // metadata etc + public Pleroma.Metadata metadata; + + @Parcel + public static class Metadata { + public List features; + public Pleroma.Metadata.FieldsLimits fieldsLimits; + + @Parcel + public static class FieldsLimits { + public int maxFields; + public int maxRemoteFields; + public int nameLength; + public int valueLength; + } + } + } + + @Parcel + public static class PleromaPollLimits { + public int maxExpiration; + public int maxOptionChars; + public int maxOptions; + public int minExpiration; } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/Marker.java b/mastodon/src/main/java/org/joinmastodon/android/model/Marker.java index 1bab926a7..636d91155 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/Marker.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Marker.java @@ -1,5 +1,7 @@ package org.joinmastodon.android.model; +import com.google.gson.annotations.SerializedName; + import org.joinmastodon.android.api.AllFieldsAreRequired; import java.time.Instant; @@ -18,4 +20,11 @@ public class Marker extends BaseModel{ ", updatedAt="+updatedAt+ '}'; } + + public enum Type { + @SerializedName("home") + HOME, + @SerializedName("notifications") + NOTIFICATIONS + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/Markers.java b/mastodon/src/main/java/org/joinmastodon/android/model/Markers.java new file mode 100644 index 000000000..6c07cdce1 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Markers.java @@ -0,0 +1,14 @@ +package org.joinmastodon.android.model; + +public class Markers { + public Marker notifications; + public Marker home; + + @Override + public String toString() { + return "Markers{" + + "notifications=" + notifications + + ", home=" + home + + '}'; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/Notification.java b/mastodon/src/main/java/org/joinmastodon/android/model/Notification.java index b104e86bf..b4340fd09 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/Notification.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Notification.java @@ -20,6 +20,8 @@ public class Notification extends BaseModel implements DisplayItemsParent{ public Account account; public Status status; public Report report; + public String emoji; + public String emojiUrl; @Override public void postprocess() throws ObjectValidationException{ @@ -51,6 +53,10 @@ public class Notification extends BaseModel implements DisplayItemsParent{ STATUS, @SerializedName("update") UPDATE, + @SerializedName("reaction") + REACTION, + @SerializedName("pleroma:emoji_reaction") + PLEROMA_EMOJI_REACTION, @SerializedName("admin.sign_up") SIGN_UP, @SerializedName("admin.report") diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/NotificationAction.java b/mastodon/src/main/java/org/joinmastodon/android/model/NotificationAction.java new file mode 100644 index 000000000..4f43a51a8 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/NotificationAction.java @@ -0,0 +1,9 @@ +package org.joinmastodon.android.model; + +public enum NotificationAction { + FAVORITE, + REBLOG, + UNDO_REBLOG, + BOOKMARK, + REPLY, +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/ParsedAccount.java b/mastodon/src/main/java/org/joinmastodon/android/model/ParsedAccount.java new file mode 100644 index 000000000..751b2c0ed --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/ParsedAccount.java @@ -0,0 +1,33 @@ +package org.joinmastodon.android.model; + +import android.text.SpannableStringBuilder; + +import org.joinmastodon.android.GlobalUserPreferences; +import org.joinmastodon.android.ui.text.HtmlParser; +import org.joinmastodon.android.ui.utils.CustomEmojiHelper; + +import java.util.Collections; + +import me.grishka.appkit.imageloader.requests.ImageLoaderRequest; +import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest; +import me.grishka.appkit.utils.V; + +public class ParsedAccount{ + public Account account; + public CharSequence parsedName, parsedBio; + public CustomEmojiHelper emojiHelper; + public ImageLoaderRequest avatarRequest; + + public ParsedAccount(Account account, String accountID){ + this.account=account; + parsedName=HtmlParser.parseCustomEmoji(account.displayName, account.emojis); + parsedBio=HtmlParser.parse(account.note, account.emojis, Collections.emptyList(), Collections.emptyList(), accountID); + + emojiHelper=new CustomEmojiHelper(); + SpannableStringBuilder ssb=new SpannableStringBuilder(parsedName); + ssb.append(parsedBio); + emojiHelper.setText(ssb); + + avatarRequest=new UrlImageLoaderRequest(GlobalUserPreferences.playGifs ? account.avatar : account.avatarStatic, V.dp(40), V.dp(40)); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/Poll.java b/mastodon/src/main/java/org/joinmastodon/android/model/Poll.java index b07e501d5..739a79f71 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/Poll.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Poll.java @@ -16,12 +16,13 @@ public class Poll extends BaseModel{ private boolean expired; public boolean multiple; public int votersCount; + public int votesCount; public boolean voted; - @RequiredField +// @RequiredField public List ownVotes; @RequiredField public List