Loading app/src/main/java/app/fedilab/android/mastodon/client/entities/api/Status.java +1 −0 Original line number Diff line number Diff line Loading @@ -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; Loading app/src/main/java/app/fedilab/android/mastodon/client/entities/app/SeenComments.java 0 → 100644 +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; } } app/src/main/java/app/fedilab/android/mastodon/ui/drawer/StatusAdapter.java +6 −0 Original line number Diff line number Diff line Loading @@ -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); Loading app/src/main/java/app/fedilab/android/mastodon/ui/fragment/timeline/FragmentMastodonContext.java +28 −0 Original line number Diff line number Diff line Loading @@ -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; Loading @@ -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; Loading Loading @@ -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) { Loading app/src/main/java/app/fedilab/android/sqlite/Sqlite.java +15 −1 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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, " Loading Loading @@ -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); Loading Loading @@ -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 Loading Loading @@ -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 Loading
app/src/main/java/app/fedilab/android/mastodon/client/entities/api/Status.java +1 −0 Original line number Diff line number Diff line Loading @@ -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; Loading
app/src/main/java/app/fedilab/android/mastodon/client/entities/app/SeenComments.java 0 → 100644 +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; } }
app/src/main/java/app/fedilab/android/mastodon/ui/drawer/StatusAdapter.java +6 −0 Original line number Diff line number Diff line Loading @@ -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); Loading
app/src/main/java/app/fedilab/android/mastodon/ui/fragment/timeline/FragmentMastodonContext.java +28 −0 Original line number Diff line number Diff line Loading @@ -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; Loading @@ -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; Loading Loading @@ -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) { Loading
app/src/main/java/app/fedilab/android/sqlite/Sqlite.java +15 −1 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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, " Loading Loading @@ -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); Loading Loading @@ -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 Loading Loading @@ -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