New link previews (initial implementation) (#15891)

This is the introductory work to support the new requirements for unfurling
URLs (while the message is a draft) and displaying link previews (after the
message is sent). Refer to the related status-go PR for a lot more interesting
details https://github.com/status-im/status-go/pull/3471.

Fixes https://github.com/status-im/status-mobile/issues/15469

### Notes

- The old link preview code will be removed separately, both in status-go and
  status-mobile.
- I did the bulk of the work in status-go
  https://github.com/status-im/status-go/pull/3471. If you want to understand
  how this is all implemented, do check out the status-go PR because I heavily
  documented the solution, rationale, next steps, etc.

### Performance

Does the feature perform well? Yes, there's very little overhead because
unfurling URLs happen in status-go and the event is debounced. I also payed
special attention to use a simple caching mechanism to avoid doing unnecessary
RPC requests to status-go if the URLs are cached in the client.

I have some ideas on how to improve performance further, but not in this PR
which is already screaming for reviews.
This commit is contained in:
Icaro Motta 2023-05-18 16:19:41 -03:00 committed by GitHub
parent a17efa7299
commit 19526508f2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 665 additions and 130 deletions

View File

@ -6,6 +6,7 @@
{:title "Some title"
:description "Some description"
:link "status.im"
:logo "data:image/png,logo-x"
:thumbnail "data:image/png,whatever"})
(h/describe "Links - Link Preview"
@ -19,12 +20,17 @@
(h/is-truthy (h/query-by-text (:title props)))
(h/is-truthy (h/query-by-text (:description props)))
(h/is-truthy (h/query-by-text (:link props)))
(h/is-truthy (h/query-by-label-text :logo))
(h/is-truthy (h/query-by-label-text :thumbnail)))
(h/test "does not render thumbnail if prop is not present"
(h/render [view/view (dissoc props :thumbnail)])
(h/is-null (h/query-by-label-text :thumbnail)))
(h/test "does not render logo if prop is not present"
(h/render [view/view (dissoc props :logo)])
(h/is-null (h/query-by-label-text :logo)))
(h/test "shows button to enable preview when preview is disabled"
(h/render [view/view
(assoc props

View File

@ -54,7 +54,9 @@
[logo]
[rn/image
{:accessibility-label :logo
:source logo
:source (if (string? logo)
{:uri logo}
logo)
:style style/logo}])
(defn view
@ -68,9 +70,11 @@
(if enabled?
[:<>
[rn/view {:style style/header-container}
[logo-comp logo]
(when logo
[logo-comp logo])
[title-comp title]]
[description-comp description]
(when description
[description-comp description])
[link-comp link]
(when thumbnail
[thumbnail-comp thumbnail thumbnail-size])]

View File

@ -7,10 +7,14 @@
(h/test "default render"
(h/render [view/view])
(h/is-truthy (h/query-by-label-text :title))
(h/is-truthy (h/query-by-label-text :logo))
(h/is-truthy (h/query-by-label-text :button-clear-preview))
(h/is-null (h/query-by-label-text :logo))
(h/is-null (h/query-by-label-text :url-preview-loading)))
(h/test "renders logo when prop is present"
(h/render [view/view {:logo "data:image/png,logo"}])
(h/is-truthy (h/query-by-label-text :logo)))
(h/test "on-clear event"
(let [on-clear (h/mock-fn)]
(h/render [view/view {:on-clear on-clear}])

View File

@ -6,11 +6,13 @@
[quo2.foundations.colors :as colors]
[react-native.core :as rn]))
(defn- logo-component
(defn- logo-comp
[{:keys [logo]}]
[rn/image
{:accessibility-label :logo
:source logo
:source (if (string? logo)
{:uri logo}
logo)
:style style/logo}])
(defn- content
@ -57,6 +59,7 @@
[rn/view
{:accessibility-label :url-preview
:style (merge (style/container) container-style)}
[logo-component {:logo logo}]
(when logo
[logo-comp {:logo logo}])
[content {:title title :body body}]
[clear-button {:on-press on-clear}]]))

View File

@ -1,10 +1,9 @@
(ns quo2.components.links.url-preview-list.view
(:require
[oops.core :as oops]
[quo2.components.links.url-preview-list.style :as style]
[quo2.components.links.url-preview.view :as url-preview]
[react-native.core :as rn]
[reagent.core :as reagent]))
[react-native.gesture :as gesture]))
(defn- use-scroll-to-last-item
[flat-list-ref item-count item-width]
@ -44,39 +43,33 @@
:on-clear on-clear
:container-style (merge container-style {:width width})}])
(defn- calculate-width
[preview-width horizontal-spacing ^js e]
(reset! preview-width
(- (oops/oget e "nativeEvent.layout.width")
(* 2 horizontal-spacing))))
(defn- f-view
[]
(let [preview-width (reagent/atom 0)
flat-list-ref (atom nil)]
(let [flat-list-ref (atom nil)]
(fn [{:keys [data key-fn horizontal-spacing on-clear loading-message
container-style container-style-item]}]
(use-scroll-to-last-item flat-list-ref (count data) @preview-width)
container-style container-style-item
preview-width]}]
(use-scroll-to-last-item flat-list-ref (count data) preview-width)
;; We need to use a wrapping view expanded to 100% instead of "flex 1",
;; otherwise `on-layout` will be triggered multiple times as the flat list
;; renders its children.
[rn/view
{:style (merge container-style {:width "100%"})
{:style container-style
:accessibility-label :url-preview-list}
[rn/flat-list
[gesture/flat-list
{:ref #(reset! flat-list-ref %)
:keyboard-should-persist-taps :always
:key-fn key-fn
:on-layout #(calculate-width preview-width horizontal-spacing %)
:horizontal true
:deceleration-rate :fast
:on-scroll-to-index-failed identity
:content-container-style {:padding-horizontal horizontal-spacing}
:separator [separator]
:snap-to-interval (+ @preview-width style/url-preview-gap)
:snap-to-interval (+ preview-width style/url-preview-gap)
:shows-horizontal-scroll-indicator false
:data data
:render-fn item-component
:render-data {:width @preview-width
:render-data {:width preview-width
:on-clear on-clear
:loading-message loading-message
:container-style container-style-item}}]])))

View File

@ -6,11 +6,12 @@
[status-im.chat.models.mentions :as mentions]
[status-im.chat.models.message :as chat.message]
[status-im.chat.models.message-content :as message-content]
[status-im2.constants :as constants]
[utils.re-frame :as rf]
[utils.i18n :as i18n]
[status-im.utils.utils :as utils]
[taoensso.timbre :as log]))
[status-im2.constants :as constants]
[status-im2.contexts.chat.composer.link-preview.events :as link-preview]
[taoensso.timbre :as log]
[utils.i18n :as i18n]
[utils.re-frame :as rf]))
(defn text->emoji
"Replaces emojis in a specified `text`"
@ -150,7 +151,9 @@
constants/content-type-text)
:text input-text
:response-to message-id
:ens-name preferred-name})))
:ens-name preferred-name
:link-previews (map #(select-keys % [:url :title :description :thumbnail])
(get-in db [:chat/link-previews :unfurled]))})))
(defn build-image-messages
[{db :db} chat-id input-text]
@ -185,6 +188,7 @@
(let [current-chat-id (:current-chat-id db)]
(rf/merge cofx
(clean-input current-chat-id)
(link-preview/reset-unfurled)
(mentions/clear-mentions))))
(rf/defn send-messages
@ -196,6 +200,7 @@
(when (seq messages)
(rf/merge cofx
(clean-input (:current-chat-id db))
(link-preview/reset-unfurled)
(chat.message/send-messages messages)))))
(rf/defn send-audio-message
@ -241,6 +246,7 @@
:on-success (fn [result]
(re-frame/dispatch [:sanitize-messages-and-process-response
result]))}]}
(link-preview/reset-unfurled)
(cancel-message-edit)))
(rf/defn send-current-message

View File

@ -31,6 +31,7 @@
(= {:last-message {:quoted-message nil
:outgoing-status nil
:command-parameters nil
:link-previews []
:content {:sticker nil
:rtl? nil
:ens-name nil
@ -46,6 +47,7 @@
:reply-message {:quoted-message nil
:outgoing-status nil
:command-parameters nil
:link-previews []
:content {:sticker nil
:rtl? nil
:ens-name nil

View File

@ -15,6 +15,13 @@
:community-id :communityId
:clock-value :clock})))
(defn- <-link-preview-rpc
[preview]
(update preview
:thumbnail
(fn [thumbnail]
(set/rename-keys thumbnail {:dataUri :data-uri}))))
(defn <-rpc
[message]
(-> message
@ -43,8 +50,9 @@
:imageHeight :image-height
:new :new?
:albumImagesCount :album-images-count
:displayName :display-name})
:displayName :display-name
:linkPreviews :link-previews})
(update :link-previews #(map <-link-preview-rpc %))
(update :quoted-message
set/rename-keys
{:parsedText :parsed-text

View File

@ -8,7 +8,7 @@
"0x0424a68f89ba5fcd5e0640c1e1f591d561fa4125ca4e2a43592bc4123eca10ce064e522c254bb83079ba404327f6eafc01ec90a1444331fe769d3f3a7f90b0dde1")
(deftest message<-rpc
(testing "message to rpc"
(testing "message from RPC"
(let [expected {:message-id message-id
:content {:chat-id chat-id
:sticker {:hash "hash" :pack 1}
@ -34,7 +34,9 @@
:text "reply"}
:content-type 1
:compressed-key "c"
:timestamp 3}
:timestamp 3
:link-previews [{:thumbnail {:url "http://localhost"
:data-uri "data:image/png"}}]}
message {:id message-id
:whisperTimestamp 1
:parsedText "parsed-text"
@ -56,5 +58,7 @@
:quotedMessage {:from "from"
:text "reply"}
:timestamp 3
:outgoingStatus "sending"}]
:outgoingStatus "sending"
:linkPreviews [{:thumbnail {:url "http://localhost"
:dataUri "data:image/png"}}]}]
(is (= expected (m/<-rpc message))))))

View File

@ -1,35 +1,35 @@
(ns ^{:doc "Protocol API and protocol utils"} status-im.transport.message.protocol
(:require [re-frame.core :as re-frame]
[utils.re-frame :as rf]
[taoensso.timbre :as log]))
(:require [clojure.set :as set]
[re-frame.core :as re-frame]
[taoensso.timbre :as log]
[utils.re-frame :as rf]))
(defn- link-preview->rpc
[preview]
(update preview
:thumbnail
(fn [thumbnail]
(set/rename-keys thumbnail {:data-uri :dataUri}))))
(defn build-message
[{:keys [chat-id
album-id
image-width
image-height
text
response-to
ens-name
community-id
image-path
audio-path
audio-duration-ms
sticker
content-type]}]
{:chatId chat-id
:albumId album-id
:imageWidth image-width
:imageHeight image-height
:text text
:responseTo response-to
:ensName ens-name
:imagePath image-path
:audioPath audio-path
:audioDurationMs audio-duration-ms
:communityId community-id
:sticker sticker
:contentType content-type})
[msg]
(-> msg
(update :link-previews #(map link-preview->rpc %))
(set/rename-keys
{:album-id :albumId
:audio-duration-ms :audioDurationMs
:audio-path :audioPath
:chat-id :chatId
:community-id :communityId
:content-type :contentType
:ens-name :ensName
:image-height :imageHeight
:image-path :imagePath
:image-width :imageWidth
:link-previews :linkPreviews
:response-to :responseTo
:sticker :sticker
:text :text})))
(rf/defn send-chat-messages
[_ messages]

View File

@ -202,6 +202,7 @@
:text nil}
:outgoing false
:outgoing-status nil
:link-previews []
:quoted-message nil}
:name "0x04d03f"
:read true

