mirror of
https://github.com/portapack-mayhem/mayhem-firmware.git
synced 2024-12-04 23:45:26 +00:00
Create the Shopping Cart Lock app (#2326)
This commit is contained in:
parent
079329fc6c
commit
09dff447de
6
firmware/application/external/external.cmake
vendored
6
firmware/application/external/external.cmake
vendored
@ -106,7 +106,10 @@ set(EXTCPPSRC
|
|||||||
#acars
|
#acars
|
||||||
external/acars_rx/main.cpp
|
external/acars_rx/main.cpp
|
||||||
external/acars_rx/acars_app.cpp
|
external/acars_rx/acars_app.cpp
|
||||||
|
|
||||||
|
#shoppingcart_lock
|
||||||
|
external/shoppingcart_lock/main.cpp
|
||||||
|
external/shoppingcart_lock/shoppingcart_lock.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
set(EXTAPPLIST
|
set(EXTAPPLIST
|
||||||
@ -135,4 +138,5 @@ set(EXTAPPLIST
|
|||||||
sstvtx
|
sstvtx
|
||||||
random_password
|
random_password
|
||||||
#acars_rx
|
#acars_rx
|
||||||
|
shoppingcart_lock
|
||||||
)
|
)
|
||||||
|
7
firmware/application/external/external.ld
vendored
7
firmware/application/external/external.ld
vendored
@ -48,6 +48,7 @@ MEMORY
|
|||||||
ram_external_app_sstvtx(rwx) : org = 0xADC70000, len = 32k
|
ram_external_app_sstvtx(rwx) : org = 0xADC70000, len = 32k
|
||||||
ram_external_app_random_password(rwx) : org = 0xADC80000, len = 32k
|
ram_external_app_random_password(rwx) : org = 0xADC80000, len = 32k
|
||||||
ram_external_app_acars_rx(rwx) : org = 0xADC90000, len = 32k
|
ram_external_app_acars_rx(rwx) : org = 0xADC90000, len = 32k
|
||||||
|
ram_external_app_shoppingcart_lock(rwx) : org = 0xADCA0000, len = 32k
|
||||||
}
|
}
|
||||||
|
|
||||||
SECTIONS
|
SECTIONS
|
||||||
@ -166,7 +167,6 @@ SECTIONS
|
|||||||
*(*ui*external_app*tpmsrx*);
|
*(*ui*external_app*tpmsrx*);
|
||||||
} > ram_external_app_tpmsrx
|
} > ram_external_app_tpmsrx
|
||||||
|
|
||||||
|
|
||||||
.external_app_protoview : ALIGN(4) SUBALIGN(4)
|
.external_app_protoview : ALIGN(4) SUBALIGN(4)
|
||||||
{
|
{
|
||||||
KEEP(*(.external_app.app_protoview.application_information));
|
KEEP(*(.external_app.app_protoview.application_information));
|
||||||
@ -204,6 +204,11 @@ SECTIONS
|
|||||||
*(*ui*external_app*acars_rx*);
|
*(*ui*external_app*acars_rx*);
|
||||||
} > ram_external_app_acars_rx
|
} > ram_external_app_acars_rx
|
||||||
|
|
||||||
|
.external_app_shoppingcart_lock : ALIGN(4) SUBALIGN(4)
|
||||||
|
{
|
||||||
|
KEEP(*(.external_app.app_shoppingcart_lock.application_information));
|
||||||
|
*(*ui*external_app*shoppingcart_lock*);
|
||||||
|
} > ram_external_app_shoppingcart_lock
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
63
firmware/application/external/shoppingcart_lock/main.cpp
vendored
Normal file
63
firmware/application/external/shoppingcart_lock/main.cpp
vendored
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
|
||||||
|
// RocketGod's Shopping Cart Lock app
|
||||||
|
// https://betaskynet.com
|
||||||
|
#include "ui.hpp"
|
||||||
|
#include "shoppingcart_lock.hpp"
|
||||||
|
#include "ui_navigation.hpp"
|
||||||
|
#include "external_app.hpp"
|
||||||
|
|
||||||
|
namespace ui::external_app::shoppingcart_lock {
|
||||||
|
void initialize_app(NavigationView& nav) {
|
||||||
|
baseband::run_image(portapack::spi_flash::image_tag_audio_tx);
|
||||||
|
nav.push<ShoppingCartLock>();
|
||||||
|
}
|
||||||
|
} // namespace ui::external_app::shoppingcart_lock
|
||||||
|
|
||||||
|
extern "C" {
|
||||||
|
|
||||||
|
__attribute__((section(".external_app.app_shoppingcart_lock.application_information"), used)) application_information_t _application_information_shoppingcart_lock = {
|
||||||
|
(uint8_t*)0x00000000,
|
||||||
|
ui::external_app::shoppingcart_lock::initialize_app,
|
||||||
|
CURRENT_HEADER_VERSION,
|
||||||
|
VERSION_MD5,
|
||||||
|
"Cart Lock",
|
||||||
|
{
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x7E,
|
||||||
|
0x7E,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x7E,
|
||||||
|
0x81,
|
||||||
|
0x81,
|
||||||
|
0x7E,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x7E,
|
||||||
|
0x81,
|
||||||
|
0x81,
|
||||||
|
0x81,
|
||||||
|
0x81,
|
||||||
|
0x7E,
|
||||||
|
0x00,
|
||||||
|
},
|
||||||
|
ui::Color::red().v,
|
||||||
|
app_location_t::UTILITIES,
|
||||||
|
{'P', 'A', 'T', 'X'},
|
||||||
|
0x00000000,
|
||||||
|
};
|
||||||
|
}
|
235
firmware/application/external/shoppingcart_lock/shoppingcart_lock.cpp
vendored
Normal file
235
firmware/application/external/shoppingcart_lock/shoppingcart_lock.cpp
vendored
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
// RocketGod's Shopping Cart Lock app
|
||||||
|
// https://betaskynet.com
|
||||||
|
#include "shoppingcart_lock.hpp"
|
||||||
|
|
||||||
|
using namespace portapack;
|
||||||
|
|
||||||
|
namespace ui::external_app::shoppingcart_lock {
|
||||||
|
|
||||||
|
void ShoppingCartLock::log_event(const std::string& message) {
|
||||||
|
static const size_t MAX_LOG_LINES = 50;
|
||||||
|
static std::vector<std::string> message_history;
|
||||||
|
|
||||||
|
message_history.push_back(message);
|
||||||
|
if (message_history.size() > MAX_LOG_LINES) {
|
||||||
|
message_history.erase(message_history.begin());
|
||||||
|
menu_view.clear();
|
||||||
|
for (const auto& msg : message_history) {
|
||||||
|
menu_view.add_item({msg,
|
||||||
|
ui::Theme::getInstance()->fg_green->foreground,
|
||||||
|
nullptr,
|
||||||
|
[](KeyEvent) {}});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
menu_view.add_item({message,
|
||||||
|
ui::Theme::getInstance()->fg_green->foreground,
|
||||||
|
nullptr,
|
||||||
|
[](KeyEvent) {}});
|
||||||
|
}
|
||||||
|
|
||||||
|
menu_view.set_highlighted(menu_view.item_count() - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ShoppingCartLock::is_active() const {
|
||||||
|
return (bool)replay_thread;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ShoppingCartLock::focus() {
|
||||||
|
menu_view.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ShoppingCartLock::stop() {
|
||||||
|
log_event(">>> STOP_SEQUENCE_START");
|
||||||
|
if (is_active()) {
|
||||||
|
log_event("... Resetting Replay Thread");
|
||||||
|
replay_thread.reset();
|
||||||
|
}
|
||||||
|
log_event("... Stopping Audio Output");
|
||||||
|
audio::output::stop();
|
||||||
|
|
||||||
|
log_event("... Resetting State Variables");
|
||||||
|
transmitter_model.disable();
|
||||||
|
ready_signal = false;
|
||||||
|
thread_sync_complete = false;
|
||||||
|
looping = false;
|
||||||
|
current_file = "";
|
||||||
|
log_event("<<< STOP_SEQUENCE_COMPLETE");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string ShoppingCartLock::list_wav_files() {
|
||||||
|
log_event(">>> WAV_SCAN_START");
|
||||||
|
auto reader = std::make_unique<WAVFileReader>();
|
||||||
|
bool found_lock = false;
|
||||||
|
bool found_unlock = false;
|
||||||
|
|
||||||
|
for (const auto& entry : std::filesystem::directory_iterator(wav_dir, u"*")) {
|
||||||
|
if (std::filesystem::is_regular_file(entry.status())) {
|
||||||
|
auto filename = entry.path().filename().string();
|
||||||
|
std::transform(filename.begin(), filename.end(), filename.begin(), ::tolower);
|
||||||
|
|
||||||
|
if (filename == shoppingcart_lock_file || filename == shoppingcart_unlock_file) {
|
||||||
|
std::string file_path = (wav_dir / filename).string();
|
||||||
|
if (reader->open(file_path)) {
|
||||||
|
if (filename == shoppingcart_lock_file) {
|
||||||
|
found_lock = true;
|
||||||
|
log_event("... Found: " + shoppingcart_lock_file);
|
||||||
|
log_event("Sample Rate: " + std::to_string(reader->sample_rate()));
|
||||||
|
log_event("Channels: " + std::to_string(reader->channels()));
|
||||||
|
log_event("Bits/Sample: " + std::to_string(reader->bits_per_sample()));
|
||||||
|
}
|
||||||
|
if (filename == shoppingcart_unlock_file) {
|
||||||
|
found_unlock = true;
|
||||||
|
log_event("... Found: " + shoppingcart_unlock_file);
|
||||||
|
log_event("Sample Rate: " + std::to_string(reader->sample_rate()));
|
||||||
|
log_event("Channels: " + std::to_string(reader->channels()));
|
||||||
|
log_event("Bits/Sample: " + std::to_string(reader->bits_per_sample()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (found_lock && found_unlock) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!found_lock || !found_unlock) {
|
||||||
|
log_event("!!! Missing Required Files:");
|
||||||
|
if (!found_lock) log_event("!!! Missing: " + shoppingcart_lock_file);
|
||||||
|
if (!found_unlock) log_event("!!! Missing: " + shoppingcart_unlock_file);
|
||||||
|
menu_view.hidden(true);
|
||||||
|
text_empty.hidden(false);
|
||||||
|
} else {
|
||||||
|
log_event("... All Required Files Found");
|
||||||
|
menu_view.hidden(false);
|
||||||
|
text_empty.hidden(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
log_event("<<< WAV_SCAN_COMPLETE");
|
||||||
|
return found_lock && found_unlock ? "Required WAV files found" : "Missing required WAV files";
|
||||||
|
}
|
||||||
|
|
||||||
|
void ShoppingCartLock::wait_for_thread() {
|
||||||
|
uint32_t timeout = 100;
|
||||||
|
while (!ready_signal && timeout > 0) {
|
||||||
|
chThdYield();
|
||||||
|
timeout--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ShoppingCartLock::restart_playback() {
|
||||||
|
auto reader = std::make_unique<WAVFileReader>();
|
||||||
|
std::string file_path = (wav_dir / current_file).string();
|
||||||
|
|
||||||
|
if (!reader->open(file_path)) return;
|
||||||
|
|
||||||
|
replay_thread = std::make_unique<ReplayThread>(
|
||||||
|
std::move(reader),
|
||||||
|
BUFFER_SIZE,
|
||||||
|
NUM_BUFFERS,
|
||||||
|
&ready_signal,
|
||||||
|
[](uint32_t return_code) {
|
||||||
|
ReplayThreadDoneMessage message{return_code};
|
||||||
|
EventDispatcher::send_message(message);
|
||||||
|
});
|
||||||
|
|
||||||
|
log_event(">> SENDING <<");
|
||||||
|
audio::output::start();
|
||||||
|
transmitter_model.enable();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ShoppingCartLock::play_audio(const std::string& filename, bool loop) {
|
||||||
|
auto reader = std::make_unique<WAVFileReader>();
|
||||||
|
stop();
|
||||||
|
|
||||||
|
std::string file_path = (wav_dir / filename).string();
|
||||||
|
if (!reader->open(file_path)) {
|
||||||
|
nav_.display_modal("Error", "Cannot open " + filename);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uint32_t wav_sample_rate = reader->sample_rate();
|
||||||
|
const uint16_t wav_bits_per_sample = reader->bits_per_sample();
|
||||||
|
|
||||||
|
current_file = filename;
|
||||||
|
looping = loop;
|
||||||
|
|
||||||
|
replay_thread = std::make_unique<ReplayThread>(
|
||||||
|
std::move(reader),
|
||||||
|
BUFFER_SIZE,
|
||||||
|
NUM_BUFFERS,
|
||||||
|
&ready_signal,
|
||||||
|
[](uint32_t return_code) {
|
||||||
|
ReplayThreadDoneMessage message{return_code};
|
||||||
|
EventDispatcher::send_message(message);
|
||||||
|
});
|
||||||
|
|
||||||
|
wait_for_thread();
|
||||||
|
|
||||||
|
log_event("... Configuring Baseband");
|
||||||
|
|
||||||
|
const uint32_t bb_sample_rate = 1536000;
|
||||||
|
const uint32_t decimation = bb_sample_rate / wav_sample_rate;
|
||||||
|
|
||||||
|
baseband::set_audiotx_config(
|
||||||
|
bb_sample_rate / decimation,
|
||||||
|
0.0f,
|
||||||
|
5.0f,
|
||||||
|
wav_bits_per_sample,
|
||||||
|
wav_bits_per_sample,
|
||||||
|
0,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false);
|
||||||
|
|
||||||
|
baseband::set_sample_rate(wav_sample_rate);
|
||||||
|
|
||||||
|
log_event("... Starting Audio Output");
|
||||||
|
audio::output::start();
|
||||||
|
log_event("... Setting Max Volume");
|
||||||
|
audio::headphone::set_volume(audio::headphone::volume_range().max);
|
||||||
|
|
||||||
|
transmitter_model.enable();
|
||||||
|
|
||||||
|
log_event(">>> Playback Started <<<");
|
||||||
|
}
|
||||||
|
|
||||||
|
ShoppingCartLock::ShoppingCartLock(NavigationView& nav)
|
||||||
|
: nav_{nav} {
|
||||||
|
add_children({&menu_view,
|
||||||
|
&text_empty,
|
||||||
|
&button_lock,
|
||||||
|
&button_unlock,
|
||||||
|
&button_stop});
|
||||||
|
|
||||||
|
button_lock.on_select = [this](Button&) {
|
||||||
|
if (is_active()) stop();
|
||||||
|
log_event(">>> LOCK_SEQUENCE_START");
|
||||||
|
play_audio(shoppingcart_lock_file, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
button_unlock.on_select = [this](Button&) {
|
||||||
|
if (is_active()) stop();
|
||||||
|
log_event(">>> UNLOCK_SEQUENCE_START");
|
||||||
|
play_audio(shoppingcart_unlock_file, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
button_stop.on_select = [this](Button&) {
|
||||||
|
log_event(">>> STOPPING AUDIO");
|
||||||
|
stop();
|
||||||
|
};
|
||||||
|
|
||||||
|
list_wav_files();
|
||||||
|
|
||||||
|
log_event("[+] INITIALIZATION COMPLETE");
|
||||||
|
log_event("[+] PORTAPACK ARMED");
|
||||||
|
log_event("[*] STATUS: READY");
|
||||||
|
}
|
||||||
|
|
||||||
|
ShoppingCartLock::~ShoppingCartLock() {
|
||||||
|
stop();
|
||||||
|
baseband::shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace ui::external_app::shoppingcart_lock
|
102
firmware/application/external/shoppingcart_lock/shoppingcart_lock.hpp
vendored
Normal file
102
firmware/application/external/shoppingcart_lock/shoppingcart_lock.hpp
vendored
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
// RocketGod's Shopping Cart Lock app
|
||||||
|
// https://betaskynet.com
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "ui_widget.hpp"
|
||||||
|
#include "ui_transmitter.hpp"
|
||||||
|
#include "replay_thread.hpp"
|
||||||
|
#include "baseband_api.hpp"
|
||||||
|
#include "io_wave.hpp"
|
||||||
|
#include "audio.hpp"
|
||||||
|
#include "portapack_shared_memory.hpp"
|
||||||
|
#include "ui_language.hpp"
|
||||||
|
#include "file_path.hpp"
|
||||||
|
|
||||||
|
namespace ui::external_app::shoppingcart_lock {
|
||||||
|
|
||||||
|
class ShoppingCartLock : public View {
|
||||||
|
public:
|
||||||
|
explicit ShoppingCartLock(NavigationView& nav);
|
||||||
|
~ShoppingCartLock();
|
||||||
|
|
||||||
|
ShoppingCartLock(const ShoppingCartLock&) = delete;
|
||||||
|
ShoppingCartLock& operator=(const ShoppingCartLock&) = delete;
|
||||||
|
|
||||||
|
std::string title() const override { return "Cart Lock"; };
|
||||||
|
|
||||||
|
void focus() override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
static constexpr size_t BUFFER_SIZE = 8192;
|
||||||
|
static constexpr size_t NUM_BUFFERS = 8;
|
||||||
|
const std::string shoppingcart_lock_file{"shopping_cart_lock.wav"};
|
||||||
|
const std::string shoppingcart_unlock_file{"shopping_cart_unlock.wav"};
|
||||||
|
|
||||||
|
NavigationView& nav_;
|
||||||
|
std::unique_ptr<ReplayThread> replay_thread{};
|
||||||
|
bool ready_signal{false};
|
||||||
|
bool thread_sync_complete{false};
|
||||||
|
bool looping{false};
|
||||||
|
std::string current_file{};
|
||||||
|
|
||||||
|
struct WAVProperties {
|
||||||
|
uint32_t sample_rate;
|
||||||
|
uint16_t bits_per_sample;
|
||||||
|
size_t file_size;
|
||||||
|
};
|
||||||
|
|
||||||
|
void log_event(const std::string& message);
|
||||||
|
std::string list_wav_files();
|
||||||
|
void handle_error(const std::string& message);
|
||||||
|
void play_audio(const std::string& filename, bool loop = false);
|
||||||
|
void stop();
|
||||||
|
bool is_active() const;
|
||||||
|
void wait_for_thread();
|
||||||
|
void restart_playback();
|
||||||
|
|
||||||
|
MenuView menu_view{
|
||||||
|
{0, 0, 240, 150},
|
||||||
|
true};
|
||||||
|
|
||||||
|
Text text_empty{
|
||||||
|
{40, 70, 160, 16},
|
||||||
|
"RocketGod was here"};
|
||||||
|
|
||||||
|
Button button_lock{
|
||||||
|
{40, 165, 160, 35},
|
||||||
|
LanguageHelper::currentMessages[LANG_LOCK]};
|
||||||
|
|
||||||
|
Button button_unlock{
|
||||||
|
{40, 205, 160, 35},
|
||||||
|
LanguageHelper::currentMessages[LANG_UNLOCK]};
|
||||||
|
|
||||||
|
Button button_stop{
|
||||||
|
{40, 245, 160, 35},
|
||||||
|
LanguageHelper::currentMessages[LANG_STOP]};
|
||||||
|
|
||||||
|
MessageHandlerRegistration message_handler_fifo_signal{
|
||||||
|
Message::ID::RequestSignal,
|
||||||
|
[this](const Message* const p) {
|
||||||
|
const auto message = static_cast<const RequestSignalMessage*>(p);
|
||||||
|
if (message->signal == RequestSignalMessage::Signal::FillRequest) {
|
||||||
|
ready_signal = true;
|
||||||
|
}
|
||||||
|
}};
|
||||||
|
|
||||||
|
MessageHandlerRegistration message_handler_replay_thread_done{
|
||||||
|
Message::ID::ReplayThreadDone,
|
||||||
|
[this](const Message* const p) {
|
||||||
|
const auto message = *reinterpret_cast<const ReplayThreadDoneMessage*>(p);
|
||||||
|
if (message.return_code == ReplayThread::END_OF_FILE && looping) {
|
||||||
|
if (is_active()) {
|
||||||
|
chThdSleepMilliseconds(50);
|
||||||
|
restart_playback();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
thread_sync_complete = true;
|
||||||
|
stop();
|
||||||
|
}
|
||||||
|
}};
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace ui::external_app::shoppingcart_lock
|
@ -1,7 +1,7 @@
|
|||||||
#include "ui_language.hpp"
|
#include "ui_language.hpp"
|
||||||
|
|
||||||
// use the exact position in this array! the enum's value is the identifier. Best to add to the end
|
// use the exact position in this array! the enum's value is the identifier. Best to add to the end
|
||||||
const char* LanguageHelper::englishMessages[] = {"OK", "Cancel", "Error", "Modem setup", "Debug", "Log", "Done", "Start", "Stop", "Scan", "Clear", "Ready", "Data:", "Loop", "Reset", "Pause", "Resume", "Flood", "Show QR", "Save"};
|
const char* LanguageHelper::englishMessages[] = {"OK", "Cancel", "Error", "Modem setup", "Debug", "Log", "Done", "Start", "Stop", "Scan", "Clear", "Ready", "Data:", "Loop", "Reset", "Pause", "Resume", "Flood", "Show QR", "Save", "Lock", "Unlock"};
|
||||||
|
|
||||||
// multi language support will changes (not in use for now)
|
// multi language support will changes (not in use for now)
|
||||||
const char** LanguageHelper::currentMessages = englishMessages;
|
const char** LanguageHelper::currentMessages = englishMessages;
|
||||||
|
@ -58,7 +58,9 @@ enum LangConsts {
|
|||||||
LANG_RESUME,
|
LANG_RESUME,
|
||||||
LANG_FLOOD,
|
LANG_FLOOD,
|
||||||
LANG_SHOWQR,
|
LANG_SHOWQR,
|
||||||
LANG_SAVE
|
LANG_SAVE,
|
||||||
|
LANG_LOCK,
|
||||||
|
LANG_UNLOCK
|
||||||
};
|
};
|
||||||
|
|
||||||
class LanguageHelper {
|
class LanguageHelper {
|
||||||
|
BIN
sdcard/WAV/shopping_cart_lock.wav
Normal file
BIN
sdcard/WAV/shopping_cart_lock.wav
Normal file
Binary file not shown.
BIN
sdcard/WAV/shopping_cart_unlock.wav
Normal file
BIN
sdcard/WAV/shopping_cart_unlock.wav
Normal file
Binary file not shown.
Loading…
Reference in New Issue
Block a user