Tab customization & direct messages tab (#1012)

* custom tabs

* custom tabs interface

* implement custom tab functionality

* add database migration

* fix bugs, improve ThemeUtils nullability handling

* implement conversationsfragment

* setup ConversationViewHolder

* implement favs

* add button functionality

* revert 10.json

* revert item_status_notification.xml

* implement more menu, replying, fix stuff, clean up

* fix tests

* fix bug with expanding statuses

* min and max number of tabs

* settings support, fix bugs

* database migration

* fix scrolling to top after refresh

* fix                                 bugs

* fix warning in item_conversation
This commit is contained in:
Konrad Pozniak 2019-02-12 19:22:37 +01:00 committed by GitHub
parent adf573646e
commit e371fa0e24
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
75 changed files with 3663 additions and 296 deletions

View File

@ -137,4 +137,5 @@ dependencies {
implementation 'com.squareup.retrofit2:adapter-rxjava2:2.5.0'
implementation 'com.uber.autodispose:autodispose-android-archcomponents:1.1.0'
implementation 'com.uber.autodispose:autodispose-ktx:1.1.0'
implementation 'androidx.paging:paging-runtime-ktx:2.1.0-rc01'
}

View File

@ -0,0 +1,668 @@
{
"formatVersion": 1,
"database": {
"version": 12,
"identityHash": "d4d3d4c683ab7f681459b9edab92301c",
"entities": [
{
"tableName": "TootEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `text` TEXT, `urls` TEXT, `descriptions` TEXT, `contentWarning` TEXT, `inReplyToId` TEXT, `inReplyToText` TEXT, `inReplyToUsername` TEXT, `visibility` INTEGER)",
"fields": [
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "text",
"columnName": "text",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "urls",
"columnName": "urls",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "descriptions",
"columnName": "descriptions",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "contentWarning",
"columnName": "contentWarning",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "inReplyToId",
"columnName": "inReplyToId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "inReplyToText",
"columnName": "inReplyToText",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "inReplyToUsername",
"columnName": "inReplyToUsername",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "visibility",
"columnName": "visibility",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"uid"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "AccountEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "domain",
"columnName": "domain",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accessToken",
"columnName": "accessToken",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isActive",
"columnName": "isActive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "profilePictureUrl",
"columnName": "profilePictureUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "notificationsEnabled",
"columnName": "notificationsEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsMentioned",
"columnName": "notificationsMentioned",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsFollowed",
"columnName": "notificationsFollowed",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsReblogged",
"columnName": "notificationsReblogged",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationsFavorited",
"columnName": "notificationsFavorited",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationSound",
"columnName": "notificationSound",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationVibration",
"columnName": "notificationVibration",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notificationLight",
"columnName": "notificationLight",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "defaultPostPrivacy",
"columnName": "defaultPostPrivacy",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "defaultMediaSensitivity",
"columnName": "defaultMediaSensitivity",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "alwaysShowSensitiveMedia",
"columnName": "alwaysShowSensitiveMedia",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "mediaPreviewEnabled",
"columnName": "mediaPreviewEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastNotificationId",
"columnName": "lastNotificationId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "activeNotifications",
"columnName": "activeNotifications",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "tabPreferences",
"columnName": "tabPreferences",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_AccountEntity_domain_accountId",
"unique": true,
"columnNames": [
"domain",
"accountId"
],
"createSql": "CREATE UNIQUE INDEX `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)"
}
],
"foreignKeys": []
},
{
"tableName": "InstanceEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, PRIMARY KEY(`instance`))",
"fields": [
{
"fieldPath": "instance",
"columnName": "instance",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "emojiList",
"columnName": "emojiList",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "maximumTootCharacters",
"columnName": "maximumTootCharacters",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"instance"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "TimelineStatusEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `instance` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT, `visibility` INTEGER, `attachments` TEXT, `mentions` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
"fields": [
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "timelineUserId",
"columnName": "timelineUserId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "authorServerId",
"columnName": "authorServerId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "instance",
"columnName": "instance",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "inReplyToId",
"columnName": "inReplyToId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "inReplyToAccountId",
"columnName": "inReplyToAccountId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogsCount",
"columnName": "reblogsCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "favouritesCount",
"columnName": "favouritesCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "reblogged",
"columnName": "reblogged",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "favourited",
"columnName": "favourited",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sensitive",
"columnName": "sensitive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "spoilerText",
"columnName": "spoilerText",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "visibility",
"columnName": "visibility",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "mentions",
"columnName": "mentions",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "application",
"columnName": "application",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogServerId",
"columnName": "reblogServerId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "reblogAccountId",
"columnName": "reblogAccountId",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"serverId",
"timelineUserId"
],
"autoGenerate": false
},
"indices": [
{
"name": "index_TimelineStatusEntity_authorServerId_timelineUserId",
"unique": false,
"columnNames": [
"authorServerId",
"timelineUserId"
],
"createSql": "CREATE INDEX `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)"
}
],
"foreignKeys": [
{
"table": "TimelineAccountEntity",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"authorServerId",
"timelineUserId"
],
"referencedColumns": [
"serverId",
"timelineUserId"
]
}
]
},
{
"tableName": "TimelineAccountEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `instance` TEXT NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))",
"fields": [
{
"fieldPath": "serverId",
"columnName": "serverId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "timelineUserId",
"columnName": "timelineUserId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "instance",
"columnName": "instance",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "localUsername",
"columnName": "localUsername",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "avatar",
"columnName": "avatar",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "emojis",
"columnName": "emojis",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"serverId",
"timelineUserId"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "ConversationEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, PRIMARY KEY(`id`, `accountId`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accounts",
"columnName": "accounts",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "unread",
"columnName": "unread",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.id",
"columnName": "s_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.url",
"columnName": "s_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.inReplyToId",
"columnName": "s_inReplyToId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.inReplyToAccountId",
"columnName": "s_inReplyToAccountId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastStatus.account",
"columnName": "s_account",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.content",
"columnName": "s_content",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.createdAt",
"columnName": "s_createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.emojis",
"columnName": "s_emojis",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.favouritesCount",
"columnName": "s_favouritesCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.favourited",
"columnName": "s_favourited",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.sensitive",
"columnName": "s_sensitive",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.spoilerText",
"columnName": "s_spoilerText",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.attachments",
"columnName": "s_attachments",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.mentions",
"columnName": "s_mentions",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastStatus.showingHiddenContent",
"columnName": "s_showingHiddenContent",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.expanded",
"columnName": "s_expanded",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.collapsible",
"columnName": "s_collapsible",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastStatus.collapsed",
"columnName": "s_collapsed",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id",
"accountId"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, \"d4d3d4c683ab7f681459b9edab92301c\")"
]
}
}

View File

@ -97,6 +97,7 @@
<activity android:name=".FavouritesActivity" />
<activity android:name=".AccountListActivity" />
<activity android:name=".AboutActivity" />
<activity android:name=".TabPreferenceActivity" />
<activity
android:name=".ReportActivity"
android:windowSoftInputMode="stateVisible|adjustResize" />

View File

@ -85,7 +85,7 @@ abstract class BottomSheetActivity : BaseActivity() {
val searchResult = response.body()
if(searchResult != null) {
if (searchResult.statuses.isNotEmpty()) {
viewThread(searchResult.statuses[0])
viewThread(searchResult.statuses[0].id, searchResult.statuses[0].url)
return
} else if (searchResult.accounts.isNotEmpty()) {
viewAccount(searchResult.accounts[0].id)
@ -107,11 +107,11 @@ abstract class BottomSheetActivity : BaseActivity() {
onBeginSearch(url)
}
open fun viewThread(status: Status) {
open fun viewThread(statusId: String, url: String?) {
if (!isSearching()) {
val intent = Intent(this, ViewThreadActivity::class.java)
intent.putExtra("id", status.actionableId)
intent.putExtra("url", status.actionableStatus.url)
intent.putExtra("id", statusId)
intent.putExtra("url", url)
startActivityWithSlideInAnimation(intent)
}
}

View File

@ -8,12 +8,10 @@ import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.annotation.StringRes
import androidx.appcompat.widget.Toolbar
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.LoadingState.*
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.MastoList

View File

@ -38,11 +38,12 @@ import android.widget.ImageView;
import com.keylesspalace.tusky.appstore.CacheUpdater;
import com.keylesspalace.tusky.appstore.EventHub;
import com.keylesspalace.tusky.appstore.MainTabsChangedEvent;
import com.keylesspalace.tusky.appstore.ProfileEditedEvent;
import com.keylesspalace.tusky.db.AccountEntity;
import com.keylesspalace.tusky.entity.Account;
import com.keylesspalace.tusky.interfaces.ActionButtonActivity;
import com.keylesspalace.tusky.pager.TimelinePagerAdapter;
import com.keylesspalace.tusky.pager.MainPagerAdapter;
import com.keylesspalace.tusky.util.CustomEmojiHelper;
import com.keylesspalace.tusky.util.NotificationHelper;
import com.keylesspalace.tusky.util.ThemeUtils;
@ -106,22 +107,17 @@ public final class MainActivity extends BottomSheetActivity implements ActionBut
private FloatingActionButton composeButton;
private AccountHeader headerResult;
private Drawer drawer;
private TabLayout tabLayout;
private ViewPager viewPager;
private void forwardShare(Intent intent) {
Intent composeIntent = new Intent(this, ComposeActivity.class);
composeIntent.setAction(intent.getAction());
composeIntent.setType(intent.getType());
composeIntent.putExtras(intent);
startActivity(composeIntent);
}
private int notificationTabPosition;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Intent intent = getIntent();
int tabPosition = 0;
boolean showNotificationTab = false;
if (intent != null) {
long accountId = intent.getLongExtra(NotificationHelper.ACCOUNT_ID, -1);
@ -156,14 +152,14 @@ public final class MainActivity extends BottomSheetActivity implements ActionBut
}
} else if (accountRequested) {
// user clicked a notification, show notification tab and switch user if necessary
tabPosition = 1;
showNotificationTab = true;
}
}
setContentView(R.layout.activity_main);
composeButton = findViewById(R.id.floating_btn);
ImageButton drawerToggle = findViewById(R.id.drawer_toggle);
TabLayout tabLayout = findViewById(R.id.tab_layout);
tabLayout = findViewById(R.id.tab_layout);
viewPager = findViewById(R.id.pager);
composeButton.setOnClickListener(v -> {
@ -181,60 +177,26 @@ public final class MainActivity extends BottomSheetActivity implements ActionBut
* drawer, though, because its callback touches the header in the drawer. */
fetchUserInfo();
// Setup the tabs and timeline pager.
TimelinePagerAdapter adapter = new TimelinePagerAdapter(getSupportFragmentManager());
setupTabs(showNotificationTab);
int pageMargin = getResources().getDimensionPixelSize(R.dimen.tab_page_margin);
viewPager.setPageMargin(pageMargin);
Drawable pageMarginDrawable = ThemeUtils.getDrawable(this, R.attr.tab_page_margin_drawable,
R.drawable.tab_page_margin_dark);
viewPager.setPageMarginDrawable(pageMarginDrawable);
viewPager.setAdapter(adapter);
tabLayout.setupWithViewPager(viewPager);
int[] tabIcons = {
R.drawable.ic_home_24dp,
R.drawable.ic_notifications_24dp,
R.drawable.ic_local_24dp,
R.drawable.ic_public_24dp,
};
String[] pageTitles = {
getString(R.string.title_home),
getString(R.string.title_notifications),
getString(R.string.title_public_local),
getString(R.string.title_public_federated),
};
for (int i = 0; i < 4; i++) {
TabLayout.Tab tab = tabLayout.getTabAt(i);
tab.setIcon(tabIcons[i]);
tab.setContentDescription(pageTitles[i]);
}
if (tabPosition != 0) {
TabLayout.Tab tab = tabLayout.getTabAt(tabPosition);
if (tab != null) {
tab.select();
} else {
tabPosition = 0;
}
}
tabLayout.addOnTabSelectedListener(new TabLayout.OnTabSelectedListener() {
@Override
public void onTabSelected(TabLayout.Tab tab) {
viewPager.setCurrentItem(tab.getPosition());
tintTab(tab, true);
if (tab.getPosition() == 1) {
if (tab.getPosition() == notificationTabPosition) {
NotificationHelper.clearNotificationsForActiveAccount(MainActivity.this, accountManager);
}
}
@Override
public void onTabUnselected(TabLayout.Tab tab) {
tintTab(tab, false);
}
@Override
@ -242,10 +204,6 @@ public final class MainActivity extends BottomSheetActivity implements ActionBut
}
});
for (int i = 0; i < 4; i++) {
tintTab(tabLayout.getTabAt(i), i == tabPosition);
}
// Setup push notifications
if (NotificationHelper.areNotificationsEnabled(this, accountManager)) {
NotificationHelper.enablePullNotifications();
@ -260,6 +218,9 @@ public final class MainActivity extends BottomSheetActivity implements ActionBut
if (event instanceof ProfileEditedEvent) {
onFetchUserInfoSuccess(((ProfileEditedEvent) event).getNewProfileData());
}
if (event instanceof MainTabsChangedEvent) {
setupTabs(false);
}
});
// Flush old media that was cached for sharing
@ -316,9 +277,12 @@ public final class MainActivity extends BottomSheetActivity implements ActionBut
}
}
private void tintTab(TabLayout.Tab tab, boolean tinted) {
int color = (tinted) ? R.attr.tab_icon_selected_tint : R.attr.toolbar_icon_tint;
ThemeUtils.setDrawableTint(this, tab.getIcon(), color);
private void forwardShare(Intent intent) {
Intent composeIntent = new Intent(this, ComposeActivity.class);
composeIntent.setAction(intent.getAction());
composeIntent.setType(intent.getType());
composeIntent.putExtras(intent);
startActivity(composeIntent);
}
private void setupDrawer() {
@ -433,6 +397,29 @@ public final class MainActivity extends BottomSheetActivity implements ActionBut
});
}
private void setupTabs(boolean selectNotificationTab) {
List<TabData> tabs = accountManager.getActiveAccount().getTabPreferences();
MainPagerAdapter adapter = new MainPagerAdapter(tabs, getSupportFragmentManager());
viewPager.setAdapter(adapter);
tabLayout.setupWithViewPager(viewPager);
tabLayout.removeAllTabs();
for (int i = 0; i < tabs.size(); i++) {
TabLayout.Tab tab = tabLayout.newTab()
.setIcon(tabs.get(i).getIcon())
.setContentDescription(tabs.get(i).getText());
tabLayout.addTab(tab);
if(tabs.get(i).getId().equals(TabDataKt.NOTIFICATIONS)) {
notificationTabPosition = i;
if(selectNotificationTab) {
tab.select();
}
}
}
}
private boolean handleProfileClick(IProfile profile, boolean current) {
AccountEntity activeAccount = accountManager.getActiveAccount();

View File

@ -92,7 +92,7 @@ public final class SavedTootActivity extends BaseActivity implements SavedTootAd
bar.setDisplayShowHomeEnabled(true);
}
RecyclerView recyclerView = findViewById(R.id.recycler_view);
RecyclerView recyclerView = findViewById(R.id.recyclerView);
noContent = findViewById(R.id.no_content);
recyclerView.setHasFixedSize(true);
LinearLayoutManager layoutManager = new LinearLayoutManager(this);

View File

@ -0,0 +1,57 @@
/* Copyright 2019 Conny Duck
*
* This file is a part of Tusky.
*
* 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.
*
* Tusky 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 Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.fragment.app.Fragment
import com.keylesspalace.tusky.components.conversation.ConversationsFragment
import com.keylesspalace.tusky.fragment.NotificationsFragment
import com.keylesspalace.tusky.fragment.TimelineFragment
/** this would be a good case for a sealed class, but that does not work nice with Room */
const val HOME = "Home"
const val NOTIFICATIONS = "Notifications"
const val LOCAL = "Local"
const val FEDERATED = "Federated"
const val DIRECT = "Direct"
data class TabData(val id: String,
@StringRes val text: Int,
@DrawableRes val icon: Int,
val fragment: () -> Fragment)
fun createTabDataFromId(id: String): TabData {
return when (id) {
HOME -> TabData(HOME, R.string.title_home, R.drawable.ic_home_24dp) { TimelineFragment.newInstance(TimelineFragment.Kind.HOME) }
NOTIFICATIONS -> TabData(NOTIFICATIONS, R.string.title_notifications, R.drawable.ic_notifications_24dp) { NotificationsFragment.newInstance() }
LOCAL -> TabData(LOCAL, R.string.title_public_local, R.drawable.ic_local_24dp) { TimelineFragment.newInstance(TimelineFragment.Kind.PUBLIC_LOCAL) }
FEDERATED -> TabData(FEDERATED, R.string.title_public_federated, R.drawable.ic_public_24dp) { TimelineFragment.newInstance(TimelineFragment.Kind.PUBLIC_FEDERATED) }
DIRECT -> TabData(DIRECT, R.string.title_direct_messages, R.drawable.reblog_direct_dark) { ConversationsFragment.newInstance() }
else -> throw IllegalArgumentException("unknown tab type")
}
}
fun defaultTabs(): List<TabData> {
return listOf(
createTabDataFromId(HOME),
createTabDataFromId(NOTIFICATIONS),
createTabDataFromId(LOCAL),
createTabDataFromId(FEDERATED)
)
}

View File

@ -0,0 +1,205 @@
/* Copyright 2019 Conny Duck
*
* This file is a part of Tusky.
*
* 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.
*
* Tusky 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 Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky
import android.os.Bundle
import android.view.MenuItem
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.adapter.ItemInteractionListener
import com.keylesspalace.tusky.adapter.TabAdapter
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.MainTabsChangedEvent
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.util.visible
import kotlinx.android.synthetic.main.activity_tab_preference.*
import kotlinx.android.synthetic.main.toolbar_basic.*
import javax.inject.Inject
class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListener {
@Inject
lateinit var eventHub: EventHub
private lateinit var currentTabs: MutableList<TabData>
private lateinit var currentTabsAdapter: TabAdapter
private lateinit var touchHelper: ItemTouchHelper
private lateinit var addTabAdapter: TabAdapter
private val selectedItemElevation by lazy { resources.getDimension(R.dimen.selected_drag_item_elevation) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_tab_preference)
setSupportActionBar(toolbar)
supportActionBar?.apply {
setTitle(R.string.title_tab_preferences)
setDisplayHomeAsUpEnabled(true)
setDisplayShowHomeEnabled(true)
}
currentTabs = (accountManager.activeAccount?.tabPreferences ?: emptyList()).toMutableList()
currentTabsAdapter = TabAdapter(currentTabs, false, this)
currentTabsRecyclerView.adapter = currentTabsAdapter
currentTabsRecyclerView.layoutManager = LinearLayoutManager(this)
currentTabsRecyclerView.addItemDecoration(DividerItemDecoration(this, LinearLayoutManager.VERTICAL))
addTabAdapter = TabAdapter(listOf(createTabDataFromId(DIRECT)), true, this)
addTabRecyclerView.adapter = addTabAdapter
addTabRecyclerView.layoutManager = LinearLayoutManager(this)
touchHelper = ItemTouchHelper(object: ItemTouchHelper.Callback(){
override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int {
return makeMovementFlags(ItemTouchHelper.UP or ItemTouchHelper.DOWN, ItemTouchHelper.END)
}
override fun isLongPressDragEnabled(): Boolean {
return true
}
override fun isItemViewSwipeEnabled(): Boolean {
return MIN_TAB_COUNT < currentTabs.size
}
override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean {
val temp = currentTabs[viewHolder.adapterPosition]
currentTabs[viewHolder.adapterPosition] = currentTabs[target.adapterPosition]
currentTabs[target.adapterPosition] = temp
currentTabsAdapter.notifyItemMoved(viewHolder.adapterPosition, target.adapterPosition)
saveTabs()
return true
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
currentTabs.removeAt(viewHolder.adapterPosition)
currentTabsAdapter.notifyItemRemoved(viewHolder.adapterPosition)
updateAvailableTabs()
saveTabs()
}
override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
if(actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
viewHolder?.itemView?.elevation = selectedItemElevation
}
}
override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
super.clearView(recyclerView, viewHolder)
viewHolder.itemView.elevation = 0f
}
})
touchHelper.attachToRecyclerView(currentTabsRecyclerView)
actionButton.setOnClickListener {
actionButton.isExpanded = true
}
scrim.setOnClickListener {
actionButton.isExpanded = false
}
updateAvailableTabs()
}
override fun onTabAdded(tab: TabData) {
currentTabs.add(tab)
currentTabsAdapter.notifyItemInserted(currentTabs.size - 1)
actionButton.isExpanded = false
updateAvailableTabs()
saveTabs()
}
private fun updateAvailableTabs() {
val addableTabs: MutableList<TabData> = mutableListOf()
val homeTab = createTabDataFromId(HOME)
if(!currentTabs.contains(homeTab)) {
addableTabs.add(homeTab)
}
val notificationTab = createTabDataFromId(NOTIFICATIONS)
if(!currentTabs.contains(notificationTab)) {
addableTabs.add(notificationTab)
}
val localTab = createTabDataFromId(LOCAL)
if(!currentTabs.contains(localTab)) {
addableTabs.add(localTab)
}
val federatedTab = createTabDataFromId(FEDERATED)
if(!currentTabs.contains(federatedTab)) {
addableTabs.add(federatedTab)
}
val directMessagesTab = createTabDataFromId(DIRECT)
if(!currentTabs.contains(directMessagesTab)) {
addableTabs.add(directMessagesTab)
}
addTabAdapter.updateData(addableTabs)
maxTabsInfo.visible(addableTabs.size == 0 || currentTabs.size >= MAX_TAB_COUNT)
}
override fun onStartDelete(viewHolder: RecyclerView.ViewHolder) {
touchHelper.startSwipe(viewHolder)
}
override fun onStartDrag(viewHolder: RecyclerView.ViewHolder) {
touchHelper.startDrag(viewHolder)
}
private fun saveTabs() {
accountManager.activeAccount?.let {
it.tabPreferences = currentTabs
accountManager.saveAccount(it)
}
}
override fun onBackPressed() {
if (actionButton.isExpanded) {
actionButton.isExpanded = false
} else {
super.onBackPressed()
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == android.R.id.home) {
onBackPressed()
return true
}
return false
}
override fun onPause() {
super.onPause()
eventHub.dispatch(MainTabsChangedEvent(currentTabs))
}
companion object {
private const val MIN_TAB_COUNT = 2
private const val MAX_TAB_COUNT = 5
}
}

View File

@ -66,7 +66,8 @@ public class TuskyApplication extends Application implements HasActivityInjector
.allowMainThreadQueries()
.addMigrations(AppDatabase.MIGRATION_2_3, AppDatabase.MIGRATION_3_4, AppDatabase.MIGRATION_4_5,
AppDatabase.MIGRATION_5_6, AppDatabase.MIGRATION_6_7, AppDatabase.MIGRATION_7_8,
AppDatabase.MIGRATION_8_9, AppDatabase.MIGRATION_9_10, AppDatabase.MIGRATION_10_11)
AppDatabase.MIGRATION_8_9, AppDatabase.MIGRATION_9_10, AppDatabase.MIGRATION_10_11,
AppDatabase.MIGRATION_11_12)
.build();
accountManager = new AccountManager(appDatabase);
serviceLocator = new ServiceLocator() {

View File

@ -0,0 +1,45 @@
/* Copyright 2019 Conny Duck
*
* This file is a part of Tusky.
*
* 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.
*
* Tusky 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 Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.adapter
import androidx.recyclerview.widget.RecyclerView
import android.view.View
import android.view.ViewGroup
import com.keylesspalace.tusky.util.NetworkState
import com.keylesspalace.tusky.util.Status
import com.keylesspalace.tusky.util.visible
import kotlinx.android.synthetic.main.item_network_state.view.*
class NetworkStateViewHolder(itemView: View,
private val retryCallback: () -> Unit)
: RecyclerView.ViewHolder(itemView) {
fun setUpWithNetworkState(state: NetworkState?, fullScreen: Boolean) {
itemView.progressBar.visible(state?.status == Status.RUNNING)
itemView.retryButton.visible(state?.status == Status.FAILED)
itemView.errorMsg.visible(state?.msg != null)
itemView.errorMsg.text = state?.msg
itemView.retryButton.setOnClickListener {
retryCallback()
}
if(fullScreen) {
itemView.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT
} else {
itemView.layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT
}
}
}

View File

@ -31,7 +31,7 @@ public final class PlaceholderViewHolder extends RecyclerView.ViewHolder {
PlaceholderViewHolder(View itemView) {
super(itemView);
loadMoreButton = itemView.findViewById(R.id.button_load_more);
progressBar = itemView.findViewById(R.id.progress_bar);
progressBar = itemView.findViewById(R.id.progressBar);
}
public void setup(final StatusActionListener listener, boolean progress) {

View File

@ -44,7 +44,7 @@ import java.lang.CharSequence;
import at.connyduck.sparkbutton.SparkButton;
import at.connyduck.sparkbutton.SparkEventListener;
abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
private TextView displayName;
private TextView username;
@ -54,23 +54,23 @@ abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
private ImageButton moreButton;
private boolean favourited;
private boolean reblogged;
private MediaPreviewImageView[] mediaPreviews;
protected MediaPreviewImageView[] mediaPreviews;
private ImageView[] mediaOverlays;
private TextView sensitiveMediaWarning;
private View sensitiveMediaShow;
private TextView mediaLabel;
protected TextView mediaLabel;
private ToggleButton contentWarningButton;
ImageView avatar;
TextView timestampInfo;
TextView content;
TextView contentWarningDescription;
public ImageView avatar;
public TextView timestampInfo;
public TextView content;
public TextView contentWarningDescription;
private boolean useAbsoluteTime;
private SimpleDateFormat shortSdf;
private SimpleDateFormat longSdf;
StatusBaseViewHolder(View itemView, boolean useAbsoluteTime) {
protected StatusBaseViewHolder(View itemView, boolean useAbsoluteTime) {
super(itemView);
displayName = itemView.findViewById(R.id.status_display_name);
username = itemView.findViewById(R.id.status_username);
@ -108,28 +108,30 @@ abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
protected abstract int getMediaPreviewHeight(Context context);
private void setDisplayName(String name, List<Emoji> customEmojis) {
protected void setDisplayName(String name, List<Emoji> customEmojis) {
CharSequence emojifiedName = CustomEmojiHelper.emojifyString(name, customEmojis, displayName);
displayName.setText(emojifiedName);
}
private void setUsername(String name) {
protected void setUsername(String name) {
Context context = username.getContext();
String format = context.getString(R.string.status_username_format);
String usernameText = String.format(format, name);
username.setText(usernameText);
}
private void setSpoilerAndContent(StatusViewData.Concrete status,
final StatusActionListener listener) {
if (status.getSpoilerText() == null || status.getSpoilerText().isEmpty()) {
protected void setSpoilerAndContent(boolean expanded,
@NonNull Spanned content,
@Nullable String spoilerText,
@Nullable Status.Mention[] mentions,
@NonNull List<Emoji> emojis,
final StatusActionListener listener) {
if (TextUtils.isEmpty(spoilerText)) {
contentWarningDescription.setVisibility(View.GONE);
contentWarningButton.setVisibility(View.GONE);
this.setTextVisible(true, status, listener);
this.setTextVisible(true, content, mentions, emojis, listener);
} else {
boolean expanded = status.isExpanded();
CharSequence emojiSpoiler = CustomEmojiHelper.emojifyString(
status.getSpoilerText(), status.getStatusEmojis(), contentWarningDescription);
CharSequence emojiSpoiler = CustomEmojiHelper.emojifyString(spoilerText, emojis, contentWarningDescription);
contentWarningDescription.setText(emojiSpoiler);
contentWarningDescription.setVisibility(View.VISIBLE);
contentWarningButton.setVisibility(View.VISIBLE);
@ -139,18 +141,19 @@ abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
if (getAdapterPosition() != RecyclerView.NO_POSITION) {
listener.onExpandedChange(isChecked, getAdapterPosition());
}
this.setTextVisible(isChecked, status, listener);
this.setTextVisible(isChecked, content, mentions, emojis, listener);
});
this.setTextVisible(expanded, status, listener);
this.setTextVisible(expanded, content, mentions, emojis, listener);
}
}
private void setTextVisible(boolean expanded, StatusViewData.Concrete status,
private void setTextVisible(boolean expanded,
Spanned content,
Status.Mention[] mentions,
List<Emoji> emojis,
final StatusActionListener listener) {
Status.Mention[] mentions = status.getMentions();
if (expanded) {
Spanned emojifiedText = CustomEmojiHelper.emojifyText(
status.getContent(), status.getStatusEmojis(), this.content);
Spanned emojifiedText = CustomEmojiHelper.emojifyText(content, emojis, this.content);
LinkHelper.setClickableText(this.content, emojifiedText, mentions, listener);
} else {
LinkHelper.setClickableMentions(this.content, mentions, listener);
@ -162,7 +165,7 @@ abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
}
}
void setAvatar(String url, @Nullable String rebloggedUrl) {
protected void setAvatar(String url, @Nullable String rebloggedUrl) {
if (TextUtils.isEmpty(url)) {
avatar.setImageResource(R.drawable.avatar_default);
} else {
@ -219,7 +222,7 @@ abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
}
}
private void setIsReply(boolean isReply) {
protected void setIsReply(boolean isReply) {
if (isReply) {
replyButton.setImageResource(R.drawable.ic_reply_all_24dp);
} else {
@ -265,13 +268,13 @@ abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
}
}
private void setFavourited(boolean favourited) {
protected void setFavourited(boolean favourited) {
this.favourited = favourited;
favouriteButton.setChecked(favourited);
}
private void setMediaPreviews(final List<Attachment> attachments, boolean sensitive,
final StatusActionListener listener, boolean showingContent) {
protected void setMediaPreviews(final List<Attachment> attachments, boolean sensitive,
final StatusActionListener listener, boolean showingContent) {
Context context = itemView.getContext();
@ -406,8 +409,8 @@ abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
}
}
private void setMediaLabel(List<Attachment> attachments, boolean sensitive,
final StatusActionListener listener) {
protected void setMediaLabel(List<Attachment> attachments, boolean sensitive,
final StatusActionListener listener) {
if (attachments.size() == 0) {
mediaLabel.setVisibility(View.GONE);
return;
@ -432,12 +435,12 @@ abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
mediaLabel.setOnClickListener(v -> listener.onViewMedia(getAdapterPosition(), 0, null));
}
private void hideSensitiveMediaWarning() {
protected void hideSensitiveMediaWarning() {
sensitiveMediaWarning.setVisibility(View.GONE);
sensitiveMediaShow.setVisibility(View.GONE);
}
private void setupButtons(final StatusActionListener listener, final String accountId) {
protected void setupButtons(final StatusActionListener listener, final String accountId) {
/* Originally position was passed through to all these listeners, but it caused several
* bugs where other statuses in the list would be removed or added and cause the position
* here to become outdated. So, getting the adapter position at the time the listener is
@ -449,23 +452,25 @@ abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
listener.onReply(position);
}
});
reblogButton.setEventListener(new SparkEventListener() {
@Override
public void onEvent(ImageView button, boolean buttonState) {
int position = getAdapterPosition();
if (position != RecyclerView.NO_POSITION) {
listener.onReblog(!reblogged, position);
if(reblogButton != null) {
reblogButton.setEventListener(new SparkEventListener() {
@Override
public void onEvent(ImageView button, boolean buttonState) {
int position = getAdapterPosition();
if (position != RecyclerView.NO_POSITION) {
listener.onReblog(!reblogged, position);
}
}
}
@Override
public void onEventAnimationEnd(ImageView button, boolean buttonState) {
}
@Override
public void onEventAnimationEnd(ImageView button, boolean buttonState) {
}
@Override
public void onEventAnimationStart(ImageView button, boolean buttonState) {
}
});
@Override
public void onEventAnimationStart(ImageView button, boolean buttonState) {
}
});
}
favouriteButton.setEventListener(new SparkEventListener() {
@Override
public void onEvent(ImageView button, boolean buttonState) {
@ -503,8 +508,8 @@ abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
itemView.setOnClickListener(viewThreadListener);
}
void setupWithStatus(StatusViewData.Concrete status, final StatusActionListener listener,
boolean mediaPreviewEnabled) {
protected void setupWithStatus(StatusViewData.Concrete status, final StatusActionListener listener,
boolean mediaPreviewEnabled) {
setDisplayName(status.getUserFullName(), status.getAccountEmojis());
setUsername(status.getNickname());
setCreatedAt(status.getCreatedAt());
@ -535,7 +540,7 @@ abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
setupButtons(listener, status.getSenderId());
setRebloggingEnabled(status.getRebloggingEnabled(), status.getVisibility());
setSpoilerAndContent(status, listener);
setSpoilerAndContent(status.isExpanded(), status.getContent(), status.getSpoilerText(), status.getMentions(), status.getStatusEmojis(), listener);
}
}

View File

@ -130,8 +130,8 @@ class StatusDetailedViewHolder extends StatusBaseViewHolder {
}
@Override
void setupWithStatus(final StatusViewData.Concrete status, final StatusActionListener listener,
boolean mediaPreviewEnabled) {
protected void setupWithStatus(final StatusViewData.Concrete status, final StatusActionListener listener,
boolean mediaPreviewEnabled) {
super.setupWithStatus(status, listener, mediaPreviewEnabled);
setReblogAndFavCount(status.getReblogsCount(), status.getFavouritesCount(), listener);

View File

@ -49,7 +49,7 @@ public class StatusViewHolder extends StatusBaseViewHolder {
}
@Override
void setAvatar(String url, @Nullable String rebloggedUrl) {
protected void setAvatar(String url, @Nullable String rebloggedUrl) {
super.setAvatar(url, rebloggedUrl);
Context context = avatar.getContext();
@ -75,8 +75,8 @@ public class StatusViewHolder extends StatusBaseViewHolder {
}
@Override
void setupWithStatus(StatusViewData.Concrete status, final StatusActionListener listener,
boolean mediaPreviewEnabled) {
protected void setupWithStatus(StatusViewData.Concrete status, final StatusActionListener listener,
boolean mediaPreviewEnabled) {
if(status == null) {
showContent(false);
} else {

View File

@ -0,0 +1,80 @@
/* Copyright 2019 Conny Duck
*
* This file is a part of Tusky.
*
* 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.
*
* Tusky 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 Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.adapter
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.TabData
import com.keylesspalace.tusky.util.ThemeUtils
import kotlinx.android.synthetic.main.item_tab_preference.view.*
interface ItemInteractionListener {
fun onTabAdded(tab: TabData)
fun onStartDelete(viewHolder: RecyclerView.ViewHolder)
fun onStartDrag(viewHolder: RecyclerView.ViewHolder)
}
class TabAdapter(var data: List<TabData>,
val small: Boolean = false,
val listener: ItemInteractionListener? = null) : RecyclerView.Adapter<TabAdapter.ViewHolder>() {
fun updateData(newData: List<TabData>) {
this.data = newData
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val layoutId = if(small) {
R.layout.item_tab_preference_small
} else {
R.layout.item_tab_preference
}
val view = LayoutInflater.from(parent.context).inflate(layoutId, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.itemView.textView.setText(data[position].text)
val iconDrawable = ThemeUtils.getTintedDrawable(holder.itemView.context, data[position].icon, android.R.attr.textColorSecondary)
holder.itemView.textView.setCompoundDrawablesRelativeWithIntrinsicBounds(iconDrawable, null, null, null)
if(small) {
holder.itemView.textView.setOnClickListener {
listener?.onTabAdded(data[position])
}
}
holder.itemView.imageView?.setOnTouchListener { _, event ->
if(event.action == MotionEvent.ACTION_DOWN) {
listener?.onStartDrag(holder)
true
} else {
false
}
}
}
override fun getItemCount(): Int {
return data.size
}
class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView)
}

View File

@ -10,7 +10,7 @@ import javax.inject.Inject
class CacheUpdater @Inject constructor(
eventHub: EventHub,
accountManager: AccountManager,
val appDatabase: AppDatabase
private val appDatabase: AppDatabase
) {
private val disposable: Disposable

View File

@ -1,5 +1,6 @@
package com.keylesspalace.tusky.appstore
import com.keylesspalace.tusky.TabData
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Status
@ -11,4 +12,5 @@ data class MuteEvent(val accountId: String) : Dispatchable
data class StatusDeletedEvent(val statusId: String) : Dispatchable
data class StatusComposedEvent(val status: Status) : Dispatchable
data class ProfileEditedEvent(val newProfileData: Account) : Dispatchable
data class PreferenceChangedEvent(val preferenceKey: String) : Dispatchable
data class PreferenceChangedEvent(val preferenceKey: String) : Dispatchable
data class MainTabsChangedEvent(val newTabs: List<TabData>) : Dispatchable

View File

@ -0,0 +1,108 @@
package com.keylesspalace.tusky.components.conversation
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.paging.AsyncPagedListDiffer
import androidx.paging.PagedList
import androidx.recyclerview.widget.AsyncDifferConfig
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListUpdateCallback
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.NetworkStateViewHolder
import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.util.NetworkState
class ConversationAdapter(private val useAbsoluteTime: Boolean,
private val mediaPreviewEnabled: Boolean,
private val listener: StatusActionListener,
private val topLoadedCallback: () -> Unit,
private val retryCallback: () -> Unit)
: RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private var networkState: NetworkState? = null
private val differ: AsyncPagedListDiffer<ConversationEntity> = AsyncPagedListDiffer(object: ListUpdateCallback {
override fun onInserted(position: Int, count: Int) {
notifyItemRangeInserted(position, count)
if(position == 0) {
topLoadedCallback()
}
}
override fun onRemoved(position: Int, count: Int) {
notifyItemRangeRemoved(position, count)
}
override fun onMoved(fromPosition: Int, toPosition: Int) {
notifyItemMoved(fromPosition, toPosition)
}
override fun onChanged(position: Int, count: Int, payload: Any?) {
notifyItemRangeChanged(position, count, payload)
}
}, AsyncDifferConfig.Builder<ConversationEntity>(CONVERSATION_COMPARATOR).build())
fun submitList(list: PagedList<ConversationEntity>) {
differ.submitList(list)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val view = LayoutInflater.from(parent.context).inflate(viewType, parent, false)
return when (viewType) {
R.layout.item_network_state -> NetworkStateViewHolder(view, retryCallback)
R.layout.item_conversation -> ConversationViewHolder(view, listener, useAbsoluteTime, mediaPreviewEnabled)
else -> throw IllegalArgumentException("unknown view type $viewType")
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (getItemViewType(position)) {
R.layout.item_network_state -> (holder as NetworkStateViewHolder).setUpWithNetworkState(networkState, differ.itemCount == 0)
R.layout.item_conversation -> (holder as ConversationViewHolder).setupWithConversation(differ.getItem(position))
}
}
private fun hasExtraRow() = networkState != null && networkState != NetworkState.LOADED
override fun getItemViewType(position: Int): Int {
return if (hasExtraRow() && position == itemCount - 1) {
R.layout.item_network_state
} else {
R.layout.item_conversation
}
}
override fun getItemCount(): Int {
return differ.itemCount + if (hasExtraRow()) 1 else 0
}
fun setNetworkState(newNetworkState: NetworkState?) {
val previousState = this.networkState
val hadExtraRow = hasExtraRow()
this.networkState = newNetworkState
val hasExtraRow = hasExtraRow()
if (hadExtraRow != hasExtraRow) {
if (hadExtraRow) {
notifyItemRemoved(differ.itemCount)
} else {
notifyItemInserted(differ.itemCount)
}
} else if (hasExtraRow && previousState != newNetworkState) {
notifyItemChanged(itemCount - 1)
}
}
companion object {
val CONVERSATION_COMPARATOR = object : DiffUtil.ItemCallback<ConversationEntity>() {
override fun areContentsTheSame(oldItem: ConversationEntity, newItem: ConversationEntity): Boolean =
oldItem == newItem
override fun areItemsTheSame(oldItem: ConversationEntity, newItem: ConversationEntity): Boolean =
oldItem.id == newItem.id
}
}
}

View File

@ -0,0 +1,186 @@
/* Copyright 2019 Conny Duck
*
* This file is a part of Tusky.
*
* 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.
*
* Tusky 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 Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.conversation
import android.text.Spanned
import android.text.SpannedString
import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.TypeConverters
import com.keylesspalace.tusky.db.Converters
import com.keylesspalace.tusky.entity.*
import com.keylesspalace.tusky.util.SmartLengthInputFilter
import java.util.*
@Entity(primaryKeys = ["id","accountId"])
@TypeConverters(Converters::class)
data class ConversationEntity(
val accountId: Long,
val id: String,
val accounts: List<ConversationAccountEntity>,
val unread: Boolean,
@Embedded(prefix = "s_") val lastStatus: ConversationStatusEntity
)
data class ConversationAccountEntity(
val id: String,
val username: String,
val displayName: String,
val avatar: String,
val emojis: List<Emoji>
) {
fun toAccount(): Account {
return Account(
id = id,
username = username,
displayName = displayName,
avatar = avatar,
emojis = emojis,
url = "",
localUsername = "",
note = SpannedString(""),
header = ""
)
}
}
@TypeConverters(Converters::class)
data class ConversationStatusEntity(
val id: String,
val url: String?,
val inReplyToId: String?,
val inReplyToAccountId: String?,
val account: ConversationAccountEntity,
val content: Spanned,
val createdAt: Date,
val emojis: List<Emoji>,
val favouritesCount: Int,
val favourited: Boolean,
val sensitive: Boolean,
val spoilerText: String,
val attachments: List<Attachment>,
val mentions: Array<Status.Mention>,
val showingHiddenContent: Boolean,
val expanded: Boolean,
val collapsible: Boolean,
val collapsed: Boolean
) {
/** its necessary to override this because Spanned.equals does not work as expected */
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ConversationStatusEntity
if (id != other.id) return false
if (url != other.url) return false
if (inReplyToId != other.inReplyToId) return false
if (inReplyToAccountId != other.inReplyToAccountId) return false
if (account != other.account) return false
if (content.toString() != other.content.toString()) return false //TODO find a better method to compare two spanned strings
if (createdAt != other.createdAt) return false
if (emojis != other.emojis) return false
if (favouritesCount != other.favouritesCount) return false
if (favourited != other.favourited) return false
if (sensitive != other.sensitive) return false
if (spoilerText != other.spoilerText) return false
if (attachments != other.attachments) return false
if (!mentions.contentEquals(other.mentions)) return false
if (showingHiddenContent != other.showingHiddenContent) return false
if (expanded != other.expanded) return false
if (collapsible != other.collapsible) return false
if (collapsed != other.collapsed) return false
return true
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + (url?.hashCode() ?: 0)
result = 31 * result + (inReplyToId?.hashCode() ?: 0)
result = 31 * result + (inReplyToAccountId?.hashCode() ?: 0)
result = 31 * result + account.hashCode()
result = 31 * result + content.hashCode()
result = 31 * result + createdAt.hashCode()
result = 31 * result + emojis.hashCode()
result = 31 * result + favouritesCount
result = 31 * result + favourited.hashCode()
result = 31 * result + sensitive.hashCode()
result = 31 * result + spoilerText.hashCode()
result = 31 * result + attachments.hashCode()
result = 31 * result + mentions.contentHashCode()
result = 31 * result + showingHiddenContent.hashCode()
result = 31 * result + expanded.hashCode()
result = 31 * result + collapsible.hashCode()
result = 31 * result + collapsed.hashCode()
return result
}
fun toStatus(): Status {
return Status(
id = id,
url = url,
account = account.toAccount(),
inReplyToId = inReplyToId,
inReplyToAccountId = inReplyToAccountId,
content = content,
reblog = null,
createdAt = createdAt,
emojis = emojis,
reblogsCount = 0,
favouritesCount = favouritesCount,
reblogged = false,
favourited = favourited,
sensitive= sensitive,
spoilerText = spoilerText,
visibility = Status.Visibility.PRIVATE,
attachments = attachments,
mentions = mentions,
application = null,
pinned = false)
}
}
fun Account.toEntity() =
ConversationAccountEntity(
id,
username,
displayName,
avatar,
emojis ?: emptyList()
)
fun Status.toEntity() =
ConversationStatusEntity(
id, url, inReplyToId, inReplyToAccountId, account.toEntity(), content,
createdAt, emojis, favouritesCount, favourited, sensitive,
spoilerText, attachments, mentions,
false,
false,
!SmartLengthInputFilter.hasBadRatio(content, SmartLengthInputFilter.LENGTH_DEFAULT),
true
)
fun Conversation.toEntity(accountId: Long) =
ConversationEntity(
accountId,
id,
accounts.map { it.toEntity() },
unread,
lastStatus.toEntity()
)

View File

@ -0,0 +1,157 @@
/* Copyright 2017 Andrew Dawson
*
* This file is a part of Tusky.
*
* 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.
*
* Tusky 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 Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.conversation;
import android.content.Context;
import android.text.InputFilter;
import android.text.TextUtils;
import android.view.View;
import android.widget.ImageView;
import android.widget.TextView;
import android.widget.ToggleButton;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.adapter.StatusBaseViewHolder;
import com.keylesspalace.tusky.entity.Attachment;
import com.keylesspalace.tusky.interfaces.StatusActionListener;
import com.keylesspalace.tusky.util.SmartLengthInputFilter;
import com.squareup.picasso.Picasso;
import java.util.List;
import androidx.recyclerview.widget.RecyclerView;
public class ConversationViewHolder extends StatusBaseViewHolder {
private static final InputFilter[] COLLAPSE_INPUT_FILTER = new InputFilter[]{SmartLengthInputFilter.INSTANCE};
private static final InputFilter[] NO_INPUT_FILTER = new InputFilter[0];
private TextView conversationNameTextView;
private ToggleButton contentCollapseButton;
private ImageView[] avatars;
private StatusActionListener listener;
private boolean mediaPreviewEnabled;
ConversationViewHolder(View itemView,
StatusActionListener listener,
boolean useAbsoluteTime,
boolean mediaPreviewEnabled) {
super(itemView, useAbsoluteTime);
conversationNameTextView = itemView.findViewById(R.id.conversation_name);
contentCollapseButton = itemView.findViewById(R.id.button_toggle_content);
avatars = new ImageView[]{avatar, itemView.findViewById(R.id.status_avatar_1), itemView.findViewById(R.id.status_avatar_2)};
this.listener = listener;
this.mediaPreviewEnabled = mediaPreviewEnabled;
}
@Override
protected int getMediaPreviewHeight(Context context) {
return context.getResources().getDimensionPixelSize(R.dimen.status_media_preview_height);
}
void setupWithConversation(ConversationEntity conversation) {
ConversationStatusEntity status = conversation.getLastStatus();
ConversationAccountEntity account = status.getAccount();
setupCollapsedState(status.getCollapsible(), status.getCollapsed(), status.getExpanded(), status.getSpoilerText(), listener);
setDisplayName(account.getDisplayName(), account.getEmojis());
setUsername(account.getUsername());
setCreatedAt(status.getCreatedAt());
setIsReply(status.getInReplyToId() != null);
setFavourited(status.getFavourited());
List<Attachment> attachments = status.getAttachments();
boolean sensitive = status.getSensitive();
if(mediaPreviewEnabled) {
setMediaPreviews(attachments, sensitive, listener, status.getShowingHiddenContent());
if (attachments.size() == 0) {
hideSensitiveMediaWarning();
}
// Hide the unused label.
mediaLabel.setVisibility(View.GONE);
} else {
setMediaLabel(attachments, sensitive, listener);
// Hide all unused views.
mediaPreviews[0].setVisibility(View.GONE);
mediaPreviews[1].setVisibility(View.GONE);
mediaPreviews[2].setVisibility(View.GONE);
mediaPreviews[3].setVisibility(View.GONE);
hideSensitiveMediaWarning();
}
setupButtons(listener, account.getId());
setSpoilerAndContent(status.getExpanded(), status.getContent(), status.getSpoilerText(), status.getMentions(), status.getEmojis(), listener);
setConversationName(conversation.getAccounts());
setAvatars(conversation.getAccounts());
}
private void setConversationName(List<ConversationAccountEntity> accounts) {
Context context = conversationNameTextView.getContext();
String conversationName;
if(accounts.size() == 1) {
conversationName = context.getString(R.string.conversation_1_recipients, accounts.get(0).getUsername());
} else if(accounts.size() == 2) {
conversationName = context.getString(R.string.conversation_2_recipients, accounts.get(0).getUsername(), accounts.get(1).getUsername());
} else {
conversationName = context.getString(R.string.conversation_more_recipients, accounts.get(0).getUsername(), accounts.get(1).getUsername(), accounts.size() - 2);
}
conversationNameTextView.setText(conversationName);
}
private void setAvatars(List<ConversationAccountEntity> accounts) {
for(int i=0; i < avatars.length; i++) {
ImageView avatarView = avatars[i];
if(i < accounts.size()) {
Picasso.with(avatarView.getContext())
.load(accounts.get(i).getAvatar())
.into(avatarView);
avatarView.setVisibility(View.VISIBLE);
} else {
avatarView.setVisibility(View.GONE);
}
}
}
private void setupCollapsedState(boolean collapsible, boolean collapsed, boolean expanded, String spoilerText, final StatusActionListener listener) {
/* input filter for TextViews have to be set before text */
if (collapsible && (expanded || TextUtils.isEmpty(spoilerText))) {
contentCollapseButton.setOnCheckedChangeListener((buttonView, isChecked) -> {
int position = getAdapterPosition();
if (position != RecyclerView.NO_POSITION)
listener.onContentCollapsedChange(isChecked, position);
});
contentCollapseButton.setVisibility(View.VISIBLE);
if (collapsed) {
contentCollapseButton.setChecked(true);
content.setFilters(COLLAPSE_INPUT_FILTER);
} else {
contentCollapseButton.setChecked(false);
content.setFilters(NO_INPUT_FILTER);
}
} else {
contentCollapseButton.setVisibility(View.GONE);
content.setFilters(NO_INPUT_FILTER);
}
}
}