View File

@ -19,6 +19,8 @@
(def ^:const images-container-height 76)
(def ^:const links-container-height 76)
(def ^:const reply-container-height 32)
(def ^:const edit-container-height 32)
@ -36,3 +38,7 @@
(def ^:const background-threshold 0.75)
(def ^:const max-text-size 4096)
(def ^:const unfurl-debounce-ms
"Use a high threshold to prevent unnecessary rendering overhead."
400)

View File

@ -1,14 +1,16 @@
(ns status-im2.contexts.chat.composer.effects
(:require
[react-native.platform :as platform]
[status-im.async-storage.core :as async-storage]
[clojure.string :as string]
[oops.core :as oops]
[react-native.core :as rn]
[react-native.platform :as platform]
[react-native.reanimated :as reanimated]
[status-im.async-storage.core :as async-storage]
[status-im2.contexts.chat.composer.constants :as constants]
[status-im2.contexts.chat.composer.keyboard :as kb]
[utils.debounce :as debounce]
[utils.number :as utils.number]
[oops.core :as oops]
[utils.debounce :as debounce]))
[utils.re-frame :as rf]))
(defn reenter-screen-effect
[{:keys [text-value saved-cursor-position maximized?]}
@ -53,6 +55,12 @@
(reanimated/set-shared-value background-y 0)
(reanimated/animate opacity 1)))
(defn link-preview-effect
[{:keys [text-value]}]
(let [text @text-value]
(when-not (string/blank? text)
(rf/dispatch [:link-preview/unfurl-urls text]))))
(defn images-effect
[{:keys [sending-images? input-ref]}
{:keys [container-opacity]}
@ -78,10 +86,17 @@
[{:keys [text-value maximized? focused?]}
{:keys [container-opacity]}
images?
link-previews?
reply?
audio]
(when
(and (empty? @text-value) (not images?) (not reply?) (not @maximized?) (not @focused?) (not audio))
(and (empty? @text-value)
(not images?)
(not link-previews?)
(not reply?)
(not @maximized?)
(not @focused?)
(not audio))
(reanimated/animate-delay container-opacity constants/empty-opacity 200)))
(defn component-will-unmount
@ -91,7 +106,8 @@
(.remove ^js @keyboard-frame-listener))
(defn initialize
[props state animations {:keys [max-height] :as dimensions} {:keys [chat-input images reply audio]}]
[props state animations {:keys [max-height] :as dimensions}
{:keys [chat-input images link-previews? reply audio]}]
(rn/use-effect
(fn []
(maximized-effect state animations dimensions chat-input)
@ -100,8 +116,9 @@
(kb-default-height-effect state)
(background-effect state animations dimensions chat-input)
(images-effect props animations images)
(link-preview-effect state)
(audio-effect state animations audio)
(empty-effect state animations images reply audio)
(empty-effect state animations images link-previews? reply audio)
(kb/add-kb-listeners props state animations dimensions)
#(component-will-unmount props))
[max-height]))

View File

@ -1,15 +1,15 @@
(ns status-im2.contexts.chat.composer.handlers
(:require
[oops.core :as oops]
[react-native.core :as rn]
[react-native.reanimated :as reanimated]
[reagent.core :as reagent]
[oops.core :as oops]
[status-im2.contexts.chat.composer.constants :as constants]
[status-im2.contexts.chat.composer.keyboard :as kb]
[status-im2.contexts.chat.composer.utils :as utils]
[status-im2.contexts.chat.composer.selection :as selection]
[utils.re-frame :as rf]
[utils.debounce :as debounce]))
[status-im2.contexts.chat.composer.utils :as utils]
[utils.debounce :as debounce]
[utils.re-frame :as rf]))
(defn focus
[{:keys [input-ref] :as props}
@ -39,7 +39,7 @@
maximized? recording?]}
{:keys [height saved-height last-height gradient-opacity container-opacity opacity background-y]}
{:keys [content-height max-height window-height]}
{:keys [images reply]}]
{:keys [images link-previews? reply]}]
(when-not @recording?
(let [lines (utils/calc-lines (- @content-height constants/extra-content-offset))
min-height (utils/get-min-height lines)
@ -51,7 +51,7 @@
(reanimated/set-shared-value saved-height min-height)
(reanimated/animate opacity 0)
(js/setTimeout #(reanimated/set-shared-value background-y (- window-height)) 300)
(when (utils/empty-input? @text-value images reply nil)
(when (utils/empty-input? @text-value images link-previews? reply nil)
(reanimated/animate container-opacity constants/empty-opacity))
(reanimated/animate gradient-opacity 0)
(reset! lock-selection? true)
@ -111,6 +111,9 @@
[text
{:keys [input-ref record-reset-fn]}
{:keys [text-value cursor-position recording?]}]
(debounce/debounce-and-dispatch [:link-preview/unfurl-urls text]
constants/unfurl-debounce-ms)
(reset! text-value text)
(reagent/next-tick #(when @input-ref
(.setNativeProps ^js @input-ref

View File

@ -0,0 +1,132 @@
(ns status-im2.contexts.chat.composer.link-preview.events
(:require
[clojure.set :as set]
[clojure.string :as string]
[status-im.data-store.messages :as data-store.messages]
[taoensso.timbre :as log]
[utils.collection]
[utils.re-frame :as rf]))
(rf/defn unfurl-urls
{:events [:link-preview/unfurl-urls]}
[{:keys [db]} text]
(if (string/blank? text)
{:db (update db :chat/link-previews dissoc :unfurled :request-id)}
{:json-rpc/call
[{:method "wakuext_getTextURLs"
:params [text]
:on-success #(rf/dispatch [:link-preview/unfurl-parsed-urls %])
:on-error (fn [error]
(log/error "Failed to parse text and extract URLs"
{:error error
:event :link-preview/unfurl-urls}))}]}))
(defn- urls->previews
[preview-cache urls]
(->> urls
(map #(get preview-cache % {:url % :loading? true}))
(remove :failed?)))
(def new-request-id (comp str random-uuid))
(rf/defn unfurl-parsed-urls
{:events [:link-preview/unfurl-parsed-urls]}
[{:keys [db]} urls]
(let [cleared (set (get-in db [:chat/link-previews :cleared]))]
(when (or (empty? urls)
(not= (set urls) cleared))
(let [cache (get-in db [:chat/link-previews :cache])
previews (urls->previews cache urls)
new-urls (->> previews
(filter :loading?)
(map :url))
;; `request-id` is a must because we need to process only the last
;; unfurling event, as well as avoid needlessly updating the app db
;; if the user changes the URLs in the input text when there are
;; in-flight RPC requests.
request-id (new-request-id)]
(merge {:db (-> db
(assoc-in [:chat/link-previews :unfurled] previews)
(assoc-in [:chat/link-previews :request-id] request-id)
(update :chat/link-previews dissoc :cleared))}
(when (seq new-urls)
(log/debug "Unfurling URLs" {:urls new-urls :request-id request-id})
{:json-rpc/call
[{:method "wakuext_unfurlURLs"
:params [new-urls]
:on-success #(rf/dispatch [:link-preview/unfurl-parsed-urls-success request-id %])
:on-error #(rf/dispatch [:link-preview/unfurl-parsed-urls-error request-id
%])}]}))))))
(defn- failed-previews
[curr-previews new-previews]
(let [curr-urls (set (->> curr-previews
(filter :loading?)
(map :url)))
new-urls (set (map :url new-previews))]
(map (fn [url]
{:url url :failed? true})
(set/difference curr-urls new-urls))))
(defn- reconcile-unfurled
[curr-previews indexed-new-previews]
(reduce (fn [acc preview]
(if (:loading? preview)
(if-let [loaded-preview (get indexed-new-previews
(:url preview))]
(conj acc loaded-preview)
acc)
(conj acc preview)))
[]
curr-previews))
(rf/defn unfurl-parsed-urls-success
{:events [:link-preview/unfurl-parsed-urls-success]}
[{:keys [db]} request-id new-previews]
(when (= request-id (get-in db [:chat/link-previews :request-id]))
(let [new-previews (map data-store.messages/<-link-preview-rpc new-previews)
curr-previews (get-in db [:chat/link-previews :unfurled])
indexed-new-previews (utils.collection/index-by :url new-previews)]
(log/debug "URLs unfurled"
{:event :link-preview/unfurl-parsed-urls-success
:previews (map #(update % :thumbnail dissoc :data-uri) new-previews)
:request-id request-id})
{:db (-> db
(update-in [:chat/link-previews :unfurled] reconcile-unfurled indexed-new-previews)
(update-in [:chat/link-previews :cache]
merge
indexed-new-previews
(utils.collection/index-by :url
(failed-previews curr-previews new-previews))))})))
(rf/defn unfurl-parsed-urls-error
{:events [:link-preview/unfurl-parsed-urls-error]}
[{:keys [db]} request-id error]
(log/error "Failed to unfurl URLs"
{:request-id request-id
:error error
:event :link-preview/unfurl-parsed-urls}))
(rf/defn reset-unfurled
"Reset preview state, but keep the cache. Use this event after a message is
sent."
{:events [:link-preview/reset-unfurled]}
[{:keys [db]}]
{:db (update db :chat/link-previews dissoc :unfurled :request-id :cleared)})
(rf/defn reset-all
"Reset all preview state. It is especially important to delete any cached
URLs, as failing to do so results in its unbounded growth."
{:events [:link-preview/reset-all]}
[{:keys [db]}]
{:db (dissoc db :chat/link-previews)})
(rf/defn clear-link-previews
"Mark current unfurled URLs as `cleared`, meaning the user won't see previews
until they insert/remove non-cleared URL(s)."
{:events [:link-preview/clear]}
[{:keys [db]}]
(let [unfurled-urls (set (map :url (get-in db [:chat/link-previews :unfurled])))]
{:db (-> db
(update :chat/link-previews dissoc :unfurled :request-id)
(assoc-in [:chat/link-previews :cleared] unfurled-urls))}))

View File

@ -0,0 +1,204 @@
(ns status-im2.contexts.chat.composer.link-preview.events-test
(:require [status-im2.contexts.chat.composer.link-preview.events :as events]
[cljs.test :refer [is deftest testing]]))
(def url-github "https://github.com")
(def url-gitlab "https://gitlab.com")
(def preview-github {:url url-github :thumbnail nil})
(def preview-gitlab {:url url-gitlab :thumbnail nil})
(def request-id "abc123")
(defn remove-rpc-callbacks
[effects]
(if (get-in effects [:json-rpc/call 0])
(update-in effects [:json-rpc/call 0] dissoc :on-success :on-error)
effects))
(deftest unfurl-urls
(testing "clear up state when text is empty"
(let [cofx {:db {:chat/link-previews {:unfurled {}
:cache {}
:request-id "123"}}}]
(is (= {:db {:chat/link-previews {:cache {}}}}
(events/unfurl-urls cofx " ")))))
(testing "fetches parsed URLs"
(let [cofx {:db {:chat/link-previews {:unfurled {}
:cache {}
:request-id "123"}}}]
(is (= {:json-rpc/call [{:method "wakuext_getTextURLs"
:params [url-github]}]}
(remove-rpc-callbacks
(events/unfurl-urls cofx url-github)))))))
(deftest unfurl-parsed-urls-test
(with-redefs [events/new-request-id (constantly request-id)]
(testing "empty state with 2 stale URLs"
(let [cofx {:db {:chat/link-previews
{:cache {}
:unfurled []}}}]
(is (= {:db {:chat/link-previews
{:request-id request-id
:cache {}
:unfurled [{:url url-github :loading? true}
{:url url-gitlab :loading? true}]}}
:json-rpc/call [{:method "wakuext_unfurlURLs"
:params [[url-github url-gitlab]]}]}
(remove-rpc-callbacks
(events/unfurl-parsed-urls cofx [url-github url-gitlab]))))))
(testing "nothing unfurled, 1 cached URL and the other stale"
(let [cache {url-github preview-github}
cofx {:db {:chat/link-previews
{:cache cache
:unfurled []}}}]
(is (= {:db {:chat/link-previews
{:request-id request-id
:cache cache
:unfurled [preview-github
{:url url-gitlab :loading? true}]}}
:json-rpc/call [{:method "wakuext_unfurlURLs"
:params [[url-gitlab]]}]}
(remove-rpc-callbacks
(events/unfurl-parsed-urls cofx [url-github url-gitlab]))))))
(testing "does nothing when the URLs are identical to the last cleared ones"
(let [cofx {:db {:chat/link-previews
{:cache {url-github preview-github}
:cleared #{url-github url-gitlab}
:unfurled [preview-github preview-gitlab]}}}]
(is (nil? (events/unfurl-parsed-urls cofx [url-github url-gitlab])))))
(testing "app db has previews, but the new parsed text has no valid URLs"
(let [cache {url-github preview-github}
cofx {:db {:chat/link-previews
{:cache cache
:unfurled [preview-github]}}}]
(is (= {:db {:chat/link-previews
{:request-id request-id
:cache cache
:unfurled []}}}
(remove-rpc-callbacks
(events/unfurl-parsed-urls cofx []))))))
(testing "2 unfurled, 2 cached URLs"
(let [cache {url-github preview-github url-gitlab preview-gitlab}
cofx {:db {:chat/link-previews
{:cache cache
:unfurled [preview-github preview-gitlab]}}}]
(is (= {:db {:chat/link-previews
{:request-id request-id
:cache cache
:unfurled [preview-github preview-gitlab]}}}
(remove-rpc-callbacks
(events/unfurl-parsed-urls cofx [url-github url-gitlab]))))))
(testing "1 unfurled, 1 successful in the cache, 1 failed in the cache"
(let [cache {url-github preview-github
url-gitlab (assoc preview-gitlab :failed? true)}
cofx {:db {:chat/link-previews
{:cache cache
:unfurled [preview-github]}}}]
(is (= {:db {:chat/link-previews
{:request-id request-id
:cache cache
:unfurled [preview-github]}}}
(events/unfurl-parsed-urls cofx [url-github url-gitlab])))))
(testing "nothing unfurled, 1 failed in the cache"
(let [cache {url-gitlab (assoc preview-gitlab :failed? true)}
cofx {:db {:chat/link-previews
{:cache cache
:unfurled []}}}]
(is (= {:db {:chat/link-previews
{:request-id request-id
:cache cache
:unfurled [{:url url-github :loading? true}]}}
:json-rpc/call [{:method "wakuext_unfurlURLs"
:params [[url-github]]}]}
(remove-rpc-callbacks
(events/unfurl-parsed-urls cofx [url-github url-gitlab]))))))
(testing "empties unfurled collection when there are no valid previews"
(let [unknown-url "https://github.abcdef"
cache {unknown-url {:url unknown-url :failed? true}
url-github preview-github}
cofx {:db {:chat/link-previews
{:cache cache
:unfurled [preview-github]}}}]
(is (= {:db {:chat/link-previews
{:request-id request-id
:cache cache
:unfurled []}}}
(remove-rpc-callbacks
(events/unfurl-parsed-urls cofx [unknown-url]))))))))
(deftest unfurl-parsed-urls-success-test
(testing "does nothing if the success request-id is different than the current one"
(let [cofx {:db {:chat/link-previews
{:request-id request-id
:unfurled []
:cache {}}}}]
(is (nil? (events/unfurl-parsed-urls-success cofx "banana" [preview-github])))))
(testing "reconciles new previews with existing ones"
(let [cofx {:db {:chat/link-previews
{:request-id request-id
:unfurled [preview-github
{:url url-gitlab :loading? true}]
:cache {url-github preview-github}}}}
{db :db} (events/unfurl-parsed-urls-success cofx
request-id
[preview-gitlab])]
(is (= {:chat/link-previews
{:request-id request-id
:unfurled [preview-github preview-gitlab]
:cache {url-github preview-github
url-gitlab preview-gitlab}}}
db))))
(testing "identify and write failed preview in the cache"
(let [preview-youtube {:url "https://youtube.com" :thumbnail nil}
cofx {:db {:chat/link-previews
{:request-id request-id
:unfurled [{:url url-github :loading? true}
preview-youtube
{:url url-gitlab :loading? true}]
:cache {(:url preview-youtube) preview-youtube}}}}
{db :db} (events/unfurl-parsed-urls-success cofx
request-id
[preview-github
preview-youtube])]
(is (= {:chat/link-previews
{:request-id request-id
:unfurled [preview-github preview-youtube]
:cache {(:url preview-youtube) preview-youtube
url-github preview-github
url-gitlab {:url "https://gitlab.com" :failed? true}}}}
db)))))
(deftest clear-link-previews-test
(let [cache {url-github preview-github}
effects (events/clear-link-previews
{:db {:chat/link-previews {:unfurled [preview-github]
:request-id request-id
:cache cache}}})]
(is (= {:db {:chat/link-previews {:cache cache
:cleared #{url-github}}}}
effects))))
(deftest reset-unfurled-test
(let [cache {url-github preview-github}]
(is (= {:db {:chat/link-previews {:cache cache}}}
(events/reset-unfurled
{:db {:chat/link-previews {:unfurled [preview-github]
:request-id request-id
:cleared #{url-github}
:cache cache}}})))))
(deftest reset-all-test
(is (= {:db {:non-related-key :some-value}}
(events/reset-all
{:db {:non-related-key :some-value
:chat/link-previews {:request-id request-id
:any-other-key "farewell"}}}))))

View File

@ -0,0 +1,14 @@
(ns status-im2.contexts.chat.composer.link-preview.style)
(def padding-horizontal 20)
(def preview-list-padding-top 12)
(def preview-list-padding-bottom 8)
(def preview-height 56)
(def preview-list
{:padding-top preview-list-padding-top
:padding-bottom preview-list-padding-bottom
:margin-horizontal (- padding-horizontal)
;; Keep a high index, otherwise the parent gesture detector used by the
;; composer grabs the initiating gesture event.
:z-index 9999})

View File

@ -0,0 +1,28 @@
(ns status-im2.contexts.chat.composer.link-preview.view
(:require
[quo2.core :as quo]
[react-native.core :as rn]
[status-im2.contexts.chat.composer.link-preview.events]
[status-im2.contexts.chat.composer.link-preview.style :as style]
[utils.i18n :as i18n]
[utils.re-frame :as rf]))
(defn message-draft-link-previews
[]
(let [previews (rf/sub [:chats/link-previews-unfurled])]
[quo/url-preview-list
{:key-fn :url
:preview-width (- (:width (rn/get-window))
(* 2 style/padding-horizontal))
:container-style (when (seq previews) style/preview-list)
:container-style-item {:height style/preview-height}
:horizontal-spacing style/padding-horizontal
:loading-message (i18n/label :t/link-preview-loading-message)
:on-clear #(rf/dispatch [:link-preview/clear])
:data (map (fn [{:keys [title thumbnail hostname loading? url]}]
{:title title
:body hostname
:loading? loading?
:thumbnail (:data-uri thumbnail)
:url url})
previews)}]))

View File

@ -30,7 +30,7 @@
user])
(defn- f-view
[suggestions-atom props state animations max-height cursor-pos images reply edit]
[suggestions-atom props state animations max-height cursor-pos images link-previews? reply edit]
(let [suggestions (rf/sub [:chat/mention-suggestions])
opacity (reanimated/use-shared-value (if (seq suggestions) 1 0))
size (count suggestions)
@ -39,6 +39,7 @@
:curr-height (reanimated/get-shared-value (:height animations))
:window-height (:height (rn/get-window))
:images images
:link-previews? link-previews?
:reply reply
:edit edit}
mentions-pos (utils/calc-suggestions-position cursor-pos max-height size state data)]
@ -61,6 +62,7 @@
:accessibility-label :mentions-list}]]))
(defn view
[props state animations max-height cursor-pos images reply edit]
[props state animations max-height cursor-pos images link-previews? reply edit]
(let [suggestions-atom (reagent/atom {})]
[:f> f-view suggestions-atom props state animations max-height cursor-pos images reply edit]))
[:f> f-view suggestions-atom props state animations max-height cursor-pos images link-previews? reply
edit]))

View File

@ -26,16 +26,17 @@
[:f> f-blur-view layout-height focused?])
(defn- f-shell-button
[{:keys [maximized?]} {:keys [height]} {:keys [images reply edit]}]
[{:keys [maximized?]} {:keys [height]} {:keys [images link-previews? reply edit]}]
(let [insets (safe-area/get-insets)
extra-height (utils/calc-extra-content-height images reply edit)
extra-height (utils/calc-extra-content-height images link-previews? reply edit)
translate-y (reanimated/use-shared-value
(utils/calc-shell-neg-y insets maximized? extra-height))]
(rn/use-effect (fn []
(let [extra-height (utils/calc-extra-content-height images reply edit)]
(rn/use-effect
(fn []
(let [extra-height (utils/calc-extra-content-height images link-previews? reply edit)]
(reanimated/animate translate-y
(utils/calc-shell-neg-y insets maximized? extra-height))))
[@maximized? images reply edit])
[@maximized? images link-previews? reply edit])
[reanimated/view
{:style (reanimated/apply-animations-to-style
{:bottom height ; we use height of the input directly as bottom position

View File

@ -66,30 +66,31 @@
(if platform/ios? lines (dec lines))))
(defn calc-extra-content-height
[images? reply? edit?]
[images? link-previews? reply? edit?]
(let [height (if images? constants/images-container-height 0)
height (if link-previews? (+ height constants/links-container-height) height)
height (if reply? (+ height constants/reply-container-height) height)
height (if edit? (+ height constants/edit-container-height) height)]
height))
(defn calc-max-height
[{:keys [images reply edit]} window-height kb-height insets]
[{:keys [images link-previews? reply edit]} window-height kb-height insets]
(let [margin-top (if platform/ios? (:top insets) (+ 10 (:top insets)))
max-height (- window-height
margin-top
kb-height
constants/bar-container-height
constants/actions-container-height)
max-height (- max-height (calc-extra-content-height images reply edit))]
max-height (- max-height (calc-extra-content-height images link-previews? reply edit))]
max-height))
(defn empty-input?
[text images reply? audio?]
(and (empty? text) (empty? images) (not reply?) (not audio?)))
(defn android-elevation?
[lines images reply? edit?]
(or (> lines 1) (seq images) reply? edit?))
[text images link-previews? reply? audio?]
(and (empty? text)
(empty? images)
(not link-previews?)
(not reply?)
(not audio?)))
(defn cancel-edit-message
[{:keys [text-value]}]
@ -126,10 +127,10 @@
(defn calc-suggestions-position
[cursor-pos max-height size
{:keys [maximized?]}
{:keys [insets curr-height window-height keyboard-height images reply edit]}]
{:keys [insets curr-height window-height keyboard-height images link-previews? reply edit]}]
(let [base (+ constants/composer-default-height (:bottom insets) 8)
base (+ base (- curr-height constants/input-height))
base (+ base (calc-extra-content-height images reply edit))
base (+ base (calc-extra-content-height images link-previews? reply edit))
view-height (- window-height keyboard-height (:top insets))
container-height (bounded-val
(* (/ constants/mentions-max-height 4) size)
@ -181,6 +182,7 @@
[]
(let [chat-input (rf/sub [:chats/current-chat-input])]
{:images (seq (rf/sub [:chats/sending-image]))
:link-previews? (rf/sub [:chats/link-previews?])
:audio (rf/sub [:chats/sending-audio])
:reply (rf/sub [:chats/reply-message])
:edit (rf/sub [:chats/edit-message])
@ -189,7 +191,7 @@
:input-content-height (:input-content-height chat-input)}))
(defn init-animations
[{:keys [input-text images reply audio]}
[{:keys [input-text images link-previews? reply audio]}
lines content-height max-height opacity background-y]
(let [initial-height (if (> lines 1)
constants/multiline-minimized-height
@ -199,6 +201,7 @@
(if (empty-input?
input-text
images
link-previews?
reply
audio)
0.7

View File

@ -8,6 +8,7 @@
[reagent.core :as reagent]
[utils.i18n :as i18n]
[status-im2.contexts.chat.composer.style :as style]
[status-im2.contexts.chat.composer.link-preview.view :as link-preview]
[status-im2.contexts.chat.composer.images.view :as images]
[status-im2.contexts.chat.composer.reply.view :as reply]
[status-im2.contexts.chat.composer.edit.view :as edit]
@ -62,7 +63,10 @@
(effects/edit-mentions props state subs)
[:<>
[sub-view/shell-button state animations subs]
[mentions/view props state animations max-height cursor-pos (:images subs) (:reply subs)
[mentions/view props state animations max-height cursor-pos
(:images subs)
(:link-previews? subs)
(:reply subs)
(:edit subs)]
[gesture/gesture-detector
{:gesture (drag-gesture/drag-gesture props state animations dimensions keyboard-shown)}
@ -104,6 +108,7 @@
:max-length constants/max-text-size
:accessibility-label :chat-message-input}]]
[gradients/view props state animations show-bottom-gradient?]]
[link-preview/message-draft-link-previews]
[images/images-list]
[actions/view props state animations window-height insets subs]]]]))

View File

@ -6,6 +6,7 @@
[status-im2.contexts.chat.messages.list.state :as chat.state]
[status-im2.contexts.chat.messages.delete-message-for-me.events :as delete-for-me]
[status-im2.contexts.chat.messages.delete-message.events :as delete-message]
[status-im2.contexts.chat.composer.link-preview.events :as link-preview]
[status-im2.navigation.events :as navigation]
[status-im2.constants :as constants]
[status-im.chat.models.loading :as loading]
@ -177,6 +178,7 @@
(when (and community-id (not navigate-to-shell?))
{:dispatch-n [[:shell/add-switcher-card
:community-overview community-id]]})))
(link-preview/reset-all)
(delete-for-me/sync-all)
(delete-message/send-all)
(offload-messages chat-id))))

View File

@ -0,0 +1,27 @@
(ns status-im2.contexts.chat.messages.content.link-preview.view
(:require
[quo2.core :as quo]
[utils.re-frame :as rf]))
(defn nearly-square?
[{:keys [width height]}]
(if (or (zero? width) (zero? height))
false
(let [ratio (/ (max width height)
(min width height))]
(< (Math/abs (dec ratio)) 0.1))))
(defn view
[{:keys [chat-id message-id]}]
(let [previews (rf/sub [:chats/message-link-previews chat-id message-id])]
(when (seq previews)
[:<>
(for [{:keys [url title description thumbnail hostname]} previews]
^{:key url}
[quo/link-preview
{:title title
:description description
:link hostname
:thumbnail (:url thumbnail)
:thumbnail-size (when (nearly-square? thumbnail) :large)
:container-style {:margin-top 8}}])])))

View File

@ -0,0 +1,16 @@
(ns status-im2.contexts.chat.messages.content.link-preview.view-test
(:require [status-im2.contexts.chat.messages.content.link-preview.view :as view]
[cljs.test :refer [is deftest are]]))
(deftest nearly-square?-test
(are [pred width height] (is (pred (view/nearly-square? {:width width :height height})))
false? 0 0
true? 1 1
false? 100 89
false? 100 90
true? 100 91
true? 100 92
true? 100 101
true? 100 109
false? 100 110
false? 100 111))

View File

@ -3,11 +3,10 @@
[quo2.core :as quo]
[quo2.foundations.colors :as colors]
[react-native.core :as rn]
[status-im2.contexts.chat.messages.content.link-preview.view :as link-preview]
[status-im2.contexts.chat.messages.content.text.style :as style]
[status-im2.contexts.chat.messages.link-preview.view :as link-preview]
[utils.re-frame :as rf]
[utils.i18n :as i18n]))
[utils.i18n :as i18n]
[utils.re-frame :as rf]))
(defn render-inline
[units {:keys [type literal destination]} chat-id]
@ -134,7 +133,7 @@
add-edited-tag))])
(defn text-content
[message-data context]
[message-data _]
[rn/view
[render-parsed-text message-data]
[link-preview/link-preview message-data context]])
[link-preview/view message-data]])

