Commit 374be454 authored by Thomas's avatar Thomas
Browse files

- Fix #1355 Add visual indicator for new comments in conversation threads

parent bfff3f85
Loading
Loading
Loading
Loading
+1 −0
Original line number Diff line number Diff line
@@ -187,6 +187,7 @@ public class Status implements Serializable, Cloneable {
    public transient boolean submitted = false;

    public transient boolean underlined = false;
    public transient boolean isNewComment = false;
    public boolean spoilerChecked = false;
    public Filter filteredByApp;
    public transient Spannable contentSpan;
+132 −0
Original line number Diff line number Diff line
package app.fedilab.android.mastodon.client.entities.app;
/* Copyright 2026 Thomas Schneider
 *
 * This file is a part of Fedilab
 *
 * This program is free software; you can redistribute it and/or modify it under the terms of the
 * GNU General Public License as published by the Free Software Foundation; either version 3 of the
 * License, or (at your option) any later version.
 *
 * Fedilab is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
 * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
 * Public License for more details.
 *
 * You should have received a copy of the GNU General Public License along with Fedilab; if not,
 * see <http://www.gnu.org/licenses>. */

import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;

import com.google.gson.Gson;
import com.google.gson.annotations.SerializedName;
import com.google.gson.reflect.TypeToken;

import java.io.Serializable;
import java.util.List;

import app.fedilab.android.mastodon.exception.DBException;
import app.fedilab.android.sqlite.Sqlite;


public class SeenComments implements Serializable {


    private transient final SQLiteDatabase db;
    @SerializedName("id")
    public long id = -1;
    @SerializedName("instance")
    public String instance;
    @SerializedName("user_id")
    public String user_id;
    @SerializedName("context_status_id")
    public String context_status_id;
    @SerializedName("descendant_ids")
    public List<String> descendant_ids;

    public SeenComments() {
        db = null;
    }

    public SeenComments(Context context) {
        this.db = Sqlite.getInstance(context.getApplicationContext(), Sqlite.DB_NAME, null, Sqlite.DB_VERSION).open();
    }

    public static String idListToStringStorage(List<String> ids) {
        Gson gson = new Gson();
        try {
            return gson.toJson(ids);
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }

    public static List<String> restoreIdListFromString(String serializedIds) {
        Gson gson = new Gson();
        try {
            return gson.fromJson(serializedIds, new TypeToken<List<String>>() {
            }.getType());
        } catch (Exception e) {
            return null;
        }
    }

    public SeenComments getSeenComments(BaseAccount account, String contextStatusId) throws DBException {
        if (db == null) {
            throw new DBException("db is null. Wrong initialization.");
        }
        try {
            Cursor c = db.query(Sqlite.TABLE_SEEN_COMMENTS, null, Sqlite.COL_INSTANCE + " = '" + account.instance + "' AND " + Sqlite.COL_USER_ID + " = '" + account.user_id + "' AND " + Sqlite.COL_CONTEXT_STATUS_ID + " = '" + contextStatusId + "'", null, null, null, null, "1");
            return convertCursorToSeenComments(c);
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }

    public long insertOrUpdate(BaseAccount account, String contextStatusId, List<String> descendantIds) throws DBException {
        if (db == null) {
            throw new DBException("db is null. Wrong initialization.");
        }
        boolean insert = false;
        SeenComments seenComments = getSeenComments(account, contextStatusId);
        ContentValues values = new ContentValues();
        if (seenComments == null) {
            values.put(Sqlite.COL_INSTANCE, account.instance);
            values.put(Sqlite.COL_USER_ID, account.user_id);
            values.put(Sqlite.COL_CONTEXT_STATUS_ID, contextStatusId);
            insert = true;
        }
        values.put(Sqlite.COL_DESCENDANT_IDS, idListToStringStorage(descendantIds));
        try {
            if (insert) {
                return db.insertOrThrow(Sqlite.TABLE_SEEN_COMMENTS, null, values);
            } else {
                return db.update(Sqlite.TABLE_SEEN_COMMENTS,
                        values, Sqlite.COL_USER_ID + " =  ? AND " + Sqlite.COL_INSTANCE + " = ? AND " + Sqlite.COL_CONTEXT_STATUS_ID + " = ?",
                        new String[]{account.user_id, account.instance, contextStatusId});
            }
        } catch (Exception e) {
            e.printStackTrace();
            return -1;
        }
    }

    private SeenComments convertCursorToSeenComments(Cursor c) {
        if (c.getCount() == 0) {
            c.close();
            return null;
        }
        c.moveToFirst();
        SeenComments seenComments = new SeenComments();
        seenComments.id = c.getInt(c.getColumnIndexOrThrow(Sqlite.COL_ID));
        seenComments.instance = c.getString(c.getColumnIndexOrThrow(Sqlite.COL_INSTANCE));
        seenComments.user_id = c.getString(c.getColumnIndexOrThrow(Sqlite.COL_USER_ID));
        seenComments.context_status_id = c.getString(c.getColumnIndexOrThrow(Sqlite.COL_CONTEXT_STATUS_ID));
        seenComments.descendant_ids = restoreIdListFromString(c.getString(c.getColumnIndexOrThrow(Sqlite.COL_DESCENDANT_IDS)));
        c.close();
        return seenComments;
    }
}
+6 −0
Original line number Diff line number Diff line
@@ -3845,6 +3845,12 @@ public class StatusAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder>
            SearchVM searchVM = new ViewModelProvider((ViewModelStoreOwner) context).get(SearchVM.class);
            statusManagement(context, statusesVM, searchVM, holder, mRecyclerView, this, statusList, status, timelineType, minified, canBeFederated, checkRemotely, fetchMoreCallBack);
            applyColor(context, holder);
            if (status.isNewComment) {
                holder.binding.cardviewContainer.setStrokeColor(ThemeHelper.fetchAccentColor(context));
                holder.binding.cardviewContainer.setStrokeWidth((int) (2 * context.getResources().getDisplayMetrics().density));
            } else {
                holder.binding.cardviewContainer.setStrokeWidth(0);
            }
        } else if (viewHolder.getItemViewType() == STATUS_FILTERED_HIDE) {
            StatusViewHolder holder = (StatusViewHolder) viewHolder;
            SharedPreferences sharedpreferences = PreferenceManager.getDefaultSharedPreferences(context);
+28 −0
Original line number Diff line number Diff line
@@ -36,7 +36,9 @@ import androidx.preference.PreferenceManager;
import androidx.recyclerview.widget.LinearLayoutManager;

import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

import app.fedilab.android.R;
import app.fedilab.android.activities.MainActivity;
@@ -44,7 +46,9 @@ import app.fedilab.android.databinding.FragmentPaginationBinding;
import app.fedilab.android.mastodon.activities.ContextActivity;
import app.fedilab.android.mastodon.client.entities.api.Context;
import app.fedilab.android.mastodon.client.entities.api.Status;
import app.fedilab.android.mastodon.client.entities.app.BaseAccount;
import app.fedilab.android.mastodon.client.entities.app.CachedBundle;
import app.fedilab.android.mastodon.client.entities.app.SeenComments;
import app.fedilab.android.mastodon.client.entities.app.Timeline;
import app.fedilab.android.mastodon.helper.CommentDecorationHelper;
import app.fedilab.android.mastodon.helper.DividerDecoration;
@@ -317,6 +321,30 @@ public class FragmentMastodonContext extends Fragment {
        }
        allParentIds.add(focusedStatus.id);
        List<Status> sortedDescendants = CommentDecorationHelper.sortDescendantsAsTree(context.descendants, allParentIds);
        SharedPreferences sharedpreferences = PreferenceManager.getDefaultSharedPreferences(requireActivity());
        boolean highlightNewComments = sharedpreferences.getBoolean(getString(R.string.SET_HIGHLIGHT_NEW_COMMENTS), true);
        if (highlightNewComments) try {
            SeenComments seenCommentsDAO = new SeenComments(requireActivity());
            BaseAccount currentAccount = Helper.getCurrentAccount(requireActivity());
            if (currentAccount != null) {
                List<String> currentDescendantIds = new ArrayList<>();
                for (Status descendant : sortedDescendants) {
                    currentDescendantIds.add(descendant.id);
                }
                SeenComments previouslySeen = seenCommentsDAO.getSeenComments(currentAccount, focusedStatus.id);
                if (previouslySeen != null && previouslySeen.descendant_ids != null) {
                    Set<String> seenSet = new HashSet<>(previouslySeen.descendant_ids);
                    for (Status descendant : sortedDescendants) {
                        if (!seenSet.contains(descendant.id)) {
                            descendant.isNewComment = true;
                        }
                    }
                }
                seenCommentsDAO.insertOrUpdate(currentAccount, focusedStatus.id, currentDescendantIds);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
        statuses.addAll(statusPosition + 1, sortedDescendants);
        statusAdapter.notifyItemRangeInserted(statusPosition + 1, sortedDescendants.size());
        if (binding.recyclerView.getItemDecorationCount() > 0) {
+15 −1
Original line number Diff line number Diff line
@@ -23,7 +23,7 @@ import android.database.sqlite.SQLiteOpenHelper;
public class Sqlite extends SQLiteOpenHelper {


    public static final int DB_VERSION = 12;
    public static final int DB_VERSION = 13;
    public static final String DB_NAME = "fedilab_db";

    //Table of owned accounts
@@ -110,6 +110,10 @@ public class Sqlite extends SQLiteOpenHelper {
    public static final String COL_BUNDLE = "BUNDLE";
    public static final String COL_TARGET_ID = "TARGET_ID";

    public static final String TABLE_SEEN_COMMENTS = "SEEN_COMMENTS";
    public static final String COL_CONTEXT_STATUS_ID = "CONTEXT_STATUS_ID";
    public static final String COL_DESCENDANT_IDS = "DESCENDANT_IDS";


    private static final String CREATE_TABLE_USER_ACCOUNT = "CREATE TABLE " + TABLE_USER_ACCOUNT + " ("
            + COL_USER_ID + " TEXT NOT NULL, "
@@ -247,6 +251,13 @@ public class Sqlite extends SQLiteOpenHelper {
            + COL_BUNDLE + " TEXT, "
            + COL_CREATED_AT + " TEXT NOT NULL)";

    private static final String CREATE_TABLE_SEEN_COMMENTS = "CREATE TABLE IF NOT EXISTS " + TABLE_SEEN_COMMENTS + " ("
            + COL_ID + " INTEGER PRIMARY KEY AUTOINCREMENT, "
            + COL_INSTANCE + " TEXT NOT NULL, "
            + COL_USER_ID + " TEXT NOT NULL, "
            + COL_CONTEXT_STATUS_ID + " TEXT NOT NULL, "
            + COL_DESCENDANT_IDS + " TEXT NOT NULL)";


    public Sqlite(Context context, String name, SQLiteDatabase.CursorFactory factory, int version) {
        super(context, name, factory, version);
@@ -278,6 +289,7 @@ public class Sqlite extends SQLiteOpenHelper {
        db.execSQL(CREATE_TABLE_CACHE_TAGS);
        db.execSQL(CREATE_TABLE_TIMELINE_CACHE_LOGS);
        db.execSQL(CREATE_TABLE_INTENT);
        db.execSQL(CREATE_TABLE_SEEN_COMMENTS);
    }

    @Override
@@ -312,6 +324,8 @@ public class Sqlite extends SQLiteOpenHelper {
                db.execSQL(CREATE_TABLE_TIMELINE_CACHE_LOGS);
            case 11:
                db.execSQL(CREATE_TABLE_INTENT);
            case 12:
                db.execSQL(CREATE_TABLE_SEEN_COMMENTS);
            default:
                break;
        }
Loading