mirror of
https://github.com/zed-industries/zed.git
synced 2024-12-25 09:42:45 +03:00
wayland: File drag and drop (#10817)
Implements file drag and drop on Wayland https://github.com/zed-industries/zed/assets/71973804/febcfbfe-3a23-4593-8dd3-e85254e58eb5 Release Notes: - N/A
This commit is contained in:
parent
029eb67043
commit
ae3c641bbe
12
Cargo.lock
generated
12
Cargo.lock
generated
@ -3799,6 +3799,17 @@ dependencies = [
|
||||
"util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filedescriptor"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7199d965852c3bac31f779ef99cbb4537f80e952e2d6aa0ffeb30cce00f4f46e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"thiserror",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.22"
|
||||
@ -4482,6 +4493,7 @@ dependencies = [
|
||||
"derive_more",
|
||||
"env_logger",
|
||||
"etagere",
|
||||
"filedescriptor",
|
||||
"flume",
|
||||
"font-kit",
|
||||
"foreign-types 0.5.0",
|
||||
|
@ -115,6 +115,7 @@ wayland-protocols = { version = "0.31.2", features = [
|
||||
] }
|
||||
oo7 = "0.3.0"
|
||||
open = "5.1.2"
|
||||
filedescriptor = "0.8.2"
|
||||
x11rb = { version = "0.13.0", features = ["allow-unsafe-code", "xkb", "randr"] }
|
||||
xkbcommon = { version = "0.7", features = ["wayland", "x11"] }
|
||||
|
||||
|
@ -3,7 +3,10 @@
|
||||
use std::any::{type_name, Any};
|
||||
use std::cell::{self, RefCell};
|
||||
use std::env;
|
||||
use std::fs::File;
|
||||
use std::io::Read;
|
||||
use std::ops::{Deref, DerefMut};
|
||||
use std::os::fd::{AsRawFd, FromRawFd};
|
||||
use std::panic::Location;
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
@ -19,6 +22,7 @@ use async_task::Runnable;
|
||||
use calloop::channel::Channel;
|
||||
use calloop::{EventLoop, LoopHandle, LoopSignal};
|
||||
use copypasta::ClipboardProvider;
|
||||
use filedescriptor::FileDescriptor;
|
||||
use flume::{Receiver, Sender};
|
||||
use futures::channel::oneshot;
|
||||
use parking_lot::Mutex;
|
||||
@ -484,6 +488,19 @@ pub(super) fn is_within_click_distance(a: Point<Pixels>, b: Point<Pixels>) -> bo
|
||||
diff.x.abs() <= DOUBLE_CLICK_DISTANCE && diff.y.abs() <= DOUBLE_CLICK_DISTANCE
|
||||
}
|
||||
|
||||
pub(super) unsafe fn read_fd(mut fd: FileDescriptor) -> Result<String> {
|
||||
let mut file = File::from_raw_fd(fd.as_raw_fd());
|
||||
|
||||
let mut buffer = String::new();
|
||||
file.read_to_string(&mut buffer)?;
|
||||
|
||||
// Normalize the text to unix line endings, otherwise
|
||||
// copying from eg: firefox inserts a lot of blank
|
||||
// lines, and that is super annoying.
|
||||
let result = buffer.replace("\r\n", "\n");
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
impl Keystroke {
|
||||
pub(super) fn from_xkb(state: &State, modifiers: Modifiers, keycode: Keycode) -> Self {
|
||||
let mut modifiers = modifiers;
|
||||
|
@ -1,5 +1,9 @@
|
||||
use core::hash;
|
||||
use std::cell::{RefCell, RefMut};
|
||||
use std::os::fd::{AsRawFd, BorrowedFd};
|
||||
use std::path::PathBuf;
|
||||
use std::rc::{Rc, Weak};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use async_task::Runnable;
|
||||
@ -9,13 +13,19 @@ use calloop_wayland_source::WaylandSource;
|
||||
use collections::HashMap;
|
||||
use copypasta::wayland_clipboard::{create_clipboards_from_external, Clipboard, Primary};
|
||||
use copypasta::ClipboardProvider;
|
||||
use filedescriptor::Pipe;
|
||||
use smallvec::SmallVec;
|
||||
use util::ResultExt;
|
||||
use wayland_backend::client::ObjectId;
|
||||
use wayland_backend::protocol::WEnum;
|
||||
use wayland_client::event_created_child;
|
||||
use wayland_client::globals::{registry_queue_init, GlobalList, GlobalListContents};
|
||||
use wayland_client::protocol::wl_callback::{self, WlCallback};
|
||||
use wayland_client::protocol::wl_output;
|
||||
use wayland_client::protocol::wl_data_device_manager::DndAction;
|
||||
use wayland_client::protocol::wl_pointer::{AxisRelativeDirection, AxisSource};
|
||||
use wayland_client::protocol::{
|
||||
wl_data_device, wl_data_device_manager, wl_data_offer, wl_data_source, wl_output,
|
||||
};
|
||||
use wayland_client::{
|
||||
delegate_noop,
|
||||
protocol::{
|
||||
@ -35,14 +45,14 @@ use wayland_protocols::xdg::shell::client::{xdg_surface, xdg_toplevel, xdg_wm_ba
|
||||
use xkbcommon::xkb::ffi::XKB_KEYMAP_FORMAT_TEXT_V1;
|
||||
use xkbcommon::xkb::{self, Keycode, KEYMAP_COMPILE_NO_FLAGS};
|
||||
|
||||
use super::super::DOUBLE_CLICK_INTERVAL;
|
||||
use super::super::{read_fd, DOUBLE_CLICK_INTERVAL};
|
||||
use super::window::{WaylandWindowState, WaylandWindowStatePtr};
|
||||
use crate::platform::linux::is_within_click_distance;
|
||||
use crate::platform::linux::wayland::cursor::Cursor;
|
||||
use crate::platform::linux::wayland::window::WaylandWindow;
|
||||
use crate::platform::linux::LinuxClient;
|
||||
use crate::platform::PlatformWindow;
|
||||
use crate::{point, px, ForegroundExecutor, MouseExitEvent};
|
||||
use crate::{point, px, FileDropEvent, ForegroundExecutor, MouseExitEvent};
|
||||
use crate::{
|
||||
AnyWindowHandle, CursorStyle, DisplayId, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers,
|
||||
ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent,
|
||||
@ -58,6 +68,7 @@ const MIN_KEYCODE: u32 = 8;
|
||||
pub struct Globals {
|
||||
pub qh: QueueHandle<WaylandClientStatePtr>,
|
||||
pub compositor: wl_compositor::WlCompositor,
|
||||
pub data_device_manager: Option<wl_data_device_manager::WlDataDeviceManager>,
|
||||
pub wm_base: xdg_wm_base::XdgWmBase,
|
||||
pub shm: wl_shm::WlShm,
|
||||
pub viewporter: Option<wp_viewporter::WpViewporter>,
|
||||
@ -82,6 +93,13 @@ impl Globals {
|
||||
(),
|
||||
)
|
||||
.unwrap(),
|
||||
data_device_manager: globals
|
||||
.bind(
|
||||
&qh,
|
||||
WL_DATA_DEVICE_MANAGER_VERSION..=WL_DATA_DEVICE_MANAGER_VERSION,
|
||||
(),
|
||||
)
|
||||
.ok(),
|
||||
shm: globals.bind(&qh, 1..=1, ()).unwrap(),
|
||||
wm_base: globals.bind(&qh, 1..=1, ()).unwrap(),
|
||||
viewporter: globals.bind(&qh, 1..=1, ()).ok(),
|
||||
@ -94,13 +112,16 @@ impl Globals {
|
||||
}
|
||||
|
||||
pub(crate) struct WaylandClientState {
|
||||
serial: u32,
|
||||
globals: Globals,
|
||||
wl_pointer: Option<wl_pointer::WlPointer>,
|
||||
data_device: Option<wl_data_device::WlDataDevice>,
|
||||
// Surface to Window mapping
|
||||
windows: HashMap<ObjectId, WaylandWindowStatePtr>,
|
||||
// Output to scale mapping
|
||||
output_scales: HashMap<ObjectId, i32>,
|
||||
keymap_state: Option<xkb::State>,
|
||||
drag: DragState,
|
||||
click: ClickState,
|
||||
repeat: KeyRepeat,
|
||||
modifiers: Modifiers,
|
||||
@ -124,6 +145,12 @@ pub(crate) struct WaylandClientState {
|
||||
common: LinuxCommon,
|
||||
}
|
||||
|
||||
pub struct DragState {
|
||||
data_offer: Option<wl_data_offer::WlDataOffer>,
|
||||
window: Option<WaylandWindowStatePtr>,
|
||||
position: Point<Pixels>,
|
||||
}
|
||||
|
||||
pub struct ClickState {
|
||||
last_click: Instant,
|
||||
last_location: Point<Pixels>,
|
||||
@ -167,6 +194,12 @@ impl WaylandClientStatePtr {
|
||||
// Drop the clipboard to prevent a seg fault after we've closed all Wayland connections.
|
||||
state.clipboard = None;
|
||||
state.primary = None;
|
||||
if let Some(wl_pointer) = &state.wl_pointer {
|
||||
wl_pointer.release();
|
||||
}
|
||||
if let Some(data_device) = &state.data_device {
|
||||
data_device.release();
|
||||
}
|
||||
state.common.signal.stop();
|
||||
}
|
||||
}
|
||||
@ -175,6 +208,7 @@ impl WaylandClientStatePtr {
|
||||
#[derive(Clone)]
|
||||
pub struct WaylandClient(Rc<RefCell<WaylandClientState>>);
|
||||
|
||||
const WL_DATA_DEVICE_MANAGER_VERSION: u32 = 3;
|
||||
const WL_OUTPUT_VERSION: u32 = 2;
|
||||
|
||||
fn wl_seat_version(version: u32) -> u32 {
|
||||
@ -199,18 +233,20 @@ impl WaylandClient {
|
||||
let (globals, mut event_queue) =
|
||||
registry_queue_init::<WaylandClientStatePtr>(&conn).unwrap();
|
||||
let qh = event_queue.handle();
|
||||
let mut outputs = HashMap::default();
|
||||
|
||||
let mut seat: Option<wl_seat::WlSeat> = None;
|
||||
let mut outputs = HashMap::default();
|
||||
globals.contents().with_list(|list| {
|
||||
for global in list {
|
||||
match &global.interface[..] {
|
||||
"wl_seat" => {
|
||||
globals.registry().bind::<wl_seat::WlSeat, _, _>(
|
||||
// TODO: multi-seat support
|
||||
seat = Some(globals.registry().bind::<wl_seat::WlSeat, _, _>(
|
||||
global.name,
|
||||
wl_seat_version(global.version),
|
||||
&qh,
|
||||
(),
|
||||
);
|
||||
));
|
||||
}
|
||||
"wl_output" => {
|
||||
let output = globals.registry().bind::<wl_output::WlOutput, _, _>(
|
||||
@ -227,34 +263,47 @@ impl WaylandClient {
|
||||
});
|
||||
|
||||
let display = conn.backend().display_ptr() as *mut std::ffi::c_void;
|
||||
let (primary, clipboard) = unsafe { create_clipboards_from_external(display) };
|
||||
|
||||
let event_loop = EventLoop::<WaylandClientStatePtr>::try_new().unwrap();
|
||||
|
||||
let (common, main_receiver) = LinuxCommon::new(event_loop.get_signal());
|
||||
|
||||
let handle = event_loop.handle();
|
||||
|
||||
handle.insert_source(main_receiver, |event, _, _: &mut WaylandClientStatePtr| {
|
||||
if let calloop::channel::Event::Msg(runnable) = event {
|
||||
runnable.run();
|
||||
}
|
||||
});
|
||||
|
||||
let globals = Globals::new(globals, common.foreground_executor.clone(), qh);
|
||||
let seat = seat.unwrap();
|
||||
let globals = Globals::new(globals, common.foreground_executor.clone(), qh.clone());
|
||||
|
||||
let data_device = globals
|
||||
.data_device_manager
|
||||
.as_ref()
|
||||
.map(|data_device_manager| data_device_manager.get_data_device(&seat, &qh, ()));
|
||||
|
||||
let (primary, clipboard) = unsafe { create_clipboards_from_external(display) };
|
||||
|
||||
let cursor = Cursor::new(&conn, &globals, 24);
|
||||
|
||||
let mut state = Rc::new(RefCell::new(WaylandClientState {
|
||||
serial: 0,
|
||||
globals,
|
||||
wl_pointer: None,
|
||||
data_device,
|
||||
output_scales: outputs,
|
||||
windows: HashMap::default(),
|
||||
common,
|
||||
keymap_state: None,
|
||||
drag: DragState {
|
||||
data_offer: None,
|
||||
window: None,
|
||||
position: Point::default(),
|
||||
},
|
||||
click: ClickState {
|
||||
last_click: Instant::now(),
|
||||
last_location: Point::new(px(0.0), px(0.0)),
|
||||
last_location: Point::default(),
|
||||
current_count: 0,
|
||||
},
|
||||
repeat: KeyRepeat {
|
||||
@ -467,6 +516,7 @@ impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for WaylandClientStat
|
||||
}
|
||||
|
||||
delegate_noop!(WaylandClientStatePtr: ignore wl_compositor::WlCompositor);
|
||||
delegate_noop!(WaylandClientStatePtr: ignore wl_data_device_manager::WlDataDeviceManager);
|
||||
delegate_noop!(WaylandClientStatePtr: ignore wl_shm::WlShm);
|
||||
delegate_noop!(WaylandClientStatePtr: ignore wl_shm_pool::WlShmPool);
|
||||
delegate_noop!(WaylandClientStatePtr: ignore wl_buffer::WlBuffer);
|
||||
@ -599,7 +649,7 @@ impl Dispatch<xdg_toplevel::XdgToplevel, ObjectId> for WaylandClientStatePtr {
|
||||
|
||||
impl Dispatch<xdg_wm_base::XdgWmBase, ()> for WaylandClientStatePtr {
|
||||
fn event(
|
||||
_: &mut Self,
|
||||
this: &mut Self,
|
||||
wm_base: &xdg_wm_base::XdgWmBase,
|
||||
event: <xdg_wm_base::XdgWmBase as Proxy>::Event,
|
||||
_: &(),
|
||||
@ -607,6 +657,9 @@ impl Dispatch<xdg_wm_base::XdgWmBase, ()> for WaylandClientStatePtr {
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
if let xdg_wm_base::Event::Ping { serial } = event {
|
||||
let client = this.get_client();
|
||||
let mut state = client.borrow_mut();
|
||||
state.serial = serial;
|
||||
wm_base.pong(serial);
|
||||
}
|
||||
}
|
||||
@ -678,7 +731,10 @@ impl Dispatch<wl_keyboard::WlKeyboard, ()> for WaylandClientStatePtr {
|
||||
};
|
||||
state.keymap_state = Some(xkb::State::new(&keymap));
|
||||
}
|
||||
wl_keyboard::Event::Enter { surface, .. } => {
|
||||
wl_keyboard::Event::Enter {
|
||||
serial, surface, ..
|
||||
} => {
|
||||
state.serial = serial;
|
||||
state.keyboard_focused_window = get_window(&mut state, &surface.id());
|
||||
|
||||
if let Some(window) = state.keyboard_focused_window.clone() {
|
||||
@ -686,7 +742,10 @@ impl Dispatch<wl_keyboard::WlKeyboard, ()> for WaylandClientStatePtr {
|
||||
window.set_focused(true);
|
||||
}
|
||||
}
|
||||
wl_keyboard::Event::Leave { surface, .. } => {
|
||||
wl_keyboard::Event::Leave {
|
||||
serial, surface, ..
|
||||
} => {
|
||||
state.serial = serial;
|
||||
let keyboard_focused_window = get_window(&mut state, &surface.id());
|
||||
state.keyboard_focused_window = None;
|
||||
|
||||
@ -696,12 +755,14 @@ impl Dispatch<wl_keyboard::WlKeyboard, ()> for WaylandClientStatePtr {
|
||||
}
|
||||
}
|
||||
wl_keyboard::Event::Modifiers {
|
||||
serial,
|
||||
mods_depressed,
|
||||
mods_latched,
|
||||
mods_locked,
|
||||
group,
|
||||
..
|
||||
} => {
|
||||
state.serial = serial;
|
||||
let focused_window = state.keyboard_focused_window.clone();
|
||||
let Some(focused_window) = focused_window else {
|
||||
return;
|
||||
@ -721,8 +782,11 @@ impl Dispatch<wl_keyboard::WlKeyboard, ()> for WaylandClientStatePtr {
|
||||
wl_keyboard::Event::Key {
|
||||
key,
|
||||
state: WEnum::Value(key_state),
|
||||
serial,
|
||||
..
|
||||
} => {
|
||||
state.serial = serial;
|
||||
|
||||
let focused_window = state.keyboard_focused_window.clone();
|
||||
let Some(focused_window) = focused_window else {
|
||||
return;
|
||||
@ -833,6 +897,7 @@ impl Dispatch<wl_pointer::WlPointer, ()> for WaylandClientStatePtr {
|
||||
surface_y,
|
||||
..
|
||||
} => {
|
||||
state.serial = serial;
|
||||
state.mouse_location = Some(point(px(surface_x as f32), px(surface_y as f32)));
|
||||
|
||||
if let Some(window) = get_window(&mut state, &surface.id()) {
|
||||
@ -885,10 +950,12 @@ impl Dispatch<wl_pointer::WlPointer, ()> for WaylandClientStatePtr {
|
||||
}
|
||||
}
|
||||
wl_pointer::Event::Button {
|
||||
serial,
|
||||
button,
|
||||
state: WEnum::Value(button_state),
|
||||
..
|
||||
} => {
|
||||
state.serial = serial;
|
||||
let button = linux_button_to_gpui(button);
|
||||
let Some(button) = button else { return };
|
||||
if state.mouse_focused_window.is_none() {
|
||||
@ -1123,3 +1190,163 @@ impl Dispatch<zxdg_toplevel_decoration_v1::ZxdgToplevelDecorationV1, ObjectId>
|
||||
window.handle_toplevel_decoration_event(event);
|
||||
}
|
||||
}
|
||||
|
||||
const FILE_LIST_MIME_TYPE: &str = "text/uri-list";
|
||||
|
||||
impl Dispatch<wl_data_device::WlDataDevice, ()> for WaylandClientStatePtr {
|
||||
fn event(
|
||||
this: &mut Self,
|
||||
_: &wl_data_device::WlDataDevice,
|
||||
event: wl_data_device::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
let client = this.get_client();
|
||||
let mut state = client.borrow_mut();
|
||||
|
||||
match event {
|
||||
wl_data_device::Event::Enter {
|
||||
serial,
|
||||
surface,
|
||||
x,
|
||||
y,
|
||||
id: data_offer,
|
||||
} => {
|
||||
state.serial = serial;
|
||||
if let Some(data_offer) = data_offer {
|
||||
let Some(drag_window) = get_window(&mut state, &surface.id()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
const ACTIONS: DndAction = DndAction::Copy;
|
||||
data_offer.set_actions(ACTIONS, ACTIONS);
|
||||
|
||||
let pipe = Pipe::new().unwrap();
|
||||
data_offer.receive(FILE_LIST_MIME_TYPE.to_string(), unsafe {
|
||||
BorrowedFd::borrow_raw(pipe.write.as_raw_fd())
|
||||
});
|
||||
let fd = pipe.read;
|
||||
drop(pipe.write);
|
||||
|
||||
let read_task = state
|
||||
.common
|
||||
.background_executor
|
||||
.spawn(async { unsafe { read_fd(fd) } });
|
||||
|
||||
let this = this.clone();
|
||||
state
|
||||
.common
|
||||
.foreground_executor
|
||||
.spawn(async move {
|
||||
let file_list = match read_task.await {
|
||||
Ok(list) => list,
|
||||
Err(err) => {
|
||||
log::error!("error reading drag and drop pipe: {err:?}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let paths: SmallVec<[_; 2]> = file_list
|
||||
.lines()
|
||||
.map(|path| PathBuf::from(path.replace("file://", "")))
|
||||
.collect();
|
||||
let position = Point::new(x.into(), y.into());
|
||||
|
||||
// Prevent dropping text from other programs.
|
||||
if paths.is_empty() {
|
||||
data_offer.finish();
|
||||
data_offer.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
let input = PlatformInput::FileDrop(FileDropEvent::Entered {
|
||||
position,
|
||||
paths: crate::ExternalPaths(paths),
|
||||
});
|
||||
|
||||
let client = this.get_client();
|
||||
let mut state = client.borrow_mut();
|
||||
state.drag.data_offer = Some(data_offer);
|
||||
state.drag.window = Some(drag_window.clone());
|
||||
state.drag.position = position;
|
||||
|
||||
drop(state);
|
||||
drag_window.handle_input(input);
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
wl_data_device::Event::Motion { x, y, .. } => {
|
||||
let Some(drag_window) = state.drag.window.clone() else {
|
||||
return;
|
||||
};
|
||||
let position = Point::new(x.into(), y.into());
|
||||
state.drag.position = position;
|
||||
|
||||
let input = PlatformInput::FileDrop(FileDropEvent::Pending { position });
|
||||
drop(state);
|
||||
drag_window.handle_input(input);
|
||||
}
|
||||
wl_data_device::Event::Leave => {
|
||||
let Some(drag_window) = state.drag.window.clone() else {
|
||||
return;
|
||||
};
|
||||
let data_offer = state.drag.data_offer.clone().unwrap();
|
||||
data_offer.destroy();
|
||||
|
||||
state.drag.data_offer = None;
|
||||
state.drag.window = None;
|
||||
|
||||
let input = PlatformInput::FileDrop(FileDropEvent::Exited {});
|
||||
drop(state);
|
||||
drag_window.handle_input(input);
|
||||
}
|
||||
wl_data_device::Event::Drop => {
|
||||
let Some(drag_window) = state.drag.window.clone() else {
|
||||
return;
|
||||
};
|
||||
let data_offer = state.drag.data_offer.clone().unwrap();
|
||||
data_offer.finish();
|
||||
data_offer.destroy();
|
||||
|
||||
state.drag.data_offer = None;
|
||||
state.drag.window = None;
|
||||
|
||||
let input = PlatformInput::FileDrop(FileDropEvent::Submit {
|
||||
position: state.drag.position,
|
||||
});
|
||||
drop(state);
|
||||
drag_window.handle_input(input);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
event_created_child!(WaylandClientStatePtr, wl_data_device::WlDataDevice, [
|
||||
wl_data_device::EVT_DATA_OFFER_OPCODE => (wl_data_offer::WlDataOffer, ()),
|
||||
]);
|
||||
}
|
||||
|
||||
impl Dispatch<wl_data_offer::WlDataOffer, ()> for WaylandClientStatePtr {
|
||||
fn event(
|
||||
this: &mut Self,
|
||||
data_offer: &wl_data_offer::WlDataOffer,
|
||||
event: wl_data_offer::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
let client = this.get_client();
|
||||
let mut state = client.borrow_mut();
|
||||
|
||||
match event {
|
||||
wl_data_offer::Event::Offer { mime_type } => {
|
||||
if mime_type == FILE_LIST_MIME_TYPE {
|
||||
data_offer.accept(state.serial, Some(mime_type));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user