Grouped notifications

This commit is contained in:
Grishka 2024-09-20 11:38:52 +03:00
parent 80323f8236
commit 26f7a75628
23 changed files with 758 additions and 251 deletions

View file

@ -23,6 +23,7 @@ import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.Mention; import org.joinmastodon.android.model.Mention;
import org.joinmastodon.android.model.NotificationType;
import org.joinmastodon.android.model.PushNotification; import org.joinmastodon.android.model.PushNotification;
import org.joinmastodon.android.model.StatusPrivacy; import org.joinmastodon.android.model.StatusPrivacy;
import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.ui.utils.UiUtils;
@ -183,7 +184,7 @@ public class PushNotificationReceiver extends BroadcastReceiver{
builder.setSubText(accountName); builder.setSubText(accountName);
} }
String notificationTag=accountID+"_"+(notification==null ? 0 : notification.id); String notificationTag=accountID+"_"+(notification==null ? 0 : notification.id);
if(notification!=null && (notification.type==org.joinmastodon.android.model.Notification.Type.MENTION)){ if(notification!=null && (notification.type==NotificationType.MENTION)){
ArrayList<String> mentions=new ArrayList<>(); ArrayList<String> mentions=new ArrayList<>();
String ownID=AccountSessionManager.getInstance().getAccount(accountID).self.id; String ownID=AccountSessionManager.getInstance().getAccount(accountID).self.id;
if(!notification.status.account.id.equals(ownID)) if(!notification.status.account.id.equals(ownID))

View file

@ -14,26 +14,35 @@ import com.google.gson.reflect.TypeToken;
import org.joinmastodon.android.BuildConfig; import org.joinmastodon.android.BuildConfig;
import org.joinmastodon.android.MastodonApp; import org.joinmastodon.android.MastodonApp;
import org.joinmastodon.android.api.requests.lists.GetLists; import org.joinmastodon.android.api.requests.lists.GetLists;
import org.joinmastodon.android.api.requests.notifications.GetNotifications; import org.joinmastodon.android.api.requests.notifications.GetNotificationsV1;
import org.joinmastodon.android.api.requests.notifications.GetNotificationsV2;
import org.joinmastodon.android.api.requests.timelines.GetHomeTimeline; import org.joinmastodon.android.api.requests.timelines.GetHomeTimeline;
import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.CacheablePaginatedResponse; import org.joinmastodon.android.model.CacheablePaginatedResponse;
import org.joinmastodon.android.model.FilterContext; import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.FollowList; import org.joinmastodon.android.model.FollowList;
import org.joinmastodon.android.model.Notification; import org.joinmastodon.android.model.Notification;
import org.joinmastodon.android.model.NotificationGroup;
import org.joinmastodon.android.model.NotificationType;
import org.joinmastodon.android.model.PaginatedResponse; import org.joinmastodon.android.model.PaginatedResponse;
import org.joinmastodon.android.model.SearchResult; import org.joinmastodon.android.model.SearchResult;
import org.joinmastodon.android.model.Status; import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.model.viewmodel.NotificationViewModel;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStreamReader;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator; import java.util.Comparator;
import java.util.EnumSet; import java.util.EnumSet;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Consumer; import java.util.function.Consumer;
import java.util.function.Function;
import java.util.stream.Collectors;
import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse; import me.grishka.appkit.api.ErrorResponse;
@ -41,7 +50,7 @@ import me.grishka.appkit.utils.WorkerThread;
public class CacheController{ public class CacheController{
private static final String TAG="CacheController"; private static final String TAG="CacheController";
private static final int DB_VERSION=4; private static final int DB_VERSION=5;
public static final WorkerThread databaseThread=new WorkerThread("databaseThread"); public static final WorkerThread databaseThread=new WorkerThread("databaseThread");
public static final Handler uiHandler=new Handler(Looper.getMainLooper()); public static final Handler uiHandler=new Handler(Looper.getMainLooper());
@ -49,7 +58,7 @@ public class CacheController{
private DatabaseHelper db; private DatabaseHelper db;
private final Runnable databaseCloseRunnable=this::closeDatabase; private final Runnable databaseCloseRunnable=this::closeDatabase;
private boolean loadingNotifications; private boolean loadingNotifications;
private final ArrayList<Callback<PaginatedResponse<List<Notification>>>> pendingNotificationsCallbacks=new ArrayList<>(); private final ArrayList<Callback<PaginatedResponse<List<NotificationViewModel>>>> pendingNotificationsCallbacks=new ArrayList<>();
private List<FollowList> lists; private List<FollowList> lists;
private static final int POST_FLAG_GAP_AFTER=1; private static final int POST_FLAG_GAP_AFTER=1;
@ -133,53 +142,106 @@ public class CacheController{
}); });
} }
public void getNotifications(String maxID, int count, boolean onlyMentions, boolean forceReload, Callback<PaginatedResponse<List<Notification>>> callback){ private List<NotificationViewModel> makeNotificationViewModels(List<NotificationGroup> notifications, Map<String, Account> accounts, Map<String, Status> statuses){
return notifications.stream()
.filter(ng->ng.type!=null)
.map(ng->{
NotificationViewModel nvm=new NotificationViewModel();
nvm.notification=ng;
nvm.accounts=ng.sampleAccountIds.stream().map(accounts::get).collect(Collectors.toList());
if(nvm.accounts.size()!=ng.sampleAccountIds.size())
return null;
if(ng.statusId!=null){
nvm.status=statuses.get(ng.statusId);
if(nvm.status==null)
return null;
}
return nvm;
})
.filter(Objects::nonNull)
.collect(Collectors.toList());
}
public void getNotifications(String maxID, int count, boolean onlyMentions, boolean forceReload, Callback<PaginatedResponse<List<NotificationViewModel>>> callback){
cancelDelayedClose(); cancelDelayedClose();
databaseThread.postRunnable(()->{ databaseThread.postRunnable(()->{
try{ try{
if(!onlyMentions && loadingNotifications){
synchronized(pendingNotificationsCallbacks){
pendingNotificationsCallbacks.add(callback);
}
return;
}
if(!forceReload){ if(!forceReload){
SQLiteDatabase db=getOrOpenDatabase(); SQLiteDatabase db=getOrOpenDatabase();
try(Cursor cursor=db.query(onlyMentions ? "notifications_mentions" : "notifications_all", new String[]{"json"}, maxID==null ? null : "`id`<?", maxID==null ? null : new String[]{maxID}, null, null, "`time` DESC", count+"")){ String suffix=onlyMentions ? "mentions" : "all";
String table="notifications_"+suffix;
String accountsTable="notifications_accounts_"+suffix;
String statusesTable="notifications_statuses_"+suffix;
try(Cursor cursor=db.query(table, new String[]{"json"}, maxID==null ? null : "`max_id`<?", maxID==null ? null : new String[]{maxID}, null, null, "`time` DESC", count+"")){
if(cursor.getCount()==count){ if(cursor.getCount()==count){
ArrayList<Notification> result=new ArrayList<>(); ArrayList<NotificationGroup> result=new ArrayList<>();
cursor.moveToFirst(); cursor.moveToFirst();
String newMaxID; String newMaxID;
HashSet<String> needAccounts=new HashSet<>(), needStatuses=new HashSet<>();
do{ do{
Notification ntf=MastodonAPIController.gson.fromJson(cursor.getString(0), Notification.class); NotificationGroup ntf=MastodonAPIController.gson.fromJson(cursor.getString(0), NotificationGroup.class);
ntf.postprocess(); ntf.postprocess();
newMaxID=ntf.id; newMaxID=ntf.pageMinId;
needAccounts.addAll(ntf.sampleAccountIds);
if(ntf.statusId!=null)
needStatuses.add(ntf.statusId);
result.add(ntf); result.add(ntf);
}while(cursor.moveToNext()); }while(cursor.moveToNext());
String _newMaxID=newMaxID; String _newMaxID=newMaxID;
AccountSessionManager.get(accountID).filterStatusContainingObjects(result, n->n.status, FilterContext.NOTIFICATIONS); HashMap<String, Account> accounts=new HashMap<>();
uiHandler.post(()->callback.onSuccess(new PaginatedResponse<>(result, _newMaxID))); HashMap<String, Status> statuses=new HashMap<>();
if(!needAccounts.isEmpty()){
try(Cursor cursor2=db.query(accountsTable, new String[]{"json"}, "`id` IN ("+String.join(", ", Collections.nCopies(needAccounts.size(), "?"))+")",
needAccounts.toArray(new String[0]), null, null, null)){
while(cursor2.moveToNext()){
Account acc=MastodonAPIController.gson.fromJson(cursor2.getString(0), Account.class);
acc.postprocess();
accounts.put(acc.id, acc);
}
}
}
if(!needStatuses.isEmpty()){
try(Cursor cursor2=db.query(statusesTable, new String[]{"json"}, "`id` IN ("+String.join(", ", Collections.nCopies(needStatuses.size(), "?"))+")",
needStatuses.toArray(new String[0]), null, null, null)){
while(cursor2.moveToNext()){
Status s=MastodonAPIController.gson.fromJson(cursor2.getString(0), Status.class);
s.postprocess();
statuses.put(s.id, s);
}
}
}
uiHandler.post(()->callback.onSuccess(new PaginatedResponse<>(makeNotificationViewModels(result, accounts, statuses), _newMaxID)));
return; return;
} }
}catch(IOException x){ }catch(IOException x){
Log.w(TAG, "getNotifications: corrupted notification object in database", x); Log.w(TAG, "getNotifications: corrupted notification object in database", x);
} }
} }
if(!onlyMentions && loadingNotifications){
synchronized(pendingNotificationsCallbacks){
pendingNotificationsCallbacks.add(callback);
}
return;
}
if(!onlyMentions) if(!onlyMentions)
loadingNotifications=true; loadingNotifications=true;
new GetNotifications(maxID, count, onlyMentions ? EnumSet.of(Notification.Type.MENTION): EnumSet.allOf(Notification.Type.class)) if(AccountSessionManager.get(accountID).getInstanceInfo().getApiVersion()>=2){
new GetNotificationsV2(maxID, count, onlyMentions ? EnumSet.of(NotificationType.MENTION): EnumSet.allOf(NotificationType.class), NotificationType.getGroupableTypes())
.setCallback(new Callback<>(){ .setCallback(new Callback<>(){
@Override @Override
public void onSuccess(List<Notification> result){ public void onSuccess(GetNotificationsV2.GroupedNotificationsResults result){
ArrayList<Notification> filtered=new ArrayList<>(result); Map<String, Account> accounts=result.accounts.stream().collect(Collectors.toMap(a->a.id, Function.identity(), (a1, a2)->a2));
AccountSessionManager.get(accountID).filterStatusContainingObjects(filtered, n->n.status, FilterContext.NOTIFICATIONS); Map<String, Status> statuses=result.statuses.stream().collect(Collectors.toMap(s->s.id, Function.identity(), (s1, s2)->s2));
PaginatedResponse<List<Notification>> res=new PaginatedResponse<>(filtered, result.isEmpty() ? null : result.get(result.size()-1).id); List<NotificationViewModel> notifications=makeNotificationViewModels(result.notificationGroups, accounts, statuses);
databaseThread.postRunnable(()->putNotifications(result.notificationGroups, result.accounts, result.statuses, onlyMentions, maxID==null), 0);
PaginatedResponse<List<NotificationViewModel>> res=new PaginatedResponse<>(notifications,
result.notificationGroups.isEmpty() ? null : result.notificationGroups.get(result.notificationGroups.size()-1).pageMinId);
callback.onSuccess(res); callback.onSuccess(res);
putNotifications(result, onlyMentions, maxID==null);
if(!onlyMentions){ if(!onlyMentions){
loadingNotifications=false; loadingNotifications=false;
synchronized(pendingNotificationsCallbacks){ synchronized(pendingNotificationsCallbacks){
for(Callback<PaginatedResponse<List<Notification>>> cb:pendingNotificationsCallbacks){ for(Callback<PaginatedResponse<List<NotificationViewModel>>> cb:pendingNotificationsCallbacks){
cb.onSuccess(res); cb.onSuccess(res);
} }
pendingNotificationsCallbacks.clear(); pendingNotificationsCallbacks.clear();
@ -193,7 +255,7 @@ public class CacheController{
if(!onlyMentions){ if(!onlyMentions){
loadingNotifications=false; loadingNotifications=false;
synchronized(pendingNotificationsCallbacks){ synchronized(pendingNotificationsCallbacks){
for(Callback<PaginatedResponse<List<Notification>>> cb:pendingNotificationsCallbacks){ for(Callback<PaginatedResponse<List<NotificationViewModel>>> cb:pendingNotificationsCallbacks){
cb.onError(error); cb.onError(error);
} }
pendingNotificationsCallbacks.clear(); pendingNotificationsCallbacks.clear();
@ -202,6 +264,54 @@ public class CacheController{
} }
}) })
.exec(accountID); .exec(accountID);
}else{
new GetNotificationsV1(maxID, count, onlyMentions ? EnumSet.of(NotificationType.MENTION): EnumSet.allOf(NotificationType.class))
.setCallback(new Callback<>(){
@Override
public void onSuccess(List<Notification> result){
ArrayList<Notification> filtered=new ArrayList<>(result);
AccountSessionManager.get(accountID).filterStatusContainingObjects(filtered, n->n.status, FilterContext.NOTIFICATIONS);
List<Status> statuses=filtered.stream().map(n->n.status).filter(Objects::nonNull).collect(Collectors.toList());
List<Account> accounts=filtered.stream().map(n->n.account).collect(Collectors.toList());
List<NotificationViewModel> converted=filtered.stream()
.map(n->{
NotificationGroup group=new NotificationGroup();
group.groupKey="converted-"+n.id;
group.notificationsCount=1;
group.type=n.type;
group.mostRecentNotificationId=group.pageMaxId=group.pageMinId=n.id;
group.latestPageNotificationAt=n.createdAt;
group.sampleAccountIds=List.of(n.account.id);
if(n.status!=null)
group.statusId=n.status.id;
NotificationViewModel nvm=new NotificationViewModel();
nvm.notification=group;
nvm.status=n.status;
nvm.accounts=List.of(n.account);
return nvm;
})
.collect(Collectors.toList());
PaginatedResponse<List<NotificationViewModel>> res=new PaginatedResponse<>(converted, result.isEmpty() ? null : result.get(result.size()-1).id);
callback.onSuccess(res);
databaseThread.postRunnable(()->putNotifications(converted.stream().map(nvm->nvm.notification).collect(Collectors.toList()), accounts, statuses, onlyMentions, maxID==null), 0);
}
@Override
public void onError(ErrorResponse error){
callback.onError(error);
if(!onlyMentions){
loadingNotifications=false;
synchronized(pendingNotificationsCallbacks){
for(Callback<PaginatedResponse<List<NotificationViewModel>>> cb:pendingNotificationsCallbacks){
cb.onError(error);
}
pendingNotificationsCallbacks.clear();
}
}
}
})
.exec(accountID);
}
}catch(SQLiteException x){ }catch(SQLiteException x){
Log.w(TAG, x); Log.w(TAG, x);
uiHandler.post(()->callback.onError(new MastodonErrorResponse(x.getLocalizedMessage(), 500, x))); uiHandler.post(()->callback.onError(new MastodonErrorResponse(x.getLocalizedMessage(), 500, x)));
@ -211,22 +321,40 @@ public class CacheController{
}, 0); }, 0);
} }
private void putNotifications(List<Notification> notifications, boolean onlyMentions, boolean clear){ private void putNotifications(List<NotificationGroup> notifications, List<Account> accounts, List<Status> statuses, boolean onlyMentions, boolean clear){
runOnDbThread((db)->{ runOnDbThread((db)->{
String table=onlyMentions ? "notifications_mentions" : "notifications_all"; String suffix=onlyMentions ? "mentions" : "all";
if(clear) String table="notifications_"+suffix;
String accountsTable="notifications_accounts_"+suffix;
String statusesTable="notifications_statuses_"+suffix;
if(clear){
db.delete(table, null, null); db.delete(table, null, null);
db.delete(accountsTable, null, null);
db.delete(statusesTable, null, null);
}
ContentValues values=new ContentValues(4); ContentValues values=new ContentValues(4);
for(Notification n:notifications){ for(NotificationGroup n:notifications){
if(n.type==null){ if(n.type==null){
continue; continue;
} }
values.put("id", n.id); values.put("id", n.groupKey);
values.put("json", MastodonAPIController.gson.toJson(n)); values.put("json", MastodonAPIController.gson.toJson(n));
values.put("type", n.type.ordinal()); values.put("type", n.type.ordinal());
values.put("time", n.createdAt.getEpochSecond()); values.put("time", n.latestPageNotificationAt.getEpochSecond());
values.put("max_id", n.pageMaxId);
db.insertWithOnConflict(table, null, values, SQLiteDatabase.CONFLICT_REPLACE); db.insertWithOnConflict(table, null, values, SQLiteDatabase.CONFLICT_REPLACE);
} }
values.clear();
for(Account acc:accounts){
values.put("id", acc.id);
values.put("json", MastodonAPIController.gson.toJson(acc));
db.insertWithOnConflict(accountsTable, null, values, SQLiteDatabase.CONFLICT_REPLACE);
}
for(Status s:statuses){
values.put("id", s.id);
values.put("json", MastodonAPIController.gson.toJson(s));
db.insertWithOnConflict(statusesTable, null, values, SQLiteDatabase.CONFLICT_REPLACE);
}
}); });
} }
@ -409,22 +537,8 @@ public class CacheController{
`flags` INTEGER NOT NULL DEFAULT 0, `flags` INTEGER NOT NULL DEFAULT 0,
`time` INTEGER NOT NULL `time` INTEGER NOT NULL
)"""); )""");
db.execSQL(""" createNotificationsTables(db, "all");
CREATE TABLE `notifications_all` ( createNotificationsTables(db, "mentions");
`id` VARCHAR(25) NOT NULL PRIMARY KEY,
`json` TEXT NOT NULL,
`flags` INTEGER NOT NULL DEFAULT 0,
`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,
`time` INTEGER NOT NULL
)""");
createRecentSearchesTable(db); createRecentSearchesTable(db);
createMiscTable(db); createMiscTable(db);
} }
@ -440,6 +554,12 @@ public class CacheController{
if(oldVersion<4){ if(oldVersion<4){
createMiscTable(db); createMiscTable(db);
} }
if(oldVersion<5){
db.execSQL("DROP TABLE `notifications_all`");
db.execSQL("DROP TABLE `notifications_mentions`");
createNotificationsTables(db, "all");
createNotificationsTables(db, "mentions");
}
} }
private void createRecentSearchesTable(SQLiteDatabase db){ private void createRecentSearchesTable(SQLiteDatabase db){
@ -467,5 +587,28 @@ public class CacheController{
`value` TEXT `value` TEXT
)"""); )""");
} }
private void createNotificationsTables(SQLiteDatabase db, String suffix){
db.execSQL("CREATE TABLE `notifications_"+suffix+"` ("+
"""
`id` VARCHAR(100) NOT NULL PRIMARY KEY,
`json` TEXT NOT NULL,
`flags` INTEGER NOT NULL DEFAULT 0,
`type` INTEGER NOT NULL,
`time` INTEGER NOT NULL,
`max_id` VARCHAR(25) NOT NULL
)""");
db.execSQL("CREATE INDEX `notifications_"+suffix+"_max_id` ON `notifications_"+suffix+"`(`max_id`)");
db.execSQL("CREATE TABLE `notifications_accounts_"+suffix+"` ("+
"""
`id` VARCHAR(25) NOT NULL PRIMARY KEY,
`json` TEXT NOT NULL
)""");
db.execSQL("CREATE TABLE `notifications_statuses_"+suffix+"` ("+
"""
`id` VARCHAR(25) NOT NULL PRIMARY KEY,
`json` TEXT NOT NULL
)""");
}
} }
} }

View file

@ -7,26 +7,27 @@ import com.google.gson.reflect.TypeToken;
import org.joinmastodon.android.api.ApiUtils; import org.joinmastodon.android.api.ApiUtils;
import org.joinmastodon.android.api.MastodonAPIRequest; import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.Notification; import org.joinmastodon.android.model.Notification;
import org.joinmastodon.android.model.NotificationType;
import java.util.EnumSet; import java.util.EnumSet;
import java.util.List; import java.util.List;
public class GetNotifications extends MastodonAPIRequest<List<Notification>>{ public class GetNotificationsV1 extends MastodonAPIRequest<List<Notification>>{
public GetNotifications(String maxID, int limit, EnumSet<Notification.Type> includeTypes){ public GetNotificationsV1(String maxID, int limit, EnumSet<NotificationType> includeTypes){
this(maxID, limit, includeTypes, null); this(maxID, limit, includeTypes, null);
} }
public GetNotifications(String maxID, int limit, EnumSet<Notification.Type> includeTypes, String onlyAccountID){ public GetNotificationsV1(String maxID, int limit, EnumSet<NotificationType> includeTypes, String onlyAccountID){
super(HttpMethod.GET, "/notifications", new TypeToken<>(){}); super(HttpMethod.GET, "/notifications", new TypeToken<>(){});
if(maxID!=null) if(maxID!=null)
addQueryParameter("max_id", maxID); addQueryParameter("max_id", maxID);
if(limit>0) if(limit>0)
addQueryParameter("limit", ""+limit); addQueryParameter("limit", ""+limit);
if(includeTypes!=null){ if(includeTypes!=null){
for(String type:ApiUtils.enumSetToStrings(includeTypes, Notification.Type.class)){ for(String type:ApiUtils.enumSetToStrings(includeTypes, NotificationType.class)){
addQueryParameter("types[]", type); addQueryParameter("types[]", type);
} }
for(String type:ApiUtils.enumSetToStrings(EnumSet.complementOf(includeTypes), Notification.Type.class)){ for(String type:ApiUtils.enumSetToStrings(EnumSet.complementOf(includeTypes), NotificationType.class)){
addQueryParameter("exclude_types[]", type); addQueryParameter("exclude_types[]", type);
} }
} }

View file

@ -0,0 +1,69 @@
package org.joinmastodon.android.api.requests.notifications;
import android.text.TextUtils;
import org.joinmastodon.android.api.AllFieldsAreRequired;
import org.joinmastodon.android.api.ApiUtils;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.api.ObjectValidationException;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.BaseModel;
import org.joinmastodon.android.model.NotificationGroup;
import org.joinmastodon.android.model.NotificationType;
import org.joinmastodon.android.model.Status;
import java.util.EnumSet;
import java.util.List;
public class GetNotificationsV2 extends MastodonAPIRequest<GetNotificationsV2.GroupedNotificationsResults>{
public GetNotificationsV2(String maxID, int limit, EnumSet<NotificationType> includeTypes, EnumSet<NotificationType> groupedTypes){
this(maxID, limit, includeTypes, groupedTypes, null);
}
public GetNotificationsV2(String maxID, int limit, EnumSet<NotificationType> includeTypes, EnumSet<NotificationType> groupedTypes, String onlyAccountID){
super(HttpMethod.GET, "/notifications", GroupedNotificationsResults.class);
if(maxID!=null)
addQueryParameter("max_id", maxID);
if(limit>0)
addQueryParameter("limit", ""+limit);
if(includeTypes!=null){
for(String type:ApiUtils.enumSetToStrings(includeTypes, NotificationType.class)){
addQueryParameter("types[]", type);
}
for(String type:ApiUtils.enumSetToStrings(EnumSet.complementOf(includeTypes), NotificationType.class)){
addQueryParameter("exclude_types[]", type);
}
}
if(groupedTypes!=null){
for(String type:ApiUtils.enumSetToStrings(groupedTypes, NotificationType.class)){
addQueryParameter("grouped_types[]", type);
}
}
if(!TextUtils.isEmpty(onlyAccountID))
addQueryParameter("account_id", onlyAccountID);
removeUnsupportedItems=true;
}
@Override
protected String getPathPrefix(){
return "/api/v2";
}
@AllFieldsAreRequired
public static class GroupedNotificationsResults extends BaseModel{
public List<Account> accounts;
public List<Status> statuses;
public List<NotificationGroup> notificationGroups;
@Override
public void postprocess() throws ObjectValidationException{
super.postprocess();
for(Account acc:accounts)
acc.postprocess();
for(Status s:statuses)
s.postprocess();
for(NotificationGroup ng:notificationGroups)
ng.postprocess();
}
}
}

View file

@ -0,0 +1,18 @@
package org.joinmastodon.android.api.requests.notifications;
import org.joinmastodon.android.api.MastodonAPIRequest;
public class GetUnreadNotificationsCount extends MastodonAPIRequest<GetUnreadNotificationsCount.Response>{
public GetUnreadNotificationsCount(){
super(HttpMethod.GET, "/notifications/unread_count", Response.class);
}
@Override
protected String getPathPrefix(){
return "/api/v2";
}
public static class Response{
public int count;
}
}

View file

@ -31,6 +31,7 @@ import org.joinmastodon.android.model.FilterAction;
import org.joinmastodon.android.model.FilterContext; import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.FilterResult; import org.joinmastodon.android.model.FilterResult;
import org.joinmastodon.android.model.FollowList; import org.joinmastodon.android.model.FollowList;
import org.joinmastodon.android.model.Instance;
import org.joinmastodon.android.model.LegacyFilter; import org.joinmastodon.android.model.LegacyFilter;
import org.joinmastodon.android.model.Preferences; import org.joinmastodon.android.model.Preferences;
import org.joinmastodon.android.model.PushSubscription; import org.joinmastodon.android.model.PushSubscription;
@ -350,4 +351,8 @@ public class AccountSession{
public int getDonationSeed(){ public int getDonationSeed(){
return Math.abs(getFullUsername().hashCode())%100; return Math.abs(getFullUsername().hashCode())%100;
} }
public Instance getInstanceInfo(){
return AccountSessionManager.getInstance().getInstanceInfo(domain);
}
} }

View file

@ -455,6 +455,7 @@ public class AccountSessionManager{
.toString()); .toString());
values.put("push_subscription", MastodonAPIController.gson.toJson(session.pushSubscription)); values.put("push_subscription", MastodonAPIController.gson.toJson(session.pushSubscription));
values.put("flags", session.getFlagsForDatabase()); values.put("flags", session.getFlagsForDatabase());
values.put("push_id", session.pushAccountID);
db.update("accounts", values, "`id`=?", new String[]{id}); db.update("accounts", values, "`id`=?", new String[]{id});
}); });
} }