View File

@ -0,0 +1,98 @@
/*
* Copyright (C) 2017 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.keylesspalace.tusky.components.conversation
import androidx.annotation.MainThread
import androidx.paging.PagedList
import com.keylesspalace.tusky.entity.Conversation
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.PagingRequestHelper
import com.keylesspalace.tusky.util.createStatusLiveData
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import java.util.concurrent.Executor
/**
* This boundary callback gets notified when user reaches to the edges of the list such that the
* database cannot provide any more data.
* <p>
* The boundary callback might be called multiple times for the same direction so it does its own
* rate limiting using the PagingRequestHelper class.
*/
class ConversationsBoundaryCallback(
private val accountId: Long,
private val mastodonApi: MastodonApi,
private val handleResponse: (Long, List<Conversation>?) -> Unit,
private val ioExecutor: Executor,
private val networkPageSize: Int)
: PagedList.BoundaryCallback<ConversationEntity>() {
val helper = PagingRequestHelper(ioExecutor)
val networkState = helper.createStatusLiveData()
/**
* Database returned 0 items. We should query the backend for more items.
*/
@MainThread
override fun onZeroItemsLoaded() {
helper.runIfNotRunning(PagingRequestHelper.RequestType.INITIAL) {
mastodonApi.getConversations(null, networkPageSize)
.enqueue(createWebserviceCallback(it))
}
}
/**
* User reached to the end of the list.
*/
@MainThread
override fun onItemAtEndLoaded(itemAtEnd: ConversationEntity) {
helper.runIfNotRunning(PagingRequestHelper.RequestType.AFTER) {
mastodonApi.getConversations(itemAtEnd.lastStatus.id, networkPageSize)
.enqueue(createWebserviceCallback(it))
}
}
/**
* every time it gets new items, boundary callback simply inserts them into the database and
* paging library takes care of refreshing the list if necessary.
*/
private fun insertItemsIntoDb(
response: Response<List<Conversation>>,
it: PagingRequestHelper.Request.Callback) {
ioExecutor.execute {
handleResponse(accountId, response.body())
it.recordSuccess()
}
}
override fun onItemAtFrontLoaded(itemAtFront: ConversationEntity) {
// ignored, since we only ever append to what's in the DB
}
private fun createWebserviceCallback(it: PagingRequestHelper.Request.Callback): Callback<List<Conversation>> {
return object : Callback<List<Conversation>> {
override fun onFailure(call: Call<List<Conversation>>, t: Throwable) {
it.recordFailure(t)
}
override fun onResponse(call: Call<List<Conversation>>, response: Response<List<Conversation>>) {
insertItemsIntoDb(response, it)
}
}
}
}

