Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[#21658] - Avatars blinking #21782

Open
wants to merge 8 commits into
base: develop
Choose a base branch
from
Open

[#21658] - Avatars blinking #21782

wants to merge 8 commits into from

Conversation

ulisesmac
Copy link
Contributor

@ulisesmac ulisesmac commented Dec 10, 2024

fixes #21658
fixes #21215

Summary

This PR fixes some issues found related to the rendering of some components. The code fixes are listed in the self-review.

The main fixes are:

1. fast-image usages blinking

A background image for the image was added, this fix might be polemic because we are duplicating the images rendered, so feel free to share your thoughts about it. (btw, the no-flicker-image in the repo already does this for rn/image). The comparison:

Before:

avatars-blinking.mp4

After:

avatars-fixed.mp4

2. Avatar not being updated in profile screen:

Before:

Avatar-not-updated.mp4

After:

avatar-not-updated-fixed.mp4

3. Fixed the avatar styles and animation.

Reported in:

Now it follows designs and the calculations are simpler. You can see the animation also looks more stable:

Before:

avatar-animation.mp4

After:

avatar-animation-fixed.mp4

4. Fixed the ring around the avatar on Android, it changes its color. This feature still works on iOS consistently.

Before:

avatar-circle-not-matching.mp4

After:

avatar.circle.mp4

Platforms

  • Android
  • iOS

status: ready

Comment on lines 39 to 43
on-image-loaded (fn [event on-load source]
(when (fn? on-load) (on-load event))
(reset! loaded? true)
(reset! error? false)
(reset! previous-source source))]
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fast image fix 👀

Comment on lines 30 to 42
(defn base-list-props
[{:keys [key-fn render-fn empty-component header footer separator data render-data on-drag-end-fn]
[{:keys [key-fn data render-fn empty-component header footer separator render-data on-drag-end-fn]
:as props}]
(merge
{:data (to-array data)}
(when key-fn {:keyExtractor (wrap-key-fn key-fn)})
(when render-fn {:renderItem (wrap-render-fn render-fn render-data)})
(when separator {:ItemSeparatorComponent (fn [] (reagent/as-element separator))})
(when empty-component {:ListEmptyComponent (fn [] (reagent/as-element empty-component))})
(when header {:ListHeaderComponent (reagent/as-element header)})
(when footer {:ListFooterComponent (reagent/as-element footer)})
(when on-drag-end-fn {:onDragEnd (wrap-on-drag-end-fn on-drag-end-fn)})
(dissoc props :data :header :footer :empty-component :separator :render-fn :key-fn :on-drag-end-fn)))
(cond-> {:data (to-array data)}
key-fn (assoc :keyExtractor (wrap-key-fn key-fn))
render-fn (assoc :renderItem (wrap-render-fn render-fn render-data))
separator (assoc :ItemSeparatorComponent (fn [] (reagent/as-element separator)))
empty-component (assoc :ListEmptyComponent (fn [] (reagent/as-element empty-component)))
header (assoc :ListHeaderComponent (reagent/as-element header))
footer (assoc :ListFooterComponent (reagent/as-element footer))
on-drag-end-fn (assoc :onDragEnd (wrap-on-drag-end-fn on-drag-end-fn))
:always (merge (dissoc-custom-props props))))

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

multiple merge -> multiple assoc

Comment on lines -10 to +12
(defn f-avatar
(def scroll-range #js [0 48])
(def scale-range #js [1 0.4])

(defn view
[{:keys [scroll-y full-name online? profile-picture customization-color border-color]}]
(let [image-scale-animation (reanimated/interpolate scroll-y
scroll-animation-input-range
[1 0.4]
header-extrapolation-option)
image-top-margin-animation (reanimated/interpolate scroll-y
scroll-animation-input-range
[0 20]
header-extrapolation-option)
image-side-margin-animation (reanimated/interpolate scroll-y
scroll-animation-input-range
[-4 -20]
header-extrapolation-option)]
[reanimated/view
{:style (style/wrapper {:scale image-scale-animation
:margin-top image-top-margin-animation
:margin image-side-margin-animation
:border-color border-color})}
(let [image-scale (reanimated/interpolate scroll-y scroll-range scale-range :clamp)]
[reanimated/view {:style (style/wrapper border-color image-scale)}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avatar animation fix

Comment on lines -65 to +66
:render-fn (fn [item]
(chat-list-item/chat-list-item item theme))
:render-data {:theme theme}
:render-fn chat-list-item/chat-list-item
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix rerenders on items for this flat-list

Comment on lines +25 to +29
(defn avatar-border-color
[theme]
(if platform/android?
colors/neutral-80-opa-80 ;; Fix is not needed because Android doesn't use blur
(colors/theme-colors colors/border-avatar-light colors/neutral-80-opa-80 theme)))
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avatar's ring fix

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth noting that we have an issue that we can play in 2.33 to remove identity rings #21743

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This ring I'm referring to is the one showed in PR's video:

avatar-circle-not-matching.mp4

different to the identity ring

@status-im-auto
Copy link
Member

status-im-auto commented Dec 10, 2024

Jenkins Builds

Click to see older builds (9)
Commit #️⃣ Finished (UTC) Duration Platform Result
✔️ f87590c #1 2024-12-10 02:53:40 ~5 min tests 📄log
✔️ f87590c #1 2024-12-10 02:55:22 ~7 min android-e2e 🤖apk 📲
✔️ f87590c #1 2024-12-10 02:57:00 ~9 min android 🤖apk 📲
✔️ f87590c #1 2024-12-10 02:59:45 ~11 min ios 📱ipa 📲
✔️ 7764e2c #2 2024-12-11 17:56:21 ~5 min tests 📄log
✔️ 7764e2c #2 2024-12-11 17:59:47 ~8 min android-e2e 🤖apk 📲
✔️ 7764e2c #2 2024-12-11 18:00:33 ~9 min android 🤖apk 📲
✔️ 7764e2c #2 2024-12-11 18:08:52 ~17 min ios 📱ipa 📲
0bd6156 #3 2024-12-13 01:25:50 ~2 min tests 📄log
Commit #️⃣ Finished (UTC) Duration Platform Result
✔️ 0560f79 #4 2024-12-13 01:33:52 ~4 min tests 📄log
✔️ 0560f79 #4 2024-12-13 01:36:03 ~6 min android 🤖apk 📲
✔️ 0560f79 #4 2024-12-13 01:36:11 ~7 min ios 📱ipa 📲
✔️ 0560f79 #4 2024-12-13 01:36:57 ~7 min android-e2e 🤖apk 📲
99b30d2 #5 2024-12-17 01:34:39 ~2 min tests 📄log
✔️ 99b30d2 #5 2024-12-17 01:38:14 ~6 min ios 📱ipa 📲
✔️ 99b30d2 #5 2024-12-17 01:38:26 ~6 min android 🤖apk 📲
✔️ 99b30d2 #5 2024-12-17 01:39:11 ~7 min android-e2e 🤖apk 📲

Copy link
Contributor

@mohsen-ghafouri mohsen-ghafouri left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, Nice improvement 🚀

@flexsurfer
Copy link
Member

hey @ulisesmac do you know why we didn't have fast-image usages blinking problem before? When and where did it come from?

Comment on lines +25 to +29
(defn avatar-border-color
[theme]
(if platform/android?
colors/neutral-80-opa-80 ;; Fix is not needed because Android doesn't use blur
(colors/theme-colors colors/border-avatar-light colors/neutral-80-opa-80 theme)))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth noting that we have an issue that we can play in 2.33 to remove identity rings #21743

:priority :high}
source))

;; NOTE: We need to use ratoms to avoid the flickering since their state is updated
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ulisesmac I thought all image flickers were caused by buggy implementation (e.g. components receiving unstable function references as we both identified in #21658).

If the new props passed to fast-image are equal to previous props, Reagent would skip processing fast-image altogether and there would be no flicker. If you could pick one example where this workaround is needed, could you explain what's changing between render passes of fast-image that is causing Reagent to reprocess it?

@ulisesmac
Copy link
Contributor Author

ulisesmac commented Dec 10, 2024

@ilmotta @flexsurfer

Thank you so much for your review!
I'll explain the fix about fast-image.

I thought all image flickers were caused by buggy implementation (e.g. components receiving unstable function references as we both identified in #21658).

Yeah, that's true for unnecessary rerenders, but not all image flickerings are due to this.

If the new props passed to fast-image are equal to previous props, Reagent would skip processing fast-image altogether and there would be no flicker. If you could pick one example where this workaround is needed, could you explain what's changing between render passes of fast-image that is causing Reagent to reprocess it?

This is a side effect of the image server.

I added videos to the description, here's an example:

We use an URL A to render the avatar, but when the user opens the share modal, the app switches to a background state, so the image server will change its port, so we generate an URL B for the avatar, so a rerender is needed even though the resource is the same (i.e. the same user image).

And now, you may wonder

How is that related to fast-image?

The answer is that our wrapper for fast-image is rendering a spinner and a letter X as placeholders for loading and error states respectively, causing a quick blink when we swap URLs (sources). fast-image itself isn't able to swap sources without a visual blank state.

What I did in this PR is implementing a second bg image that avoids that visual blank state. It's just a visual fix.

This fix also improved the appearance of necessary re-renders. e.g. When the user `updates their profile name, the avatar blinks on develop, in this PR this no longer happens. LMK if you want me to record videos comparing the differences.

IMO this fix is relatively cheap and provides a lot the looking of the app.

do you know why we didn't have fast-image usages blinking problem before? When and where did it come from?

The image server dynamic port is a reason, another reason is when we just update its props, so I'd say the issue has been there all the time. Maybe on some fast iOS devices the rerender is too fast to be easily perceived

@ilmotta
Copy link
Contributor

ilmotta commented Dec 10, 2024

Thanks @ulisesmac for the detailed answer.

The fast image cache strategy we use is the default, immutable. That means whenever the port changes or any other part of the URL, the cache is thrown away. This isn't good of course.

The best possible implementation I think is if we could leverage the ETag HTTP header because then no data is even transferred if the resource didn't change. I wonder how much/well the fast-image library supports cache headers as they say with the web strategy. The ETag value is usually a hash of the resource, which status-go would compute.

A change of port shouldn't be considered a change of resource between the client & status-go communication. But I think fast-image doesn't provide a way to customize the logic to decide when the cache is stale or not, at least as far as the official docs go.

What if we provide a custom React key that's stable between re-renders by ignoring the port change? Wouldn't that solve the flashing?

The answer is that our wrapper for fast-image is rendering a spinner and a letter X as placeholders for loading and error states

For loading, we should ideally differenciate between fetching local resources and actual remote resources. The loading spinner should only be used for non-local resources, similar to how we avoid in UX to show spinners for resources loading in milliseconds. Images coming from the media-server are local as far as I understand.

For error states I'm not sure what to say, but it should be a rare problem that wouldn't justify flashing side-effects.

@ulisesmac
Copy link
Contributor Author

Thanks @ulisesmac for the detailed answer.

The best possible implementation I think is if we could leverage the ETag HTTP header because then no data is even transferred if the resource didn't change. I wonder how much/well the fast-image library supports cache headers as they say with the web strategy. The ETag value is usually a hash of the resource, which status-go would compute.

I agree, or explore better solutions for the image-server.

What if we provide a custom React key that's stable between re-renders by ignoring the port change? Wouldn't that solve the flashing?

The problem is: if we skip the rerender, how do we know when the image should have been updated?

E.g. the user actually had their profile picture updated but we ignored the rerender.

For loading, we should ideally differenciate between fetching local resources and actual remote resources. The loading spinner should only be used for non-local resources, similar to how we avoid in UX to show spinners for resources loading in milliseconds. Images coming from the media-server are local as far as I understand.

For local resources we shouldn't use fast-image, I agree. But even if no spinners or error states were displayed, the fast image still shows a blink (since it throws the previous image and loads the new one)

@ilmotta
Copy link
Contributor

ilmotta commented Dec 11, 2024

The problem is: if we skip the rerender, how do we know when the image should have been updated?

E.g. the user actually had their profile picture updated but we ignored the rerender.

The idea is that the port number shouldn't be considered part of the cache key for FastImage or any other mechanism. If we use React.memo we can provide a custom comparator function that ignores the port number in the URI. I tested this solution and the blinking disappeared completely from all avatars and the share QR code screen.

Don't judge too much the following snippet @ulisesmac, but it's a starting point and seems to work well. I wouldn't be surprised if this idea is flawed. For simplification purposes I ignored loading and error conditions.

(defn clean-port
  [uri]
  (string/replace-first uri #":\d+" ""))

(def fast-image
  (reagent/adapt-react-class
   (rn/memo
    (fn [^js props]
      (let [source (oops/oget props :source)]
        (react/createElement
         FastImage
         (js/Object.assign
          #js {}
          props
          (when (and props source)
            #js {:source (if (string? source)
                           #js {:uri      source
                                :priority "high"}
                           #js {:uri      (oops/oget source :uri)
                                :priority "high"})})))))
    (fn [prev-props next-props]
      (let [prev-uri (oops/oget prev-props :source :uri)
            next-uri (oops/oget next-props :source :uri)]
        (and prev-uri
             next-uri
             (= (remove-port prev-uri) (remove-port next-uri))))))))

In any case, it's not my intention to suggest this is the only way, I leave it up to you and other CCs to judge. It's good to know we have other options in the future to better fix blinking images.

@flexsurfer
Copy link
Member

flexsurfer commented Dec 11, 2024

but when the user opens the share modal, the app switches to a background state

i'm not sure i understand how that happen, could you elaborate? why does it switch to a background state in that case?

@seanstrom
Copy link
Member

@flexsurfer I think when an iOS share-sheet opens, the OS will sorta transition the the app into an inactive/background state because share-sheet is native OS menu and outside of the app's control. I think also might happen when someone pulls down the iOS control-center from the top-right while running the app.

@seanstrom
Copy link
Member

@ulisesmac I like the idea of having a component that maintains a background image and foreground image, that could be pretty useful for transitioning between two images!

Though I was thinking that @ilmotta's approach with using a component to ignore changes with port prop to also be compelling, since it would save us from needing to have a second image for each image (right?).

Also, we still have a clock that's useful for determining when a profile image has been changed. So we could potentially rely on the clock field and other source name fields to help with determining when a profiles avatar/image has changed. In the case with the initials-avatar, I think it should be the display-name (or full-name) and the clock field that should cause a re-render.

Copy link
Contributor

@ilmotta ilmotta left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even though I think there are better solutions to the blinking problem, I think your solution is practical and solves the problem without introducing performance regressions. Approved! Thanks @ulisesmac for solving this old problem!

@ulisesmac
Copy link
Contributor Author

@flexsurfer

i'm not sure i understand how that happen, could you elaborate? why does it switch to a background state in that case?

It's switched to a background state because, as @seanstrom said, the share sheet comes from the OS, so it swithces our app to bg, after the sheet is closed our app returns to foreground.

Because of this, the image server changes its ports and a re-render happens for all the images provided by the server.

@ulisesmac
Copy link
Contributor Author

ulisesmac commented Dec 12, 2024

@ilmotta Thanks for remembering me memo is an option.

The solution you provided looks better to me since we aren't duplicating the images shown. However, although it solves the unnecessary re-render problem, it doesn't solve the issue about swapping the image without a blink.

Thanks for approving, but I'll test your suggestion to see how it behaves 👍 Thanks again

- On Android, the border was inconsistent depending on the theme.
- Fix animation, simplify the calculation, and now it matches designs
@ulisesmac ulisesmac force-pushed the 21658-avatars-blinking branch from 7764e2c to 0bd6156 Compare December 13, 2024 01:22
Comment on lines 22 to 71
(defn- placeholder
[{:keys [style fallback-content error? loaded?]}]
[rn/view
{:style (assoc style
:flex 1
:justify-content :center
:align-items :center)}
(cond
(and error? fallback-content) fallback-content
error? [rn/text "X"]
(not loaded?) [rn/activity-indicator {:animating true}])])

;; We cannot use hooks since `reactify-component` seems to ignore the functional compiler
(defn- internal-fast-image
[_]
(let [loaded? (reagent/atom false)
error? (reagent/atom false)]
(fn [{:keys [source fallback-content] :as props}]
[fast-image-class
(merge
props
{:source (if (string? source)
{:uri source
:priority :high}
source)
:on-error (fn [e]
(when-let [on-error (:on-error props)]
(on-error e))
(reset! error? true))
:on-load (fn [e]
(when-let [on-load (:on-load props)]
(on-load e))
(reset! loaded? true)
(reset! error? false))})
(let [loaded? (reagent/atom false)
error? (reagent/atom false)
on-image-error (fn [event on-error]
(when (fn? on-error) (on-error event))
(reset! error? true))
on-image-loaded (fn [event on-load]
(when (fn? on-load) (on-load event))
(reset! loaded? true)
(reset! error? false))]
(fn [{:keys [source fallback-content on-error on-load] :as props}]
[:> FastImage
(assoc props
:source (build-source source)
:on-error #(on-image-error % on-error)
:on-load #(on-image-loaded % on-load))
(when (or @error? (not @loaded?))
[placeholder (:style props)
(if @error?
(or fallback-content [rn/text "X"])
(when-not @loaded?
[rn/activity-indicator {:animating true}]))])])))
[placeholder
{:style (js->clj (:style props))
:fallback-content fallback-content
:error? @error?
:loaded? @loaded?}])])))

(defn- compare-sources
[old-props new-props]
(let [old-source (oops/oget old-props :source)
new-source (oops/oget new-props :source)]
(and old-source
new-source
(= (remove-port old-source) (remove-port new-source)))))

(def fast-image
(-> internal-fast-image
(reagent/reactify-component)
(rn/memo compare-sources)
(reagent/adapt-react-class)))
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ilmotta took your suggestion here, it actually fixes the problem. Thanks!

I prefer this solution since we aren't duplicating a component. mantainability isn't hurt since reactify-component was used.

BTW, for some reason, reactify-component seems to ignore the functional compiler, so no hooks were used (the comment on the code has been already added).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The way you used reactify-component made things much cleaner 👍🏼

Comment on lines +16 to +20
(defn- remove-port
[source]
(if (string? source)
(string/replace-first source #":\d+" "")
(string/replace-first (oops/oget source :uri) #":\d+" "")))
Copy link
Member

@seanstrom seanstrom Dec 17, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure, but does oops/get throw an exception if it doesn't find :uri?

If so, it might be worth using goog.object/get with a default value like: (goog.object/get source :uri "")

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question @seanstrom. Some time ago I changed the default behavior of oops during development, which is to instrument calls and throw on missing keys. Now it just warns. In advanced compilation instrumentation in oops is disabled by default and we don't have to configure anything special.

"Change oops defaults to warn and print instead of throwing exceptions during
development."
[]
(oops.config/update-current-runtime-config!
merge
{:error-reporting :console
:expected-function-value :warn
:invalid-selector :warn

@mariia-skrypnyk
Copy link

Hey @ulisesmac !

Will it be needed to test this PR?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
Status: REVIEW
Development

Successfully merging this pull request may close these issues.

Avatars blink all over the app Profile - Status dropdown text is misaligned in profile settings
7 participants