View File

@ -21,6 +21,15 @@
{:label "Container width"
:key :width
:type :text}
{:label "With logo?"
:key :with-logo?
:type :boolean}
{:label "With description?"
:key :with-description?
:type :boolean}
{:label "With thumbnail?"
:key :with-thumbnail?
:type :boolean}
{:label "Disabled text"
:key :disabled-text
:type :text}
@ -50,6 +59,9 @@
:link "rarible.com"
:thumbnail :collectible
:width "295"
:with-logo? true
:with-thumbnail? true
:with-description? true
:enabled? true
:thumbnail-size :normal
:disabled-text "Enable Preview"})]
@ -62,14 +74,17 @@
{:style {:align-items :center
:margin-top 20}}
[quo/link-preview
{:logo (resources/get-mock-image :status-logo)
{:logo (when (:with-logo? @state)
(resources/get-mock-image :status-logo))
:title (:title @state)
:description (:description @state)
:description (when (:with-description? @state)
(:description @state))
:enabled? (:enabled? @state)
:on-enable #(js/alert "Button pressed")
:disabled-text (:disabled-text @state)
:link (:link @state)
:thumbnail thumbnail
:thumbnail (when (:with-thumbnail? @state)
thumbnail)
:thumbnail-size (:thumbnail-size @state)
:container-style {:width width}}]]]))))

View File

@ -14,6 +14,9 @@
{:label "Body"
:key :body
:type :text}
{:label "With logo?"
:key :with-logo?
:type :boolean}
{:label "Loading?"
:key :loading?
:type :boolean}
@ -26,6 +29,7 @@
(let [state (reagent/atom
{:title "Status - Private, Secure Communication"
:body "Status.im"
:with-logo? true
:loading? false
:loading-message "Generating preview"})]
(fn []
@ -39,7 +43,8 @@
[quo/url-preview
{:title (:title @state)
:body (:body @state)
:logo (resources/get-mock-image :status-logo)
:logo (when (:with-logo? @state)
(resources/get-mock-image :status-logo))
:loading? (:loading? @state)
:loading-message (:loading-message @state)
:on-clear #(js/alert "Clear button pressed")}]]]])))

View File

@ -17,14 +17,17 @@
[]
(let [state (reagent/atom {:previews-length "3"})]
(fn []
(let [previews-length (min 6 (utils.number/parse-int (:previews-length @state)))]
(let [previews-length (min 6 (utils.number/parse-int (:previews-length @state)))
padding 20]
[rn/view {:style {:padding-bottom 150}}
[preview/customizer state descriptor]
[rn/view
{:style {:align-items :center
:margin-top 50}}
[quo/url-preview-list
{:horizontal-spacing 20
{:horizontal-spacing padding
:preview-width (- (:width (rn/get-window))
(* 2 padding))
:on-clear #(js/alert "Clear button pressed")
:key-fn :url
:data (for [index (range previews-length)

View File

@ -401,3 +401,15 @@
:<- [:chat/inputs-with-mentions]
(fn [[chat-id cursor]]
(get cursor chat-id)))
(re-frame/reg-sub
:chats/link-previews-unfurled
:<- [:chat/link-previews]
(fn [previews]
(get previews :unfurled)))
(re-frame/reg-sub
:chats/link-previews?
:<- [:chats/link-previews-unfurled]
(fn [previews]
(boolean (seq previews))))

View File

@ -144,6 +144,12 @@
(fn [messages [_ chat-id]]
(get messages chat-id {})))
(re-frame/reg-sub
:chats/message-link-previews
:<- [:messages/messages]
(fn [messages [_ chat-id message-id]]
(get-in messages [chat-id message-id :link-previews])))
(re-frame/reg-sub
:chats/pinned
:<- [:messages/pin-messages]
@ -251,4 +257,3 @@
(if (= mention constants/everyone-mention-id)
(i18n/label :t/everyone-mention)
contact-name)))

View File

@ -68,7 +68,6 @@
(reg-root-key-sub :supported-biometric-auth :supported-biometric-auth)
(reg-root-key-sub :connectivity/ui-status-properties :connectivity/ui-status-properties)
(reg-root-key-sub :logged-in-since :logged-in-since)
(reg-root-key-sub :link-previews-whitelist :link-previews-whitelist)
(reg-root-key-sub :app-state :app-state)
(reg-root-key-sub :home-items-show-number :home-items-show-number)
(reg-root-key-sub :waku/v2-peer-stats :peer-stats)
@ -194,6 +193,11 @@
(reg-root-key-sub :wallet/swap-to-token-amount :wallet/swap-to-token-amount)
(reg-root-key-sub :wallet/swap-advanced-mode? :wallet/swap-advanced-mode?)
;;; Link previews
(reg-root-key-sub :link-previews-whitelist :link-previews-whitelist)
(reg-root-key-sub :chat/link-previews :chat/link-previews)
;;commands
(reg-root-key-sub :commands/select-account :commands/select-account)

View File

@ -3,7 +3,7 @@
"_comment": "Instead use: scripts/update-status-go.sh <rev>",
"owner": "status-im",
"repo": "status-go",
"version": "v0.151.11",
"commit-sha1": "c7aebfeed36754e03d002cda03ff3339d3d1d2f7",
"src-sha256": "162v2r5af8m9ia1pd4wxip9a472cg65z8k25rrip3dc31pb5gw8d"
"version": "v0.151.12",
"commit-sha1": "6fa8c113821509d744b81d90dc21c1e79c575577",
"src-sha256": "02mmhy4z6gd9xlqjhw4i4j48gd85pijyyga48if9dd3zasqkp3y1"
}

View File

@ -2083,6 +2083,7 @@
"scan-sync-qr-code": "Scan QR code",
"enter-sync-code": "Enter sync code",
"enable-access-to-camera": "Enable access to camera",
"link-preview-loading-message": "Generating preview",
"to-scan-a-qr-enable-your-camera": "To scan a QR, enable your camera",
"enable-camera": "Enable camera",
"ensure-qr-code-in-focus-to-scan": "Ensure that the QR code is in focus to scan",