View File

@ -0,0 +1,189 @@
/* Copyright 2019 Conny Duck
*
* This file is a part of Tusky.
*
* 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.
*
* Tusky 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 Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.conversation
import android.content.Intent
import android.os.Bundle
import android.preference.PreferenceManager
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders
import androidx.paging.PagedList
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.SimpleItemAnimator
import com.keylesspalace.tusky.AccountActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.ViewTagActivity
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.fragment.SFragment
import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.network.TimelineCases
import com.keylesspalace.tusky.util.NetworkState
import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import kotlinx.android.synthetic.main.fragment_timeline.*
import javax.inject.Inject
class ConversationsFragment : SFragment(), StatusActionListener, Injectable {
@Inject
lateinit var timelineCases: TimelineCases
@Inject
lateinit var viewModelFactory: ViewModelFactory
@Inject
lateinit var db: AppDatabase
private lateinit var viewModel: ConversationsViewModel
private lateinit var adapter: ConversationAdapter
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
viewModel = ViewModelProviders.of(this, viewModelFactory)[ConversationsViewModel::class.java]
return inflater.inflate(R.layout.fragment_timeline, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val preferences = PreferenceManager.getDefaultSharedPreferences(view.context)
val useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false)
val account = accountManager.activeAccount
val mediaPreviewEnabled = account?.mediaPreviewEnabled ?: true
adapter = ConversationAdapter(useAbsoluteTime, mediaPreviewEnabled,this, ::onTopLoaded, viewModel::retry)
val divider = DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL)
val drawable = ThemeUtils.getDrawable(view.context, R.attr.status_divider_drawable, R.drawable.status_divider_dark)
divider.setDrawable(drawable)
recyclerView.addItemDecoration(divider)
recyclerView.layoutManager = LinearLayoutManager(view.context)
recyclerView.adapter = adapter
(recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
progressBar.hide()
statusView.hide()
initSwipeToRefresh()
viewModel.conversations.observe(this, Observer<PagedList<ConversationEntity>> {
adapter.submitList(it)
})
viewModel.networkState.observe(this, Observer {
adapter.setNetworkState(it)
})
viewModel.load()
}
private fun initSwipeToRefresh() {
viewModel.refreshState.observe(this, Observer {
swipeRefreshLayout.isRefreshing = it == NetworkState.LOADING
})
swipeRefreshLayout.setOnRefreshListener {
viewModel.refresh()
}
swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
swipeRefreshLayout.setProgressBackgroundColorSchemeColor(ThemeUtils.getColor(swipeRefreshLayout.context, android.R.attr.colorBackground))
}
private fun onTopLoaded() {
recyclerView.scrollToPosition(0)
}
override fun onReblog(reblog: Boolean, position: Int) {
// its impossible to reblog private messages
}
override fun onFavourite(favourite: Boolean, position: Int) {
viewModel.favourite(favourite, position)
}
override fun onMore(view: View, position: Int) {
viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let {
more(it.toStatus(), view, position)
}
}
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View) {
viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let {
viewMedia(attachmentIndex, it.toStatus(), view)
}
}
override fun onViewThread(position: Int) {
viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let {
viewThread(it.toStatus())
}
}
override fun onOpenReblog(position: Int) {
// there are no reblogs in search results
}
override fun onExpandedChange(expanded: Boolean, position: Int) {
viewModel.expandHiddenStatus(expanded, position)
}
override fun onContentHiddenChange(isShowing: Boolean, position: Int) {
viewModel.showContent(isShowing, position)
}
override fun onLoadMore(position: Int) {
// not using the old way of pagination
}
override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) {
viewModel.collapseLongStatus(isCollapsed, position)
}
override fun onViewAccount(id: String) {
val intent = AccountActivity.getIntent(requireContext(), id)
startActivity(intent)
}
override fun onViewTag(tag: String) {
val intent = Intent(context, ViewTagActivity::class.java)
intent.putExtra("hashtag", tag)
startActivity(intent)
}
override fun timelineCases(): TimelineCases {
return timelineCases
}
override fun removeItem(position: Int) {
viewModel.remove(position)
}
override fun onReply(position: Int) {
viewModel.conversations.value?.getOrNull(position)?.lastStatus?.let {
reply(it.toStatus())
}
}
companion object {
fun newInstance() = ConversationsFragment()
}
}

View File

@ -0,0 +1,101 @@
package com.keylesspalace.tusky.components.conversation
import androidx.annotation.MainThread
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations
import androidx.paging.Config
import androidx.paging.toLiveData
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.entity.Conversation
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.Listing
import com.keylesspalace.tusky.util.NetworkState
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import java.util.concurrent.Executors
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ConversationsRepository @Inject constructor(val mastodonApi: MastodonApi, val db: AppDatabase) {
private val ioExecutor = Executors.newSingleThreadExecutor()
companion object {
private const val DEFAULT_PAGE_SIZE = 20
}
@MainThread
fun refresh(accountId: Long, showLoadingIndicator: Boolean): LiveData<NetworkState> {
val networkState = MutableLiveData<NetworkState>()
if(showLoadingIndicator) {
networkState.value = NetworkState.LOADING
}
mastodonApi.getConversations(null, DEFAULT_PAGE_SIZE).enqueue(
object : Callback<List<Conversation>> {
override fun onFailure(call: Call<List<Conversation>>, t: Throwable) {
// retrofit calls this on main thread so safe to call set value
networkState.value = NetworkState.error(t.message)
}
override fun onResponse(call: Call<List<Conversation>>, response: Response<List<Conversation>>) {
ioExecutor.execute {
db.runInTransaction {
db.conversationDao().deleteForAccount(accountId)
insertResultIntoDb(accountId, response.body())
}
// since we are in bg thread now, post the result.
networkState.postValue(NetworkState.LOADED)
}
}
}
)
return networkState
}
@MainThread
fun conversations(accountId: Long): Listing<ConversationEntity> {
// create a boundary callback which will observe when the user reaches to the edges of
// the list and update the database with extra data.
val boundaryCallback = ConversationsBoundaryCallback(
accountId = accountId,
mastodonApi = mastodonApi,
handleResponse = this::insertResultIntoDb,
ioExecutor = ioExecutor,
networkPageSize = DEFAULT_PAGE_SIZE)
// we are using a mutable live data to trigger refresh requests which eventually calls
// refresh method and gets a new live data. Each refresh request by the user becomes a newly
// dispatched data in refreshTrigger
val refreshTrigger = MutableLiveData<Unit>()
val refreshState = Transformations.switchMap(refreshTrigger) {
refresh(accountId, true)
}
// We use toLiveData Kotlin extension function here, you could also use LivePagedListBuilder
val livePagedList = db.conversationDao().conversationsForAccount(accountId).toLiveData(
config = Config(pageSize = DEFAULT_PAGE_SIZE, prefetchDistance = DEFAULT_PAGE_SIZE / 2, enablePlaceholders = false),
boundaryCallback = boundaryCallback
)
return Listing(
pagedList = livePagedList,
networkState = boundaryCallback.networkState,
retry = {
boundaryCallback.helper.retryAllFailed()
},
refresh = {
refreshTrigger.value = null
},
refreshState = refreshState
)
}
private fun insertResultIntoDb(accountId: Long, result: List<Conversation>?) {
result?.let { conversations ->
db.conversationDao().insert(conversations.map { it.toEntity(accountId) })
}
}
}

View File

@ -0,0 +1,104 @@
package com.keylesspalace.tusky.components.conversation
import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations
import androidx.lifecycle.ViewModel
import androidx.paging.PagedList
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.network.TimelineCases
import com.keylesspalace.tusky.util.Listing
import com.keylesspalace.tusky.util.NetworkState
import io.reactivex.disposables.CompositeDisposable
import io.reactivex.rxkotlin.addTo
import javax.inject.Inject
class ConversationsViewModel @Inject constructor(
private val repository: ConversationsRepository,
private val timelineCases: TimelineCases,
private val database: AppDatabase,
private val accountManager: AccountManager
): ViewModel() {
private val repoResult = MutableLiveData<Listing<ConversationEntity>>()
val conversations: LiveData<PagedList<ConversationEntity>> = Transformations.switchMap(repoResult) { it.pagedList }
val networkState: LiveData<NetworkState> = Transformations.switchMap(repoResult) { it.networkState }
val refreshState: LiveData<NetworkState> = Transformations.switchMap(repoResult) { it.refreshState }
private val disposables = CompositeDisposable()
fun load() {
val accountId = accountManager.activeAccount?.id ?: return
if(repoResult.value == null) {
repository.refresh(accountId, false)
}
repoResult.value = repository.conversations(accountId)
}
fun refresh() {
repoResult.value?.refresh?.invoke()
}
fun retry() {
repoResult.value?.retry?.invoke()
}
fun favourite(favourite: Boolean, position: Int) {
conversations.value?.getOrNull(position)?.let { conversation ->
timelineCases.favourite(conversation.lastStatus.toStatus(), favourite)
.subscribe({
val newConversation = conversation.copy(
lastStatus = conversation.lastStatus.copy(favourited = favourite)
)
database.conversationDao().insert(newConversation)
}, { t -> Log.w("ConversationViewModel", "Failed to favourite conversation", t) })
.addTo(disposables)
}
}
fun expandHiddenStatus(expanded: Boolean, position: Int) {
conversations.value?.getOrNull(position)?.let { conversation ->
val newConversation = conversation.copy(
lastStatus = conversation.lastStatus.copy(expanded = expanded)
)
database.conversationDao().insert(newConversation)
}
}
fun collapseLongStatus(collapsed: Boolean, position: Int) {
conversations.value?.getOrNull(position)?.let { conversation ->
val newConversation = conversation.copy(
lastStatus = conversation.lastStatus.copy(collapsed = collapsed)
)
database.conversationDao().insert(newConversation)
}
}
fun showContent(showing: Boolean, position: Int) {
conversations.value?.getOrNull(position)?.let { conversation ->
val newConversation = conversation.copy(
lastStatus = conversation.lastStatus.copy(showingHiddenContent = showing)
)
database.conversationDao().insert(newConversation)
}
}
fun remove(position: Int) {
conversations.value?.getOrNull(position)?.let { conversation ->
/* this is not ideal since deleting last toot from an conversation
should not delete the conversation but show another toot of the conversation */
timelineCases.delete(conversation.lastStatus.id)
database.conversationDao().delete(conversation)
}
}
override fun onCleared() {
disposables.dispose()
}
}

