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
This commit is contained in:
parent
f822234995
commit
addce87eb6
34 changed files with 1294 additions and 296 deletions
789
app/schemas/com.keylesspalace.tusky.db.AppDatabase/29.json
Normal file
789
app/schemas/com.keylesspalace.tusky.db.AppDatabase/29.json
Normal file
|
@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -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() {
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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<Status.Mention> mentions,
|
||||
@Nullable List<HashTag> tags,
|
||||
@NonNull List<Emoji> 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<Status.Mention> mentions,
|
||||
List<HashTag> tags,
|
||||
List<Emoji> 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);
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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<Attachment>,
|
||||
val mentions: List<Status.Mention>,
|
||||
val tags: List<HashTag>,
|
||||
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),
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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<Status.Mention>?,
|
||||
mentions: List<Status.Mention>,
|
||||
tags: List<HashTag>?,
|
||||
emojis: List<Emoji>,
|
||||
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()
|
||||
|
|
|
@ -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<Pair<Status, StatusViewData.Concre
|
|||
}
|
||||
}
|
||||
Attachment.Type.UNKNOWN -> {
|
||||
LinkHelper.openLink(actionable.attachments[attachmentIndex].url, context)
|
||||
context?.openLink(actionable.attachments[attachmentIndex].url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<ArrayList<Attachment>>() {}.type
|
||||
private val emojisListType = object : TypeToken<List<Emoji>>() {}.type
|
||||
private val mentionListType = object : TypeToken<List<Status.Mention>>() {}.type
|
||||
private val tagListType = object : TypeToken<List<HashTag>>() {}.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<Attachment> = gson.fromJson(status.attachments, attachmentArrayListType) ?: arrayListOf()
|
||||
val mentions: List<Status.Mention> = gson.fromJson(status.mentions, mentionListType) ?: emptyList()
|
||||
val tags: List<HashTag> = gson.fromJson(status.tags, tagListType) ?: emptyList()
|
||||
val application = gson.fromJson(status.application, Status.Application::class.java)
|
||||
val emojis: List<Emoji> = 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,
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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<List<Status.Mention>>() {}.type)
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun tagListToJson(tagArray: List<HashTag>?): String? {
|
||||
return gson.toJson(tagArray)
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun jsonToTagArray(tagListJson: String?): List<HashTag>? {
|
||||
return gson.fromJson(tagListJson, object : TypeToken<List<HashTag>>() {}.type)
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun dateToLong(date: Date): Long {
|
||||
return date.time
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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?,
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
package com.keylesspalace.tusky.entity
|
||||
|
||||
data class HashTag(val name: String)
|
||||
data class HashTag(val name: String, val url: String)
|
||||
|
|
|
@ -42,6 +42,7 @@ data class Status(
|
|||
val visibility: Visibility,
|
||||
@SerializedName("media_attachments") var attachments: ArrayList<Attachment>,
|
||||
val mentions: List<Mention>,
|
||||
val tags: List<HashTag>,
|
||||
val application: Application?,
|
||||
val pinned: Boolean?,
|
||||
val muted: Boolean?,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 <http://www.gnu.org/licenses>. */
|
||||
|
||||
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<Status.Mention> 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<Status.Mention> 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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
239
app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt
Normal file
239
app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt
Normal file
|
@ -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 <http://www.gnu.org/licenses>. */
|
||||
@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<Mention>, tags: List<HashTag>?, 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<Mention>,
|
||||
tags: List<HashTag>?,
|
||||
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<HashTag>?, 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<HashTag>?, 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<Mention>, 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<Mention>?, 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"
|
|
@ -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) }
|
||||
}
|
||||
|
|
|
@ -29,6 +29,6 @@ open class NoUnderlineURLSpan(
|
|||
}
|
||||
|
||||
override fun onClick(view: View) {
|
||||
LinkHelper.openLink(url, view.context)
|
||||
view.context.openLink(url)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -93,6 +93,7 @@ class BottomSheetActivityTest {
|
|||
visibility = Status.Visibility.PUBLIC,
|
||||
attachments = ArrayList(),
|
||||
mentions = emptyList(),
|
||||
tags = emptyList(),
|
||||
application = null,
|
||||
pinned = false,
|
||||
muted = false,
|
||||
|
|
|
@ -189,6 +189,7 @@ class FilterTest {
|
|||
)
|
||||
} else arrayListOf(),
|
||||
mentions = listOf(),
|
||||
tags = listOf(),
|
||||
application = null,
|
||||
pinned = false,
|
||||
muted = false,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
172
app/src/test/java/com/keylesspalace/tusky/util/LinkHelperTest.kt
Normal file
172
app/src/test/java/com/keylesspalace/tusky/util/LinkHelperTest.kt
Normal file
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue