From bd0b624dbfac79cd7e20fa104c10cfc67ffaf1a1 Mon Sep 17 00:00:00 2001
From: Contributor <>
Date: Sun, 12 Jul 2026 11:10:10 -0700
Subject: [PATCH] Add post moderation menu.
---
.../src/main/assets/javascript/thread.js | 1 +
.../src/main/assets/mustache/post.mustache | 2 +-
.../java/com/ferg/awfulapp/NavigationEvent.kt | 29 ++++
.../ferg/awfulapp/ThreadDisplayFragment.java | 9 +-
.../ferg/awfulapp/constants/Constants.java | 5 +
.../awfulapp/popupmenu/ModeratePostMenu.kt | 102 ++++++++++++
.../awfulapp/popupmenu/PostContextMenu.java | 25 ++-
.../ferg/awfulapp/provider/AwfulProvider.java | 1 +
.../awfulapp/provider/DatabaseHelper.java | 5 +-
.../com/ferg/awfulapp/task/ModQueueRequest.kt | 93 +++++++++++
.../ferg/awfulapp/thread/AwfulHtmlPage.java | 1 +
.../com/ferg/awfulapp/thread/AwfulPost.java | 12 ++
.../com/ferg/awfulapp/thread/AwfulURL.java | 36 ++++-
.../com/ferg/awfulapp/thread/ForumParsing.kt | 1 +
.../awfulapp/users/ModQueueRequestFragment.kt | 150 ++++++++++++++++++
.../src/main/res/drawable/ic_info_dark.xml | 9 ++
.../src/main/res/layout/modqueue_request.xml | 125 +++++++++++++++
Awful.apk/src/main/res/values/strings.xml | 20 +++
18 files changed, 620 insertions(+), 6 deletions(-)
create mode 100644 Awful.apk/src/main/java/com/ferg/awfulapp/popupmenu/ModeratePostMenu.kt
create mode 100644 Awful.apk/src/main/java/com/ferg/awfulapp/task/ModQueueRequest.kt
create mode 100644 Awful.apk/src/main/java/com/ferg/awfulapp/users/ModQueueRequestFragment.kt
create mode 100644 Awful.apk/src/main/res/drawable/ic_info_dark.xml
create mode 100644 Awful.apk/src/main/res/layout/modqueue_request.xml
diff --git a/Awful.apk/src/main/assets/javascript/thread.js b/Awful.apk/src/main/assets/javascript/thread.js
index 27f8cb052..20d5bd934 100644
--- a/Awful.apk/src/main/assets/javascript/thread.js
+++ b/Awful.apk/src/main/assets/javascript/thread.js
@@ -522,6 +522,7 @@ function showPostMenu(postMenu) {
postMenu.getAttribute('userid'),
postMenu.getAttribute('lastreadurl'),
postMenu.hasAttribute('editable'),
+ postMenu.hasAttribute('modControls'),
postMenu.getAttribute('data-role'),
postMenu.hasAttribute('isPlat'),
avatar ? avatar.getAttribute('src') : null
diff --git a/Awful.apk/src/main/assets/mustache/post.mustache b/Awful.apk/src/main/assets/mustache/post.mustache
index 078e444a6..3e2717fd3 100644
--- a/Awful.apk/src/main/assets/mustache/post.mustache
+++ b/Awful.apk/src/main/assets/mustache/post.mustache
@@ -11,7 +11,7 @@
{{#avatarText}}{{/avatarText}}
-
+
diff --git a/Awful.apk/src/main/java/com/ferg/awfulapp/NavigationEvent.kt b/Awful.apk/src/main/java/com/ferg/awfulapp/NavigationEvent.kt
index e4408e169..f61212c39 100644
--- a/Awful.apk/src/main/java/com/ferg/awfulapp/NavigationEvent.kt
+++ b/Awful.apk/src/main/java/com/ferg/awfulapp/NavigationEvent.kt
@@ -11,6 +11,7 @@ import com.ferg.awfulapp.search.SearchFilter
import com.ferg.awfulapp.search.SearchFragment
import com.ferg.awfulapp.thread.AwfulURL
import com.ferg.awfulapp.users.LepersColonyFragment
+import com.ferg.awfulapp.users.ModQueueRequestFragment
import com.ferg.awfulapp.util.AwfulUtils
import com.ferg.awfulapp.util.tryGetIntExtra
import timber.log.Timber
@@ -135,6 +136,24 @@ sealed class NavigationEvent(private val extraTypeId: String) {
}
}
+ /**
+ * Show the modqueue probation/ban request page for a post ([ban] selects which), targeting
+ * the [userId] who made the post [postId] in thread [threadId].
+ */
+ data class ModQueueRequest(val ban: Boolean, val userId: Int, val postId: Int, val threadId: Int) : NavigationEvent(TYPE_MODQUEUE_REQUEST) {
+
+ override fun activityIntent(context: Context) =
+ BasicActivity.intentFor(ModQueueRequestFragment::class.java, context,
+ context.getString(if (ban) R.string.mod_ban_request_title else R.string.mod_probation_request_title))
+
+ override val addDataToIntent: Intent.() -> Unit = {
+ putExtra(KEY_MODQUEUE_IS_BAN, ban)
+ putExtra(Constants.PARAM_USER_ID, userId)
+ putExtra(Constants.PARAM_POST_ID, postId)
+ putExtra(Constants.PARAM_THREAD_ID, threadId)
+ }
+ }
+
/**
* Build an Intent for this NavigationEvent.
@@ -168,8 +187,10 @@ sealed class NavigationEvent(private val extraTypeId: String) {
private const val TYPE_COMPOSE_PRIVATE_MESSAGE = "nav_compose_private_message"
private const val TYPE_ANNOUNCEMENTS = "nav_announcements"
private const val TYPE_LEPERS_COLONY = "nav_rap_sheet"
+ private const val TYPE_MODQUEUE_REQUEST = "nav_modqueue_request"
private const val KEY_SEARCH_FILTERS = "key_search_filters"
+ private const val KEY_MODQUEUE_IS_BAN = "key_modqueue_is_ban"
private fun Context.intentFor(clazz: Class): Intent = Intent().setClass(this, clazz)
@@ -211,6 +232,12 @@ sealed class NavigationEvent(private val extraTypeId: String) {
userId = getIntExtra(Constants.PARAM_USER_ID),
page = getIntExtra(Constants.PARAM_PAGE) ?: LepersColonyFragment.FIRST_PAGE
)
+ TYPE_MODQUEUE_REQUEST -> ModQueueRequest(
+ ban = getBooleanExtra(KEY_MODQUEUE_IS_BAN, false),
+ userId = getIntExtra(Constants.PARAM_USER_ID)!!,
+ postId = getIntExtra(Constants.PARAM_POST_ID)!!,
+ threadId = getIntExtra(Constants.PARAM_THREAD_ID)!!
+ )
else -> {
Timber.w("Couldn't parse Intent as NavigationEvent - event key: ${getStringExtra(EVENT_EXTRA_KEY)}")
MainActivity
@@ -244,6 +271,8 @@ sealed class NavigationEvent(private val extraTypeId: String) {
ForumIndex
isBanlist ->
LepersColony(id.toInt())
+ isModQueueRequest ->
+ ModQueueRequest(isBanRequest, id.toInt(), targetPostId.toInt(), threadId.toInt())
else -> null
}
}
diff --git a/Awful.apk/src/main/java/com/ferg/awfulapp/ThreadDisplayFragment.java b/Awful.apk/src/main/java/com/ferg/awfulapp/ThreadDisplayFragment.java
index 6b9bd09a8..d91a4ced8 100644
--- a/Awful.apk/src/main/java/com/ferg/awfulapp/ThreadDisplayFragment.java
+++ b/Awful.apk/src/main/java/com/ferg/awfulapp/ThreadDisplayFragment.java
@@ -377,6 +377,10 @@ public boolean shouldOverrideUrlLoading(WebView aView, String aUrl) {
case BANLIST:
navigate(new NavigationEvent.LepersColony((int) aLink.getId()));
break;
+ case MODQUEUE_REQUEST:
+ navigate(new NavigationEvent.ModQueueRequest(aLink.isBanRequest(),
+ (int) aLink.getId(), (int) aLink.getTargetPostId(), (int) aLink.getThreadId()));
+ break;
case INDEX:
navigate(NavigationEvent.ForumIndex.INSTANCE);
break;
@@ -1180,6 +1184,7 @@ public void onMoreClick(
final String aUserId,
final String lastReadUrl,
final boolean editable,
+ final boolean hasModControls,
final String posterRole,
final boolean isPlat,
final String avatarUrl) {
@@ -1187,7 +1192,9 @@ public void onMoreClick(
PostContextMenu postActions = PostContextMenu.newInstance(getThreadId(),
Integer.parseInt(aPostId),
Integer.parseInt(lastReadUrl),
- editable, aUsername,
+ editable,
+ hasModControls,
+ aUsername,
Integer.parseInt(aUserId),
isPlat,
posterRole,
diff --git a/Awful.apk/src/main/java/com/ferg/awfulapp/constants/Constants.java b/Awful.apk/src/main/java/com/ferg/awfulapp/constants/Constants.java
index f4a919394..ba5674f78 100644
--- a/Awful.apk/src/main/java/com/ferg/awfulapp/constants/Constants.java
+++ b/Awful.apk/src/main/java/com/ferg/awfulapp/constants/Constants.java
@@ -54,6 +54,7 @@ public class Constants {
public static final String FUNCTION_RATE_THREAD = BASE_URL + "/threadrate.php";
public static final String FUNCTION_MISC = BASE_URL + "/misc.php";
public static final String FUNCTION_REPORT = BASE_URL + "/modalert.php";
+ public static final String FUNCTION_MODQUEUE = BASE_URL + "/modqueue.php";
public static final String FUNCTION_POSTINGS = BASE_URL + "/postings.php";
public static final String FUNCTION_NEW_THREAD = BASE_URL + "/newthread.php";
@@ -62,6 +63,7 @@ public class Constants {
public static final String PATH_BOOKMARKS = "bookmarkthreads.php";
public static final String PATH_USERCP = "usercp.php";
public static final String PATH_BANLIST = "banlist.php";
+ public static final String PATH_MODQUEUE = "modqueue.php";
public static final String ACTION_PROFILE = "getinfo";
public static final String ACTION_SEARCH_POST_HISTORY = "do_search_posthistory";
@@ -72,6 +74,8 @@ public class Constants {
public static final String ACTION_QUERY = "query";
public static final String ACTION_RESULTS = "results";
public static final String ACTION_TOGGLE_THREAD_LOCKED = "openclosethread";
+ public static final String ACTION_REQUEST_PROBATION = "request_probation";
+ public static final String ACTION_REQUEST_BAN = "request_ban";
public static final String PARAM_USER_ID = "userid";
public static final String PARAM_USERNAME = "username";
@@ -87,6 +91,7 @@ public class Constants {
public static final String PARAM_PRIVATE_MESSAGE_ID = "privatemessageid";
public static final String PARAM_VOTE = "vote";
public static final String PARAM_POST_ID = "postid";
+ public static final String PARAM_TARGET_POST_ID = "tpostid";
public static final String PARAM_USERLIST = "userlist";
public static final String PARAM_FORMKEY = "formkey";
public static final String PARAM_FORM_COOKIE = "form_cookie";
diff --git a/Awful.apk/src/main/java/com/ferg/awfulapp/popupmenu/ModeratePostMenu.kt b/Awful.apk/src/main/java/com/ferg/awfulapp/popupmenu/ModeratePostMenu.kt
new file mode 100644
index 000000000..6dc1ba70b
--- /dev/null
+++ b/Awful.apk/src/main/java/com/ferg/awfulapp/popupmenu/ModeratePostMenu.kt
@@ -0,0 +1,102 @@
+package com.ferg.awfulapp.popupmenu
+
+import android.os.Bundle
+import androidx.annotation.DrawableRes
+import androidx.annotation.StringRes
+import com.ferg.awfulapp.AwfulActivity
+import com.ferg.awfulapp.NavigationEvent
+import com.ferg.awfulapp.R
+import com.ferg.awfulapp.ThreadDisplayFragment
+import com.ferg.awfulapp.popupmenu.ModeratePostMenu.ModerateMenuAction.EDIT_POST
+import com.ferg.awfulapp.popupmenu.ModeratePostMenu.ModerateMenuAction.REQUEST_BAN
+import com.ferg.awfulapp.popupmenu.ModeratePostMenu.ModerateMenuAction.REQUEST_PROBATION
+import com.ferg.awfulapp.thread.AwfulMessage
+
+/**
+ * Context menu with moderation actions for someone else's post.
+ *
+ * Shows an edit option when the post is editable by the current user, plus the modqueue
+ * probation/ban request options when the post carries modqueue controls.
+ */
+class ModeratePostMenu : BasePopupMenu() {
+
+ private var threadId = 0
+ private var postId = 0
+ private var posterUserId = 0
+ private var editable = false
+ private var hasModControls = false
+
+ companion object {
+ @JvmField
+ val TAG: String = ModeratePostMenu::class.java.simpleName
+
+ private const val ARG_THREAD_ID = "threadId"
+ private const val ARG_POST_ID = "postId"
+ private const val ARG_POSTER_USER_ID = "posterUserId"
+ private const val ARG_EDITABLE = "editable"
+ private const val ARG_HAS_MOD_CONTROLS = "hasModControls"
+
+ /**
+ * Get a moderation menu for a post, where [editable] adds the edit option and
+ * [hasModControls] adds the modqueue probation/ban request options.
+ */
+ @JvmStatic
+ fun newInstance(threadId: Int, postId: Int, posterUserId: Int, editable: Boolean, hasModControls: Boolean) =
+ ModeratePostMenu().apply {
+ arguments = Bundle().apply {
+ putInt(ARG_THREAD_ID, threadId)
+ putInt(ARG_POST_ID, postId)
+ putInt(ARG_POSTER_USER_ID, posterUserId)
+ putBoolean(ARG_EDITABLE, editable)
+ putBoolean(ARG_HAS_MOD_CONTROLS, hasModControls)
+ }
+ }
+ }
+
+ override fun init(args: Bundle) = with(args) {
+ threadId = getInt(ARG_THREAD_ID)
+ postId = getInt(ARG_POST_ID)
+ posterUserId = getInt(ARG_POSTER_USER_ID)
+ editable = getBoolean(ARG_EDITABLE)
+ hasModControls = getBoolean(ARG_HAS_MOD_CONTROLS)
+ }
+
+ override fun generateMenuItems() =
+ mutableListOf()
+ .apply { if (editable) add(EDIT_POST) }
+ .apply { if (hasModControls) { add(REQUEST_PROBATION); add(REQUEST_BAN) } }
+
+ override fun getMenuLabel(action: ModerateMenuAction): String = getString(action.menuLabelRes)
+
+ override fun getTitle(): String = getString(R.string.action_moderate_post)
+
+ override fun onActionClicked(action: ModerateMenuAction) {
+ when (action) {
+ EDIT_POST ->
+ (targetFragment as? ThreadDisplayFragment)
+ ?.displayPostReplyDialog(threadId, postId, AwfulMessage.TYPE_EDIT)
+ REQUEST_PROBATION ->
+ navigateTo(NavigationEvent.ModQueueRequest(ban = false, userId = posterUserId, postId = postId, threadId = threadId))
+ REQUEST_BAN ->
+ navigateTo(NavigationEvent.ModQueueRequest(ban = true, userId = posterUserId, postId = postId, threadId = threadId))
+ }
+ }
+
+ private fun navigateTo(event: NavigationEvent) {
+ (activity as AwfulActivity?)?.navigate(event)
+ }
+
+ enum class ModerateMenuAction(
+ @DrawableRes private val iconResId: Int,
+ @StringRes val menuLabelRes: Int
+ ) : AwfulAction {
+
+ EDIT_POST(R.drawable.ic_create_dark, R.string.action_edit_post),
+ REQUEST_PROBATION(R.drawable.ic_history_dark_24dp, R.string.action_request_probation),
+ REQUEST_BAN(R.drawable.ic_gavel_dark_24dp, R.string.action_request_ban);
+
+ override fun getIconId() = iconResId
+ // labels are resolved from menuLabelRes in getMenuLabel above, where a Context is available
+ override fun getMenuLabel() = ""
+ }
+}
diff --git a/Awful.apk/src/main/java/com/ferg/awfulapp/popupmenu/PostContextMenu.java b/Awful.apk/src/main/java/com/ferg/awfulapp/popupmenu/PostContextMenu.java
index c4ddcfe66..818c330e4 100644
--- a/Awful.apk/src/main/java/com/ferg/awfulapp/popupmenu/PostContextMenu.java
+++ b/Awful.apk/src/main/java/com/ferg/awfulapp/popupmenu/PostContextMenu.java
@@ -23,6 +23,7 @@
import static com.ferg.awfulapp.popupmenu.PostContextMenu.PostMenuAction.IGNORE_USER;
import static com.ferg.awfulapp.popupmenu.PostContextMenu.PostMenuAction.MARK_LAST_SEEN;
import static com.ferg.awfulapp.popupmenu.PostContextMenu.PostMenuAction.MARK_USER;
+import static com.ferg.awfulapp.popupmenu.PostContextMenu.PostMenuAction.MODERATE;
import static com.ferg.awfulapp.popupmenu.PostContextMenu.PostMenuAction.RAP_SHEET;
import static com.ferg.awfulapp.popupmenu.PostContextMenu.PostMenuAction.REPORT_POST;
import static com.ferg.awfulapp.popupmenu.PostContextMenu.PostMenuAction.SEND_PM;
@@ -42,6 +43,7 @@ public class PostContextMenu extends BasePopupMenu generateMenuItems() {
List awfulActions = new ArrayList<>();
awfulActions.add(PostMenuAction.QUOTE);
- if (editable) {
+ if (editable && ownPost) {
awfulActions.add(EDIT);
}
awfulActions.add(MARK_LAST_SEEN);
@@ -158,6 +165,9 @@ List generateMenuItems() {
awfulActions.add(prefs.blockedAvatarUrls.contains(posterAvatarUrl) ?
SHOW_AVATAR : HIDE_AVATAR);
}
+ if (!ownPost && (editable || hasModControls)) {
+ awfulActions.add(MODERATE);
+ }
return awfulActions;
}
@@ -212,6 +222,12 @@ void onActionClicked(@NonNull PostMenuAction action) {
case HIDE_AVATAR:
parent.toggleAvatar(posterAvatarUrl);
break;
+ case MODERATE:
+ ModeratePostMenu moderateMenu = ModeratePostMenu.newInstance(threadId, postId,
+ posterUserId, editable, hasModControls);
+ moderateMenu.setTargetFragment(parent, -1);
+ moderateMenu.show(parent.getParentFragmentManager(), ModeratePostMenu.TAG);
+ break;
}
}
@@ -219,6 +235,9 @@ void onActionClicked(@NonNull PostMenuAction action) {
@NonNull
@Override
String getMenuLabel(@NonNull PostMenuAction action) {
+ if (action == MODERATE) {
+ return getString(R.string.action_moderate_post);
+ }
// need to add the post's username to some of these
return String.format(action.getMenuLabel(), posterUsername);
}
@@ -244,7 +263,9 @@ public enum PostMenuAction implements AwfulAction {
RAP_SHEET(R.drawable.ic_gavel_dark_24dp, "%s's rap sheet"),
IGNORE_USER(R.drawable.ic_ignore_dark, "Ignore %s"),
SHOW_AVATAR(R.drawable.ic_visibility_dark, "Show avatar of %s"),
- HIDE_AVATAR(R.drawable.ic_ignore_dark, "Hide avatar of %s");
+ HIDE_AVATAR(R.drawable.ic_ignore_dark, "Hide avatar of %s"),
+ // label comes from R.string.action_moderate_post, see #getMenuLabel
+ MODERATE(R.drawable.ic_info_dark, "");
final int iconId;
@NonNull
diff --git a/Awful.apk/src/main/java/com/ferg/awfulapp/provider/AwfulProvider.java b/Awful.apk/src/main/java/com/ferg/awfulapp/provider/AwfulProvider.java
index 17c7eb856..d6f8acdc5 100644
--- a/Awful.apk/src/main/java/com/ferg/awfulapp/provider/AwfulProvider.java
+++ b/Awful.apk/src/main/java/com/ferg/awfulapp/provider/AwfulProvider.java
@@ -187,6 +187,7 @@ private static String[] arrayOfKeys(@NonNull Map map) {
sPostProjectionMap.put(AwfulPost.IS_IGNORED, AwfulPost.IS_IGNORED);
sPostProjectionMap.put(AwfulPost.PREVIOUSLY_READ, AwfulPost.PREVIOUSLY_READ);
sPostProjectionMap.put(AwfulPost.EDITABLE, AwfulPost.EDITABLE);
+ sPostProjectionMap.put(AwfulPost.HAS_MOD_CONTROLS, AwfulPost.HAS_MOD_CONTROLS);
sPostProjectionMap.put(AwfulPost.IS_OP, AwfulPost.IS_OP);
sPostProjectionMap.put(AwfulPost.IS_PLAT, AwfulPost.IS_PLAT);
sPostProjectionMap.put(AwfulPost.ROLE, AwfulPost.ROLE);
diff --git a/Awful.apk/src/main/java/com/ferg/awfulapp/provider/DatabaseHelper.java b/Awful.apk/src/main/java/com/ferg/awfulapp/provider/DatabaseHelper.java
index f3768db61..fb779a8ee 100644
--- a/Awful.apk/src/main/java/com/ferg/awfulapp/provider/DatabaseHelper.java
+++ b/Awful.apk/src/main/java/com/ferg/awfulapp/provider/DatabaseHelper.java
@@ -20,7 +20,7 @@
public class DatabaseHelper extends SQLiteOpenHelper {
private static final String DATABASE_NAME = "awful.db";
- private static final int DATABASE_VERSION = 39;
+ private static final int DATABASE_VERSION = 40;
static final String TABLE_FORUM = "forum";
public static final String TABLE_THREADS = "threads";
@@ -109,6 +109,7 @@ private void createPostTable(SQLiteDatabase aDb) {
AwfulPost.IS_IGNORED + " INTEGER," +
AwfulPost.PREVIOUSLY_READ + " INTEGER," +
AwfulPost.EDITABLE + " INTEGER," +
+ AwfulPost.HAS_MOD_CONTROLS + " INTEGER," +
AwfulPost.IS_OP + " INTEGER," +
AwfulPost.IS_PLAT + " INTEGER," +
AwfulPost.ROLE + " VARCHAR," +
@@ -215,6 +216,8 @@ public void onUpgrade(SQLiteDatabase aDb, int aOldVersion, int aNewVersion) {
createPostTable(aDb);
case 38:
aDb.execSQL("ALTER TABLE " + TABLE_THREADS + " ADD COLUMN " + AwfulThread.LAST_POST_DATE + " INTEGER DEFAULT 0");
+ case 39:
+ aDb.execSQL("ALTER TABLE " + TABLE_POSTS + " ADD COLUMN " + AwfulPost.HAS_MOD_CONTROLS + " INTEGER DEFAULT 0");
break;//make sure to keep this break statement on the last case of this switch
default:
wipeRecreateTables(aDb);
diff --git a/Awful.apk/src/main/java/com/ferg/awfulapp/task/ModQueueRequest.kt b/Awful.apk/src/main/java/com/ferg/awfulapp/task/ModQueueRequest.kt
new file mode 100644
index 000000000..b0216711d
--- /dev/null
+++ b/Awful.apk/src/main/java/com/ferg/awfulapp/task/ModQueueRequest.kt
@@ -0,0 +1,93 @@
+package com.ferg.awfulapp.task
+
+import android.content.Context
+import com.ferg.awfulapp.R
+import com.ferg.awfulapp.constants.Constants.ACTION_REQUEST_BAN
+import com.ferg.awfulapp.constants.Constants.ACTION_REQUEST_PROBATION
+import com.ferg.awfulapp.constants.Constants.FUNCTION_MODQUEUE
+import com.ferg.awfulapp.constants.Constants.PARAM_ACTION
+import com.ferg.awfulapp.constants.Constants.PARAM_TARGET_POST_ID
+import com.ferg.awfulapp.constants.Constants.PARAM_THREAD_ID
+import com.ferg.awfulapp.constants.Constants.PARAM_USER_ID
+import com.ferg.awfulapp.util.AwfulError
+import org.jsoup.nodes.Document
+
+/**
+ * Request to load the modqueue probation/ban request page for a post, parsing out the details
+ * needed to display the form and submit it through a [ModQueueSubmitRequest].
+ */
+class ModQueueFormRequest(context: Context, ban: Boolean, userId: Int, postId: Int, threadId: Int) :
+ AwfulRequest(context, FUNCTION_MODQUEUE) {
+
+ companion object {
+ const val FIELD_DURATION = "data"
+ const val FIELD_BAN_TYPE = "act"
+ const val FIELD_REASON = "reason"
+ const val FIELD_NOTES = "notes"
+ }
+
+ init {
+ with(parameters) {
+ add(PARAM_ACTION, if (ban) ACTION_REQUEST_BAN else ACTION_REQUEST_PROBATION)
+ add(PARAM_USER_ID, userId.toString())
+ add(PARAM_TARGET_POST_ID, postId.toString())
+ add(PARAM_THREAD_ID, threadId.toString())
+ }
+ }
+
+ @Throws(AwfulError::class)
+ override fun handleResponse(doc: Document): ModQueueForm {
+ val form = doc.selectFirst("form[action=modqueue.php]")
+ ?: throw AwfulError(context.getString(R.string.mod_request_form_load_failed))
+
+ val hiddenFields = form.select("input[type=hidden]")
+ .associate { it.attr("name") to it.attr("value") }
+
+ fun tableValueFor(label: String) =
+ form.select("td").firstOrNull { it.text().trim() == label }?.nextElementSibling()
+
+ val username = tableValueFor("User:")?.text()?.trim()
+ val history = tableValueFor("History:")?.run {
+ select("a").remove()
+ text().replace("""\(\s*\)""".toRegex(), "").trim()
+ }
+
+ val durations = form.select("select[name=$FIELD_DURATION] option")
+ .map { Choice(it.`val`(), it.text().trim(), it.hasAttr("selected")) }
+ val banTypes = form.select("input[name=$FIELD_BAN_TYPE][type=radio]")
+ .map { Choice(it.`val`(), it.nextElementSibling()?.text()?.trim() ?: "", it.hasAttr("checked")) }
+
+ return ModQueueForm(username, history, durations, banTypes, hiddenFields)
+ }
+
+ /** A selectable option in the form, e.g. a probation duration or ban type */
+ data class Choice(val value: String, val label: String, val selected: Boolean)
+
+ /**
+ * The pertinent parts of a modqueue request page: the target [username] and punishment
+ * [history], the available [durations] (probation) or [banTypes] (ban), and the form's
+ * [hiddenFields] which need to be sent back on submission.
+ */
+ data class ModQueueForm(
+ val username: String?,
+ val history: String?,
+ val durations: List,
+ val banTypes: List,
+ val hiddenFields: Map
+ )
+}
+
+
+/**
+ * Submits a modqueue probation/ban request. [params] should hold the form's hidden fields plus
+ * the user's input, as parsed from a [ModQueueFormRequest.ModQueueForm].
+ */
+class ModQueueSubmitRequest(context: Context, params: Map) :
+ AwfulRequest(context, FUNCTION_MODQUEUE, isPostRequest = true) {
+
+ init {
+ params.forEach { (name, value) -> parameters.add(name, value) }
+ }
+
+ override fun handleResponse(doc: Document): Void? = null
+}
diff --git a/Awful.apk/src/main/java/com/ferg/awfulapp/thread/AwfulHtmlPage.java b/Awful.apk/src/main/java/com/ferg/awfulapp/thread/AwfulHtmlPage.java
index f24a30aaa..9f5df4d99 100644
--- a/Awful.apk/src/main/java/com/ferg/awfulapp/thread/AwfulHtmlPage.java
+++ b/Awful.apk/src/main/java/com/ferg/awfulapp/thread/AwfulHtmlPage.java
@@ -226,6 +226,7 @@ private static String getPostsHtml(List aPosts, AwfulPreferences aPre
postData.put("avatarText", post.getAvatarText());
postData.put("lastReadUrl", post.getLastReadUrl());
postData.put("editable", post.isEditable() ? "editable" : null);
+ postData.put("modControls", post.hasModControls() ? "modControls" : null);
postData.put("postcontent", post.getContent());
postData.put("hideAvatar", aPrefs.isBlockedAvatar(avatar) ? "blockedAvatar" : null);
diff --git a/Awful.apk/src/main/java/com/ferg/awfulapp/thread/AwfulPost.java b/Awful.apk/src/main/java/com/ferg/awfulapp/thread/AwfulPost.java
index 89bb949a9..68f6d65d7 100644
--- a/Awful.apk/src/main/java/com/ferg/awfulapp/thread/AwfulPost.java
+++ b/Awful.apk/src/main/java/com/ferg/awfulapp/thread/AwfulPost.java
@@ -87,6 +87,7 @@ public class AwfulPost {
public static final String IS_IGNORED = "is_ignored";
public static final String PREVIOUSLY_READ = "previously_read";
public static final String EDITABLE = "editable";
+ public static final String HAS_MOD_CONTROLS = "has_mod_controls";
public static final String IS_OP = "is_op";
public static final String IS_PLAT = "is_plat";
public static final String ROLE = "role";
@@ -126,6 +127,7 @@ public class AwfulPost {
private boolean mPreviouslyRead = false;
private String mLastReadUrl = "";
private boolean mEditable;
+ private boolean mHasModControls = false;
private boolean isOp = false;
private boolean isPlat = false;
private String mRole = "";
@@ -260,6 +262,7 @@ public static ArrayList fromCursor(Context aContext, Cursor aCursor)
int isIgnoredIndex = aCursor.getColumnIndex(IS_IGNORED);
int previouslyReadIndex = aCursor.getColumnIndex(PREVIOUSLY_READ);
int editableIndex = aCursor.getColumnIndex(EDITABLE);
+ int hasModControlsIndex = aCursor.getColumnIndex(HAS_MOD_CONTROLS);
int isOpIndex = aCursor.getColumnIndex(IS_OP);
int isPlatIndex = aCursor.getColumnIndex(IS_PLAT);
int roleIndex = aCursor.getColumnIndex(ROLE);
@@ -284,6 +287,7 @@ public static ArrayList fromCursor(Context aContext, Cursor aCursor)
current.setPreviouslyRead(aCursor.getInt(previouslyReadIndex) > 0);
current.setLastReadUrl(aCursor.getInt(postIndexIndex)+"");
current.setEditable(aCursor.getInt(editableIndex) == 1);
+ current.setHasModControls(aCursor.getInt(hasModControlsIndex) == 1);
current.setIsOp(aCursor.getInt(isOpIndex) == 1);
current.setIsPlat(aCursor.getInt(isPlatIndex) > 0);
current.setRole(aCursor.getString(roleIndex));
@@ -496,6 +500,14 @@ public void setEditable(boolean aEditable) {
mEditable = aEditable;
}
+ public boolean hasModControls() {
+ return mHasModControls;
+ }
+
+ public void setHasModControls(boolean aHasModControls) {
+ mHasModControls = aHasModControls;
+ }
+
/**
diff --git a/Awful.apk/src/main/java/com/ferg/awfulapp/thread/AwfulURL.java b/Awful.apk/src/main/java/com/ferg/awfulapp/thread/AwfulURL.java
index ff29f64e8..18774275e 100644
--- a/Awful.apk/src/main/java/com/ferg/awfulapp/thread/AwfulURL.java
+++ b/Awful.apk/src/main/java/com/ferg/awfulapp/thread/AwfulURL.java
@@ -9,7 +9,7 @@
public class AwfulURL {
- public enum TYPE{FORUM,THREAD,POST,EXTERNAL,NONE,INDEX,BANLIST}
+ public enum TYPE{FORUM,THREAD,POST,EXTERNAL,NONE,INDEX,BANLIST,MODQUEUE_REQUEST}
private long id;
private long pageNum = 1;
private int perPage = Constants.ITEMS_PER_PAGE;
@@ -17,6 +17,9 @@ public enum TYPE{FORUM,THREAD,POST,EXTERNAL,NONE,INDEX,BANLIST}
private TYPE type = TYPE.NONE;
private String gotoParam;
private String fragment;
+ private long targetPostId;
+ private long threadId;
+ private boolean banRequest;
public static AwfulURL forum(long id){
return forum(id, 1);
@@ -121,6 +124,14 @@ public static AwfulURL parse(String url){
}else if(Constants.PATH_BANLIST.equals(uri.getLastPathSegment())){
aurl.type = TYPE.BANLIST;
aurl.id = AwfulUtils.safeParseLong(uri.getQueryParameter(Constants.PARAM_USER_ID), 0);
+ }else if(Constants.PATH_MODQUEUE.equals(uri.getLastPathSegment())
+ && (Constants.ACTION_REQUEST_PROBATION.equals(uri.getQueryParameter(Constants.PARAM_ACTION))
+ || Constants.ACTION_REQUEST_BAN.equals(uri.getQueryParameter(Constants.PARAM_ACTION)))){
+ aurl.type = TYPE.MODQUEUE_REQUEST;
+ aurl.banRequest = Constants.ACTION_REQUEST_BAN.equals(uri.getQueryParameter(Constants.PARAM_ACTION));
+ aurl.id = AwfulUtils.safeParseLong(uri.getQueryParameter(Constants.PARAM_USER_ID), 0);
+ aurl.targetPostId = AwfulUtils.safeParseLong(uri.getQueryParameter(Constants.PARAM_TARGET_POST_ID), 0);
+ aurl.threadId = AwfulUtils.safeParseLong(uri.getQueryParameter(Constants.PARAM_THREAD_ID), 0);
}else if("index.php".equalsIgnoreCase(uri.getLastPathSegment()) || uri.getPath() == null || uri.getPath().length() < 2){
aurl.type = TYPE.INDEX;
}else{
@@ -172,6 +183,13 @@ public String getURL(int postPerPage){
url.appendQueryParameter(Constants.PARAM_PER_PAGE, Integer.toString(postPerPage));
url.appendQueryParameter(Constants.PARAM_POST_ID, Long.toString(id));
break;
+ case MODQUEUE_REQUEST:
+ url = Uri.parse(Constants.FUNCTION_MODQUEUE).buildUpon();
+ url.appendQueryParameter(Constants.PARAM_ACTION, banRequest ? Constants.ACTION_REQUEST_BAN : Constants.ACTION_REQUEST_PROBATION);
+ url.appendQueryParameter(Constants.PARAM_USER_ID, Long.toString(id));
+ url.appendQueryParameter(Constants.PARAM_TARGET_POST_ID, Long.toString(targetPostId));
+ url.appendQueryParameter(Constants.PARAM_THREAD_ID, Long.toString(threadId));
+ break;
case EXTERNAL:
return externalURL;
case INDEX:
@@ -250,6 +268,22 @@ public boolean isBanlist() {
return type == TYPE.BANLIST;
}
+ public boolean isModQueueRequest() {
+ return type == TYPE.MODQUEUE_REQUEST;
+ }
+
+ public boolean isBanRequest() {
+ return banRequest;
+ }
+
+ public long getTargetPostId() {
+ return targetPostId;
+ }
+
+ public long getThreadId() {
+ return threadId;
+ }
+
public AwfulURL setPerPage(int postPerPage) {
perPage = postPerPage;
return this;
diff --git a/Awful.apk/src/main/java/com/ferg/awfulapp/thread/ForumParsing.kt b/Awful.apk/src/main/java/com/ferg/awfulapp/thread/ForumParsing.kt
index 5aeee6ea0..5e1860014 100644
--- a/Awful.apk/src/main/java/com/ferg/awfulapp/thread/ForumParsing.kt
+++ b/Awful.apk/src/main/java/com/ferg/awfulapp/thread/ForumParsing.kt
@@ -196,6 +196,7 @@ class PostParseTask(
?.let { put(EDITED, "${it.text()}") }
put(EDITABLE, postData.getElementsByAttributeValue("alt", "Edit").isNotEmpty().sqlBool)
+ put(HAS_MOD_CONTROLS, postData.hasDescendantWithClass("modqueue_ctl").sqlBool)
}
}
diff --git a/Awful.apk/src/main/java/com/ferg/awfulapp/users/ModQueueRequestFragment.kt b/Awful.apk/src/main/java/com/ferg/awfulapp/users/ModQueueRequestFragment.kt
new file mode 100644
index 000000000..d33fcdc8b
--- /dev/null
+++ b/Awful.apk/src/main/java/com/ferg/awfulapp/users/ModQueueRequestFragment.kt
@@ -0,0 +1,150 @@
+package com.ferg.awfulapp.users
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ArrayAdapter
+import android.widget.Button
+import android.widget.EditText
+import android.widget.RadioButton
+import android.widget.RadioGroup
+import android.widget.Spinner
+import android.widget.TextView
+import com.android.volley.VolleyError
+import com.ferg.awfulapp.AwfulFragment
+import com.ferg.awfulapp.NavigationEvent
+import com.ferg.awfulapp.NavigationEvent.Companion.parse
+import com.ferg.awfulapp.R
+import com.ferg.awfulapp.constants.Constants.PARAM_SUBMIT
+import com.ferg.awfulapp.task.AwfulRequest
+import com.ferg.awfulapp.task.ModQueueFormRequest
+import com.ferg.awfulapp.task.ModQueueFormRequest.ModQueueForm
+import com.ferg.awfulapp.task.ModQueueSubmitRequest
+import com.ferg.awfulapp.util.bind
+
+/**
+ * Displays a modqueue probation/ban request page as a native form, and submits the request.
+ *
+ * The page to load is described by a [NavigationEvent.ModQueueRequest] parsed from the activity's
+ * intent. Submitting the request just waits for the form post to complete (without displaying the
+ * site's response) and closes the screen, so the user lands back where they came from.
+ */
+class ModQueueRequestFragment : AwfulFragment() {
+
+ private val formContainer: View by bind(R.id.form_container)
+ private val userName: TextView by bind(R.id.user_name)
+ private val userHistory: TextView by bind(R.id.user_history)
+ private val durationSection: View by bind(R.id.duration_section)
+ private val durationSpinner: Spinner by bind(R.id.duration_spinner)
+ private val banTypeSection: View by bind(R.id.ban_type_section)
+ private val banTypeGroup: RadioGroup by bind(R.id.ban_type_group)
+ private val reasonInput: EditText by bind(R.id.reason_input)
+ private val notesInput: EditText by bind(R.id.notes_input)
+ private val submitButton: Button by bind(R.id.submit_button)
+
+ private lateinit var request: NavigationEvent.ModQueueRequest
+ private var form: ModQueueForm? = null
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? =
+ inflateView(R.layout.modqueue_request, container, inflater)
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ submitButton.setOnClickListener { submit() }
+ }
+
+ override fun onActivityCreated(aSavedState: Bundle?) {
+ super.onActivityCreated(aSavedState)
+ val event = activity?.intent?.parse() as? NavigationEvent.ModQueueRequest
+ if (event == null) {
+ activity?.finish()
+ return
+ }
+ request = event
+ loadForm()
+ }
+
+ override fun getTitle(): String? =
+ if (::request.isInitialized) {
+ getString(if (request.ban) R.string.mod_ban_request_title else R.string.mod_probation_request_title)
+ } else null
+
+ private fun loadForm() {
+ activity?.let { context ->
+ queueRequest(ModQueueFormRequest(context, request.ban, request.userId, request.postId, request.threadId)
+ .build(this, object : AwfulRequest.AwfulResultCallback {
+
+ override fun success(result: ModQueueForm) {
+ form = result
+ showForm(result)
+ }
+
+ override fun failure(error: VolleyError?) {}
+ }))
+ }
+ }
+
+ private fun showForm(form: ModQueueForm) {
+ userName.text = getString(R.string.mod_request_user, form.username ?: "")
+ userHistory.text = form.history
+ userHistory.visibility = if (form.history.isNullOrEmpty()) View.GONE else View.VISIBLE
+
+ if (request.ban) {
+ banTypeSection.visibility = View.VISIBLE
+ banTypeGroup.removeAllViews()
+ form.banTypes.forEach { choice ->
+ val button = RadioButton(requireContext()).apply {
+ id = View.generateViewId()
+ text = choice.label
+ tag = choice.value
+ }
+ banTypeGroup.addView(button)
+ if (choice.selected) banTypeGroup.check(button.id)
+ }
+ } else {
+ durationSection.visibility = View.VISIBLE
+ durationSpinner.adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, form.durations.map { it.label })
+ .apply { setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) }
+ durationSpinner.setSelection(form.durations.indexOfFirst { it.selected }.coerceAtLeast(0))
+ }
+ formContainer.visibility = View.VISIBLE
+ }
+
+ private fun submit() {
+ val form = form ?: return
+ val reason = reasonInput.text.toString().trim()
+ if (reason.isEmpty()) {
+ reasonInput.error = getString(R.string.mod_request_reason_required)
+ return
+ }
+ val choice = if (request.ban) {
+ val checked = banTypeGroup.findViewById(banTypeGroup.checkedRadioButtonId) ?: return
+ ModQueueFormRequest.FIELD_BAN_TYPE to checked.tag as String
+ } else {
+ val selected = form.durations.getOrNull(durationSpinner.selectedItemPosition) ?: return
+ ModQueueFormRequest.FIELD_DURATION to selected.value
+ }
+ val params = form.hiddenFields + choice + mapOf(
+ ModQueueFormRequest.FIELD_REASON to reason,
+ ModQueueFormRequest.FIELD_NOTES to notesInput.text.toString(),
+ PARAM_SUBMIT to "Submit"
+ )
+
+ submitButton.isEnabled = false
+ activity?.let { context ->
+ queueRequest(ModQueueSubmitRequest(context, params)
+ .build(this, object : AwfulRequest.AwfulResultCallback {
+
+ override fun success(result: Void?) {
+ makeToast(if (request.ban) R.string.mod_request_ban_queued else R.string.mod_request_probation_queued)
+ activity?.finish()
+ }
+
+ override fun failure(error: VolleyError?) {
+ submitButton.isEnabled = true
+ }
+ }))
+ }
+ }
+}
diff --git a/Awful.apk/src/main/res/drawable/ic_info_dark.xml b/Awful.apk/src/main/res/drawable/ic_info_dark.xml
new file mode 100644
index 000000000..d531fd379
--- /dev/null
+++ b/Awful.apk/src/main/res/drawable/ic_info_dark.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/Awful.apk/src/main/res/layout/modqueue_request.xml b/Awful.apk/src/main/res/layout/modqueue_request.xml
new file mode 100644
index 000000000..35db875d1
--- /dev/null
+++ b/Awful.apk/src/main/res/layout/modqueue_request.xml
@@ -0,0 +1,125 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Awful.apk/src/main/res/values/strings.xml b/Awful.apk/src/main/res/values/strings.xml
index ed324dc21..5d51af774 100644
--- a/Awful.apk/src/main/res/values/strings.xml
+++ b/Awful.apk/src/main/res/values/strings.xml
@@ -528,6 +528,26 @@
Did this post break the forum rules? If so, please report it by clicking below. If you would like to add any comments explaining why you submitted this post, please do so here:
I accept the terms and conditions of the forum rules
+
+ Moderate Post
+ Edit Post
+ Probation
+ Ban
+ Probation Request
+ Ban Request
+ User: %1$s
+ Block posting for
+ Ban type
+ Reason
+ Visible to ALL forum members, and used as the reason in the Leper\'s Colony.
+ Moderator notes (optional)
+ Only visible to other mods and admins.
+ A reason is required
+ Submit
+ Probation Queued
+ Ban Queued
+ Couldn\'t find the request form – do you have mod privileges?
+