View file

@ -13,25 +13,22 @@ import android.widget.TextView;
import org.joinmastodon.android.E; import org.joinmastodon.android.E;
import org.joinmastodon.android.R; import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.notifications.GetNotifications;
import org.joinmastodon.android.api.requests.notifications.RespondToNotificationRequest; import org.joinmastodon.android.api.requests.notifications.RespondToNotificationRequest;
import org.joinmastodon.android.events.NotificationRequestRespondedEvent; import org.joinmastodon.android.events.NotificationRequestRespondedEvent;
import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.Notification; import org.joinmastodon.android.model.NotificationType;
import org.joinmastodon.android.model.viewmodel.NotificationViewModel;
import org.joinmastodon.android.ui.Snackbar; import org.joinmastodon.android.ui.Snackbar;
import org.joinmastodon.android.ui.displayitems.StatusDisplayItem; import org.joinmastodon.android.ui.displayitems.StatusDisplayItem;
import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.ui.utils.UiUtils;
import org.parceler.Parcels; import org.parceler.Parcels;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List; import java.util.List;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse; import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.api.SimpleCallback;
import me.grishka.appkit.utils.MergeRecyclerAdapter; import me.grishka.appkit.utils.MergeRecyclerAdapter;
import me.grishka.appkit.utils.SingleViewRecyclerAdapter; import me.grishka.appkit.utils.SingleViewRecyclerAdapter;
@ -56,16 +53,16 @@ public class AccountNotificationsListFragment extends BaseNotificationsListFragm
protected void doLoadData(int offset, int count){ protected void doLoadData(int offset, int count){
if(!refreshing && endMark!=null) if(!refreshing && endMark!=null)
endMark.setVisibility(View.GONE); endMark.setVisibility(View.GONE);
currentRequest=new GetNotifications(offset==0 ? null : maxID, count, EnumSet.allOf(Notification.Type.class), account.id) // currentRequest=new GetNotificationsV2(offset==0 ? null : maxID, count, EnumSet.allOf(NotificationType.class), account.id)
.setCallback(new SimpleCallback<>(this){ // .setCallback(new SimpleCallback<>(this){
@Override // @Override
public void onSuccess(List<Notification> result){ // public void onSuccess(List<NotificationViewModel> result){
onDataLoaded(result, !result.isEmpty()); // onDataLoaded(result, !result.isEmpty());
maxID=result.isEmpty() ? null : result.get(result.size()-1).id; // maxID=result.isEmpty() ? null : result.get(result.size()-1).id;
endMark.setVisibility(result.isEmpty() ? View.VISIBLE : View.GONE); // endMark.setVisibility(result.isEmpty() ? View.VISIBLE : View.GONE);
} // }
}) // })
.exec(accountID); // .exec(accountID);
} }
@Override @Override
@ -153,8 +150,8 @@ public class AccountNotificationsListFragment extends BaseNotificationsListFragm
} }
@Override @Override
protected List<StatusDisplayItem> buildDisplayItems(Notification n){ protected List<StatusDisplayItem> buildDisplayItems(NotificationViewModel n){
if(n.type==Notification.Type.MENTION || n.type==Notification.Type.STATUS){ if(n.notification.type==NotificationType.MENTION || n.notification.type==NotificationType.STATUS){
return StatusDisplayItem.buildItems(this, n.status, accountID, n, knownAccounts, StatusDisplayItem.FLAG_MEDIA_FORCE_HIDDEN); return StatusDisplayItem.buildItems(this, n.status, accountID, n, knownAccounts, StatusDisplayItem.FLAG_MEDIA_FORCE_HIDDEN);
} }
return super.buildDisplayItems(n); return super.buildDisplayItems(n);

View file

@ -5,8 +5,10 @@ import android.view.LayoutInflater;
import android.view.View; import android.view.View;
import org.joinmastodon.android.R; import org.joinmastodon.android.R;
import org.joinmastodon.android.model.Notification; import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.NotificationType;
import org.joinmastodon.android.model.Status; import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.model.viewmodel.NotificationViewModel;
import org.joinmastodon.android.ui.displayitems.InlineStatusStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.InlineStatusStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.NotificationHeaderStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.NotificationHeaderStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.StatusDisplayItem; import org.joinmastodon.android.ui.displayitems.StatusDisplayItem;
@ -16,17 +18,17 @@ import java.util.List;
import me.grishka.appkit.Nav; import me.grishka.appkit.Nav;
public abstract class BaseNotificationsListFragment extends BaseStatusListFragment<Notification>{ public abstract class BaseNotificationsListFragment extends BaseStatusListFragment<NotificationViewModel>{
protected String maxID; protected String maxID;
protected View endMark; protected View endMark;
@Override @Override
protected List<StatusDisplayItem> buildDisplayItems(Notification n){ protected List<StatusDisplayItem> buildDisplayItems(NotificationViewModel n){
NotificationHeaderStatusDisplayItem titleItem; NotificationHeaderStatusDisplayItem titleItem;
if(n.type==Notification.Type.MENTION || n.type==Notification.Type.STATUS){ if(n.notification.type==NotificationType.MENTION || n.notification.type==NotificationType.STATUS){
titleItem=null; titleItem=null;
}else{ }else{
titleItem=new NotificationHeaderStatusDisplayItem(n.id, this, n, accountID); titleItem=new NotificationHeaderStatusDisplayItem(n.getID(), this, n, accountID);
if(n.status!=null){ if(n.status!=null){
n.status.card=null; n.status.card=null;
n.status.spoilerText=null; n.status.spoilerText=null;
@ -34,7 +36,7 @@ public abstract class BaseNotificationsListFragment extends BaseStatusListFragme
} }
if(n.status!=null){ if(n.status!=null){
if(titleItem!=null){ if(titleItem!=null){
return List.of(titleItem, new InlineStatusStatusDisplayItem(n.id, this, n.status)); return List.of(titleItem, new InlineStatusStatusDisplayItem(n.getID(), this, n.status));
}else{ }else{
return StatusDisplayItem.buildItems(this, n.status, accountID, n, knownAccounts, 0); return StatusDisplayItem.buildItems(this, n.status, accountID, n, knownAccounts, 0);
} }
@ -46,16 +48,18 @@ public abstract class BaseNotificationsListFragment extends BaseStatusListFragme
} }
@Override @Override
protected void addAccountToKnown(Notification s){ protected void addAccountToKnown(NotificationViewModel s){
if(!knownAccounts.containsKey(s.account.id)) for(Account a:s.accounts){
knownAccounts.put(s.account.id, s.account); if(!knownAccounts.containsKey(a.id))
knownAccounts.put(a.id, a);
}
if(s.status!=null && !knownAccounts.containsKey(s.status.account.id)) if(s.status!=null && !knownAccounts.containsKey(s.status.account.id))
knownAccounts.put(s.status.account.id, s.status.account); knownAccounts.put(s.status.account.id, s.status.account);
} }
@Override @Override
public void onItemClick(String id){ public void onItemClick(String id){
Notification n=getNotificationByID(id); NotificationViewModel n=getNotificationByID(id);
if(n.status!=null){ if(n.status!=null){
Status status=n.status; Status status=n.status;
Bundle args=new Bundle(); Bundle args=new Bundle();
@ -67,25 +71,25 @@ public abstract class BaseNotificationsListFragment extends BaseStatusListFragme
}else{ }else{
Bundle args=new Bundle(); Bundle args=new Bundle();
args.putString("account", accountID); args.putString("account", accountID);
args.putParcelable("profileAccount", Parcels.wrap(n.account)); args.putParcelable("profileAccount", Parcels.wrap(n.accounts.get(0)));
Nav.go(getActivity(), ProfileFragment.class, args); Nav.go(getActivity(), ProfileFragment.class, args);
} }
} }
private Notification getNotificationByID(String id){ protected NotificationViewModel getNotificationByID(String id){
for(Notification n : data){ for(NotificationViewModel n:data){
if(n.id.equals(id)) if(n.getID().equals(id))
return n; return n;
} }
return null; return null;
} }
protected void removeNotification(Notification n){ protected void removeNotification(NotificationViewModel n){
data.remove(n); data.remove(n);
preloadedData.remove(n); preloadedData.remove(n);
int index=-1; int index=-1;
for(int i=0; i<displayItems.size(); i++){ for(int i=0; i<displayItems.size(); i++){
if(n.id.equals(displayItems.get(i).parentID)){ if(n.getID().equals(displayItems.get(i).parentID)){
index=i; index=i;
break; break;
} }
@ -94,7 +98,7 @@ public abstract class BaseNotificationsListFragment extends BaseStatusListFragme
return; return;
int lastIndex; int lastIndex;
for(lastIndex=index; lastIndex<displayItems.size(); lastIndex++){ for(lastIndex=index; lastIndex<displayItems.size(); lastIndex++){
if(!displayItems.get(lastIndex).parentID.equals(n.id)) if(!displayItems.get(lastIndex).parentID.equals(n.getID()))
break; break;
} }
displayItems.subList(index, lastIndex).clear(); displayItems.subList(index, lastIndex).clear();

View file

@ -21,6 +21,8 @@ import org.joinmastodon.android.BuildConfig;
import org.joinmastodon.android.E; import org.joinmastodon.android.E;
import org.joinmastodon.android.PushNotificationReceiver; import org.joinmastodon.android.PushNotificationReceiver;
import org.joinmastodon.android.R; import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.notifications.GetNotificationsV1;
import org.joinmastodon.android.api.requests.notifications.GetUnreadNotificationsCount;
import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.NotificationsMarkerUpdatedEvent; import org.joinmastodon.android.events.NotificationsMarkerUpdatedEvent;
@ -29,7 +31,7 @@ import org.joinmastodon.android.fragments.discover.DiscoverFragment;
import org.joinmastodon.android.fragments.onboarding.OnboardingFollowSuggestionsFragment; import org.joinmastodon.android.fragments.onboarding.OnboardingFollowSuggestionsFragment;
import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.Notification; import org.joinmastodon.android.model.Notification;
import org.joinmastodon.android.model.PaginatedResponse; import org.joinmastodon.android.model.NotificationType;
import org.joinmastodon.android.ui.OutlineProviders; import org.joinmastodon.android.ui.OutlineProviders;
import org.joinmastodon.android.ui.sheets.AccountSwitcherSheet; import org.joinmastodon.android.ui.sheets.AccountSwitcherSheet;
import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.ui.utils.UiUtils;
@ -38,6 +40,7 @@ import org.joinmastodon.android.utils.ObjectIdComparator;
import org.parceler.Parcels; import org.parceler.Parcels;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List; import java.util.List;
import androidx.annotation.IdRes; import androidx.annotation.IdRes;
@ -288,37 +291,52 @@ public class HomeFragment extends AppKitFragment{
} }
private void reloadNotificationsForUnreadCount(){ private void reloadNotificationsForUnreadCount(){
if(AccountSessionManager.get(accountID).getInstanceInfo().getApiVersion()>=2){
new GetUnreadNotificationsCount()
.setCallback(new Callback<>(){
@Override
public void onSuccess(GetUnreadNotificationsCount.Response result){
updateUnreadNotificationsBadge(result.count, false);
}
@Override
public void onError(ErrorResponse error){
}
})
.exec(accountID);
}else{
List<Notification>[] notifications=new List[]{null}; List<Notification>[] notifications=new List[]{null};
String[] marker={null}; String[] marker={null};
AccountSessionManager.get(accountID).reloadNotificationsMarker(m->{ AccountSessionManager.get(accountID).reloadNotificationsMarker(m->{
marker[0]=m; marker[0]=m;
if(notifications[0]!=null){ if(notifications[0]!=null){
updateUnreadCount(notifications[0], marker[0]); updateUnreadCountV1(notifications[0], marker[0]);
} }
}); });
AccountSessionManager.get(accountID).getCacheController().getNotifications(null, 40, false, true, new Callback<>(){ new GetNotificationsV1(null, 40, EnumSet.allOf(NotificationType.class))
.setCallback(new Callback<>(){
@Override @Override
public void onSuccess(PaginatedResponse<List<Notification>> result){ public void onSuccess(List<Notification> result){
notifications[0]=result.items; notifications[0]=result;
if(marker[0]!=null) if(marker[0]!=null)
updateUnreadCount(notifications[0], marker[0]); updateUnreadCountV1(notifications[0], marker[0]);
} }
@Override @Override
public void onError(ErrorResponse error){} public void onError(ErrorResponse error){}
}); }).exec(accountID);
}
} }
@SuppressLint("DefaultLocale") @SuppressLint("DefaultLocale")
private void updateUnreadCount(List<Notification> notifications, String marker){ private void updateUnreadCountV1(List<Notification> notifications, String marker){
if(notifications.isEmpty() || ObjectIdComparator.INSTANCE.compare(notifications.get(0).id, marker)<=0){ if(notifications.isEmpty() || ObjectIdComparator.INSTANCE.compare(notifications.get(0).id, marker)<=0){
notificationsBadge.setVisibility(View.GONE); updateUnreadNotificationsBadge(0, false);
}else{ }else{
notificationsBadge.setVisibility(View.VISIBLE);
if(ObjectIdComparator.INSTANCE.compare(notifications.get(notifications.size()-1).id, marker)>0){ if(ObjectIdComparator.INSTANCE.compare(notifications.get(notifications.size()-1).id, marker)>0){
notificationsBadge.setText(String.format("%d+", notifications.size())); updateUnreadNotificationsBadge(notifications.size(), true);
}else{ }else{
int count=0; int count=0;
for(Notification n:notifications){ for(Notification n:notifications){
@ -326,11 +344,20 @@ public class HomeFragment extends AppKitFragment{
break; break;
count++; count++;
} }
notificationsBadge.setText(String.format("%d", count)); updateUnreadNotificationsBadge(count, false);
} }
} }
} }
private void updateUnreadNotificationsBadge(int count, boolean more){
if(count==0){
notificationsBadge.setVisibility(View.GONE);
}else{
notificationsBadge.setVisibility(View.VISIBLE);
notificationsBadge.setText(String.format(more ? "%d+" : "%d", count));
}
}
@Subscribe @Subscribe
public void onNotificationsMarkerUpdated(NotificationsMarkerUpdatedEvent ev){ public void onNotificationsMarkerUpdated(NotificationsMarkerUpdatedEvent ev){
if(!ev.accountID.equals(accountID)) if(!ev.accountID.equals(accountID))

View file

@ -24,12 +24,12 @@ import org.joinmastodon.android.api.requests.notifications.SetNotificationsPolic
import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.PollUpdatedEvent; import org.joinmastodon.android.events.PollUpdatedEvent;
import org.joinmastodon.android.events.RemoveAccountPostsEvent; import org.joinmastodon.android.events.RemoveAccountPostsEvent;
import org.joinmastodon.android.model.Notification;
import org.joinmastodon.android.model.NotificationsPolicy; import org.joinmastodon.android.model.NotificationsPolicy;
import org.joinmastodon.android.model.PaginatedResponse; import org.joinmastodon.android.model.PaginatedResponse;
import org.joinmastodon.android.model.Status; import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.model.viewmodel.CheckableListItem; import org.joinmastodon.android.model.viewmodel.CheckableListItem;
import org.joinmastodon.android.model.viewmodel.ListItem; import org.joinmastodon.android.model.viewmodel.ListItem;
import org.joinmastodon.android.model.viewmodel.NotificationViewModel;
import org.joinmastodon.android.ui.M3AlertDialogBuilder; import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.OutlineProviders; import org.joinmastodon.android.ui.OutlineProviders;
import org.joinmastodon.android.ui.adapters.GenericListItemsAdapter; import org.joinmastodon.android.ui.adapters.GenericListItemsAdapter;
@ -64,6 +64,7 @@ public class NotificationsListFragment extends BaseNotificationsListFragment{
private ArrayList<ListItem<Void>> requestsItems=new ArrayList<>(); private ArrayList<ListItem<Void>> requestsItems=new ArrayList<>();
private GenericListItemsAdapter<Void> requestsRowAdapter=new GenericListItemsAdapter<>(requestsItems); private GenericListItemsAdapter<Void> requestsRowAdapter=new GenericListItemsAdapter<>(requestsItems);
private NotificationsPolicy lastPolicy; private NotificationsPolicy lastPolicy;
private boolean refreshAfterLoading;
@Override @Override
public void onCreate(Bundle savedInstanceState){ public void onCreate(Bundle savedInstanceState){
@ -96,13 +97,17 @@ public class NotificationsListFragment extends BaseNotificationsListFragment{
.getAccount(accountID).getCacheController() .getAccount(accountID).getCacheController()
.getNotifications(offset>0 ? maxID : null, count, onlyMentions, refreshing && !reloadingFromCache, new SimpleCallback<>(this){ .getNotifications(offset>0 ? maxID : null, count, onlyMentions, refreshing && !reloadingFromCache, new SimpleCallback<>(this){
@Override @Override
public void onSuccess(PaginatedResponse<List<Notification>> result){ public void onSuccess(PaginatedResponse<List<NotificationViewModel>> result){
if(getActivity()==null) if(getActivity()==null)
return; return;
onDataLoaded(result.items.stream().filter(n->n.type!=null).collect(Collectors.toList()), !result.items.isEmpty()); onDataLoaded(result.items, !result.items.isEmpty());
maxID=result.maxID; maxID=result.maxID;
endMark.setVisibility(result.items.isEmpty() ? View.VISIBLE : View.GONE); endMark.setVisibility(result.items.isEmpty() ? View.VISIBLE : View.GONE);
reloadingFromCache=false; reloadingFromCache=false;
if(refreshAfterLoading){
refreshAfterLoading=false;
refresh();
}
} }
}); });
} }
@ -111,8 +116,10 @@ public class NotificationsListFragment extends BaseNotificationsListFragment{
protected void onShown(){ protected void onShown(){
super.onShown(); super.onShown();
unreadMarker=realUnreadMarker=AccountSessionManager.get(accountID).getLastKnownNotificationsMarker(); unreadMarker=realUnreadMarker=AccountSessionManager.get(accountID).getLastKnownNotificationsMarker();
if(!dataLoading && canRefreshWithoutUpsettingUser()){ if(canRefreshWithoutUpsettingUser()){
reloadingFromCache=true; if(dataLoading)
refreshAfterLoading=true;
else
refresh(); refresh();
} }
} }
@ -158,7 +165,7 @@ public class NotificationsListFragment extends BaseNotificationsListFragment{
for(int i=0;i<parent.getChildCount();i++){ for(int i=0;i<parent.getChildCount();i++){
View child=parent.getChildAt(i); View child=parent.getChildAt(i);
if(parent.getChildViewHolder(child) instanceof StatusDisplayItem.Holder<?> holder){ if(parent.getChildViewHolder(child) instanceof StatusDisplayItem.Holder<?> holder){
String itemID=holder.getItemID(); String itemID=getNotificationByID(holder.getItemID()).notification.pageMaxId;
if(ObjectIdComparator.INSTANCE.compare(itemID, unreadMarker)>0){ if(ObjectIdComparator.INSTANCE.compare(itemID, unreadMarker)>0){
parent.getDecoratedBoundsWithMargins(child, tmpRect); parent.getDecoratedBoundsWithMargins(child, tmpRect);
c.drawRect(tmpRect, paint); c.drawRect(tmpRect, paint);
@ -180,12 +187,12 @@ public class NotificationsListFragment extends BaseNotificationsListFragment{
public void onPollUpdated(PollUpdatedEvent ev){ public void onPollUpdated(PollUpdatedEvent ev){
if(!ev.accountID.equals(accountID)) if(!ev.accountID.equals(accountID))
return; return;
for(Notification ntf:data){ for(NotificationViewModel ntf:data){
if(ntf.status==null) if(ntf.status==null)
continue; continue;
Status contentStatus=ntf.status.getContentStatus(); Status contentStatus=ntf.status.getContentStatus();
if(contentStatus.poll!=null && contentStatus.poll.id.equals(ev.poll.id)){ if(contentStatus.poll!=null && contentStatus.poll.id.equals(ev.poll.id)){
updatePoll(ntf.id, ntf.status, ev.poll); updatePoll(ntf.getID(), ntf.status, ev.poll);
} }
} }
} }
@ -194,10 +201,10 @@ public class NotificationsListFragment extends BaseNotificationsListFragment{
public void onRemoveAccountPostsEvent(RemoveAccountPostsEvent ev){ public void onRemoveAccountPostsEvent(RemoveAccountPostsEvent ev){
if(!ev.accountID.equals(accountID) || ev.isUnfollow) if(!ev.accountID.equals(accountID) || ev.isUnfollow)
return; return;
List<Notification> toRemove=Stream.concat(data.stream(), preloadedData.stream()) List<NotificationViewModel> toRemove=Stream.concat(data.stream(), preloadedData.stream())
.filter(n->n.account!=null && n.account.id.equals(ev.postsByAccountID)) .filter(n->n.status!=null && n.status.account.id.equals(ev.postsByAccountID))
.collect(Collectors.toList()); .collect(Collectors.toList());
for(Notification n:toRemove){ for(NotificationViewModel n:toRemove){
removeNotification(n); removeNotification(n);
} }
} }
@ -253,7 +260,7 @@ public class NotificationsListFragment extends BaseNotificationsListFragment{
private void markAsRead(){ private void markAsRead(){
if(data.isEmpty()) if(data.isEmpty())
return; return;
String id=data.get(0).id; String id=data.get(0).notification.pageMaxId;
if(ObjectIdComparator.INSTANCE.compare(id, realUnreadMarker)>0){ if(ObjectIdComparator.INSTANCE.compare(id, realUnreadMarker)>0){
new SaveMarkers(null, id).exec(accountID); new SaveMarkers(null, id).exec(accountID);
AccountSessionManager.get(accountID).setNotificationsMarker(id, true); AccountSessionManager.get(accountID).setNotificationsMarker(id, true);
@ -276,12 +283,13 @@ public class NotificationsListFragment extends BaseNotificationsListFragment{
} }
@Override @Override
public void onAppendItems(List<Notification> items){ public void onAppendItems(List<NotificationViewModel> items){
super.onAppendItems(items); super.onAppendItems(items);
if(data.isEmpty() || data.get(0).id.equals(realUnreadMarker)) // TODO
if(data.isEmpty() || data.get(0).getID().equals(realUnreadMarker))
return; return;
for(Notification n:items){ for(NotificationViewModel n:items){
if(ObjectIdComparator.INSTANCE.compare(n.id, realUnreadMarker)<=0){ if(ObjectIdComparator.INSTANCE.compare(n.notification.pageMinId, realUnreadMarker)<=0){
markAsRead(); markAsRead();
break; break;
} }
@ -296,7 +304,7 @@ public class NotificationsListFragment extends BaseNotificationsListFragment{
if(list.getChildViewHolder(list.getChildAt(i)) instanceof StatusDisplayItem.Holder<?> itemHolder){ if(list.getChildViewHolder(list.getChildAt(i)) instanceof StatusDisplayItem.Holder<?> itemHolder){
String id=itemHolder.getItemID(); String id=itemHolder.getItemID();
for(int j=0;j<data.size();j++){ for(int j=0;j<data.size();j++){
if(data.get(j).id.equals(id)) if(data.get(j).getID().equals(id))
return j<itemsPerPage; // Can refresh the list without losing scroll position if it is within the first page return j<itemsPerPage; // Can refresh the list without losing scroll position if it is within the first page
} }
} }

View file

@ -73,6 +73,10 @@ public abstract class Instance extends BaseModel{
public abstract int getVersion(); public abstract int getVersion();
public abstract long getApiVersion(String name); public abstract long getApiVersion(String name);
public long getApiVersion(){
return getApiVersion("mastodon");
}
@Parcel @Parcel
public static class Rule{ public static class Rule{
public String id; public String id;

View file

@ -1,7 +1,5 @@
package org.joinmastodon.android.model; package org.joinmastodon.android.model;
import com.google.gson.annotations.SerializedName;
import org.joinmastodon.android.api.ObjectValidationException; import org.joinmastodon.android.api.ObjectValidationException;
import org.joinmastodon.android.api.RequiredField; import org.joinmastodon.android.api.RequiredField;
import org.parceler.Parcel; import org.parceler.Parcel;
@ -13,7 +11,7 @@ public class Notification extends BaseModel implements DisplayItemsParent{
@RequiredField @RequiredField
public String id; public String id;
// @RequiredField // @RequiredField
public Type type; public NotificationType type;
@RequiredField @RequiredField
public Instant createdAt; public Instant createdAt;
@RequiredField @RequiredField
@ -38,21 +36,4 @@ public class Notification extends BaseModel implements DisplayItemsParent{
public String getAccountID(){ public String getAccountID(){
return status!=null ? account.id : null; return status!=null ? account.id : null;
} }
public enum Type{
@SerializedName("follow")
FOLLOW,
@SerializedName("follow_request")
FOLLOW_REQUEST,
@SerializedName("mention")
MENTION,
@SerializedName("reblog")
REBLOG,
@SerializedName("favourite")
FAVORITE,
@SerializedName("poll")
POLL,
@SerializedName("status")
STATUS
}
} }

View file

@ -0,0 +1,24 @@
package org.joinmastodon.android.model;
import org.joinmastodon.android.api.RequiredField;
import java.time.Instant;
import java.util.List;
public class NotificationGroup extends BaseModel{
@RequiredField
public String groupKey;
public int notificationsCount;
public NotificationType type;
@RequiredField
public String mostRecentNotificationId;
public String pageMinId;
public String pageMaxId;
public Instant latestPageNotificationAt;
@RequiredField
public List<String> sampleAccountIds;
public String statusId;
// TODO report
// TODO event
// TODO moderation_warning
}

View file

@ -0,0 +1,30 @@
package org.joinmastodon.android.model;
import com.google.gson.annotations.SerializedName;
import java.util.EnumSet;
public enum NotificationType{
@SerializedName("follow")
FOLLOW,
@SerializedName("follow_request")
FOLLOW_REQUEST,
@SerializedName("mention")
MENTION,
@SerializedName("reblog")
REBLOG,
@SerializedName("favourite")
FAVORITE,
@SerializedName("poll")
POLL,
@SerializedName("status")
STATUS;
public boolean canBeGrouped(){
return this==REBLOG || this==FAVORITE;
}
public static EnumSet<NotificationType> getGroupableTypes(){
return EnumSet.of(FAVORITE, REBLOG);
}
}

View file

@ -0,0 +1,19 @@
package org.joinmastodon.android.model.viewmodel;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.DisplayItemsParent;
import org.joinmastodon.android.model.NotificationGroup;
import org.joinmastodon.android.model.Status;
import java.util.List;
public class NotificationViewModel implements DisplayItemsParent{
public NotificationGroup notification;
public List<Account> accounts;
public Status status;
@Override
public String getID(){
return notification.groupKey;
}
}

View file

@ -5,6 +5,7 @@ import android.content.res.ColorStateList;
import android.graphics.drawable.Drawable; import android.graphics.drawable.Drawable;
import android.os.Bundle; import android.os.Bundle;
import android.text.SpannableStringBuilder; import android.text.SpannableStringBuilder;
import android.text.TextPaint;
import android.text.TextUtils; import android.text.TextUtils;
import android.text.style.TypefaceSpan; import android.text.style.TypefaceSpan;
import android.view.View; import android.view.View;
@ -14,15 +15,23 @@ import android.widget.TextView;
import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R; import org.joinmastodon.android.R;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.BaseStatusListFragment; import org.joinmastodon.android.fragments.BaseStatusListFragment;
import org.joinmastodon.android.fragments.ProfileFragment; import org.joinmastodon.android.fragments.ProfileFragment;
import org.joinmastodon.android.model.Notification; import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.NotificationType;
import org.joinmastodon.android.model.viewmodel.NotificationViewModel;
import org.joinmastodon.android.ui.OutlineProviders; import org.joinmastodon.android.ui.OutlineProviders;
import org.joinmastodon.android.ui.text.HtmlParser; import org.joinmastodon.android.ui.text.HtmlParser;
import org.joinmastodon.android.ui.text.LinkSpan;
import org.joinmastodon.android.ui.utils.CustomEmojiHelper; import org.joinmastodon.android.ui.utils.CustomEmojiHelper;
import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.ui.utils.UiUtils;
import org.parceler.Parcels; import org.parceler.Parcels;
import java.util.List;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import me.grishka.appkit.Nav; import me.grishka.appkit.Nav;
import me.grishka.appkit.imageloader.ImageLoaderViewHolder; import me.grishka.appkit.imageloader.ImageLoaderViewHolder;
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest; import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
@ -30,43 +39,76 @@ import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
import me.grishka.appkit.utils.V; import me.grishka.appkit.utils.V;
public class NotificationHeaderStatusDisplayItem extends StatusDisplayItem{ public class NotificationHeaderStatusDisplayItem extends StatusDisplayItem{
public final Notification notification; public final NotificationViewModel notification;
private ImageLoaderRequest avaRequest;
private String accountID; private String accountID;
private CustomEmojiHelper emojiHelper=new CustomEmojiHelper(); private CustomEmojiHelper emojiHelper=new CustomEmojiHelper();
private CharSequence text; private CharSequence text;
private List<Account> accounts;
private List<ImageLoaderRequest> avaRequests;
public NotificationHeaderStatusDisplayItem(String parentID, BaseStatusListFragment parentFragment, Notification notification, String accountID){ public NotificationHeaderStatusDisplayItem(String parentID, BaseStatusListFragment parentFragment, NotificationViewModel notification, String accountID){
super(parentID, parentFragment); super(parentID, parentFragment);
this.notification=notification; this.notification=notification;
this.accountID=accountID; this.accountID=accountID;
if(notification.type==Notification.Type.POLL){ if(notification.accounts.size()<=6){
text=parentFragment.getString(R.string.poll_ended); accounts=notification.accounts;
}else{ }else{
avaRequest=new UrlImageLoaderRequest(GlobalUserPreferences.playGifs ? notification.account.avatar : notification.account.avatarStatic, V.dp(50), V.dp(50)); accounts=notification.accounts.subList(0, 6);
SpannableStringBuilder parsedName=new SpannableStringBuilder(notification.account.displayName); }
HtmlParser.parseCustomEmoji(parsedName, notification.account.emojis); avaRequests=accounts.stream()
.map(a->new UrlImageLoaderRequest(GlobalUserPreferences.playGifs ? a.avatar : a.avatarStatic, V.dp(50), V.dp(50)))
.collect(Collectors.toList());
if(notification.notification.type==NotificationType.POLL && AccountSessionManager.getInstance().isSelf(accountID, notification.accounts.get(0))){
text=parentFragment.getString(R.string.own_poll_ended);
}else{
Account account=notification.accounts.get(0);
SpannableStringBuilder parsedName=new SpannableStringBuilder(account.displayName);
HtmlParser.parseCustomEmoji(parsedName, account.emojis);
emojiHelper.setText(parsedName); emojiHelper.setText(parsedName);
String[] parts=parentFragment.getString(switch(notification.type){ String text;
if(accounts.size()>1 && notification.notification.type.canBeGrouped()){
text=parentFragment.getResources().getQuantityString(switch(notification.notification.type){
case FAVORITE -> R.plurals.user_and_x_more_favorited;
case REBLOG -> R.plurals.user_and_x_more_boosted;
default -> throw new IllegalStateException("Unexpected value: " + notification.notification.type);
}, notification.notification.notificationsCount-1, "{{name}}", notification.notification.notificationsCount-1);
}else if(notification.notification.type==NotificationType.POLL){
if(notification.status==null || notification.status.poll==null){
text="???";
}else{
int count=notification.status.poll.votersCount-1;
text=parentFragment.getResources().getQuantityString(R.plurals.poll_ended_x_voters, count, "{{name}}", count);
}
}else{
text=parentFragment.getString(switch(notification.notification.type){
case FOLLOW -> R.string.user_followed_you; case FOLLOW -> R.string.user_followed_you;
case FOLLOW_REQUEST -> R.string.user_sent_follow_request; case FOLLOW_REQUEST -> R.string.user_sent_follow_request;
case REBLOG -> R.string.notification_boosted; case REBLOG -> R.string.notification_boosted;
case FAVORITE -> R.string.user_favorited; case FAVORITE -> R.string.user_favorited;
default -> throw new IllegalStateException("Unexpected value: "+notification.type); default -> throw new IllegalStateException("Unexpected value: "+notification.notification.type);
}).split("%s", 2); }, "{{name}}");
SpannableStringBuilder text=new SpannableStringBuilder();
if(parts.length>1 && !TextUtils.isEmpty(parts[0]))
text.append(parts[0]);
text.append(parsedName, new TypefaceSpan("sans-serif-medium"), 0);
if(parts.length==1){
text.append(' ');
text.append(parts[0]);
}else if(!TextUtils.isEmpty(parts[1])){
text.append(parts[1]);
} }
this.text=text; String[] parts=text.split(Pattern.quote("{{name}}"), 2);
SpannableStringBuilder formattedText=new SpannableStringBuilder();
if(parts.length>1 && !TextUtils.isEmpty(parts[0]))
formattedText.append(parts[0]);
formattedText.append(parsedName, new TypefaceSpan("sans-serif-medium"), 0);
formattedText.setSpan(new NonColoredLinkSpan(null, s->{
Bundle args=new Bundle();
args.putString("account", accountID);
args.putParcelable("profileAccount", Parcels.wrap(account));
Nav.go(parentFragment.getActivity(), ProfileFragment.class, args);
}, LinkSpan.Type.CUSTOM, null, null, null), formattedText.length()-parsedName.length(), formattedText.length(), 0);
if(parts.length==1){
formattedText.append(' ');
formattedText.append(parts[0]);
}else if(!TextUtils.isEmpty(parts[1])){
formattedText.append(parts[1]);
}
this.text=formattedText;
} }
} }
@ -77,46 +119,61 @@ public class NotificationHeaderStatusDisplayItem extends StatusDisplayItem{
@Override @Override
public int getImageCount(){ public int getImageCount(){
return 1+emojiHelper.getImageCount(); return avaRequests.size()+emojiHelper.getImageCount();
} }
@Override @Override
public ImageLoaderRequest getImageRequest(int index){ public ImageLoaderRequest getImageRequest(int index){
if(index>0){ if(index>=avaRequests.size()){
return emojiHelper.getImageRequest(index-1); return emojiHelper.getImageRequest(index-avaRequests.size());
} }
return avaRequest; return avaRequests.get(index);
} }
public static class Holder extends StatusDisplayItem.Holder<NotificationHeaderStatusDisplayItem> implements ImageLoaderViewHolder{ public static class Holder extends StatusDisplayItem.Holder<NotificationHeaderStatusDisplayItem> implements ImageLoaderViewHolder{
private final ImageView icon, avatar; private final ImageView icon;
private final TextView text; private final TextView text;
private final ImageView[] avatars;
private final View avatarsContainer;
public Holder(Activity activity, ViewGroup parent){ public Holder(Activity activity, ViewGroup parent){
super(activity, R.layout.display_item_notification_header, parent); super(activity, R.layout.display_item_notification_header, parent);
icon=findViewById(R.id.icon); icon=findViewById(R.id.icon);
avatar=findViewById(R.id.avatar);
text=findViewById(R.id.text); text=findViewById(R.id.text);
avatars=new ImageView[]{
findViewById(R.id.avatar1),
findViewById(R.id.avatar2),
findViewById(R.id.avatar3),
findViewById(R.id.avatar4),
findViewById(R.id.avatar5),
findViewById(R.id.avatar6),
};
avatarsContainer=findViewById(R.id.avatars);
avatar.setOutlineProvider(OutlineProviders.roundedRect(8)); int i=0;
for(ImageView avatar:avatars){
avatar.setOutlineProvider(OutlineProviders.roundedRect(6));
avatar.setClipToOutline(true); avatar.setClipToOutline(true);
avatar.setOnClickListener(this::onAvaClick); avatar.setOnClickListener(this::onAvaClick);
avatar.setTag(i);
i++;
}
} }
@Override @Override
public void setImage(int index, Drawable image){ public void setImage(int index, Drawable image){
if(index==0){ if(index<item.avaRequests.size()){
avatar.setImageDrawable(image); avatars[index].setImageDrawable(image);
}else{ }else{
item.emojiHelper.setImageDrawable(index-1, image); item.emojiHelper.setImageDrawable(index-item.avaRequests.size(), image);
text.invalidate(); text.invalidate();
} }
} }
@Override @Override
public void clearImage(int index){ public void clearImage(int index){
if(index==0) if(index<item.avaRequests.size())
avatar.setImageResource(R.drawable.image_placeholder); avatars[index].setImageResource(R.drawable.image_placeholder);
else else
ImageLoaderViewHolder.super.clearImage(index); ImageLoaderViewHolder.super.clearImage(index);
} }
@ -124,30 +181,52 @@ public class NotificationHeaderStatusDisplayItem extends StatusDisplayItem{
@Override @Override
public void onBind(NotificationHeaderStatusDisplayItem item){ public void onBind(NotificationHeaderStatusDisplayItem item){
text.setText(item.text); text.setText(item.text);
avatar.setVisibility(item.notification.type==Notification.Type.POLL ? View.GONE : View.VISIBLE); // avatar.setVisibility(item.notification.notification.type==NotificationType.POLL ? View.GONE : View.VISIBLE);
if(item.notification.type!=Notification.Type.POLL) if(item.notification.notification.type==NotificationType.POLL){
avatar.setContentDescription(item.parentFragment.getString(R.string.avatar_description, item.notification.account.acct)); avatarsContainer.setVisibility(View.GONE);
icon.setImageResource(switch(item.notification.type){ }else{
avatarsContainer.setVisibility(View.VISIBLE);
for(int i=0;i<avatars.length;i++){
if(i>=item.accounts.size()){
avatars[i].setVisibility(View.GONE);
}else{
avatars[i].setVisibility(View.VISIBLE);
avatars[i].setContentDescription(item.parentFragment.getString(R.string.avatar_description, item.notification.accounts.get(i).acct));
}
}
}
icon.setImageResource(switch(item.notification.notification.type){
case FAVORITE -> R.drawable.ic_star_fill1_24px; case FAVORITE -> R.drawable.ic_star_fill1_24px;
case REBLOG -> R.drawable.ic_repeat_fill1_24px; case REBLOG -> R.drawable.ic_repeat_fill1_24px;
case FOLLOW, FOLLOW_REQUEST -> R.drawable.ic_person_add_fill1_24px; case FOLLOW, FOLLOW_REQUEST -> R.drawable.ic_person_add_fill1_24px;
case POLL -> R.drawable.ic_insert_chart_fill1_24px; case POLL -> R.drawable.ic_insert_chart_fill1_24px;
default -> throw new IllegalStateException("Unexpected value: "+item.notification.type); default -> throw new IllegalStateException("Unexpected value: "+item.notification.notification.type);
}); });
icon.setImageTintList(ColorStateList.valueOf(UiUtils.getThemeColor(item.parentFragment.getActivity(), switch(item.notification.type){ icon.setImageTintList(ColorStateList.valueOf(UiUtils.getThemeColor(item.parentFragment.getActivity(), switch(item.notification.notification.type){
case FAVORITE -> R.attr.colorFavorite; case FAVORITE -> R.attr.colorFavorite;
case REBLOG -> R.attr.colorBoost; case REBLOG -> R.attr.colorBoost;
case FOLLOW, FOLLOW_REQUEST -> R.attr.colorFollow; case FOLLOW, FOLLOW_REQUEST -> R.attr.colorM3Primary;
case POLL -> R.attr.colorPoll; default -> R.attr.colorM3Outline;
default -> throw new IllegalStateException("Unexpected value: "+item.notification.type);
}))); })));
itemView.setPadding(itemView.getPaddingLeft(), itemView.getPaddingTop(), itemView.getPaddingRight(), item.notification.status==null ? V.dp(12) : 0);
} }
private void onAvaClick(View v){ private void onAvaClick(View v){
Bundle args=new Bundle(); Bundle args=new Bundle();
args.putString("account", item.accountID); args.putString("account", item.accountID);
args.putParcelable("profileAccount", Parcels.wrap(item.notification.account)); args.putParcelable("profileAccount", Parcels.wrap(item.notification.accounts.get((Integer)v.getTag())));
Nav.go(item.parentFragment.getActivity(), ProfileFragment.class, args); Nav.go(item.parentFragment.getActivity(), ProfileFragment.class, args);
} }
} }
private static class NonColoredLinkSpan extends LinkSpan{
public NonColoredLinkSpan(String link, OnLinkClickListener listener, Type type, String accountID, Object linkObject, Object parentObject){
super(link, listener, type, accountID, linkObject, parentObject);
}
@Override
public void updateDrawState(TextPaint tp){
color=tp.getColor();
}
}
} }

View file

@ -12,7 +12,7 @@ import org.joinmastodon.android.ui.utils.UiUtils;
public class LinkSpan extends CharacterStyle { public class LinkSpan extends CharacterStyle {
private int color=0xFF00FF00; protected int color=0xFF00FF00;
private OnLinkClickListener listener; private OnLinkClickListener listener;
private String link; private String link;
private Type type; private Type type;

View file

@ -1,34 +1,74 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:orientation="horizontal" android:orientation="horizontal"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="64dp" android:layout_height="wrap_content"
android:gravity="center_vertical" android:paddingHorizontal="16dp"
android:paddingHorizontal="16dp"> android:paddingTop="12dp">
<ImageView <ImageView
android:id="@+id/icon" android:id="@+id/icon"
android:layout_width="28dp" android:layout_width="40dp"
android:layout_height="28dp" android:layout_height="28dp"
android:layout_alignParentTop="true"
android:layout_alignParentStart="true"
android:layout_marginEnd="8dp"
android:importantForAccessibility="no" android:importantForAccessibility="no"
android:scaleType="center"
tools:tint="#0f0" tools:tint="#0f0"
tools:src="@drawable/ic_repeat_24px"/> tools:src="@drawable/ic_repeat_24px"/>
<LinearLayout
android:id="@+id/avatars"
android:layout_width="match_parent"
android:layout_height="28dp"
android:layout_toEndOf="@id/icon"
android:layout_marginBottom="4dp"
android:orientation="horizontal">
<ImageView <ImageView
android:id="@+id/avatar" android:id="@+id/avatar1"
android:layout_width="32dp" android:layout_width="28dp"
android:layout_height="32dp" android:layout_height="28dp"
android:layout_marginStart="8dp"/> android:layout_marginEnd="8dp"/>
<ImageView
android:id="@+id/avatar2"
android:layout_width="28dp"
android:layout_height="28dp"
android:layout_marginEnd="8dp"/>
<ImageView
android:id="@+id/avatar3"
android:layout_width="28dp"
android:layout_height="28dp"
android:layout_marginEnd="8dp"/>
<ImageView
android:id="@+id/avatar4"
android:layout_width="28dp"
android:layout_height="28dp"
android:layout_marginEnd="8dp"/>
<ImageView
android:id="@+id/avatar5"
android:layout_width="28dp"
android:layout_height="28dp"
android:layout_marginEnd="8dp"/>
<ImageView
android:id="@+id/avatar6"
android:layout_width="28dp"
android:layout_height="28dp"
android:layout_marginEnd="8dp"/>
</LinearLayout>
<TextView <org.joinmastodon.android.ui.views.LinkedTextView
android:id="@+id/text" android:id="@+id/text"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="8dp" android:layout_toEndOf="@id/icon"
android:textAppearance="@style/m3_body_large" android:layout_below="@id/avatars"
android:layout_alignWithParentIfMissing="true"
android:textAppearance="@style/m3_body_medium"
android:textColor="?colorM3OnSurface" android:textColor="?colorM3OnSurface"
android:singleLine="true" android:minHeight="20dp"
android:gravity="center_vertical"
tools:text="Notification text"/> tools:text="Notification text"/>
</LinearLayout> </RelativeLayout>

View file

@ -33,8 +33,6 @@
<attr name="colorWhite" format="color"/> <attr name="colorWhite" format="color"/>
<attr name="colorFavorite" format="color" /> <attr name="colorFavorite" format="color" />
<attr name="colorBoost" format="color" /> <attr name="colorBoost" format="color" />
<attr name="colorFollow" format="color" />
<attr name="colorPoll" format="color" />
<declare-styleable name="MaxWidthFrameLayout"> <declare-styleable name="MaxWidthFrameLayout">
<attr name="android:maxWidth" format="dimension"/> <attr name="android:maxWidth" format="dimension"/>

View file

@ -54,5 +54,55 @@
<color name="m3_sys_dark_outline">#938F99</color> <color name="m3_sys_dark_outline">#938F99</color>
<color name="m3_sys_dark_outline_variant">#49454F</color> <color name="m3_sys_dark_outline_variant">#49454F</color>
<!-- extended colors -->
<color name="ext_favorite_light">#E89A00</color>
<color name="ext_favorite_light_high_contrast">#7B5800</color>
<color name="ext_favorite_light_medium_contrast">#CC9200</color>
<color name="ext_favorite_dark">#FFB014</color>
<color name="ext_on_favorite_light">#FFFFFF</color>
<color name="ext_on_favorite_light_high_contrast">#FFFFFF</color>
<color name="ext_on_favorite_light_medium_contrast">#FFFFFF</color>
<color name="ext_on_favorite_dark">#412D00</color>
<color name="ext_favorite_container_light">#FFC758</color>
<color name="ext_favorite_container_light_high_contrast">#FFC758</color>
<color name="ext_favorite_container_light_medium_contrast">#FFC758</color>
<color name="ext_favorite_container_dark">#F9B928</color>
<color name="ext_on_favorite_container_light">#503800</color>
<color name="ext_on_favorite_container_light_high_contrast">#503800</color>
<color name="ext_on_favorite_container_light_medium_contrast">#503800</color>
<color name="ext_on_favorite_container_dark">#463100</color>
<color name="ext_boost_light">#006C4E</color>
<color name="ext_boost_light_high_contrast">#006C4E</color>
<color name="ext_boost_light_medium_contrast">#006C4E</color>
<color name="ext_boost_dark">#48DEAB</color>
<color name="ext_on_boost_light">#FFFFFF</color>
<color name="ext_on_boost_light_high_contrast">#FFFFFF</color>
<color name="ext_on_boost_light_medium_contrast">#FFFFFF</color>
<color name="ext_on_boost_dark">#003827</color>
<color name="ext_boost_container_light">#25C896</color>
<color name="ext_boost_container_light_high_contrast">#25C896</color>
<color name="ext_boost_container_light_medium_contrast">#25C896</color>
<color name="ext_boost_container_dark">#00B384</color>
<color name="ext_on_boost_container_light">#002C1E</color>
<color name="ext_on_boost_container_light_high_contrast">#002C1E</color>
<color name="ext_on_boost_container_light_medium_contrast">#002C1E</color>
<color name="ext_on_boost_container_dark">#00130B</color>
<color name="ext_bookmark_light">#A2003E</color>
<color name="ext_bookmark_light_high_contrast">#A2003E</color>
<color name="ext_bookmark_light_medium_contrast">#A2003E</color>
<color name="ext_bookmark_dark">#FFB2BD</color>
<color name="ext_on_bookmark_light">#FFFFFF</color>
<color name="ext_on_bookmark_light_high_contrast">#FFFFFF</color>
<color name="ext_on_bookmark_light_medium_contrast">#FFFFFF</color>
<color name="ext_on_bookmark_dark">#670024</color>
<color name="ext_bookmark_container_light">#DF235E</color>
<color name="ext_bookmark_container_light_high_contrast">#DF235E</color>
<color name="ext_bookmark_container_light_medium_contrast">#DF235E</color>
<color name="ext_bookmark_container_dark">#D31656</color>
<color name="ext_on_bookmark_container_light">#FFFFFF</color>
<color name="ext_on_bookmark_container_light_high_contrast">#FFFFFF</color>
<color name="ext_on_bookmark_container_light_medium_contrast">#FFFFFF</color>
<color name="ext_on_bookmark_container_dark">#FFFFFF</color>
<item name="overlay_ripple_alpha" format="float" type="dimen">0.12</item> <item name="overlay_ripple_alpha" format="float" type="dimen">0.12</item>
</resources> </resources>

View file

@ -16,9 +16,8 @@
<string name="user_followed_you">%s followed you</string> <string name="user_followed_you">%s followed you</string>
<string name="user_sent_follow_request">%s sent you a follow request</string> <string name="user_sent_follow_request">%s sent you a follow request</string>
<string name="user_favorited">%s favorited your post</string> <string name="user_favorited">%s favorited:</string>
<string name="notification_boosted">%s boosted your post</string> <string name="notification_boosted">%s boosted:</string>
<string name="poll_ended">See the results of a poll you voted in</string>
<string name="share_toot_title">Share</string> <string name="share_toot_title">Share</string>
<string name="settings">Settings</string> <string name="settings">Settings</string>
@ -784,4 +783,17 @@
<item quantity="one">%d attachment</item> <item quantity="one">%d attachment</item>
<item quantity="other">%d attachments</item> <item quantity="other">%d attachments</item>
</plurals> </plurals>
<plurals name="user_and_x_more_favorited">
<item quantity="one">%1$s and %2$,d other favorited:</item>
<item quantity="other">%1$s and %2$,d others favorited:</item>
</plurals>
<plurals name="user_and_x_more_boosted">
<item quantity="one">%1$s and %2$,d other boosted:</item>
<item quantity="other">%1$s and %2$,d others boosted:</item>
</plurals>
<plurals name="poll_ended_x_voters">
<item quantity="one">%1$s ran a poll that you and %2$,d other voted in</item>
<item quantity="other">%1$s ran a poll that you and %2$,d others voted in</item>
</plurals>
<string name="own_poll_ended">Your poll has ended</string>
</resources> </resources>

View file

@ -56,10 +56,8 @@
<item name="colorM3SurfaceInverse">@color/m3_sys_dark_surface</item> <item name="colorM3SurfaceInverse">@color/m3_sys_dark_surface</item>
<item name="colorM3OnSurfaceInverse">@color/m3_sys_dark_on_surface</item> <item name="colorM3OnSurfaceInverse">@color/m3_sys_dark_on_surface</item>
<item name="colorWhite">#FFF</item> <item name="colorWhite">#FFF</item>
<item name="colorFavorite">#8b5000</item> <item name="colorFavorite">@color/ext_favorite_light</item>
<item name="colorBoost">#ab332a</item> <item name="colorBoost">@color/ext_boost_light</item>
<item name="colorFollow">#4746e3</item>
<item name="colorPoll">#006d42</item>
<item name="android:statusBarColor">?colorM3Background</item> <item name="android:statusBarColor">?colorM3Background</item>
<item name="android:navigationBarColor">@color/navigation_bar_bg_light</item> <item name="android:navigationBarColor">@color/navigation_bar_bg_light</item>
@ -126,10 +124,8 @@
<item name="colorM3SurfaceInverse">@color/m3_sys_light_surface</item> <item name="colorM3SurfaceInverse">@color/m3_sys_light_surface</item>
<item name="colorM3OnSurfaceInverse">@color/m3_sys_light_on_surface</item> <item name="colorM3OnSurfaceInverse">@color/m3_sys_light_on_surface</item>
<item name="colorWhite">#000</item> <item name="colorWhite">#000</item>
<item name="colorFavorite">#ffb871</item> <item name="colorFavorite">@color/ext_favorite_dark</item>
<item name="colorBoost">#ffb4aa</item> <item name="colorBoost">@color/ext_boost_dark</item>
<item name="colorFollow">#c1c1ff</item>
<item name="colorPoll">#77daa1</item>
<item name="android:statusBarColor">?colorM3Background</item> <item name="android:statusBarColor">?colorM3Background</item>
<item name="android:navigationBarColor">?colorM3Background</item> <item name="android:navigationBarColor">?colorM3Background</item>