From addce87eb6bf34865895f4748bb330d467461e89 Mon Sep 17 00:00:00 2001 From: Levi Bard Date: Fri, 25 Feb 2022 18:56:21 +0100 Subject: [PATCH] Use tags from status when adding handlers to hashtag spans in status content (#2344) * Migrate LinkHelper to kotlin * Support tags field on statuses * Use embedded tags list in status instead of text scraping to embed tag click handler. Fixes #2283 * Make mentions and tags lists nonnullable * Make LinkHelper.openLink a Context extension method * Use builtin extension for uri conversion * More cleanup in LinkHelper * Add tests for LinkHelper.getDomain * Unbreak tags in places that don't have a tag list (e.g. profiles) * Fixup javadoc --- .../29.json | 789 ++++++++++++++++++ .../tusky/BottomSheetActivity.kt | 7 +- .../tusky/ViewThreadActivity.java | 2 +- .../tusky/adapter/NotificationsAdapter.java | 2 +- .../tusky/adapter/StatusBaseViewHolder.java | 13 +- .../components/account/AccountActivity.kt | 14 +- .../components/account/AccountFieldAdapter.kt | 7 +- .../account/media/AccountMediaFragment.kt | 4 +- .../announcements/AnnouncementAdapter.kt | 4 +- .../conversation/ConversationEntity.kt | 6 + .../conversation/ConversationViewHolder.java | 2 +- .../report/adapter/StatusViewHolder.kt | 17 +- .../fragments/SearchStatusesFragment.kt | 4 +- .../timeline/TimelineTypeMappers.kt | 8 + .../viewmodel/NetworkTimelineViewModel.kt | 4 +- .../keylesspalace/tusky/db/AppDatabase.java | 10 +- .../com/keylesspalace/tusky/db/Converters.kt | 11 + .../com/keylesspalace/tusky/db/TimelineDao.kt | 2 +- .../tusky/db/TimelineStatusEntity.kt | 1 + .../com/keylesspalace/tusky/di/AppModule.kt | 2 +- .../com/keylesspalace/tusky/entity/HashTag.kt | 2 +- .../com/keylesspalace/tusky/entity/Status.kt | 1 + .../tusky/fragment/SFragment.java | 2 +- .../tusky/fragment/ViewThreadFragment.java | 2 +- .../keylesspalace/tusky/util/LinkHelper.java | 251 ------ .../keylesspalace/tusky/util/LinkHelper.kt | 239 ++++++ .../util/ListStatusAccessibilityDelegate.kt | 2 +- .../tusky/util/NoUnderlineURLSpan.kt | 2 +- .../keylesspalace/tusky/view/LicenseCard.kt | 4 +- .../tusky/BottomSheetActivityTest.kt | 1 + .../com/keylesspalace/tusky/FilterTest.kt | 1 + .../tusky/components/timeline/StatusMocker.kt | 1 + .../keylesspalace/tusky/db/TimelineDaoTest.kt | 1 + .../tusky/util/LinkHelperTest.kt | 172 ++++ 34 files changed, 1294 insertions(+), 296 deletions(-) create mode 100644 app/schemas/com.keylesspalace.tusky.db.AppDatabase/29.json delete mode 100644 app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.java create mode 100644 app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt create mode 100644 app/src/test/java/com/keylesspalace/tusky/util/LinkHelperTest.kt diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/29.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/29.json new file mode 100644 index 000000000..51fcfde7a --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/29.json @@ -0,0 +1,789 @@ +{ + "formatVersion": 1, + "database": { + "version": 29, + "identityHash": "d3643e2bf6d8a2efb13254a0ea3ab2a1", + "entities": [ + { + "tableName": "DraftEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountId` INTEGER NOT NULL, `inReplyToId` TEXT, `content` TEXT, `contentWarning` TEXT, `sensitive` INTEGER NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `poll` TEXT, `failedToSend` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentWarning", + "columnName": "contentWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "failedToSend", + "columnName": "failedToSend", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "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, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsSubscriptions` 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, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` 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": "notificationsFollowRequested", + "columnName": "notificationsFollowRequested", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReblogged", + "columnName": "notificationsReblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFavorited", + "columnName": "notificationsFavorited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsPolls", + "columnName": "notificationsPolls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSubscriptions", + "columnName": "notificationsSubscriptions", + "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": "alwaysOpenSpoiler", + "columnName": "alwaysOpenSpoiler", + "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 + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `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, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `version` TEXT, 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 + }, + { + "fieldPath": "maxPollOptions", + "columnName": "maxPollOptions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptionLength", + "columnName": "maxPollOptionLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "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, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, 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": "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": "bookmarked", + "columnName": "bookmarked", + "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": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "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 + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "muted", + "columnName": "muted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expanded", + "columnName": "expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentCollapsed", + "columnName": "contentCollapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentShowing", + "columnName": "contentShowing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pinned", + "columnName": "pinned", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "serverId", + "timelineUserId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `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, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "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 + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER", + "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_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_tags` TEXT NOT NULL, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, 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.bookmarked", + "columnName": "s_bookmarked", + "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.tags", + "columnName": "s_tags", + "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 + }, + { + "fieldPath": "lastStatus.muted", + "columnName": "s_muted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.poll", + "columnName": "s_poll", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id", + "accountId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "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, 'd3643e2bf6d8a2efb13254a0ea3ab2a1')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt b/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt index 599322a0f..046ab04ad 100644 --- a/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt @@ -15,6 +15,7 @@ package com.keylesspalace.tusky +import android.content.Context import android.content.Intent import android.os.Bundle import android.view.View @@ -27,7 +28,7 @@ import autodispose2.autoDispose import com.google.android.material.bottomsheet.BottomSheetBehavior import com.keylesspalace.tusky.components.account.AccountActivity import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.util.LinkHelper +import com.keylesspalace.tusky.util.openLink import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import java.net.URI import java.net.URISyntaxException @@ -157,9 +158,9 @@ abstract class BottomSheetActivity : BaseActivity() { } } - @VisibleForTesting + @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) open fun openLink(url: String) { - LinkHelper.openLink(url, this) + (this as Context).openLink(url) } private fun showQuerySheet() { diff --git a/app/src/main/java/com/keylesspalace/tusky/ViewThreadActivity.java b/app/src/main/java/com/keylesspalace/tusky/ViewThreadActivity.java index 88fb88cc7..e45b783a8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ViewThreadActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/ViewThreadActivity.java @@ -111,7 +111,7 @@ public class ViewThreadActivity extends BottomSheetActivity implements HasAndroi public boolean onOptionsItemSelected(MenuItem item) { switch (item.getItemId()) { case R.id.action_open_in_web: { - LinkHelper.openLink(getIntent().getStringExtra(URL_EXTRA), this); + openLink(getIntent().getStringExtra(URL_EXTRA)); return true; } case R.id.action_reveal: { diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java index 5b6ca64fb..d65f58821 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java @@ -644,7 +644,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter { CharSequence emojifiedText = CustomEmojiHelper.emojify( content, emojis, statusContent, statusDisplayOptions.animateEmojis() ); - LinkHelper.setClickableText(statusContent, emojifiedText, statusViewData.getActionable().getMentions(), listener); + LinkHelper.setClickableText(statusContent, emojifiedText, statusViewData.getActionable().getMentions(), statusViewData.getActionable().getTags(), listener); CharSequence emojifiedContentWarning; if (statusViewData.getSpoilerText() != null) { diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java index f45667eb0..424ef4151 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -37,6 +37,7 @@ import com.keylesspalace.tusky.entity.Attachment.Focus; import com.keylesspalace.tusky.entity.Attachment.MetaData; import com.keylesspalace.tusky.entity.Card; import com.keylesspalace.tusky.entity.Emoji; +import com.keylesspalace.tusky.entity.HashTag; import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.util.CardViewMode; @@ -202,6 +203,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { @NonNull Spanned content, @Nullable String spoilerText, @Nullable List mentions, + @Nullable List tags, @NonNull List emojis, @Nullable PollViewData poll, @NonNull StatusDisplayOptions statusDisplayOptions, @@ -222,13 +224,13 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } setContentWarningButtonText(!expanded); - this.setTextVisible(sensitive, !expanded, content, mentions, emojis, poll, statusDisplayOptions, listener); + this.setTextVisible(sensitive, !expanded, content, mentions, tags, emojis, poll, statusDisplayOptions, listener); }); - this.setTextVisible(sensitive, expanded, content, mentions, emojis, poll, statusDisplayOptions, listener); + this.setTextVisible(sensitive, expanded, content, mentions, tags, emojis, poll, statusDisplayOptions, listener); } else { contentWarningDescription.setVisibility(View.GONE); contentWarningButton.setVisibility(View.GONE); - this.setTextVisible(sensitive, true, content, mentions, emojis, poll, statusDisplayOptions, listener); + this.setTextVisible(sensitive, true, content, mentions, tags, emojis, poll, statusDisplayOptions, listener); } } @@ -244,13 +246,14 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { boolean expanded, Spanned content, List mentions, + List tags, List emojis, @Nullable PollViewData poll, StatusDisplayOptions statusDisplayOptions, final StatusActionListener listener) { if (expanded) { CharSequence emojifiedText = CustomEmojiHelper.emojify(content, emojis, this.content, statusDisplayOptions.animateEmojis()); - LinkHelper.setClickableText(this.content, emojifiedText, mentions, listener); + LinkHelper.setClickableText(this.content, emojifiedText, mentions, tags, listener); for (int i = 0; i < mediaLabels.length; ++i) { updateMediaLabel(i, sensitive, expanded); } @@ -779,7 +782,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { setRebloggingEnabled(actionable.rebloggingAllowed(), actionable.getVisibility()); setSpoilerAndContent(status.isExpanded(), status.getContent(), status.getSpoilerText(), - actionable.getMentions(), actionable.getEmojis(), + actionable.getMentions(), actionable.getTags(), actionable.getEmojis(), PollViewDataKt.toViewData(actionable.getPoll()), statusDisplayOptions, listener); diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt index 064b43e66..8ed642b84 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt @@ -71,13 +71,15 @@ import com.keylesspalace.tusky.interfaces.ReselectableFragment import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.DefaultTextWatcher import com.keylesspalace.tusky.util.Error -import com.keylesspalace.tusky.util.LinkHelper import com.keylesspalace.tusky.util.Loading import com.keylesspalace.tusky.util.Success import com.keylesspalace.tusky.util.ThemeUtils import com.keylesspalace.tusky.util.emojify +import com.keylesspalace.tusky.util.getDomain import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.loadAvatar +import com.keylesspalace.tusky.util.openLink +import com.keylesspalace.tusky.util.setClickableText import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible @@ -409,7 +411,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI binding.accountDisplayNameTextView.text = account.name.emojify(account.emojis, binding.accountDisplayNameTextView, animateEmojis) val emojifiedNote = account.note.emojify(account.emojis, binding.accountNoteTextView, animateEmojis) - LinkHelper.setClickableText(binding.accountNoteTextView, emojifiedNote, null, this) + setClickableText(binding.accountNoteTextView, emojifiedNote, emptyList(), null, this) // accountFieldAdapter.fields = account.fields ?: emptyList() accountFieldAdapter.emojis = account.emojis ?: emptyList() @@ -517,7 +519,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI if (account.isRemote()) { binding.accountRemoveView.show() binding.accountRemoveView.setOnClickListener { - LinkHelper.openLink(account.url, this) + openLink(account.url) } } } @@ -714,7 +716,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI if (loadedAccount != null) { val muteDomain = menu.findItem(R.id.action_mute_domain) - domain = LinkHelper.getDomain(loadedAccount?.url) + domain = getDomain(loadedAccount?.url) if (domain.isEmpty()) { // If we can't get the domain, there's no way we can mute it anyway... menu.removeItem(R.id.action_mute_domain) @@ -834,8 +836,8 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI when (item.itemId) { R.id.action_open_in_web -> { // If the account isn't loaded yet, eat the input. - if (loadedAccount != null) { - LinkHelper.openLink(loadedAccount?.url, this) + if (loadedAccount?.url != null) { + openLink(loadedAccount!!.url) } return true } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountFieldAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountFieldAdapter.kt index 4f4e204ce..093dbcfb5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountFieldAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountFieldAdapter.kt @@ -27,8 +27,9 @@ import com.keylesspalace.tusky.entity.IdentityProof import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.util.BindingHolder import com.keylesspalace.tusky.util.Either -import com.keylesspalace.tusky.util.LinkHelper +import com.keylesspalace.tusky.util.createClickableText import com.keylesspalace.tusky.util.emojify +import com.keylesspalace.tusky.util.setClickableText class AccountFieldAdapter( private val linkListener: LinkListener, @@ -54,7 +55,7 @@ class AccountFieldAdapter( val identityProof = proofOrField.asLeft() nameTextView.text = identityProof.provider - valueTextView.text = LinkHelper.createClickableText(identityProof.username, identityProof.profileUrl) + valueTextView.text = createClickableText(identityProof.username, identityProof.profileUrl) valueTextView.movementMethod = LinkMovementMethod.getInstance() @@ -65,7 +66,7 @@ class AccountFieldAdapter( nameTextView.text = emojifiedName val emojifiedValue = field.value.emojify(emojis, valueTextView, animateEmojis) - LinkHelper.setClickableText(valueTextView, emojifiedValue, null, linkListener) + setClickableText(valueTextView, emojifiedValue, emptyList(), null, linkListener) if (field.verifiedAt != null) { valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_check_circle, 0) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaFragment.kt index 1e1ec8862..57876d852 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaFragment.kt @@ -37,9 +37,9 @@ import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.interfaces.RefreshableFragment import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.util.LinkHelper import com.keylesspalace.tusky.util.ThemeUtils import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.openLink import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.view.SquareImageView @@ -252,7 +252,7 @@ class AccountMediaFragment : Fragment(R.layout.fragment_timeline), RefreshableFr } } Attachment.Type.UNKNOWN -> { - LinkHelper.openLink(items[currentIndex].attachment.url, context) + context?.openLink(items[currentIndex].attachment.url) } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementAdapter.kt index 27284faf8..4b5e7aa51 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementAdapter.kt @@ -31,8 +31,8 @@ import com.keylesspalace.tusky.entity.Announcement import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.util.BindingHolder import com.keylesspalace.tusky.util.EmojiSpan -import com.keylesspalace.tusky.util.LinkHelper import com.keylesspalace.tusky.util.emojify +import com.keylesspalace.tusky.util.setClickableText import java.lang.ref.WeakReference interface AnnouncementActionListener : LinkListener { @@ -62,7 +62,7 @@ class AnnouncementAdapter( val emojifiedText: CharSequence = item.content.emojify(item.emojis, text, animateEmojis) - LinkHelper.setClickableText(text, emojifiedText, item.mentions, listener) + setClickableText(text, emojifiedText, item.mentions, item.tags, listener) // If wellbeing mode is enabled, announcement badge counts should not be shown. if (wellbeingEnabled) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt index 0a4698227..f96e98d27 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt @@ -25,6 +25,7 @@ import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Conversation import com.keylesspalace.tusky.entity.Emoji +import com.keylesspalace.tusky.entity.HashTag import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.util.shouldTrimStatus @@ -79,6 +80,7 @@ data class ConversationStatusEntity( val spoilerText: String, val attachments: ArrayList, val mentions: List, + val tags: List, val showingHiddenContent: Boolean, val expanded: Boolean, val collapsible: Boolean, @@ -107,6 +109,7 @@ data class ConversationStatusEntity( if (spoilerText != other.spoilerText) return false if (attachments != other.attachments) return false if (mentions != other.mentions) return false + if (tags != other.tags) return false if (showingHiddenContent != other.showingHiddenContent) return false if (expanded != other.expanded) return false if (collapsible != other.collapsible) return false @@ -132,6 +135,7 @@ data class ConversationStatusEntity( result = 31 * result + spoilerText.hashCode() result = 31 * result + attachments.hashCode() result = 31 * result + mentions.hashCode() + result = 31 * result + tags.hashCode() result = 31 * result + showingHiddenContent.hashCode() result = 31 * result + expanded.hashCode() result = 31 * result + collapsible.hashCode() @@ -162,6 +166,7 @@ data class ConversationStatusEntity( visibility = Status.Visibility.DIRECT, attachments = attachments, mentions = mentions, + tags = tags, application = null, pinned = false, muted = muted, @@ -197,6 +202,7 @@ fun Status.toEntity() = spoilerText = spoilerText, attachments = attachments, mentions = mentions, + tags = tags, showingHiddenContent = false, expanded = false, collapsible = shouldTrimStatus(content), diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java index 2d2f683d0..d006dd1a3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java @@ -108,7 +108,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder { statusDisplayOptions); setSpoilerAndContent(status.getExpanded(), status.getContent(), status.getSpoilerText(), - status.getMentions(), status.getEmojis(), + status.getMentions(), status.getTags(), status.getEmojis(), PollViewDataKt.toViewData(status.getPoll()), statusDisplayOptions, listener); setConversationName(conversation.getAccounts()); diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt index 41486506c..0d0e42642 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt @@ -23,9 +23,9 @@ import com.keylesspalace.tusky.R import com.keylesspalace.tusky.components.report.model.StatusViewState import com.keylesspalace.tusky.databinding.ItemReportStatusBinding import com.keylesspalace.tusky.entity.Emoji +import com.keylesspalace.tusky.entity.HashTag import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.interfaces.LinkListener -import com.keylesspalace.tusky.util.LinkHelper import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.StatusViewHelper import com.keylesspalace.tusky.util.StatusViewHelper.Companion.COLLAPSE_INPUT_FILTER @@ -33,6 +33,8 @@ import com.keylesspalace.tusky.util.StatusViewHelper.Companion.NO_INPUT_FILTER import com.keylesspalace.tusky.util.TimestampUtils import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.setClickableMentions +import com.keylesspalace.tusky.util.setClickableText import com.keylesspalace.tusky.util.shouldTrimStatus import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.viewdata.toViewData @@ -96,7 +98,7 @@ class StatusViewHolder( ) if (status.spoilerText.isBlank()) { - setTextVisible(true, status.content, status.mentions, status.emojis, adapterHandler) + setTextVisible(true, status.content, status.mentions, status.tags, status.emojis, adapterHandler) binding.statusContentWarningButton.hide() binding.statusContentWarningDescription.hide() } else { @@ -110,11 +112,11 @@ class StatusViewHolder( val contentShown = viewState.isContentShow(status.id, true) binding.statusContentWarningDescription.invalidate() viewState.setContentShow(status.id, !contentShown) - setTextVisible(!contentShown, status.content, status.mentions, status.emojis, adapterHandler) + setTextVisible(!contentShown, status.content, status.mentions, status.tags, status.emojis, adapterHandler) setContentWarningButtonText(!contentShown) } } - setTextVisible(viewState.isContentShow(status.id, true), status.content, status.mentions, status.emojis, adapterHandler) + setTextVisible(viewState.isContentShow(status.id, true), status.content, status.mentions, status.tags, status.emojis, adapterHandler) } } } @@ -130,15 +132,16 @@ class StatusViewHolder( private fun setTextVisible( expanded: Boolean, content: Spanned, - mentions: List?, + mentions: List, + tags: List?, emojis: List, listener: LinkListener ) { if (expanded) { val emojifiedText = content.emojify(emojis, binding.statusContent, statusDisplayOptions.animateEmojis) - LinkHelper.setClickableText(binding.statusContent, emojifiedText, mentions, listener) + setClickableText(binding.statusContent, emojifiedText, mentions, tags, listener) } else { - LinkHelper.setClickableMentions(binding.statusContent, mentions, listener) + setClickableMentions(binding.statusContent, mentions, listener) } if (binding.statusContent.text.isNullOrBlank()) { binding.statusContent.hide() diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt index 094b02e73..4955d4747 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt @@ -54,8 +54,8 @@ import com.keylesspalace.tusky.interfaces.AccountSelectionListener import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.CardViewMode -import com.keylesspalace.tusky.util.LinkHelper import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.util.openLink import com.keylesspalace.tusky.view.showMuteAccountDialog import com.keylesspalace.tusky.viewdata.AttachmentViewData import com.keylesspalace.tusky.viewdata.StatusViewData @@ -142,7 +142,7 @@ class SearchStatusesFragment : SearchFragment { - LinkHelper.openLink(actionable.attachments[attachmentIndex].url, context) + context?.openLink(actionable.attachments[attachmentIndex].url) } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt index 1f3810f9b..b57fa2d93 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt @@ -26,6 +26,7 @@ import com.keylesspalace.tusky.db.TimelineStatusWithAccount import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Emoji +import com.keylesspalace.tusky.entity.HashTag import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.util.shouldTrimStatus @@ -41,6 +42,7 @@ data class Placeholder( private val attachmentArrayListType = object : TypeToken>() {}.type private val emojisListType = object : TypeToken>() {}.type private val mentionListType = object : TypeToken>() {}.type +private val tagListType = object : TypeToken>() {}.type fun Account.toEntity(accountId: Long, gson: Gson): TimelineAccountEntity { return TimelineAccountEntity( @@ -99,6 +101,7 @@ fun Placeholder.toEntity(timelineUserId: Long): TimelineStatusEntity { visibility = Status.Visibility.UNKNOWN, attachments = null, mentions = null, + tags = null, application = null, reblogServerId = null, reblogAccountId = null, @@ -138,6 +141,7 @@ fun Status.toEntity( visibility = actionableStatus.visibility, attachments = actionableStatus.attachments.let(gson::toJson), mentions = actionableStatus.mentions.let(gson::toJson), + tags = actionableStatus.tags.let(gson::toJson), application = actionableStatus.application.let(gson::toJson), reblogServerId = reblog?.id, reblogAccountId = reblog?.let { this.account.id }, @@ -157,6 +161,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData { val attachments: ArrayList = gson.fromJson(status.attachments, attachmentArrayListType) ?: arrayListOf() val mentions: List = gson.fromJson(status.mentions, mentionListType) ?: emptyList() + val tags: List = gson.fromJson(status.tags, tagListType) ?: emptyList() val application = gson.fromJson(status.application, Status.Application::class.java) val emojis: List = gson.fromJson(status.emojis, emojisListType) ?: emptyList() val poll: Poll? = gson.fromJson(status.poll, Poll::class.java) @@ -183,6 +188,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData { visibility = status.visibility, attachments = attachments, mentions = mentions, + tags = tags, application = application, pinned = false, muted = status.muted, @@ -211,6 +217,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData { visibility = status.visibility, attachments = ArrayList(), mentions = listOf(), + tags = listOf(), application = null, pinned = status.pinned, muted = status.muted, @@ -239,6 +246,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson): StatusViewData { visibility = status.visibility, attachments = attachments, mentions = mentions, + tags = tags, application = application, pinned = status.pinned, muted = status.muted, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt index 5815662d0..a750a3665 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt @@ -34,7 +34,7 @@ import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.TimelineCases -import com.keylesspalace.tusky.util.LinkHelper +import com.keylesspalace.tusky.util.getDomain import com.keylesspalace.tusky.util.inc import com.keylesspalace.tusky.util.toViewData import com.keylesspalace.tusky.viewdata.StatusViewData @@ -117,7 +117,7 @@ class NetworkTimelineViewModel @Inject constructor( override fun removeAllByInstance(instance: String) { statusData.removeAll { vd -> val status = vd.asStatusOrNull()?.status ?: return@removeAll false - LinkHelper.getDomain(status.account.url) == instance + getDomain(status.account.url) == instance } currentSource?.invalidate() } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java index 50e13ca25..269ee0a98 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java +++ b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java @@ -32,7 +32,7 @@ import java.io.File; @Database(entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class, TimelineAccountEntity.class, ConversationEntity.class - }, version = 28) + }, version = 29) public abstract class AppDatabase extends RoomDatabase { public abstract AccountDao accountDao(); @@ -457,4 +457,12 @@ public abstract class AppDatabase extends RoomDatabase { "ON `TimelineStatusEntity` (`authorServerId`, `timelineUserId`)"); } }; + + public static final Migration MIGRATION_28_29 = new Migration(28, 29) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `s_tags` TEXT NOT NULL"); + database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `tags` TEXT"); + } + }; } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt b/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt index a59133dea..c9daec0a1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt @@ -27,6 +27,7 @@ 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.HashTag import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Status @@ -119,6 +120,16 @@ class Converters @Inject constructor ( return gson.fromJson(mentionListJson, object : TypeToken>() {}.type) } + @TypeConverter + fun tagListToJson(tagArray: List?): String? { + return gson.toJson(tagArray) + } + + @TypeConverter + fun jsonToTagArray(tagListJson: String?): List? { + return gson.fromJson(tagListJson, object : TypeToken>() {}.type) + } + @TypeConverter fun dateToLong(date: Date): Long { return date.time diff --git a/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt index 6952ca86f..ac712f66b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt @@ -35,7 +35,7 @@ abstract class TimelineDao { SELECT s.serverId, s.url, s.timelineUserId, s.authorServerId, s.inReplyToId, s.inReplyToAccountId, s.createdAt, s.emojis, s.reblogsCount, s.favouritesCount, s.reblogged, s.favourited, s.bookmarked, s.sensitive, -s.spoilerText, s.visibility, s.mentions, s.application, s.reblogServerId,s.reblogAccountId, +s.spoilerText, s.visibility, s.mentions, s.tags, s.application, s.reblogServerId,s.reblogAccountId, s.content, s.attachments, s.poll, s.muted, s.expanded, s.contentShowing, s.contentCollapsed, s.pinned, a.serverId as 'a_serverId', a.timelineUserId as 'a_timelineUserId', a.localUsername as 'a_localUsername', a.username as 'a_username', diff --git a/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt index ce8169593..41b122c36 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt @@ -69,6 +69,7 @@ data class TimelineStatusEntity( val visibility: Status.Visibility, val attachments: String?, val mentions: String?, + val tags: String?, val application: String?, val reblogServerId: String?, // if it has a reblogged status, it's id is stored here val reblogAccountId: String?, diff --git a/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt index 7699ba697..583b5ad6b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt @@ -61,7 +61,7 @@ class AppModule { AppDatabase.MIGRATION_19_20, AppDatabase.MIGRATION_20_21, AppDatabase.MIGRATION_21_22, AppDatabase.MIGRATION_22_23, AppDatabase.MIGRATION_23_24, AppDatabase.MIGRATION_24_25, AppDatabase.Migration25_26(appContext.getExternalFilesDir("Tusky")), - AppDatabase.MIGRATION_26_27, AppDatabase.MIGRATION_27_28 + AppDatabase.MIGRATION_26_27, AppDatabase.MIGRATION_27_28, AppDatabase.MIGRATION_28_29 ) .build() } diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/HashTag.kt b/app/src/main/java/com/keylesspalace/tusky/entity/HashTag.kt index a334257a1..f3a4f65b8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/HashTag.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/HashTag.kt @@ -1,3 +1,3 @@ package com.keylesspalace.tusky.entity -data class HashTag(val name: String) +data class HashTag(val name: String, val url: String) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt index 25588f1ae..50643b56b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt @@ -42,6 +42,7 @@ data class Status( val visibility: Visibility, @SerializedName("media_attachments") var attachments: ArrayList, val mentions: List, + val tags: List, val application: Application?, val pinned: Boolean?, val muted: Boolean?, diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java index 8b22dd237..93b169b3c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.java @@ -363,7 +363,7 @@ public abstract class SFragment extends Fragment implements Injectable { } default: case UNKNOWN: { - LinkHelper.openLink(active.getAttachment().getUrl(), getContext()); + LinkHelper.openLink(requireContext(), active.getAttachment().getUrl()); break; } } diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java index 603e344d4..98466cc99 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewThreadFragment.java @@ -330,7 +330,7 @@ public final class ViewThreadFragment extends SFragment implements // already viewing the status with this url // probably just a preview federated and the user is clicking again to view more -> open the browser // this can happen with some friendica statuses - LinkHelper.openLink(url, requireContext()); + LinkHelper.openLink(requireContext(), url); return; } super.onViewUrl(url); diff --git a/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.java b/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.java deleted file mode 100644 index 4969c9ad0..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.java +++ /dev/null @@ -1,251 +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 . */ - -package com.keylesspalace.tusky.util; - -import android.content.ActivityNotFoundException; -import android.content.Context; -import android.content.Intent; -import android.net.Uri; -import android.text.SpannableStringBuilder; -import android.text.Spanned; -import android.text.method.LinkMovementMethod; -import android.text.style.ClickableSpan; -import android.text.style.URLSpan; -import android.util.Log; -import android.view.View; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.browser.customtabs.CustomTabColorSchemeParams; -import androidx.browser.customtabs.CustomTabsIntent; -import androidx.preference.PreferenceManager; - -import com.keylesspalace.tusky.R; -import com.keylesspalace.tusky.entity.Status; -import com.keylesspalace.tusky.interfaces.LinkListener; - -import java.net.URI; -import java.net.URISyntaxException; -import java.util.List; - -public class LinkHelper { - public static String getDomain(String urlString) { - URI uri; - try { - uri = new URI(urlString); - } catch (URISyntaxException e) { - return ""; - } - String host = uri.getHost(); - if(host == null) { - return ""; - } else if (host.startsWith("www.")) { - return host.substring(4); - } else { - return host; - } - } - - /** - * Finds links, mentions, and hashtags in a piece of text and makes them clickable, associating - * them with callbacks to notify when they're clicked. - * - * @param view the returned text will be put in - * @param content containing text with mentions, links, or hashtags - * @param mentions any '@' mentions which are known to be in the content - * @param listener to notify about particular spans that are clicked - */ - public static void setClickableText(TextView view, CharSequence content, - @Nullable List mentions, final LinkListener listener) { - SpannableStringBuilder builder = SpannableStringBuilder.valueOf(content); - URLSpan[] urlSpans = builder.getSpans(0, content.length(), URLSpan.class); - for (URLSpan span : urlSpans) { - int start = builder.getSpanStart(span); - int end = builder.getSpanEnd(span); - int flags = builder.getSpanFlags(span); - CharSequence text = builder.subSequence(start, end); - ClickableSpan customSpan = null; - - if (text.charAt(0) == '#') { - final String tag = text.subSequence(1, text.length()).toString(); - customSpan = new NoUnderlineURLSpan(span.getURL()) { - @Override - public void onClick(@NonNull View widget) { listener.onViewTag(tag); } - }; - } else if (text.charAt(0) == '@' && mentions != null && mentions.size() > 0) { - // https://github.com/tuskyapp/Tusky/pull/2339 - String id = null; - for (Status.Mention mention : mentions) { - if (mention.getUrl().equals(span.getURL())) { - id = mention.getId(); - break; - } - } - if (id != null) { - final String accountId = id; - customSpan = new NoUnderlineURLSpan(span.getURL()) { - @Override - public void onClick(@NonNull View widget) { listener.onViewAccount(accountId); } - }; - } - } - - if (customSpan == null) { - customSpan = new NoUnderlineURLSpan(span.getURL()) { - @Override - public void onClick(@NonNull View widget) { - listener.onViewUrl(getURL()); - } - }; - } - builder.removeSpan(span); - builder.setSpan(customSpan, start, end, flags); - - /* Add zero-width space after links in end of line to fix its too large hitbox. - * See also : https://github.com/tuskyapp/Tusky/issues/846 - * https://github.com/tuskyapp/Tusky/pull/916 */ - if (end >= builder.length() || - builder.subSequence(end, end + 1).toString().equals("\n")){ - builder.insert(end, "\u200B"); - } - } - - view.setText(builder); - view.setMovementMethod(LinkMovementMethod.getInstance()); - } - - /** - * Put mentions in a piece of text and makes them clickable, associating them with callbacks to - * notify when they're clicked. - * - * @param view the returned text will be put in - * @param mentions any '@' mentions which are known to be in the content - * @param listener to notify about particular spans that are clicked - */ - public static void setClickableMentions( - TextView view, @Nullable List mentions, final LinkListener listener) { - if (mentions == null || mentions.size() == 0) { - view.setText(null); - return; - } - SpannableStringBuilder builder = new SpannableStringBuilder(); - int start = 0; - int end = 0; - int flags; - boolean firstMention = true; - for (Status.Mention mention : mentions) { - String accountUsername = mention.getLocalUsername(); - final String accountId = mention.getId(); - ClickableSpan customSpan = new NoUnderlineURLSpan(mention.getUrl()) { - @Override - public void onClick(@NonNull View widget) { listener.onViewAccount(accountId); } - }; - - end += 1 + accountUsername.length(); // length of @ + username - flags = builder.getSpanFlags(customSpan); - if (firstMention) { - firstMention = false; - } else { - builder.append(" "); - start += 1; - end += 1; - } - builder.append("@"); - builder.append(accountUsername); - builder.setSpan(customSpan, start, end, flags); - builder.append("\u200B"); // same reasonning than in setClickableText - end += 1; // shift position to take the previous character into account - start = end; - } - view.setText(builder); - view.setMovementMethod(LinkMovementMethod.getInstance()); - } - - public static CharSequence createClickableText(String text, String link) { - URLSpan span = new NoUnderlineURLSpan(link); - - SpannableStringBuilder clickableText = new SpannableStringBuilder(text); - clickableText.setSpan(span, 0, text.length(), Spanned.SPAN_INCLUSIVE_EXCLUSIVE); - return clickableText; - } - - /** - * Opens a link, depending on the settings, either in the browser or in a custom tab - * - * @param url a string containing the url to open - * @param context context - */ - public static void openLink(String url, Context context) { - Uri uri = Uri.parse(url).normalizeScheme(); - - boolean useCustomTabs = PreferenceManager.getDefaultSharedPreferences(context) - .getBoolean("customTabs", false); - if (useCustomTabs) { - openLinkInCustomTab(uri, context); - } else { - openLinkInBrowser(uri, context); - } - } - - /** - * opens a link in the browser via Intent.ACTION_VIEW - * - * @param uri the uri to open - * @param context context - */ - public static void openLinkInBrowser(Uri uri, Context context) { - Intent intent = new Intent(Intent.ACTION_VIEW, uri); - try { - context.startActivity(intent); - } catch (ActivityNotFoundException e) { - Log.w("LinkHelper", "Actvity was not found for intent, " + intent); - } - } - - /** - * tries to open a link in a custom tab - * falls back to browser if not possible - * - * @param uri the uri to open - * @param context context - */ - public static void openLinkInCustomTab(Uri uri, Context context) { - int toolbarColor = ThemeUtils.getColor(context, R.attr.colorSurface); - int navigationbarColor = ThemeUtils.getColor(context, android.R.attr.navigationBarColor); - int navigationbarDividerColor = ThemeUtils.getColor(context, R.attr.dividerColor); - - CustomTabColorSchemeParams colorSchemeParams = new CustomTabColorSchemeParams.Builder() - .setToolbarColor(toolbarColor) - .setNavigationBarColor(navigationbarColor) - .setNavigationBarDividerColor(navigationbarDividerColor) - .build(); - - CustomTabsIntent customTabsIntent = new CustomTabsIntent.Builder() - .setDefaultColorSchemeParams(colorSchemeParams) - .setShowTitle(true) - .build(); - - try { - customTabsIntent.launchUrl(context, uri); - } catch (ActivityNotFoundException e) { - Log.w("LinkHelper", "Activity was not found for intent " + customTabsIntent); - openLinkInBrowser(uri, context); - } - - } - -} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt new file mode 100644 index 000000000..43d7c0f01 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt @@ -0,0 +1,239 @@ +/* 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 . */ +@file:JvmName("LinkHelper") + +package com.keylesspalace.tusky.util + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.text.SpannableStringBuilder +import android.text.Spanned +import android.text.method.LinkMovementMethod +import android.text.style.ClickableSpan +import android.text.style.URLSpan +import android.util.Log +import android.view.View +import android.widget.TextView +import androidx.annotation.VisibleForTesting +import androidx.browser.customtabs.CustomTabColorSchemeParams +import androidx.browser.customtabs.CustomTabsIntent +import androidx.core.net.toUri +import androidx.preference.PreferenceManager +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.entity.HashTag +import com.keylesspalace.tusky.entity.Status.Mention +import com.keylesspalace.tusky.interfaces.LinkListener + +fun getDomain(urlString: String?): String { + val host = urlString?.toUri()?.host + return when { + host == null -> "" + host.startsWith("www.") -> host.substring(4) + else -> host + } +} + +/** + * Finds links, mentions, and hashtags in a piece of text and makes them clickable, associating + * them with callbacks to notify when they're clicked. + * + * @param view the returned text will be put in + * @param content containing text with mentions, links, or hashtags + * @param mentions any '@' mentions which are known to be in the content + * @param listener to notify about particular spans that are clicked + */ +fun setClickableText(view: TextView, content: CharSequence, mentions: List, tags: List?, listener: LinkListener) { + view.text = SpannableStringBuilder.valueOf(content).apply { + getSpans(0, content.length, URLSpan::class.java).forEach { + setClickableText(it, this, mentions, tags, listener) + } + } + view.movementMethod = LinkMovementMethod.getInstance() +} + +@VisibleForTesting +fun setClickableText( + span: URLSpan, + builder: SpannableStringBuilder, + mentions: List, + tags: List?, + listener: LinkListener +) = builder.apply { + val start = getSpanStart(span) + val end = getSpanEnd(span) + val flags = getSpanFlags(span) + val text = subSequence(start, end) + + val customSpan = when (text[0]) { + '#' -> getCustomSpanForTag(text, tags, span, listener) + '@' -> getCustomSpanForMention(mentions, span, listener) + else -> null + } ?: object : NoUnderlineURLSpan(span.url) { + override fun onClick(view: View) = listener.onViewUrl(url) + } + + removeSpan(span) + setSpan(customSpan, start, end, flags) + + /* Add zero-width space after links in end of line to fix its too large hitbox. + * See also : https://github.com/tuskyapp/Tusky/issues/846 + * https://github.com/tuskyapp/Tusky/pull/916 */ + if (end >= length || subSequence(end, end + 1).toString() == "\n") { + insert(end, "\u200B") + } +} + +@VisibleForTesting +fun getTagName(text: CharSequence, tags: List?, span: URLSpan): String? { + return when (tags) { + null -> text.subSequence(1, text.length).toString() + else -> tags.firstOrNull { it.url == span.url }?.name + } +} + +private fun getCustomSpanForTag(text: CharSequence, tags: List?, span: URLSpan, listener: LinkListener): ClickableSpan? { + return getTagName(text, tags, span)?.let { + object : NoUnderlineURLSpan(span.url) { + override fun onClick(view: View) = listener.onViewTag(it) + } + } +} + +private fun getCustomSpanForMention(mentions: List, span: URLSpan, listener: LinkListener): ClickableSpan? { + // https://github.com/tuskyapp/Tusky/pull/2339 + return mentions.firstOrNull { it.url == span.url }?.let { + getCustomSpanForMentionUrl(span.url, it.id, listener) + } +} + +private fun getCustomSpanForMentionUrl(url: String, mentionId: String, listener: LinkListener): ClickableSpan { + return object : NoUnderlineURLSpan(url) { + override fun onClick(view: View) = listener.onViewAccount(mentionId) + } +} + +/** + * Put mentions in a piece of text and makes them clickable, associating them with callbacks to + * notify when they're clicked. + * + * @param view the returned text will be put in + * @param mentions any '@' mentions which are known to be in the content + * @param listener to notify about particular spans that are clicked + */ +fun setClickableMentions(view: TextView, mentions: List?, listener: LinkListener) { + if (mentions?.isEmpty() != false) { + view.text = null + return + } + + view.text = SpannableStringBuilder().apply { + var start = 0 + var end = 0 + var flags: Int + var firstMention = true + + for (mention in mentions) { + val customSpan = getCustomSpanForMentionUrl(mention.url, mention.id, listener) + end += 1 + mention.username.length // length of @ + username + flags = getSpanFlags(customSpan) + if (firstMention) { + firstMention = false + } else { + append(" ") + start += 1 + end += 1 + } + + append("@") + append(mention.username) + setSpan(customSpan, start, end, flags) + append("\u200B") // same reasoning as in setClickableText + end += 1 // shift position to take the previous character into account + start = end + } + } + view.movementMethod = LinkMovementMethod.getInstance() +} + +fun createClickableText(text: String, link: String): CharSequence { + return SpannableStringBuilder(text).apply { + setSpan(NoUnderlineURLSpan(link), 0, text.length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE) + } +} + +/** + * Opens a link, depending on the settings, either in the browser or in a custom tab + * + * @receiver the Context to open the link from + * @param url a string containing the url to open + */ +fun Context.openLink(url: String) { + val uri = url.toUri().normalizeScheme() + val useCustomTabs = PreferenceManager.getDefaultSharedPreferences(this).getBoolean("customTabs", false) + + if (useCustomTabs) { + openLinkInCustomTab(uri, this) + } else { + openLinkInBrowser(uri, this) + } +} + +/** + * opens a link in the browser via Intent.ACTION_VIEW + * + * @param uri the uri to open + * @param context context + */ +private fun openLinkInBrowser(uri: Uri?, context: Context) { + val intent = Intent(Intent.ACTION_VIEW, uri) + try { + context.startActivity(intent) + } catch (e: ActivityNotFoundException) { + Log.w(TAG, "Actvity was not found for intent, $intent") + } +} + +/** + * tries to open a link in a custom tab + * falls back to browser if not possible + * + * @param uri the uri to open + * @param context context + */ +private fun openLinkInCustomTab(uri: Uri, context: Context) { + val toolbarColor = ThemeUtils.getColor(context, R.attr.colorSurface) + val navigationbarColor = ThemeUtils.getColor(context, android.R.attr.navigationBarColor) + val navigationbarDividerColor = ThemeUtils.getColor(context, R.attr.dividerColor) + val colorSchemeParams = CustomTabColorSchemeParams.Builder() + .setToolbarColor(toolbarColor) + .setNavigationBarColor(navigationbarColor) + .setNavigationBarDividerColor(navigationbarDividerColor) + .build() + val customTabsIntent = CustomTabsIntent.Builder() + .setDefaultColorSchemeParams(colorSchemeParams) + .setShowTitle(true) + .build() + + try { + customTabsIntent.launchUrl(context, uri) + } catch (e: ActivityNotFoundException) { + Log.w(TAG, "Activity was not found for intent $customTabsIntent") + openLinkInBrowser(uri, context) + } +} + +private const val TAG = "LinkHelper" diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt b/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt index 879fccc30..cd5be1c54 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt @@ -182,7 +182,7 @@ class ListStatusAccessibilityDelegate( android.R.layout.simple_list_item_1, textLinks ) - ) { _, which -> LinkHelper.openLink(links[which].link, host.context) } + ) { _, which -> host.context.openLink(links[which].link) } .show() .let { forceFocus(it.listView) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/NoUnderlineURLSpan.kt b/app/src/main/java/com/keylesspalace/tusky/util/NoUnderlineURLSpan.kt index a9b56b894..779a7e6e9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/NoUnderlineURLSpan.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/NoUnderlineURLSpan.kt @@ -29,6 +29,6 @@ open class NoUnderlineURLSpan( } override fun onClick(view: View) { - LinkHelper.openLink(url, view.context) + view.context.openLink(url) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/view/LicenseCard.kt b/app/src/main/java/com/keylesspalace/tusky/view/LicenseCard.kt index 116f01703..59b359246 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/LicenseCard.kt +++ b/app/src/main/java/com/keylesspalace/tusky/view/LicenseCard.kt @@ -21,9 +21,9 @@ import android.view.LayoutInflater import com.google.android.material.card.MaterialCardView import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.CardLicenseBinding -import com.keylesspalace.tusky.util.LinkHelper import com.keylesspalace.tusky.util.ThemeUtils import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.openLink class LicenseCard @JvmOverloads constructor( @@ -50,7 +50,7 @@ class LicenseCard binding.licenseCardLink.hide() } else { binding.licenseCardLink.text = link - setOnClickListener { LinkHelper.openLink(link, context) } + setOnClickListener { context.openLink(link) } } } } diff --git a/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt b/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt index 6d761b38b..c2a607977 100644 --- a/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt @@ -93,6 +93,7 @@ class BottomSheetActivityTest { visibility = Status.Visibility.PUBLIC, attachments = ArrayList(), mentions = emptyList(), + tags = emptyList(), application = null, pinned = false, muted = false, diff --git a/app/src/test/java/com/keylesspalace/tusky/FilterTest.kt b/app/src/test/java/com/keylesspalace/tusky/FilterTest.kt index a48ff1219..03fff5ee1 100644 --- a/app/src/test/java/com/keylesspalace/tusky/FilterTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/FilterTest.kt @@ -189,6 +189,7 @@ class FilterTest { ) } else arrayListOf(), mentions = listOf(), + tags = listOf(), application = null, pinned = false, muted = false, diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/StatusMocker.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/StatusMocker.kt index c4ab2faa5..0ef5b6587 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/StatusMocker.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/StatusMocker.kt @@ -40,6 +40,7 @@ fun mockStatus(id: String = "100") = Status( visibility = Status.Visibility.PUBLIC, attachments = ArrayList(), mentions = emptyList(), + tags = emptyList(), application = Status.Application("Tusky", "https://tusky.app"), pinned = false, muted = false, diff --git a/app/src/test/java/com/keylesspalace/tusky/db/TimelineDaoTest.kt b/app/src/test/java/com/keylesspalace/tusky/db/TimelineDaoTest.kt index c60f7d4fb..687b5dcb0 100644 --- a/app/src/test/java/com/keylesspalace/tusky/db/TimelineDaoTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/db/TimelineDaoTest.kt @@ -410,6 +410,7 @@ class TimelineDaoTest { visibility = Status.Visibility.PRIVATE, attachments = "attachments$accountId", mentions = "mentions$accountId", + tags = "tags$accountId", application = "application$accountId", reblogServerId = if (reblog) (statusId * 100).toString() else null, reblogAccountId = reblogAuthor?.serverId, diff --git a/app/src/test/java/com/keylesspalace/tusky/util/LinkHelperTest.kt b/app/src/test/java/com/keylesspalace/tusky/util/LinkHelperTest.kt new file mode 100644 index 000000000..cdc210592 --- /dev/null +++ b/app/src/test/java/com/keylesspalace/tusky/util/LinkHelperTest.kt @@ -0,0 +1,172 @@ +package com.keylesspalace.tusky.util + +import android.text.SpannableStringBuilder +import android.text.style.URLSpan +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.keylesspalace.tusky.entity.HashTag +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.interfaces.LinkListener +import org.junit.Assert +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@Config(sdk = [28]) +@RunWith(AndroidJUnit4::class) +class LinkHelperTest { + private val listener = object : LinkListener { + override fun onViewTag(tag: String?) { } + override fun onViewAccount(id: String?) { } + override fun onViewUrl(url: String?) { } + } + + private val mentions = listOf( + Status.Mention("1", "https://example.com/@user", "user", "user"), + Status.Mention("2", "https://example.com/@anotherUser", "anotherUser", "anotherUser"), + ) + private val tags = listOf( + HashTag("Tusky", "https://example.com/Tags/Tusky"), + HashTag("mastodev", "https://example.com/Tags/mastodev"), + ) + + @Test + fun whenSettingClickableText_mentionUrlsArePreserved() { + val builder = SpannableStringBuilder() + for (mention in mentions) { + builder.append("@${mention.username}", URLSpan(mention.url), 0) + builder.append(" ") + } + + var urlSpans = builder.getSpans(0, builder.length, URLSpan::class.java) + for (span in urlSpans) { + setClickableText(span, builder, mentions, null, listener) + } + + urlSpans = builder.getSpans(0, builder.length, URLSpan::class.java) + for (span in urlSpans) { + Assert.assertNotNull(mentions.firstOrNull { it.url == span.url }) + } + } + + @Test + fun whenSettingClickableText_nonMentionsAreNotConvertedToMentions() { + val builder = SpannableStringBuilder() + val nonMentionUrl = "http://example.com/" + for (mention in mentions) { + builder.append("@${mention.username}", URLSpan(nonMentionUrl), 0) + builder.append(" ") + builder.append("@${mention.username} ") + } + + var urlSpans = builder.getSpans(0, builder.length, URLSpan::class.java) + for (span in urlSpans) { + setClickableText(span, builder, mentions, null, listener) + } + + urlSpans = builder.getSpans(0, builder.length, URLSpan::class.java) + for (span in urlSpans) { + Assert.assertEquals(nonMentionUrl, span.url) + } + } + + @Test + fun whenSettingClickableTest_tagUrlsArePreserved() { + val builder = SpannableStringBuilder() + for (tag in tags) { + builder.append("#${tag.name}", URLSpan(tag.url), 0) + builder.append(" ") + } + + var urlSpans = builder.getSpans(0, builder.length, URLSpan::class.java) + for (span in urlSpans) { + setClickableText(span, builder, emptyList(), tags, listener) + } + + urlSpans = builder.getSpans(0, builder.length, URLSpan::class.java) + for (span in urlSpans) { + Assert.assertNotNull(tags.firstOrNull { it.url == span.url }) + } + } + + @Test + fun whenSettingClickableTest_nonTagUrlsAreNotConverted() { + val builder = SpannableStringBuilder() + val nonTagUrl = "http://example.com/" + for (tag in tags) { + builder.append("#${tag.name}", URLSpan(nonTagUrl), 0) + builder.append(" ") + builder.append("#${tag.name} ") + } + + var urlSpans = builder.getSpans(0, builder.length, URLSpan::class.java) + for (span in urlSpans) { + setClickableText(span, builder, emptyList(), tags, listener) + } + + urlSpans = builder.getSpans(0, builder.length, URLSpan::class.java) + for (span in urlSpans) { + Assert.assertEquals(nonTagUrl, span.url) + } + } + + @Test + fun whenTagsAreNull_tagNameIsGeneratedFromText() { + SpannableStringBuilder().apply { + for (tag in tags) { + append("#${tag.name}", URLSpan(tag.url), 0) + append(" ") + } + + getSpans(0, length, URLSpan::class.java).forEach { + Assert.assertNotNull(getTagName(subSequence(getSpanStart(it), getSpanEnd(it)), null, it)) + } + } + } + + @Test + fun whenStringIsInvalidUri_emptyStringIsReturnedFromGetDomain() { + listOf( + null, + "foo bar baz", + "http:/foo.bar", + "c:/foo/bar", + ).forEach { + Assert.assertEquals("", getDomain(it)) + } + } + + @Test + fun whenUrlIsValid_correctDomainIsReturned() { + listOf( + "example.com", + "localhost", + "sub.domain.com", + "10.45.0.123", + ).forEach { domain -> + listOf( + "https://$domain", + "https://$domain/", + "https://$domain/foo/bar", + "https://$domain/foo/bar.html", + "https://$domain/foo/bar.html#", + "https://$domain/foo/bar.html#anchor", + "https://$domain/foo/bar.html?argument=value", + "https://$domain/foo/bar.html?argument=value&otherArgument=otherValue", + ).forEach { url -> + Assert.assertEquals(domain, getDomain(url)) + } + } + } + + @Test + fun wwwPrefixIsStrippedFromGetDomain() { + mapOf( + "https://www.example.com/foo/bar" to "example.com", + "https://awww.example.com/foo/bar" to "awww.example.com", + "http://www.localhost" to "localhost", + "https://wwwexample.com/" to "wwwexample.com", + ).forEach { (url, domain) -> + Assert.assertEquals(domain, getDomain(url)) + } + } +}