View File

@ -19,6 +19,8 @@ import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
import androidx.room.TypeConverters
import com.keylesspalace.tusky.TabData
import com.keylesspalace.tusky.defaultTabs
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.Status
@ -48,7 +50,8 @@ data class AccountEntity(@field:PrimaryKey(autoGenerate = true) var id: Long,
var mediaPreviewEnabled: Boolean = true,
var lastNotificationId: String = "0",
var activeNotifications: String = "[]",
var emojis: List<Emoji> = emptyList()) {
var emojis: List<Emoji> = emptyList(),
var tabPreferences: List<TabData> = defaultTabs()) {
val identifier: String
get() = "$domain:$accountId"

View File

@ -15,6 +15,9 @@
package com.keylesspalace.tusky.db;
import com.keylesspalace.tusky.TabDataKt;
import com.keylesspalace.tusky.components.conversation.ConversationEntity;
import androidx.sqlite.db.SupportSQLiteDatabase;
import androidx.room.Database;
import androidx.room.RoomDatabase;
@ -25,14 +28,15 @@ import androidx.annotation.NonNull;
* DB version & declare DAO
*/
@Database(entities = {TootEntity.class, AccountEntity.class, InstanceEntity.class,TimelineStatusEntity.class,
TimelineAccountEntity.class
}, version = 11)
@Database(entities = {TootEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class,
TimelineAccountEntity.class, ConversationEntity.class
}, version = 12)
public abstract class AppDatabase extends RoomDatabase {
public abstract TootDao tootDao();
public abstract AccountDao accountDao();
public abstract InstanceDao instanceDao();
public abstract ConversationsDao conversationDao();
public abstract TimelineDao timelineDao();
public static final Migration MIGRATION_2_3 = new Migration(2, 3) {
@ -166,4 +170,41 @@ public abstract class AppDatabase extends RoomDatabase {
}
};
public static final Migration MIGRATION_11_12 = new Migration(11, 12) {
@Override
public void migrate(@NonNull SupportSQLiteDatabase database) {
String defaultTabs = TabDataKt.HOME + ";" +
TabDataKt.NOTIFICATIONS + ";" +
TabDataKt.LOCAL + ";" +
TabDataKt.FEDERATED;
database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `tabPreferences` TEXT NOT NULL DEFAULT '" + defaultTabs + "'");
database.execSQL("CREATE TABLE IF NOT EXISTS `ConversationEntity` (" +
"`accountId` INTEGER NOT NULL, " +
"`id` TEXT NOT NULL, " +
"`accounts` TEXT NOT NULL, " +
"`unread` INTEGER NOT NULL, " +
"`s_id` TEXT NOT NULL, " +
"`s_url` TEXT, " +
"`s_inReplyToId` TEXT, " +
"`s_inReplyToAccountId` TEXT, " +
"`s_account` TEXT NOT NULL, " +
"`s_content` TEXT NOT NULL, " +
"`s_createdAt` INTEGER NOT NULL, " +
"`s_emojis` TEXT NOT NULL, " +
"`s_favouritesCount` INTEGER NOT NULL, " +
"`s_favourited` INTEGER NOT NULL, " +
"`s_sensitive` INTEGER NOT NULL, " +
"`s_spoilerText` TEXT NOT NULL, " +
"`s_attachments` TEXT NOT NULL, " +
"`s_mentions` TEXT NOT NULL, " +
"`s_showingHiddenContent` INTEGER NOT NULL, " +
"`s_expanded` INTEGER NOT NULL, " +
"`s_collapsible` INTEGER NOT NULL, " +
"`s_collapsed` INTEGER NOT NULL, " +
"PRIMARY KEY(`id`, `accountId`))");
}
};
}

View File

@ -0,0 +1,40 @@
/* Copyright 2018 Conny Duck
*
* This file is a part of Tusky.
*
* 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.
*
* Tusky 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 Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.db
import androidx.paging.DataSource
import androidx.room.*
import com.keylesspalace.tusky.components.conversation.ConversationEntity
@Dao
interface ConversationsDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(conversations: List<ConversationEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(conversation: ConversationEntity)
@Delete
fun delete(conversation: ConversationEntity)
@Query("SELECT * FROM ConversationEntity WHERE accountId = :accountId ORDER BY s_createdAt DESC")
fun conversationsForAccount(accountId: Long) : DataSource.Factory<Int, ConversationEntity>
@Query("DELETE FROM ConversationEntity WHERE accountId = :accountId")
fun deleteForAccount(accountId: Long)
}

View File

@ -15,15 +15,25 @@
package com.keylesspalace.tusky.db
import android.text.Spanned
import androidx.room.TypeConverter
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.reflect.TypeToken
import com.keylesspalace.tusky.TabData
import com.keylesspalace.tusky.components.conversation.ConversationAccountEntity
import com.keylesspalace.tusky.createTabDataFromId
import com.keylesspalace.tusky.entity.Attachment
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.json.SpannedTypeAdapter
import com.keylesspalace.tusky.util.HtmlUtils
import java.util.*
class Converters {
private val gson = Gson()
private val gson = GsonBuilder()
.registerTypeAdapter(Spanned::class.java, SpannedTypeAdapter())
.create()
@TypeConverter
fun jsonToEmojiList(emojiListJson: String?): List<Emoji>? {
@ -36,12 +46,90 @@ class Converters {
}
@TypeConverter
fun visibilityToInt(visibility: Status.Visibility): Int {
return visibility.num
fun visibilityToInt(visibility: Status.Visibility?): Int {
return visibility?.num ?: Status.Visibility.UNKNOWN.num
}
@TypeConverter
fun intToVisibility(visibility: Int): Status.Visibility {
return Status.Visibility.byNum(visibility)
}
@TypeConverter
fun stringToTabData(str: String?): List<TabData>? {
return str?.split(";")
?.map { createTabDataFromId(it) }
}
@TypeConverter
fun tabDataToString(tabData: List<TabData>?): String? {
return tabData?.joinToString(";") { it.id }
}
@TypeConverter
fun accountToJson(account: ConversationAccountEntity?): String {
return gson.toJson(account)
}
@TypeConverter
fun jsonToAccount(accountJson: String?): ConversationAccountEntity? {
return gson.fromJson(accountJson, ConversationAccountEntity::class.java)
}
@TypeConverter
fun accountListToJson(accountList: List<ConversationAccountEntity>?): String {
return gson.toJson(accountList)
}
@TypeConverter
fun jsonToAccountList(accountListJson: String?): List<ConversationAccountEntity>? {
return gson.fromJson(accountListJson, object : TypeToken<List<ConversationAccountEntity>>() {}.type)
}
@TypeConverter
fun attachmentListToJson(attachmentList: List<Attachment>?): String {
return gson.toJson(attachmentList)
}
@TypeConverter
fun jsonToAttachmentList(attachmentListJson: String?): List<Attachment>? {
return gson.fromJson(attachmentListJson, object : TypeToken<List<Attachment>>() {}.type)
}
@TypeConverter
fun mentionArrayToJson(mentionArray: Array<Status.Mention>?): String? {
return gson.toJson(mentionArray)
}
@TypeConverter
fun jsonToMentionArray(mentionListJson: String?): Array<Status.Mention>? {
return gson.fromJson(mentionListJson, object : TypeToken<Array<Status.Mention>>() {}.type)
}
@TypeConverter
fun dateToLong(date: Date): Long {
return date.time
}
@TypeConverter
fun longToDate(date: Long): Date {
return Date(date)
}
@TypeConverter
fun spannedToString(spanned: Spanned?): String? {
if(spanned == null) {
return null
}
return HtmlUtils.toHtml(spanned)
}
@TypeConverter
fun stringToSpanned(spannedString: String?): Spanned? {
if(spannedString == null) {
return null
}
return HtmlUtils.fromHtml(spannedString)
}
}

View File

@ -86,4 +86,7 @@ abstract class ActivitiesModule {
@ContributesAndroidInjector
abstract fun contributesLicenseActivity(): LicenseActivity
@ContributesAndroidInjector
abstract fun contributesTabPreferenceActivity(): TabPreferenceActivity
}

View File

@ -16,6 +16,7 @@
package com.keylesspalace.tusky.di
import com.keylesspalace.tusky.components.conversation.ConversationsFragment
import com.keylesspalace.tusky.fragment.*
import com.keylesspalace.tusky.fragment.preference.*
import dagger.Module
@ -51,4 +52,7 @@ abstract class FragmentBuildersModule {
@ContributesAndroidInjector
abstract fun accountPreferencesFragment(): AccountPreferencesFragment
@ContributesAndroidInjector
abstract fun directMessagesPreferencesFragment(): ConversationsFragment
}

View File

@ -46,6 +46,7 @@ import javax.inject.Singleton
@Module
class NetworkModule {
@Provides
@IntoMap
@ClassKey(Spanned::class)

View File

@ -5,6 +5,7 @@ package com.keylesspalace.tusky.di
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.keylesspalace.tusky.viewmodel.AccountViewModel
import com.keylesspalace.tusky.components.conversation.ConversationsViewModel
import com.keylesspalace.tusky.viewmodel.EditProfileViewModel
import dagger.Binds
import dagger.MapKey
@ -42,5 +43,10 @@ abstract class ViewModelModule {
@ViewModelKey(EditProfileViewModel::class)
internal abstract fun editProfileViewModel(viewModel: EditProfileViewModel): ViewModel
@Binds
@IntoMap
@ViewModelKey(ConversationsViewModel::class)
internal abstract fun conversationsViewModel(viewModel: ConversationsViewModel): ViewModel
//Add more ViewModels here
}

View File

@ -37,13 +37,13 @@ data class Account(
val avatar: String,
val header: String,
val locked: Boolean = false,
@SerializedName("followers_count") val followersCount: Int,
@SerializedName("following_count") val followingCount: Int,
@SerializedName("statuses_count") val statusesCount: Int,
val source: AccountSource?,
val bot: Boolean,
val emojis: List<Emoji>?, // nullable for backward compatibility
val fields: List<Field>?, //nullable for backward compatibility
@SerializedName("followers_count") val followersCount: Int = 0,
@SerializedName("following_count") val followingCount: Int = 0,
@SerializedName("statuses_count") val statusesCount: Int = 0,
val source: AccountSource? = null,
val bot: Boolean = false,
val emojis: List<Emoji>? = emptyList(), // nullable for backward compatibility
val fields: List<Field>? = emptyList(), //nullable for backward compatibility
val moved: Account? = null
) : Parcelable {

View File

@ -0,0 +1,25 @@
/* Copyright 2019 Conny Duck
*
* This file is a part of Tusky.
*
* 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.
*
* Tusky 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 Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.entity
import com.google.gson.annotations.SerializedName
data class Conversation(
val id: String,
val accounts: List<Account>,
@SerializedName("last_status") val lastStatus: Status,
val unread: Boolean
)

View File

@ -42,7 +42,7 @@ data class Status(
var pinned: Boolean?
) {
val actionableId: String?
val actionableId: String
get() = reblog?.id ?: id
val actionableStatus: Status

View File

@ -72,10 +72,10 @@ class AccountListFragment : BaseFragment(), AccountActionListener, Injectable {
super.onViewCreated(view, savedInstanceState)
recyclerView.setHasFixedSize(true)
val layoutManager = LinearLayoutManager(context)
val layoutManager = LinearLayoutManager(view.context)
recyclerView.layoutManager = layoutManager
val divider = DividerItemDecoration(context, layoutManager.orientation)
val drawable = ThemeUtils.getDrawable(context, R.attr.status_divider_drawable, R.drawable.status_divider_dark)
val divider = DividerItemDecoration(view.context, layoutManager.orientation)
val drawable = ThemeUtils.getDrawable(view.context, R.attr.status_divider_drawable, R.drawable.status_divider_dark)
divider.setDrawable(drawable)
recyclerView.addItemDecoration(divider)

View File

@ -81,9 +81,10 @@ class AccountMediaFragment : BaseFragment(), Injectable {
private val callback = object : Callback<List<Status>> {
override fun onFailure(call: Call<List<Status>>?, t: Throwable?) {
fetchingStatus = FetchingStatus.NOT_FETCHING
if (isAdded) {
swipe_refresh_layout.isRefreshing = false
progress_bar.visibility = View.GONE
if(isAdded) {
swipeRefreshLayout.isRefreshing = false
progressBar.visibility = View.GONE
statusView.show()
if (t is IOException) {
statusView.setup(R.drawable.elephant_offline, R.string.error_network) {
@ -101,9 +102,9 @@ class AccountMediaFragment : BaseFragment(), Injectable {
override fun onResponse(call: Call<List<Status>>, response: Response<List<Status>>) {
fetchingStatus = FetchingStatus.NOT_FETCHING
if (isAdded) {
swipe_refresh_layout.isRefreshing = false
progress_bar.visibility = View.GONE
if(isAdded) {
swipeRefreshLayout.isRefreshing = false
progressBar.visibility = View.GONE
val body = response.body()
body?.let { fetched ->
@ -114,6 +115,7 @@ class AccountMediaFragment : BaseFragment(), Injectable {
result.addAll(AttachmentViewData.list(status))
}
adapter.addTop(result)
if (statuses.isEmpty()) {
statusView.show()
statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty,
@ -159,19 +161,19 @@ class AccountMediaFragment : BaseFragment(), Injectable {
super.onViewCreated(view, savedInstanceState)
val columnCount = context?.resources?.getInteger(R.integer.profile_media_column_count) ?: 2
val layoutManager = GridLayoutManager(context, columnCount)
val columnCount = view.context.resources.getInteger(R.integer.profile_media_column_count)
val layoutManager = GridLayoutManager(view.context, columnCount)
val bgRes = ThemeUtils.getColorId(context, R.attr.window_background)
val bgRes = ThemeUtils.getColorId(view.context, R.attr.window_background)
adapter.baseItemColor = ContextCompat.getColor(recycler_view.context, bgRes)
adapter.baseItemColor = ContextCompat.getColor(recyclerView.context, bgRes)
recycler_view.layoutManager = layoutManager
recycler_view.adapter = adapter
recyclerView.layoutManager = layoutManager
recyclerView.adapter = adapter
val accountId = arguments?.getString(ACCOUNT_ID_ARG)
swipe_refresh_layout.setOnRefreshListener {
swipeRefreshLayout.setOnRefreshListener {
statusView.hide()
if (fetchingStatus != FetchingStatus.NOT_FETCHING) return@setOnRefreshListener
currentCall = if (statuses.isEmpty()) {
@ -184,12 +186,12 @@ class AccountMediaFragment : BaseFragment(), Injectable {
currentCall?.enqueue(callback)
}
swipe_refresh_layout.setColorSchemeResources(R.color.tusky_blue)
swipe_refresh_layout.setProgressBackgroundColorSchemeColor(ThemeUtils.getColor(context, android.R.attr.colorBackground))
swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
swipeRefreshLayout.setProgressBackgroundColorSchemeColor(ThemeUtils.getColor(view.context, android.R.attr.colorBackground))
statusView.visibility = View.GONE
recycler_view.addOnScrollListener(object : RecyclerView.OnScrollListener() {
recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recycler_view: RecyclerView, dx: Int, dy: Int) {
if (dy > 0) {

View File

@ -176,9 +176,9 @@ public class NotificationsFragment extends SFragment implements
@NonNull Context context = inflater.getContext(); // from inflater to silence warning
// Setup the SwipeRefreshLayout.
swipeRefreshLayout = rootView.findViewById(R.id.swipe_refresh_layout);
recyclerView = rootView.findViewById(R.id.recycler_view);
progressBar = rootView.findViewById(R.id.progress_bar);
swipeRefreshLayout = rootView.findViewById(R.id.swipeRefreshLayout);
recyclerView = rootView.findViewById(R.id.recyclerView);
progressBar = rootView.findViewById(R.id.progressBar);
statusView = rootView.findViewById(R.id.statusView);
swipeRefreshLayout.setOnRefreshListener(this);
@ -417,13 +417,13 @@ public class NotificationsFragment extends SFragment implements
}
@Override
public void onMore(View view, int position) {
public void onMore(@NonNull View view, int position) {
Notification notification = notifications.get(position).asRight();
super.more(notification.getStatus(), view, position);
}
@Override
public void onViewMedia(int position, int attachmentIndex, View view) {
public void onViewMedia(int position, int attachmentIndex, @NonNull View view) {
Notification notification = notifications.get(position).asRightOrNull();
if (notification == null || notification.getStatus() == null) return;
super.viewMedia(attachmentIndex, notification.getStatus(), view);

View File

@ -98,7 +98,7 @@ public abstract class SFragment extends BaseFragment {
}
protected void viewThread(Status status) {
bottomSheetActivity.viewThread(status);
bottomSheetActivity.viewThread(status.getActionableId(), status.getUrl());
}
protected void viewAccount(String accountId) {

View File

@ -182,14 +182,14 @@ class SearchFragment : SFragment(), StatusActionListener, Injectable {
}
}
override fun onMore(view: View?, position: Int) {
override fun onMore(view: View, position: Int) {
val status = searchAdapter.getStatusAtPosition(position)
if (status != null) {
more(status, view, position)
}
}
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) {
override fun onViewMedia(position: Int, attachmentIndex: Int, view: View) {
val status = searchAdapter.getStatusAtPosition(position) ?: return
viewMedia(attachmentIndex, status, view)
}

View File

@ -219,9 +219,9 @@ public class TimelineFragment extends SFragment implements
Bundle savedInstanceState) {
final View rootView = inflater.inflate(R.layout.fragment_timeline, container, false);
recyclerView = rootView.findViewById(R.id.recycler_view);
swipeRefreshLayout = rootView.findViewById(R.id.swipe_refresh_layout);
progressBar = rootView.findViewById(R.id.progress_bar);
recyclerView = rootView.findViewById(R.id.recyclerView);
swipeRefreshLayout = rootView.findViewById(R.id.swipeRefreshLayout);
progressBar = rootView.findViewById(R.id.progressBar);
statusView = rootView.findViewById(R.id.statusView);
setupSwipeRefreshLayout();
@ -608,7 +608,7 @@ public class TimelineFragment extends SFragment implements
}
@Override
public void onMore(View view, final int position) {
public void onMore(@NonNull View view, final int position) {
super.more(statuses.get(position).asRight(), view, position);
}
@ -689,7 +689,7 @@ public class TimelineFragment extends SFragment implements
}
@Override
public void onViewMedia(int position, int attachmentIndex, View view) {
public void onViewMedia(int position, int attachmentIndex, @NonNull View view) {
Status status = statuses.get(position).asRightOrNull();
if (status == null) return;
super.viewMedia(attachmentIndex, status, view);

View File

@ -137,13 +137,13 @@ public final class ViewThreadFragment extends SFragment implements
View rootView = inflater.inflate(R.layout.fragment_view_thread, container, false);
Context context = getContext();
swipeRefreshLayout = rootView.findViewById(R.id.swipe_refresh_layout);
swipeRefreshLayout = rootView.findViewById(R.id.swipeRefreshLayout);
swipeRefreshLayout.setOnRefreshListener(this);
swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue);
swipeRefreshLayout.setProgressBackgroundColorSchemeColor(
ThemeUtils.getColor(context, android.R.attr.colorBackground));
recyclerView = rootView.findViewById(R.id.recycler_view);
recyclerView = rootView.findViewById(R.id.recyclerView);
recyclerView.setHasFixedSize(true);
LinearLayoutManager layoutManager = new LinearLayoutManager(context);
recyclerView.setLayoutManager(layoutManager);
@ -284,12 +284,12 @@ public final class ViewThreadFragment extends SFragment implements
}
@Override
public void onMore(View view, int position) {
public void onMore(@NonNull View view, int position) {
super.more(statuses.get(position), view, position);
}
@Override
public void onViewMedia(int position, int attachmentIndex, View view) {
public void onViewMedia(int position, int attachmentIndex, @NonNull View view) {
Status status = statuses.get(position);
super.viewMedia(attachmentIndex, status, view);
}

View File

@ -26,10 +26,7 @@ import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import android.util.Log
import android.view.View
import com.keylesspalace.tusky.AccountListActivity
import com.keylesspalace.tusky.BuildConfig
import com.keylesspalace.tusky.PreferencesActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.*
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.db.AccountManager
@ -60,6 +57,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(),
lateinit var eventHub: EventHub
private lateinit var notificationPreference: Preference
private lateinit var tabPreference: Preference
private lateinit var mutedUsersPreference: Preference
private lateinit var blockedUsersPreference: Preference
@ -74,6 +72,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(),
addPreferencesFromResource(R.xml.account_preferences)
notificationPreference = findPreference("notificationPreference")
tabPreference = findPreference("tabPreference")
mutedUsersPreference = findPreference("mutedUsersPreference")
blockedUsersPreference = findPreference("blockedUsersPreference")
defaultPostPrivacyPreference = findPreference("defaultPostPrivacy") as ListPreference
@ -81,11 +80,12 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(),
mediaPreviewEnabledPreference = findPreference("mediaPreviewEnabled") as SwitchPreference
alwaysShowSensitiveMediaPreference = findPreference("alwaysShowSensitiveMedia") as SwitchPreference
notificationPreference.icon = IconicsDrawable(context, GoogleMaterial.Icon.gmd_notifications).sizePx(iconSize).color(ThemeUtils.getColor(context, R.attr.toolbar_icon_tint))
notificationPreference.icon = IconicsDrawable(notificationPreference.context, GoogleMaterial.Icon.gmd_notifications).sizePx(iconSize).color(ThemeUtils.getColor(notificationPreference.context, R.attr.toolbar_icon_tint))
mutedUsersPreference.icon = getTintedIcon(R.drawable.ic_mute_24dp)
blockedUsersPreference.icon = IconicsDrawable(context, GoogleMaterial.Icon.gmd_block).sizePx(iconSize).color(ThemeUtils.getColor(context, R.attr.toolbar_icon_tint))
blockedUsersPreference.icon = IconicsDrawable(blockedUsersPreference.context, GoogleMaterial.Icon.gmd_block).sizePx(iconSize).color(ThemeUtils.getColor(blockedUsersPreference.context, R.attr.toolbar_icon_tint))
notificationPreference.onPreferenceClickListener = this
tabPreference.onPreferenceClickListener = this
mutedUsersPreference.onPreferenceClickListener = this
blockedUsersPreference.onPreferenceClickListener = this
@ -161,6 +161,12 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(),
}
return true
}
tabPreference -> {
val intent = Intent(context, TabPreferenceActivity::class.java)
activity?.startActivity(intent)
activity?.overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left)
return true
}
mutedUsersPreference -> {
val intent = Intent(context, AccountListActivity::class.java)
intent.putExtra("type", AccountListActivity.Type.MUTES)

View File

@ -34,13 +34,13 @@ class PreferencesFragment : PreferenceFragmentCompat() {
addPreferencesFromResource(R.xml.preferences)
val themePreference: Preference = findPreference("appTheme")
themePreference.icon = IconicsDrawable(context, GoogleMaterial.Icon.gmd_palette).sizePx(iconSize).color(ThemeUtils.getColor(context, R.attr.toolbar_icon_tint))
themePreference.icon = IconicsDrawable(themePreference.context, GoogleMaterial.Icon.gmd_palette).sizePx(iconSize).color(ThemeUtils.getColor(themePreference.context, R.attr.toolbar_icon_tint))
val emojiPreference: Preference = findPreference("emojiCompat")
emojiPreference.icon = IconicsDrawable(context, GoogleMaterial.Icon.gmd_sentiment_satisfied).sizePx(iconSize).color(ThemeUtils.getColor(context, R.attr.toolbar_icon_tint))
emojiPreference.icon = IconicsDrawable(emojiPreference.context, GoogleMaterial.Icon.gmd_sentiment_satisfied).sizePx(iconSize).color(ThemeUtils.getColor(emojiPreference.context, R.attr.toolbar_icon_tint))
val textSizePreference: Preference = findPreference("statusTextSize")
textSizePreference.icon = IconicsDrawable(context, GoogleMaterial.Icon.gmd_format_size).sizePx(iconSize).color(ThemeUtils.getColor(context, R.attr.toolbar_icon_tint))
textSizePreference.icon = IconicsDrawable(textSizePreference.context, GoogleMaterial.Icon.gmd_format_size).sizePx(iconSize).color(ThemeUtils.getColor(textSizePreference.context, R.attr.toolbar_icon_tint))
val timelineFilterPreferences: Preference = findPreference("timelineFilterPreferences")
timelineFilterPreferences.setOnPreferenceClickListener {

View File

@ -17,12 +17,14 @@ package com.keylesspalace.tusky.interfaces;
import android.view.View;
import androidx.annotation.NonNull;
public interface StatusActionListener extends LinkListener {
void onReply(int position);
void onReblog(final boolean reblog, final int position);
void onFavourite(final boolean favourite, final int position);
void onMore(View view, final int position);
void onViewMedia(int position, int attachmentIndex, View view);
void onMore(@NonNull View view, final int position);
void onViewMedia(int position, int attachmentIndex, @NonNull View view);
void onViewThread(int position);
void onOpenReblog(int position);
void onExpandedChange(boolean expanded, int position);

View File

@ -22,11 +22,14 @@ import com.google.gson.JsonDeserializationContext;
import com.google.gson.JsonDeserializer;
import com.google.gson.JsonElement;
import com.google.gson.JsonParseException;
import com.google.gson.JsonPrimitive;
import com.google.gson.JsonSerializationContext;
import com.google.gson.JsonSerializer;
import com.keylesspalace.tusky.util.HtmlUtils;
import java.lang.reflect.Type;
public class SpannedTypeAdapter implements JsonDeserializer<Spanned> {
public class SpannedTypeAdapter implements JsonDeserializer<Spanned>, JsonSerializer<Spanned> {
@Override
public Spanned deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context)
throws JsonParseException {
@ -37,4 +40,9 @@ public class SpannedTypeAdapter implements JsonDeserializer<Spanned> {
return new SpannedString("");
}
}
@Override
public JsonElement serialize(Spanned src, Type typeOfSrc, JsonSerializationContext context) {
return new JsonPrimitive(HtmlUtils.toHtml(src));
}
}

View File

@ -20,6 +20,7 @@ import com.keylesspalace.tusky.entity.Account;
import com.keylesspalace.tusky.entity.AppCredentials;
import com.keylesspalace.tusky.entity.Attachment;
import com.keylesspalace.tusky.entity.Card;
import com.keylesspalace.tusky.entity.Conversation;
import com.keylesspalace.tusky.entity.Emoji;
import com.keylesspalace.tusky.entity.Instance;
import com.keylesspalace.tusky.entity.MastoList;
@ -317,4 +318,7 @@ public interface MastodonApi {
@GET("api/v1/instance")
Call<Instance> getInstance();
@GET("/api/v1/conversations")
Call<List<Conversation>> getConversations(@Nullable @Query("max_id") String maxId, @Query("limit") int limit);
}

View File

@ -0,0 +1,46 @@
/* Copyright 2017 Andrew Dawson
*
* This file is a part of Tusky.
*
* 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.
*
* Tusky 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 Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.pager
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentPagerAdapter
import androidx.viewpager.widget.PagerAdapter
import com.keylesspalace.tusky.TabData
class MainPagerAdapter(val tabs: List<TabData>, manager: FragmentManager) : FragmentPagerAdapter(manager) {
override fun getItem(position: Int): Fragment {
return tabs[position].fragment()
}
override fun getCount(): Int {
return tabs.size
}
override fun getPageTitle(position: Int): CharSequence? {
return null
}
override fun getItemId(position: Int): Long {
return tabs[position].id.hashCode().toLong()
}
override fun getItemPosition(item: Any): Int {
return PagerAdapter.POSITION_NONE
}
}

View File

@ -1,60 +0,0 @@
/* Copyright 2017 Andrew Dawson
*
* This file is a part of Tusky.
*
* 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.
*
* Tusky 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 Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.pager;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import androidx.fragment.app.FragmentPagerAdapter;
import com.keylesspalace.tusky.fragment.NotificationsFragment;
import com.keylesspalace.tusky.fragment.TimelineFragment;
public class TimelinePagerAdapter extends FragmentPagerAdapter {
public TimelinePagerAdapter(FragmentManager manager) {
super(manager);
}
@Override
public Fragment getItem(int i) {
switch (i) {
case 0: {
return TimelineFragment.newInstance(TimelineFragment.Kind.HOME);
}
case 1: {
return NotificationsFragment.newInstance();
}
case 2: {
return TimelineFragment.newInstance(TimelineFragment.Kind.PUBLIC_LOCAL);
}
case 3: {
return TimelineFragment.newInstance(TimelineFragment.Kind.PUBLIC_FEDERATED);
}
default: {
return null;
}
}
}
@Override
public int getCount() {
return 4;
}
@Override
public CharSequence getPageTitle(int position) {
return null;
}
}

View File

@ -0,0 +1,36 @@
/*
* Copyright (C) 2017 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.keylesspalace.tusky.util
import androidx.lifecycle.LiveData
import androidx.paging.PagedList
/**
* Data class that is necessary for a UI to show a listing and interact w/ the rest of the system
*/
data class Listing<T>(
// the LiveData of paged lists for the UI to observe
val pagedList: LiveData<PagedList<T>>,
// represents the network request status to show to the user
val networkState: LiveData<NetworkState>,
// represents the refresh status to show to the user. Separate from networkState, this
// value is importantly only when refresh is requested.
val refreshState: LiveData<NetworkState>,
// refreshes the whole data and fetches it from scratch.
val refresh: () -> Unit,
// retries any failed requests.
val retry: () -> Unit)

View File

@ -0,0 +1,34 @@
/*
* Copyright (C) 2017 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.keylesspalace.tusky.util
enum class Status {
RUNNING,
SUCCESS,
FAILED
}
@Suppress("DataClassPrivateConstructor")
data class NetworkState private constructor(
val status: Status,
val msg: String? = null) {
companion object {
val LOADED = NetworkState(Status.SUCCESS)
val LOADING = NetworkState(Status.RUNNING)
fun error(msg: String?) = NetworkState(Status.FAILED, msg)
}
}

View File

@ -0,0 +1,491 @@
/*
* Copyright 2017 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.keylesspalace.tusky.util;
import androidx.annotation.AnyThread;
import androidx.annotation.GuardedBy;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import java.util.Arrays;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.Executor;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* A helper class for {@link androidx.paging.PagedList.BoundaryCallback BoundaryCallback}s and
* {@link DataSource}s to help with tracking network requests.
* <p>
* It is designed to support 3 types of requests, {@link RequestType#INITIAL INITIAL},
* {@link RequestType#BEFORE BEFORE} and {@link RequestType#AFTER AFTER} and runs only 1 request
* for each of them via {@link #runIfNotRunning(RequestType, Request)}.
* <p>
* It tracks a {@link Status} and an {@code error} for each {@link RequestType}.
* <p>
* A sample usage of this class to limit requests looks like this:
* <pre>
* class PagingBoundaryCallback extends PagedList.BoundaryCallback&lt;MyItem> {
* // TODO replace with an executor from your application
* Executor executor = Executors.newSingleThreadExecutor();
* PagingRequestHelper helper = new PagingRequestHelper(executor);
* // imaginary API service, using Retrofit
* MyApi api;
*
* {@literal @}Override
* public void onItemAtFrontLoaded({@literal @}NonNull MyItem itemAtFront) {
* helper.runIfNotRunning(PagingRequestHelper.RequestType.BEFORE,
* helperCallback -> api.getTopBefore(itemAtFront.getName(), 10).enqueue(
* new Callback&lt;ApiResponse>() {
* {@literal @}Override
* public void onResponse(Call&lt;ApiResponse> call,
* Response&lt;ApiResponse> response) {
* // TODO insert new records into database
* helperCallback.recordSuccess();
* }
*
* {@literal @}Override
* public void onFailure(Call&lt;ApiResponse> call, Throwable t) {
* helperCallback.recordFailure(t);
* }
* }));
* }
*
* {@literal @}Override
* public void onItemAtEndLoaded({@literal @}NonNull MyItem itemAtEnd) {
* helper.runIfNotRunning(PagingRequestHelper.RequestType.AFTER,
* helperCallback -> api.getTopBefore(itemAtEnd.getName(), 10).enqueue(
* new Callback&lt;ApiResponse>() {
* {@literal @}Override
* public void onResponse(Call&lt;ApiResponse> call,
* Response&lt;ApiResponse> response) {
* // TODO insert new records into database
* helperCallback.recordSuccess();
* }
*
* {@literal @}Override
* public void onFailure(Call&lt;ApiResponse> call, Throwable t) {
* helperCallback.recordFailure(t);
* }
* }));
* }
* }
* </pre>
* <p>
* The helper provides an API to observe combined request status, which can be reported back to the
* application based on your business rules.
* <pre>
* MutableLiveData&lt;PagingRequestHelper.Status> combined = new MutableLiveData&lt;>();
* helper.addListener(status -> {
* // merge multiple states per request type into one, or dispatch separately depending on
* // your application logic.
* if (status.hasRunning()) {
* combined.postValue(PagingRequestHelper.Status.RUNNING);
* } else if (status.hasError()) {
* // can also obtain the error via {@link StatusReport#getErrorFor(RequestType)}
* combined.postValue(PagingRequestHelper.Status.FAILED);
* } else {
* combined.postValue(PagingRequestHelper.Status.SUCCESS);
* }
* });
* </pre>
*/
// THIS class is likely to be moved into the library in a future release. Feel free to copy it
// from this sample.
public class PagingRequestHelper {
private final Object mLock = new Object();
private final Executor mRetryService;
@GuardedBy("mLock")
private final RequestQueue[] mRequestQueues = new RequestQueue[]
{new RequestQueue(RequestType.INITIAL),
new RequestQueue(RequestType.BEFORE),
new RequestQueue(RequestType.AFTER)};
@NonNull
final CopyOnWriteArrayList<Listener> mListeners = new CopyOnWriteArrayList<>();
/**
* Creates a new PagingRequestHelper with the given {@link Executor} which is used to run
* retry actions.
*
* @param retryService The {@link Executor} that can run the retry actions.
*/
public PagingRequestHelper(@NonNull Executor retryService) {
mRetryService = retryService;
}
/**
* Adds a new listener that will be notified when any request changes {@link Status state}.
*
* @param listener The listener that will be notified each time a request's status changes.
* @return True if it is added, false otherwise (e.g. it already exists in the list).
*/
@AnyThread
public boolean addListener(@NonNull Listener listener) {
return mListeners.add(listener);
}
/**
* Removes the given listener from the listeners list.
*
* @param listener The listener that will be removed.
* @return True if the listener is removed, false otherwise (e.g. it never existed)
*/
public boolean removeListener(@NonNull Listener listener) {
return mListeners.remove(listener);
}
/**
* Runs the given {@link Request} if no other requests in the given request type is already
* running.
* <p>
* If run, the request will be run in the current thread.
*
* @param type The type of the request.
* @param request The request to run.
* @return True if the request is run, false otherwise.
*/
@SuppressWarnings("WeakerAccess")
@AnyThread
public boolean runIfNotRunning(@NonNull RequestType type, @NonNull Request request) {
boolean hasListeners = !mListeners.isEmpty();
StatusReport report = null;
synchronized (mLock) {
RequestQueue queue = mRequestQueues[type.ordinal()];
if (queue.mRunning != null) {
return false;
}
queue.mRunning = request;
queue.mStatus = Status.RUNNING;
queue.mFailed = null;
queue.mLastError = null;
if (hasListeners) {
report = prepareStatusReportLocked();
}
}
if (report != null) {
dispatchReport(report);
}
final RequestWrapper wrapper = new RequestWrapper(request, this, type);
wrapper.run();
return true;
}
@GuardedBy("mLock")
private StatusReport prepareStatusReportLocked() {
Throwable[] errors = new Throwable[]{
mRequestQueues[0].mLastError,
mRequestQueues[1].mLastError,
mRequestQueues[2].mLastError
};
return new StatusReport(
getStatusForLocked(RequestType.INITIAL),
getStatusForLocked(RequestType.BEFORE),
getStatusForLocked(RequestType.AFTER),
errors
);
}
@GuardedBy("mLock")
private Status getStatusForLocked(RequestType type) {
return mRequestQueues[type.ordinal()].mStatus;
}
@AnyThread
@VisibleForTesting
void recordResult(@NonNull RequestWrapper wrapper, @Nullable Throwable throwable) {
StatusReport report = null;
final boolean success = throwable == null;
boolean hasListeners = !mListeners.isEmpty();
synchronized (mLock) {
RequestQueue queue = mRequestQueues[wrapper.mType.ordinal()];
queue.mRunning = null;
queue.mLastError = throwable;
if (success) {
queue.mFailed = null;
queue.mStatus = Status.SUCCESS;
} else {
queue.mFailed = wrapper;
queue.mStatus = Status.FAILED;
}
if (hasListeners) {
report = prepareStatusReportLocked();
}
}
if (report != null) {
dispatchReport(report);
}
}
private void dispatchReport(StatusReport report) {
for (Listener listener : mListeners) {
listener.onStatusChange(report);
}
}
/**
* Retries all failed requests.
*
* @return True if any request is retried, false otherwise.
*/
public boolean retryAllFailed() {
final RequestWrapper[] toBeRetried = new RequestWrapper[RequestType.values().length];
boolean retried = false;
synchronized (mLock) {
for (int i = 0; i < RequestType.values().length; i++) {
toBeRetried[i] = mRequestQueues[i].mFailed;
mRequestQueues[i].mFailed = null;
}
}
for (RequestWrapper failed : toBeRetried) {
if (failed != null) {
failed.retry(mRetryService);
retried = true;
}
}
return retried;
}
static class RequestWrapper implements Runnable {
@NonNull
final Request mRequest;
@NonNull
final PagingRequestHelper mHelper;
@NonNull
final RequestType mType;
RequestWrapper(@NonNull Request request, @NonNull PagingRequestHelper helper,
@NonNull RequestType type) {
mRequest = request;
mHelper = helper;
mType = type;
}
@Override
public void run() {
mRequest.run(new Request.Callback(this, mHelper));
}
void retry(Executor service) {
service.execute(new Runnable() {
@Override
public void run() {
mHelper.runIfNotRunning(mType, mRequest);
}
});
}
}
/**
* Runner class that runs a request tracked by the {@link PagingRequestHelper}.
* <p>
* When a request is invoked, it must call one of {@link Callback#recordFailure(Throwable)}
* or {@link Callback#recordSuccess()} once and only once. This call
* can be made any time. Until that method call is made, {@link PagingRequestHelper} will
* consider the request is running.
*/
@FunctionalInterface
public interface Request {
/**
* Should run the request and call the given {@link Callback} with the result of the
* request.
*
* @param callback The callback that should be invoked with the result.
*/
void run(Callback callback);
/**
* Callback class provided to the {@link #run(Callback)} method to report the result.
*/
class Callback {
private final AtomicBoolean mCalled = new AtomicBoolean();
private final RequestWrapper mWrapper;
private final PagingRequestHelper mHelper;
Callback(RequestWrapper wrapper, PagingRequestHelper helper) {
mWrapper = wrapper;
mHelper = helper;
}
/**
* Call this method when the request succeeds and new data is fetched.
*/
@SuppressWarnings("unused")
public final void recordSuccess() {
if (mCalled.compareAndSet(false, true)) {
mHelper.recordResult(mWrapper, null);
} else {
throw new IllegalStateException(
"already called recordSuccess or recordFailure");
}
}
/**
* Call this method with the failure message and the request can be retried via
* {@link #retryAllFailed()}.
*
* @param throwable The error that occured while carrying out the request.
*/
@SuppressWarnings("unused")
public final void recordFailure(@NonNull Throwable throwable) {
//noinspection ConstantConditions
if (throwable == null) {
throw new IllegalArgumentException("You must provide a throwable describing"
+ " the error to record the failure");
}
if (mCalled.compareAndSet(false, true)) {
mHelper.recordResult(mWrapper, throwable);
} else {
throw new IllegalStateException(
"already called recordSuccess or recordFailure");
}
}
}
}
/**
* Data class that holds the information about the current status of the ongoing requests
* using this helper.
*/
public static final class StatusReport {
/**
* Status of the latest request that were submitted with {@link RequestType#INITIAL}.
*/
@NonNull
public final Status initial;
/**
* Status of the latest request that were submitted with {@link RequestType#BEFORE}.
*/
@NonNull
public final Status before;
/**
* Status of the latest request that were submitted with {@link RequestType#AFTER}.
*/
@NonNull
public final Status after;
@NonNull
private final Throwable[] mErrors;
StatusReport(@NonNull Status initial, @NonNull Status before, @NonNull Status after,
@NonNull Throwable[] errors) {
this.initial = initial;
this.before = before;
this.after = after;
this.mErrors = errors;
}
/**
* Convenience method to check if there are any running requests.
*
* @return True if there are any running requests, false otherwise.
*/
public boolean hasRunning() {
return initial == Status.RUNNING
|| before == Status.RUNNING
|| after == Status.RUNNING;
}
/**
* Convenience method to check if there are any requests that resulted in an error.
*
* @return True if there are any requests that finished with error, false otherwise.
*/
public boolean hasError() {
return initial == Status.FAILED
|| before == Status.FAILED
|| after == Status.FAILED;
}
/**
* Returns the error for the given request type.
*
* @param type The request type for which the error should be returned.
* @return The {@link Throwable} returned by the failing request with the given type or
* {@code null} if the request for the given type did not fail.
*/
@Nullable
public Throwable getErrorFor(@NonNull RequestType type) {
return mErrors[type.ordinal()];
}
@Override
public String toString() {
return "StatusReport{"
+ "initial=" + initial
+ ", before=" + before
+ ", after=" + after
+ ", mErrors=" + Arrays.toString(mErrors)
+ '}';
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
StatusReport that = (StatusReport) o;
if (initial != that.initial) return false;
if (before != that.before) return false;
if (after != that.after) return false;
// Probably incorrect - comparing Object[] arrays with Arrays.equals
return Arrays.equals(mErrors, that.mErrors);
}
@Override
public int hashCode() {
int result = initial.hashCode();
result = 31 * result + before.hashCode();
result = 31 * result + after.hashCode();
result = 31 * result + Arrays.hashCode(mErrors);
return result;
}
}
/**
* Listener interface to get notified by request status changes.
*/
public interface Listener {
/**
* Called when the status for any of the requests has changed.
*
* @param report The current status report that has all the information about the requests.
*/
void onStatusChange(@NonNull StatusReport report);
}
/**
* Represents the status of a Request for each {@link RequestType}.
*/
public enum Status {
/**
* There is current a running request.
*/
RUNNING,
/**
* The last request has succeeded or no such requests have ever been run.
*/
SUCCESS,
/**
* The last request has failed.
*/
FAILED
}
/**
* Available request types.
*/
public enum RequestType {
/**
* Corresponds to an initial request made to a {@link DataSource} or the empty state for
* a {@link androidx.paging.PagedList.BoundaryCallback BoundaryCallback}.
*/
INITIAL,
/**
* Corresponds to the {@code loadBefore} calls in {@link DataSource} or
* {@code onItemAtFrontLoaded} in
* {@link androidx.paging.PagedList.BoundaryCallback BoundaryCallback}.
*/
BEFORE,
/**
* Corresponds to the {@code loadAfter} calls in {@link DataSource} or
* {@code onItemAtEndLoaded} in
* {@link androidx.paging.PagedList.BoundaryCallback BoundaryCallback}.
*/
AFTER
}
class RequestQueue {
@NonNull
final RequestType mRequestType;
@Nullable
RequestWrapper mFailed;
@Nullable
Request mRunning;
@Nullable
Throwable mLastError;
@NonNull
Status mStatus = Status.SUCCESS;
RequestQueue(@NonNull RequestType requestType) {
mRequestType = requestType;
}
}
}

View File

@ -25,7 +25,8 @@ import androidx.annotation.AttrRes;
import androidx.annotation.ColorInt;
import androidx.annotation.ColorRes;
import androidx.annotation.DrawableRes;
import androidx.core.content.ContextCompat;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatDelegate;
import android.util.TypedValue;
import android.widget.ImageView;
@ -37,12 +38,12 @@ import android.widget.ImageView;
public class ThemeUtils {
public static final String APP_THEME_DEFAULT = ThemeUtils.THEME_NIGHT;
public static final String THEME_NIGHT = "night";
public static final String THEME_DAY = "day";
public static final String THEME_BLACK = "black";
public static final String THEME_AUTO = "auto";
private static final String THEME_NIGHT = "night";
private static final String THEME_DAY = "day";
private static final String THEME_BLACK = "black";
private static final String THEME_AUTO = "auto";
public static Drawable getDrawable(Context context, @AttrRes int attribute,
public static Drawable getDrawable(@NonNull Context context, @AttrRes int attribute,
@DrawableRes int fallbackDrawable) {
TypedValue value = new TypedValue();
@DrawableRes int resourceId;
@ -51,10 +52,10 @@ public class ThemeUtils {
} else {
resourceId = fallbackDrawable;
}
return ContextCompat.getDrawable(context, resourceId);
return context.getDrawable(resourceId);
}
public static @DrawableRes int getDrawableId(Context context, @AttrRes int attribute,
public static @DrawableRes int getDrawableId(@NonNull Context context, @AttrRes int attribute,
@DrawableRes int fallbackDrawableId) {
TypedValue value = new TypedValue();
if (context.getTheme().resolveAttribute(attribute, value, true)) {
@ -64,7 +65,7 @@ public class ThemeUtils {
}
}
public static @ColorInt int getColor(Context context, @AttrRes int attribute) {
public static @ColorInt int getColor(@NonNull Context context, @AttrRes int attribute) {
TypedValue value = new TypedValue();
if (context.getTheme().resolveAttribute(attribute, value, true)) {
return value.data;
@ -73,13 +74,13 @@ public class ThemeUtils {
}
}
public static @ColorRes int getColorId(Context context, @AttrRes int attribute) {
public static @ColorRes int getColorId(@NonNull Context context, @AttrRes int attribute) {
TypedValue value = new TypedValue();
context.getTheme().resolveAttribute(attribute, value, true);
return value.resourceId;
}
public static @ColorInt int getColorById(Context context, String name) {
public static @ColorInt int getColorById(@NonNull Context context, String name) {
return getColor(context,
ResourcesUtils.getResourceIdentifier(context, "attr", name));
}
@ -88,6 +89,16 @@ public class ThemeUtils {
view.setColorFilter(getColor(view.getContext(), attribute), PorterDuff.Mode.SRC_IN);
}
/** this can be replaced with drawableTint in xml once minSdkVersion >= 23 */
public static @Nullable Drawable getTintedDrawable(@NonNull Context context, @DrawableRes int drawableId, @AttrRes int colorAttr) {
Drawable drawable = context.getDrawable(drawableId);
if(drawable == null) {
return null;
}
setDrawableTint(context, drawable, colorAttr);
return drawable;
}
public static void setDrawableTint(Context context, Drawable drawable, @AttrRes int attribute) {
drawable.setColorFilter(getColor(context, attribute), PorterDuff.Mode.SRC_IN);
}

View File

@ -0,0 +1,23 @@
package com.keylesspalace.tusky.util
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
private fun getErrorMessage(report: PagingRequestHelper.StatusReport): String {
return PagingRequestHelper.RequestType.values().mapNotNull {
report.getErrorFor(it)?.message
}.first()
}
fun PagingRequestHelper.createStatusLiveData(): LiveData<NetworkState> {
val liveData = MutableLiveData<NetworkState>()
addListener { report ->
when {
report.hasRunning() -> liveData.postValue(NetworkState.LOADING)
report.hasError() -> liveData.postValue(
NetworkState.error(getErrorMessage(report)))
else -> liveData.postValue(NetworkState.LOADED)
}
}
return liveData
}

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_selected="false" android:color="?attr/toolbar_icon_tint"/>
<item android:state_selected="true" android:color="?attr/tab_icon_selected_tint"/>
</selector>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="?attr/window_background" />
<corners android:radius="7dp"/>
<size android:height="52dp" android:width="52dp"/>
</shape>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?android:attr/textColorPrimary"
android:pathData="M11,18c0,1.1 -0.9,2 -2,2s-2,-0.9 -2,-2 0.9,-2 2,-2 2,0.9 2,2zM9,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM9,4c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM15,8c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM15,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM15,16c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2z" />
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#fff"
android:pathData="M19,13H13V19H11V13H5V11H11V5H13V11H19V13Z" />
</vector>

View File

@ -13,19 +13,19 @@
android:background="?attr/window_background">
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipe_refresh_layout"
android:id="@+id/swipeRefreshLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<ProgressBar
android:id="@+id/progress_bar"
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
@ -33,7 +33,6 @@
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.keylesspalace.tusky.view.BackgroundMessageView
android:id="@+id/statusView"
android:layout_width="wrap_content"

View File

@ -5,14 +5,14 @@
android:background="?attr/tab_page_margin_drawable">
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipe_refresh_layout"
android:id="@+id/swipeRefreshLayout"
android:layout_width="640dp"
android:layout_height="match_parent"
android:layout_gravity="center_horizontal"
android:background="?attr/window_background">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scrollbars="vertical" />

View File

@ -36,33 +36,12 @@
android:background="?android:colorBackground"
android:elevation="@dimen/actionbar_elevation"
app:tabGravity="fill"
app:tabMaxWidth="0dp"
app:tabIconTint="@color/tab_icon_color"
app:tabMode="fixed"
app:tabPaddingEnd="1dp"
app:tabPaddingStart="1dp"
app:tabPaddingTop="4dp"
app:tabUnboundedRipple="false">
<com.google.android.material.tabs.TabItem
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/title_home" />
<com.google.android.material.tabs.TabItem
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/title_notifications" />
<com.google.android.material.tabs.TabItem
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/title_public_local" />
<com.google.android.material.tabs.TabItem
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/title_public_federated" />
</com.google.android.material.tabs.TabLayout>
app:tabUnboundedRipple="false" />
<androidx.viewpager.widget.ViewPager
android:id="@+id/pager"

View File

@ -23,7 +23,7 @@
android:visibility="invisible" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent" />

View File

@ -0,0 +1,80 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<include layout="@layout/toolbar_basic" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/currentTabsRecyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
app:layout_constraintTop_toBottomOf="@id/appbar" />
<View
android:id="@+id/scrim"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/scrimBackground"
android:visibility="invisible"
app:layout_behavior="@string/fab_transformation_scrim_behavior" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/actionButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
android:src="@drawable/ic_plus_24dp" />
<com.google.android.material.transformation.TransformationChildCard
android:id="@+id/sheet"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
android:visibility="invisible"
app:cardBackgroundColor="?attr/colorSurface"
app:cardElevation="2dp"
app:layout_behavior="@string/fab_transformation_sheet_behavior">
<LinearLayout
android:layout_width="240dp"
android:layout_height="wrap_content"
android:orientation="vertical">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/addTabRecyclerView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:overScrollMode="never" />
<TextView
android:id="@+id/maxTabsInfo"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="maximum of 5 tabs reached"
android:padding="8dp"
android:lineSpacingMultiplier="1.1" />
<TextView
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_gravity="bottom"
android:background="?attr/colorPrimary"
android:drawableStart="@drawable/ic_plus_24dp"
android:drawablePadding="12dp"
android:ellipsize="end"
android:gravity="center_vertical"
android:lines="1"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:text="@string/action_add_tab"
android:textColor="?attr/colorOnPrimary"
android:textSize="?attr/status_text_large" />
</LinearLayout>
</com.google.android.material.transformation.TransformationChildCard>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -6,19 +6,19 @@
android:layout_height="match_parent">
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipe_refresh_layout"
android:id="@+id/swipeRefreshLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<ProgressBar
android:id="@+id/progress_bar"
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"

View File

@ -1,12 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/swipe_refresh_layout"
android:id="@+id/swipeRefreshLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="top">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scrollbars="vertical" />

View File

@ -0,0 +1,391 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:sparkbutton="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/status_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipChildren="false"
android:clipToPadding="false"
android:paddingStart="12dp"
android:paddingEnd="14dp">
<androidx.emoji.widget.EmojiTextView
android:id="@+id/conversation_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="2dp"
android:layout_marginTop="@dimen/status_reblogged_bar_padding_top"
android:gravity="center_vertical"
android:lineSpacingMultiplier="1.1"
android:textColor="?android:textColorPrimary"
android:textSize="?attr/status_text_medium"
android:textStyle="normal|bold"
app:layout_constraintLeft_toRightOf="parent"
app:layout_constraintRight_toLeftOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:ignore="RtlSymmetry"
tools:text="ConnyDuck boosted"
tools:visibility="visible" />
<com.keylesspalace.tusky.view.RoundedImageView
android:id="@+id/status_avatar_2"
android:layout_width="52dp"
android:layout_height="52dp"
android:layout_marginTop="22dp"
android:background="@drawable/avatar_border"
android:contentDescription="@string/action_view_profile"
android:padding="2dp"
android:scaleType="centerCrop"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/status_avatar_1"
tools:src="@drawable/avatar_default" />
<com.keylesspalace.tusky.view.RoundedImageView
android:id="@+id/status_avatar_1"
android:layout_width="52dp"
android:layout_height="52dp"
android:layout_marginTop="22dp"
android:background="@drawable/avatar_border"
android:contentDescription="@string/action_view_profile"
android:padding="2dp"
android:scaleType="centerCrop"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/status_avatar"
tools:src="@drawable/avatar_default" />
<com.keylesspalace.tusky.view.RoundedImageView
android:id="@+id/status_avatar"
android:layout_width="52dp"
android:layout_height="52dp"
android:layout_marginTop="14dp"
android:background="@drawable/avatar_border"
android:contentDescription="@string/action_view_profile"
android:padding="2dp"
android:scaleType="centerCrop"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/conversation_name"
tools:src="@drawable/avatar_default" />
<com.keylesspalace.tusky.view.RoundedImageView
android:id="@+id/status_avatar_reblog"
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="@null"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/status_avatar"
app:layout_constraintEnd_toEndOf="@id/status_avatar"
tools:src="@color/accent"
tools:visibility="visible" />
<androidx.emoji.widget.EmojiTextView
android:id="@+id/status_display_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:layout_marginTop="10dp"
android:ellipsize="end"
android:maxLines="1"
android:paddingEnd="@dimen/status_display_name_padding_end"
android:textColor="?android:textColorPrimary"
android:textSize="?attr/status_text_medium"
android:textStyle="normal|bold"
app:layout_constrainedWidth="true"
app:layout_constraintEnd_toStartOf="@id/status_timestamp_info"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toEndOf="@id/status_avatar"
app:layout_constraintTop_toBottomOf="@id/conversation_name"
tools:text="Ente r the void you foooooo" />
<TextView
android:id="@+id/status_username"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textColor="?android:textColorSecondary"
android:textSize="?attr/status_text_medium"
app:layout_constraintEnd_toStartOf="@id/status_timestamp_info"
app:layout_constraintStart_toEndOf="@id/status_display_name"
app:layout_constraintTop_toTopOf="@id/status_display_name"
tools:text="\@Entenhausen@birbsarecooooooooooool.site" />
<TextView
android:id="@+id/status_timestamp_info"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:textColor="?android:textColorSecondary"
android:textSize="?attr/status_text_medium"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/status_display_name"
tools:text="13:37" />
<androidx.emoji.widget.EmojiTextView
android:id="@+id/status_content_warning_description"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:lineSpacingMultiplier="1.1"
android:textColor="?android:textColorPrimary"
android:textSize="?attr/status_text_medium"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@id/status_display_name"
app:layout_constraintTop_toBottomOf="@id/status_display_name"
tools:text="content warning which is very long and it doesn't fit"
tools:visibility="visible" />
<ToggleButton
android:id="@+id/status_content_warning_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:layout_marginBottom="4dp"
android:background="?attr/content_warning_button"
android:minWidth="150dp"
android:minHeight="0dp"
android:paddingLeft="16dp"
android:paddingTop="4dp"
android:paddingRight="16dp"
android:paddingBottom="4dp"
android:textAllCaps="true"
android:textOff="@string/status_content_warning_show_more"
android:textOn="@string/status_content_warning_show_less"
android:textSize="?attr/status_text_medium"
android:visibility="gone"
app:layout_constraintStart_toStartOf="@id/status_display_name"
app:layout_constraintTop_toBottomOf="@id/status_content_warning_description"
tools:visibility="visible" />
<androidx.emoji.widget.EmojiTextView
android:id="@+id/status_content"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:focusable="true"
android:lineSpacingMultiplier="1.1"
android:textColor="?android:textColorPrimary"
android:textSize="?attr/status_text_medium"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@id/status_content_warning_button"
app:layout_constraintTop_toBottomOf="@id/status_content_warning_button"
tools:text="This is a status" />
<ToggleButton
android:id="@+id/button_toggle_content"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:layout_marginBottom="4dp"
android:background="?attr/content_warning_button"
android:minWidth="150dp"
android:minHeight="0dp"
android:paddingLeft="16dp"
android:paddingTop="4dp"
android:paddingRight="16dp"
android:paddingBottom="4dp"
android:textAllCaps="true"
android:textOff="@string/status_content_show_less"
android:textOn="@string/status_content_show_more"
android:textSize="?attr/status_text_medium"
android:visibility="gone"
app:layout_constraintStart_toStartOf="@id/status_display_name"
app:layout_constraintTop_toBottomOf="@id/status_content"
tools:visibility="visible" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/status_media_preview_container"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/status_media_preview_margin_top"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@id/status_display_name"
app:layout_constraintTop_toBottomOf="@id/button_toggle_content"
tools:visibility="gone">
<com.keylesspalace.tusky.view.MediaPreviewImageView
android:id="@+id/status_media_preview_0"
android:layout_width="0dp"
android:layout_height="@dimen/status_media_preview_height"
android:scaleType="centerCrop"
app:layout_constraintEnd_toStartOf="@+id/status_media_preview_1"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:ignore="ContentDescription" />
<com.keylesspalace.tusky.view.MediaPreviewImageView
android:id="@+id/status_media_preview_1"
android:layout_width="0dp"
android:layout_height="@dimen/status_media_preview_height"
android:layout_marginStart="4dp"
android:scaleType="centerCrop"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/status_media_preview_0"
app:layout_constraintTop_toTopOf="parent"
tools:ignore="ContentDescription" />
<com.keylesspalace.tusky.view.MediaPreviewImageView
android:id="@+id/status_media_preview_2"
android:layout_width="0dp"
android:layout_height="@dimen/status_media_preview_height"
android:layout_marginTop="4dp"
android:scaleType="centerCrop"
app:layout_constraintEnd_toStartOf="@+id/status_media_preview_3"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/status_media_preview_0"
tools:ignore="ContentDescription" />
<com.keylesspalace.tusky.view.MediaPreviewImageView
android:id="@+id/status_media_preview_3"
android:layout_width="0dp"
android:layout_height="@dimen/status_media_preview_height"
android:layout_marginStart="4dp"
android:layout_marginTop="4dp"
android:scaleType="centerCrop"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/status_media_preview_2"
app:layout_constraintTop_toBottomOf="@+id/status_media_preview_1"
tools:ignore="ContentDescription" />
<ImageView
android:id="@+id/status_media_overlay_0"
android:layout_width="0dp"
android:layout_height="0dp"
android:scaleType="center"
app:layout_constraintBottom_toBottomOf="@+id/status_media_preview_0"
app:layout_constraintEnd_toEndOf="@+id/status_media_preview_0"
app:layout_constraintStart_toStartOf="@+id/status_media_preview_0"
app:layout_constraintTop_toTopOf="@+id/status_media_preview_0"
app:srcCompat="?attr/play_indicator_drawable"
tools:ignore="ContentDescription" />
<ImageView
android:id="@+id/status_media_overlay_1"
android:layout_width="0dp"
android:layout_height="0dp"
android:scaleType="center"
app:layout_constraintBottom_toBottomOf="@+id/status_media_preview_1"
app:layout_constraintEnd_toEndOf="@+id/status_media_preview_1"
app:layout_constraintStart_toStartOf="@+id/status_media_preview_1"
app:layout_constraintTop_toTopOf="@+id/status_media_preview_1"
app:srcCompat="?attr/play_indicator_drawable"
tools:ignore="ContentDescription" />
<ImageView
android:id="@+id/status_media_overlay_2"
android:layout_width="0dp"
android:layout_height="0dp"
android:scaleType="center"
app:layout_constraintBottom_toBottomOf="@+id/status_media_preview_2"
app:layout_constraintEnd_toEndOf="@+id/status_media_preview_2"
app:layout_constraintStart_toStartOf="@+id/status_media_preview_2"
app:layout_constraintTop_toTopOf="@+id/status_media_preview_2"
app:srcCompat="?attr/play_indicator_drawable"
tools:ignore="ContentDescription" />
<ImageView
android:id="@+id/status_media_overlay_3"
android:layout_width="0dp"
android:layout_height="0dp"
android:scaleType="center"
app:layout_constraintBottom_toBottomOf="@+id/status_media_preview_3"
app:layout_constraintEnd_toEndOf="@+id/status_media_preview_3"
app:layout_constraintStart_toStartOf="@+id/status_media_preview_3"
app:layout_constraintTop_toTopOf="@+id/status_media_preview_3"
app:srcCompat="?attr/play_indicator_drawable"
tools:ignore="ContentDescription" />
<ImageView
android:id="@+id/status_sensitive_media_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:alpha="0.7"
android:contentDescription="@null"
android:padding="@dimen/status_sensitive_media_button_padding"
android:visibility="gone"
app:layout_constraintLeft_toLeftOf="@+id/status_media_preview_container"
app:layout_constraintTop_toTopOf="@+id/status_media_preview_container"
app:srcCompat="@drawable/ic_eye_24dp" />
<androidx.emoji.widget.EmojiTextView
android:id="@+id/status_sensitive_media_warning"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="?attr/sensitive_media_warning_background_color"
android:gravity="center"
android:lineSpacingMultiplier="1.2"
android:orientation="vertical"
android:padding="8dp"
android:textAlignment="center"
android:textColor="@android:color/white"
android:textSize="?attr/status_text_medium"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<!--TODO: Check if this needs emoji support-->
<androidx.emoji.widget.EmojiTextView
android:id="@+id/status_media_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:drawablePadding="4dp"
android:gravity="center_vertical"
android:textSize="?attr/status_text_medium"
android:visibility="gone"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<ImageButton
android:id="@+id/status_reply"
style="?attr/image_button_style"
android:layout_width="30dp"
android:layout_height="30dp"
android:layout_marginTop="4dp"
android:layout_marginBottom="4dp"
android:contentDescription="@string/action_reply"
android:padding="4dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/status_favourite"
app:layout_constraintHorizontal_chainStyle="spread_inside"
app:layout_constraintStart_toStartOf="@id/status_display_name"
app:layout_constraintTop_toBottomOf="@id/status_media_preview_container"
app:srcCompat="@drawable/ic_reply_24dp" />
<at.connyduck.sparkbutton.SparkButton
android:id="@+id/status_favourite"
android:layout_width="30dp"
android:layout_height="30dp"
android:clipToPadding="false"
android:contentDescription="@string/action_favourite"
android:padding="4dp"
app:layout_constraintEnd_toStartOf="@id/status_more"
app:layout_constraintStart_toEndOf="@id/status_reply"
app:layout_constraintTop_toTopOf="@id/status_reply"
sparkbutton:activeImage="?attr/status_favourite_active_drawable"
sparkbutton:iconSize="28dp"
sparkbutton:inactiveImage="?attr/status_favourite_inactive_drawable"
sparkbutton:primaryColor="@color/tusky_orange"
sparkbutton:secondaryColor="@color/tusky_orange_light" />
<ImageButton
android:id="@+id/status_more"
style="?attr/image_button_style"
android:layout_width="24dp"
android:layout_height="30dp"
android:layout_marginEnd="8dp"
android:contentDescription="@string/action_more"
android:padding="4dp"
app:layout_constraintBottom_toBottomOf="@id/status_reply"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/status_favourite"
app:layout_constraintTop_toTopOf="@id/status_reply"
app:srcCompat="@drawable/ic_more_horiz_24dp" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center"
android:padding="8dp">
<TextView
android:id="@+id/errorMsg"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
<ProgressBar
android:id="@+id/progressBar"
style="?android:attr/progressBarStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
<Button
android:id="@+id/retryButton"
style="@style/TuskyButton.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/action_retry"/>
</LinearLayout>

View File

@ -13,7 +13,7 @@
android:textSize="?attr/status_text_medium" />
<ProgressBar
android:id="@+id/progress_bar"
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"

View File

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?android:colorBackground"
android:orientation="horizontal"
android:padding="16dp">
<ImageView
android:id="@+id/imageView"
android:layout_gravity="end"
android:src="@drawable/ic_drag_indicator_24dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<TextView
android:id="@+id/textView"
android:textSize="?attr/status_text_large"
android:textColor="?android:attr/textColorSecondary"
android:drawableStart="@drawable/ic_home_24dp"
android:layout_width="0dp"
android:layout_marginStart="8dp"
android:drawablePadding="12dp"
android:layout_weight="1"
android:layout_height="wrap_content"/>
</LinearLayout>

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/textView"
android:layout_width="match_parent"
android:drawableStart="@drawable/ic_home_24dp"
android:layout_height="48dp"
android:gravity="center_vertical"
android:drawablePadding="12dp"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:lines="1"
android:ellipsize="end"
android:background="?attr/selectableItemBackground"
android:textColor="?android:attr/textColorSecondary"
android:textSize="?attr/status_text_large" />

View File

@ -30,4 +30,6 @@
<item name="wrap_content" format="integer" type="dimen">-2</item>
<dimen name="preference_icon_size">20dp</dimen>
<dimen name="selected_drag_item_elevation">12dp</dimen>
</resources>

View File

@ -27,6 +27,8 @@
<string name="title_notifications">Notifications</string>
<string name="title_public_local">Local</string>
<string name="title_public_federated">Federated</string>
<string name="title_direct_messages">Direct Messages</string>
<string name="title_tab_preferences">Tabs</string>
<string name="title_view_thread">Toot</string>
<string name="title_tag">#%s</string>
<string name="title_statuses">Posts</string>
@ -111,6 +113,7 @@
<string name="action_toggle_visibility">Toot visibility</string>
<string name="action_content_warning">Content warning</string>
<string name="action_emoji_keyboard">Emoji keyboard</string>
<string name="action_add_tab">Add Tab</string>
<string name="download_image">Downloading %1$s</string>
@ -378,4 +381,9 @@
<string name="title_reblogged_by">Boosted by</string>
<string name="title_favourited_by">Favourited by</string>
<string name="conversation_1_recipients">%1$s</string>
<string name="conversation_2_recipients">%1$s and %2$s</string>
<string name="conversation_more_recipients">%1$s, %2$s and %3$d more</string>
</resources>

View File

@ -6,6 +6,10 @@
android:key="notificationPreference"
android:title="@string/pref_title_edit_notification_settings" />
<Preference
android:key="tabPreference"
android:title="@string/title_tab_preferences" />
<Preference
android:key="mutedUsersPreference"
android:title="@string/action_view_mutes" />

View File

@ -196,7 +196,7 @@ class BottomSheetActivityTest {
fun search_inIdealConditions_returnsRequestedResults_forStatus() {
activity.viewUrl(statusQuery)
statusCallback.invokeCallback()
Assert.assertEquals(status, activity.status)
Assert.assertEquals(status.id, activity.statusId)
}
@Test
@ -250,7 +250,7 @@ class BottomSheetActivityTest {
// ensure that the result of the status search was recorded
// and the account search wasn't
Assert.assertEquals(status, activity.status)
Assert.assertEquals(status.id, activity.statusId)
Assert.assertEquals(null, activity.accountId)
}
@ -288,7 +288,7 @@ class BottomSheetActivityTest {
class FakeBottomSheetActivity(api: MastodonApi) : BottomSheetActivity() {
var status: Status? = null
var statusId: String? = null
var accountId: String? = null
var link: String? = null
@ -307,8 +307,8 @@ class BottomSheetActivityTest {
this.accountId = id
}
override fun viewThread(status: Status) {
this.status = status
override fun viewThread(statusId: String, url: String?) {
this.statusId = statusId
}
}