mirror of
https://github.com/trezor/trezor-firmware.git
synced 2024-12-22 06:18:07 +00:00
feat(core/mercury): animated device label on homescreen/lockscreen
This commit is contained in:
parent
3a3259b574
commit
9ab2ba9157
1
core/.changelog.d/3895.added
Normal file
1
core/.changelog.d/3895.added
Normal file
@ -0,0 +1 @@
|
|||||||
|
[T3T1] Animated device label on homescreen/lockscreen
|
@ -18,8 +18,10 @@ use crate::{
|
|||||||
use crate::ui::{
|
use crate::ui::{
|
||||||
component::Label,
|
component::Label,
|
||||||
constant::{screen, HEIGHT, WIDTH},
|
constant::{screen, HEIGHT, WIDTH},
|
||||||
|
lerp::Lerp,
|
||||||
model_mercury::{
|
model_mercury::{
|
||||||
cshape,
|
cshape,
|
||||||
|
cshape::UnlockOverlay,
|
||||||
theme::{GREY_LIGHT, HOMESCREEN_ICON, ICON_KEY},
|
theme::{GREY_LIGHT, HOMESCREEN_ICON, ICON_KEY},
|
||||||
},
|
},
|
||||||
shape::{render_on_canvas, ImageBuffer, Rgb565Canvas},
|
shape::{render_on_canvas, ImageBuffer, Rgb565Canvas},
|
||||||
@ -41,22 +43,14 @@ const LOADER_DURATION: Duration = Duration::from_millis(2000);
|
|||||||
|
|
||||||
pub const HOMESCREEN_IMAGE_WIDTH: i16 = WIDTH;
|
pub const HOMESCREEN_IMAGE_WIDTH: i16 = WIDTH;
|
||||||
pub const HOMESCREEN_IMAGE_HEIGHT: i16 = HEIGHT;
|
pub const HOMESCREEN_IMAGE_HEIGHT: i16 = HEIGHT;
|
||||||
pub const HOMESCREEN_TOIF_SIZE: i16 = 144;
|
|
||||||
|
const DEFAULT_HS_RADIUS: i16 = UnlockOverlay::RADIUS;
|
||||||
|
const DEFAULT_HS_SPAN: i16 = UnlockOverlay::SPAN;
|
||||||
|
const DEFAULT_HS_THICKNESS: i16 = 6;
|
||||||
|
const DEFAULT_HS_NUM_CIRCLES: i16 = 5;
|
||||||
|
|
||||||
fn render_default_hs<'a>(target: &mut impl Renderer<'a>) {
|
fn render_default_hs<'a>(target: &mut impl Renderer<'a>) {
|
||||||
const OVERLAY_OFFSET: i16 = 9;
|
shape::Bar::new(AREA)
|
||||||
|
|
||||||
const RADIUS: i16 = 85;
|
|
||||||
|
|
||||||
const SPAN: i16 = 10;
|
|
||||||
|
|
||||||
const THICKNESS: i16 = 6;
|
|
||||||
|
|
||||||
const NUM_CIRCLES: i16 = 5;
|
|
||||||
|
|
||||||
let area = AREA.translate(Offset::y(OVERLAY_OFFSET));
|
|
||||||
|
|
||||||
shape::Bar::new(area)
|
|
||||||
.with_fg(theme::BG)
|
.with_fg(theme::BG)
|
||||||
.with_bg(theme::BG)
|
.with_bg(theme::BG)
|
||||||
.render(target);
|
.render(target);
|
||||||
@ -66,23 +60,147 @@ fn render_default_hs<'a>(target: &mut impl Renderer<'a>) {
|
|||||||
#[cfg(not(any(feature = "universal_fw", feature = "ui_debug")))]
|
#[cfg(not(any(feature = "universal_fw", feature = "ui_debug")))]
|
||||||
let colors = [0xEEA600, 0xB27C00, 0x775300, 0x463100, 0x2C1F00];
|
let colors = [0xEEA600, 0xB27C00, 0x775300, 0x463100, 0x2C1F00];
|
||||||
|
|
||||||
for i in 0..NUM_CIRCLES {
|
for i in 0..DEFAULT_HS_NUM_CIRCLES {
|
||||||
let r = RADIUS - i * SPAN;
|
let r = DEFAULT_HS_RADIUS - i * DEFAULT_HS_SPAN;
|
||||||
let fg = Color::from_u32(colors[i as usize]);
|
let fg = Color::from_u32(colors[i as usize]);
|
||||||
let bg = theme::BG;
|
let bg = theme::BG;
|
||||||
let thickness = THICKNESS;
|
let thickness = DEFAULT_HS_THICKNESS;
|
||||||
shape::Circle::new(area.center(), r)
|
shape::Circle::new(AREA.center(), r)
|
||||||
.with_fg(fg)
|
.with_fg(fg)
|
||||||
.with_bg(bg)
|
.with_bg(bg)
|
||||||
.with_thickness(thickness)
|
.with_thickness(thickness)
|
||||||
.render(target);
|
.render(target);
|
||||||
}
|
}
|
||||||
|
|
||||||
shape::ToifImage::new(area.center(), HOMESCREEN_ICON.toif)
|
shape::ToifImage::new(AREA.center(), HOMESCREEN_ICON.toif)
|
||||||
.with_align(Alignment2D::CENTER)
|
.with_align(Alignment2D::CENTER)
|
||||||
.render(target);
|
.render(target);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct HideLabelAnimation {
|
||||||
|
pub timer: Stopwatch,
|
||||||
|
token: TimerToken,
|
||||||
|
animating: bool,
|
||||||
|
hidden: bool,
|
||||||
|
duration: Duration,
|
||||||
|
}
|
||||||
|
impl HideLabelAnimation {
|
||||||
|
const HIDE_AFTER: Duration = Duration::from_millis(3000);
|
||||||
|
|
||||||
|
fn new(label_width: i16) -> Self {
|
||||||
|
Self {
|
||||||
|
timer: Stopwatch::default(),
|
||||||
|
token: TimerToken::INVALID,
|
||||||
|
animating: false,
|
||||||
|
hidden: false,
|
||||||
|
duration: Duration::from_millis((label_width as u32 * 300) / 120),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_active(&self) -> bool {
|
||||||
|
self.timer.is_running_within(self.duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset(&mut self) {
|
||||||
|
self.timer = Stopwatch::default();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn change_dir(&mut self) {
|
||||||
|
let elapsed = self.timer.elapsed();
|
||||||
|
|
||||||
|
let start = self
|
||||||
|
.duration
|
||||||
|
.checked_sub(elapsed)
|
||||||
|
.and_then(|e| Instant::now().checked_sub(e));
|
||||||
|
|
||||||
|
if let Some(start) = start {
|
||||||
|
self.timer = Stopwatch::Running(start);
|
||||||
|
} else {
|
||||||
|
self.timer = Stopwatch::new_started();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn eval(&self, label_width: i16) -> Offset {
|
||||||
|
if animation_disabled() {
|
||||||
|
return Offset::zero();
|
||||||
|
}
|
||||||
|
|
||||||
|
let t = self.timer.elapsed().to_millis() as f32 / 1000.0;
|
||||||
|
|
||||||
|
let pos = if self.hidden {
|
||||||
|
pareen::constant(0.0)
|
||||||
|
.seq_ease_out(
|
||||||
|
0.0,
|
||||||
|
easer::functions::Cubic,
|
||||||
|
self.duration.to_millis() as f32 / 1000.0,
|
||||||
|
pareen::constant(1.0),
|
||||||
|
)
|
||||||
|
.eval(t)
|
||||||
|
} else {
|
||||||
|
pareen::constant(1.0)
|
||||||
|
.seq_ease_in(
|
||||||
|
0.0,
|
||||||
|
easer::functions::Cubic,
|
||||||
|
self.duration.to_millis() as f32 / 1000.0,
|
||||||
|
pareen::constant(0.0),
|
||||||
|
)
|
||||||
|
.eval(t)
|
||||||
|
};
|
||||||
|
|
||||||
|
Offset::x(i16::lerp(-(label_width + 12), 0, pos))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_event(&mut self, ctx: &mut EventCtx, event: Event) {
|
||||||
|
if let Event::Attach(_) = event {
|
||||||
|
ctx.request_anim_frame();
|
||||||
|
self.token = ctx.request_timer(Self::HIDE_AFTER);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Event::Timer(token) = event {
|
||||||
|
if token == self.token && !animation_disabled() {
|
||||||
|
self.timer.start();
|
||||||
|
ctx.request_anim_frame();
|
||||||
|
self.animating = true;
|
||||||
|
self.hidden = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Event::Timer(EventCtx::ANIM_FRAME_TIMER) = event {
|
||||||
|
if self.is_active() {
|
||||||
|
ctx.request_anim_frame();
|
||||||
|
ctx.request_paint();
|
||||||
|
} else if self.animating {
|
||||||
|
self.animating = false;
|
||||||
|
self.hidden = !self.hidden;
|
||||||
|
self.reset();
|
||||||
|
ctx.request_paint();
|
||||||
|
|
||||||
|
if !self.hidden {
|
||||||
|
self.token = ctx.request_timer(Self::HIDE_AFTER);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Event::Touch(TouchEvent::TouchStart(_)) = event {
|
||||||
|
if !self.animating {
|
||||||
|
if self.hidden {
|
||||||
|
self.timer.start();
|
||||||
|
self.animating = true;
|
||||||
|
ctx.request_anim_frame();
|
||||||
|
ctx.request_paint();
|
||||||
|
} else {
|
||||||
|
self.token = ctx.request_timer(Self::HIDE_AFTER);
|
||||||
|
}
|
||||||
|
} else if !self.hidden {
|
||||||
|
self.change_dir();
|
||||||
|
self.hidden = true;
|
||||||
|
ctx.request_anim_frame();
|
||||||
|
ctx.request_paint();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
pub struct HomescreenNotification {
|
pub struct HomescreenNotification {
|
||||||
pub text: TString<'static>,
|
pub text: TString<'static>,
|
||||||
@ -96,9 +214,11 @@ pub struct Homescreen {
|
|||||||
label_height: i16,
|
label_height: i16,
|
||||||
notification: Option<(TString<'static>, u8)>,
|
notification: Option<(TString<'static>, u8)>,
|
||||||
image: Option<BinaryData<'static>>,
|
image: Option<BinaryData<'static>>,
|
||||||
|
bg_image: ImageBuffer<Rgb565Canvas<'static>>,
|
||||||
hold_to_lock: bool,
|
hold_to_lock: bool,
|
||||||
loader: Loader,
|
loader: Loader,
|
||||||
delay: Option<TimerToken>,
|
delay: Option<TimerToken>,
|
||||||
|
label_anim: HideLabelAnimation,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum HomescreenMsg {
|
pub enum HomescreenMsg {
|
||||||
@ -114,15 +234,28 @@ impl Homescreen {
|
|||||||
let label_width = label.map(|t| theme::TEXT_DEMIBOLD.text_font.text_width(t));
|
let label_width = label.map(|t| theme::TEXT_DEMIBOLD.text_font.text_width(t));
|
||||||
let label_height = label.map(|t| theme::TEXT_DEMIBOLD.text_font.visible_text_height(t));
|
let label_height = label.map(|t| theme::TEXT_DEMIBOLD.text_font.visible_text_height(t));
|
||||||
|
|
||||||
|
let image = get_homescreen_image();
|
||||||
|
let mut buf = unwrap!(ImageBuffer::new(AREA.size()), "no image buf");
|
||||||
|
|
||||||
|
render_on_canvas(buf.canvas(), None, |target| {
|
||||||
|
if let Some(image) = image {
|
||||||
|
shape::JpegImage::new_image(Point::zero(), image).render(target);
|
||||||
|
} else {
|
||||||
|
render_default_hs(target);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
label: Label::new(label, Alignment::Center, theme::TEXT_DEMIBOLD).vertically_centered(),
|
label: Label::new(label, Alignment::Center, theme::TEXT_DEMIBOLD).vertically_centered(),
|
||||||
label_width,
|
label_width,
|
||||||
label_height,
|
label_height,
|
||||||
notification,
|
notification,
|
||||||
image: get_homescreen_image(),
|
image,
|
||||||
|
bg_image: buf,
|
||||||
hold_to_lock,
|
hold_to_lock,
|
||||||
loader: Loader::with_lock_icon().with_durations(LOADER_DURATION, LOADER_DURATION / 3),
|
loader: Loader::with_lock_icon().with_durations(LOADER_DURATION, LOADER_DURATION / 3),
|
||||||
delay: None,
|
delay: None,
|
||||||
|
label_anim: HideLabelAnimation::new(label_width),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -218,12 +351,15 @@ impl Component for Homescreen {
|
|||||||
fn place(&mut self, bounds: Rect) -> Rect {
|
fn place(&mut self, bounds: Rect) -> Rect {
|
||||||
self.loader.place(AREA.translate(LOADER_OFFSET));
|
self.loader.place(AREA.translate(LOADER_OFFSET));
|
||||||
self.label
|
self.label
|
||||||
.place(bounds.split_top(38).0.split_left(self.label_width + 12).0);
|
.place(bounds.split_top(32).0.with_width(self.label_width + 12));
|
||||||
bounds
|
bounds
|
||||||
}
|
}
|
||||||
|
|
||||||
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
|
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
|
||||||
Self::event_usb(self, ctx, event);
|
Self::event_usb(self, ctx, event);
|
||||||
|
|
||||||
|
self.label_anim.process_event(ctx, event);
|
||||||
|
|
||||||
if self.hold_to_lock {
|
if self.hold_to_lock {
|
||||||
Self::event_hold(self, ctx, event).then_some(HomescreenMsg::Dismissed)
|
Self::event_hold(self, ctx, event).then_some(HomescreenMsg::Dismissed)
|
||||||
} else {
|
} else {
|
||||||
@ -239,22 +375,17 @@ impl Component for Homescreen {
|
|||||||
if self.loader.is_animating() || self.loader.is_completely_grown(Instant::now()) {
|
if self.loader.is_animating() || self.loader.is_completely_grown(Instant::now()) {
|
||||||
self.render_loader(target);
|
self.render_loader(target);
|
||||||
} else {
|
} else {
|
||||||
if let Some(image) = self.image {
|
shape::RawImage::new(AREA, self.bg_image.view()).render(target);
|
||||||
if let ImageInfo::Jpeg(_) = ImageInfo::parse(image) {
|
|
||||||
shape::JpegImage::new_image(AREA.center(), image)
|
|
||||||
.with_align(Alignment2D::CENTER)
|
|
||||||
.render(target);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
render_default_hs(target);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
let y_offset = self.label_anim.eval(self.label_width);
|
||||||
|
|
||||||
|
target.with_origin(y_offset, &|target| {
|
||||||
let label_width = self
|
let label_width = self
|
||||||
.label
|
.label
|
||||||
.text()
|
.text()
|
||||||
.map(|t| theme::TEXT_DEMIBOLD.text_font.text_width(t));
|
.map(|t| theme::TEXT_DEMIBOLD.text_font.text_width(t));
|
||||||
|
|
||||||
let r = Rect::new(Point::new(-30, -30), Point::new(label_width + 12, 38));
|
let r = Rect::new(Point::new(-30, -30), Point::new(label_width + 12, 32));
|
||||||
shape::Bar::new(r)
|
shape::Bar::new(r)
|
||||||
.with_bg(Color::black())
|
.with_bg(Color::black())
|
||||||
.with_alpha(160)
|
.with_alpha(160)
|
||||||
@ -262,10 +393,11 @@ impl Component for Homescreen {
|
|||||||
.render(target);
|
.render(target);
|
||||||
|
|
||||||
self.label.render(target);
|
self.label.render(target);
|
||||||
|
});
|
||||||
|
|
||||||
if let Some(notif) = self.get_notification() {
|
if let Some(notif) = self.get_notification() {
|
||||||
const NOTIFICATION_HEIGHT: i16 = 34;
|
const NOTIFICATION_HEIGHT: i16 = 30;
|
||||||
const NOTIFICATION_TOP: i16 = 202;
|
const NOTIFICATION_TOP: i16 = 208;
|
||||||
const NOTIFICATION_BORDER: i16 = 16;
|
const NOTIFICATION_BORDER: i16 = 16;
|
||||||
|
|
||||||
notif.text.map(|t| {
|
notif.text.map(|t| {
|
||||||
@ -290,7 +422,7 @@ impl Component for Homescreen {
|
|||||||
);
|
);
|
||||||
|
|
||||||
shape::Bar::new(banner)
|
shape::Bar::new(banner)
|
||||||
.with_radius(16)
|
.with_radius(14)
|
||||||
.with_bg(theme::ORANGE_DARK)
|
.with_bg(theme::ORANGE_DARK)
|
||||||
.with_alpha(160)
|
.with_alpha(160)
|
||||||
.render(target);
|
.render(target);
|
||||||
@ -351,6 +483,7 @@ pub struct Lockscreen {
|
|||||||
bootscreen: bool,
|
bootscreen: bool,
|
||||||
coinjoin_authorized: bool,
|
coinjoin_authorized: bool,
|
||||||
bg_image: ImageBuffer<Rgb565Canvas<'static>>,
|
bg_image: ImageBuffer<Rgb565Canvas<'static>>,
|
||||||
|
label_anim: HideLabelAnimation,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Lockscreen {
|
impl Lockscreen {
|
||||||
@ -378,6 +511,7 @@ impl Lockscreen {
|
|||||||
bootscreen,
|
bootscreen,
|
||||||
coinjoin_authorized,
|
coinjoin_authorized,
|
||||||
bg_image: buf,
|
bg_image: buf,
|
||||||
|
label_anim: HideLabelAnimation::new(label_width),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -387,7 +521,7 @@ impl Component for Lockscreen {
|
|||||||
|
|
||||||
fn place(&mut self, bounds: Rect) -> Rect {
|
fn place(&mut self, bounds: Rect) -> Rect {
|
||||||
self.label
|
self.label
|
||||||
.place(bounds.split_top(38).0.split_left(self.label_width + 12).0);
|
.place(bounds.split_top(38).0.with_width(self.label_width + 12));
|
||||||
bounds
|
bounds
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -406,6 +540,8 @@ impl Component for Lockscreen {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.label_anim.process_event(ctx, event);
|
||||||
|
|
||||||
if let Event::Touch(TouchEvent::TouchEnd(_)) = event {
|
if let Event::Touch(TouchEvent::TouchEnd(_)) = event {
|
||||||
return Some(HomescreenMsg::Dismissed);
|
return Some(HomescreenMsg::Dismissed);
|
||||||
}
|
}
|
||||||
@ -418,22 +554,19 @@ impl Component for Lockscreen {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
|
fn render<'s>(&'s self, target: &mut impl Renderer<'s>) {
|
||||||
const OVERLAY_RADIUS: i16 = 85;
|
const OVERLAY_BORDER: i16 = (AREA.height() / 2) - DEFAULT_HS_RADIUS;
|
||||||
const OVERLAY_BORDER: i16 = (AREA.height() / 2) - OVERLAY_RADIUS;
|
|
||||||
const OVERLAY_OFFSET: i16 = 9;
|
|
||||||
|
|
||||||
let center = AREA.center();
|
let center = AREA.center();
|
||||||
|
|
||||||
shape::RawImage::new(AREA, self.bg_image.view()).render(target);
|
shape::RawImage::new(AREA, self.bg_image.view()).render(target);
|
||||||
|
|
||||||
cshape::UnlockOverlay::new(center + Offset::y(OVERLAY_OFFSET), self.anim.eval())
|
cshape::UnlockOverlay::new(center, self.anim.eval()).render(target);
|
||||||
.render(target);
|
|
||||||
|
|
||||||
shape::Bar::new(AREA.split_top(OVERLAY_BORDER + OVERLAY_OFFSET).0)
|
shape::Bar::new(AREA.split_top(OVERLAY_BORDER).0)
|
||||||
.with_bg(Color::black())
|
.with_bg(Color::black())
|
||||||
.render(target);
|
.render(target);
|
||||||
|
|
||||||
shape::Bar::new(AREA.split_bottom(OVERLAY_BORDER - OVERLAY_OFFSET - 2).1)
|
shape::Bar::new(AREA.split_bottom(OVERLAY_BORDER - 2).1)
|
||||||
.with_bg(Color::black())
|
.with_bg(Color::black())
|
||||||
.render(target);
|
.render(target);
|
||||||
|
|
||||||
@ -445,7 +578,7 @@ impl Component for Lockscreen {
|
|||||||
.with_bg(Color::black())
|
.with_bg(Color::black())
|
||||||
.render(target);
|
.render(target);
|
||||||
|
|
||||||
shape::ToifImage::new(center + Offset::y(OVERLAY_OFFSET), ICON_KEY.toif)
|
shape::ToifImage::new(center, ICON_KEY.toif)
|
||||||
.with_align(Alignment2D::CENTER)
|
.with_align(Alignment2D::CENTER)
|
||||||
.with_fg(GREY_LIGHT)
|
.with_fg(GREY_LIGHT)
|
||||||
.render(target);
|
.render(target);
|
||||||
@ -459,14 +592,21 @@ impl Component for Lockscreen {
|
|||||||
(None, TR::lockscreen__tap_to_unlock)
|
(None, TR::lockscreen__tap_to_unlock)
|
||||||
};
|
};
|
||||||
|
|
||||||
self.label.render(target);
|
let y_offset = self.label_anim.eval(self.label_width);
|
||||||
|
|
||||||
let mut offset = 6 + self.label_height;
|
let mut offset = 6 + self.label_height;
|
||||||
|
|
||||||
if let Some(t) = locked {
|
if let Some(t) = locked {
|
||||||
t.map_translated(|t| {
|
t.map_translated(|t| {
|
||||||
offset += theme::TEXT_SUB_GREY.text_font.visible_text_height(t);
|
offset += theme::TEXT_SUB_GREY.text_font.visible_text_height(t);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
target.with_origin(y_offset, &|target| {
|
||||||
|
self.label.render(target);
|
||||||
|
|
||||||
|
if let Some(t) = locked {
|
||||||
|
t.map_translated(|t| {
|
||||||
let text_pos = Point::new(6, offset);
|
let text_pos = Point::new(6, offset);
|
||||||
|
|
||||||
shape::Text::new(text_pos, t)
|
shape::Text::new(text_pos, t)
|
||||||
@ -475,6 +615,7 @@ impl Component for Lockscreen {
|
|||||||
.render(target);
|
.render(target);
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
});
|
||||||
|
|
||||||
tap.map_translated(|t| {
|
tap.map_translated(|t| {
|
||||||
offset = theme::TEXT_SUB_GREY.text_font.text_baseline();
|
offset = theme::TEXT_SUB_GREY.text_font.text_baseline();
|
||||||
|
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user