add the ability to select a custom profile icon

This commit is contained in:
liamcottle
2024-12-26 00:37:37 +13:00
parent 0dba56f832
commit 8596d4e406
6 changed files with 320 additions and 5 deletions

View File

@@ -23,6 +23,7 @@ from serial.tools import list_ports
import database import database
from src.backend.announce_handler import AnnounceHandler from src.backend.announce_handler import AnnounceHandler
from src.backend.colour_utils import ColourUtils
from src.backend.lxmf_message_fields import LxmfImageField, LxmfFileAttachmentsField, LxmfFileAttachment, LxmfAudioField from src.backend.lxmf_message_fields import LxmfImageField, LxmfFileAttachmentsField, LxmfFileAttachment, LxmfAudioField
from src.backend.audio_call_manager import AudioCall, AudioCallManager from src.backend.audio_call_manager import AudioCall, AudioCallManager
@@ -1666,6 +1667,18 @@ class ReticulumMeshChat:
# enable or disable local propagation node # enable or disable local propagation node
self.enable_local_propagation_node(value) self.enable_local_propagation_node(value)
# update lxmf user icon name in config
if "lxmf_user_icon_name" in data:
self.config.lxmf_user_icon_name.set(data["lxmf_user_icon_name"])
# update lxmf user icon foreground colour in config
if "lxmf_user_icon_foreground_colour" in data:
self.config.lxmf_user_icon_foreground_colour.set(data["lxmf_user_icon_foreground_colour"])
# update lxmf user icon background colour in config
if "lxmf_user_icon_background_colour" in data:
self.config.lxmf_user_icon_background_colour.set(data["lxmf_user_icon_background_colour"])
# send config to websocket clients # send config to websocket clients
await self.send_config_to_websocket_clients() await self.send_config_to_websocket_clients()
@@ -1890,6 +1903,9 @@ class ReticulumMeshChat:
"lxmf_preferred_propagation_node_destination_hash": self.config.lxmf_preferred_propagation_node_destination_hash.get(), "lxmf_preferred_propagation_node_destination_hash": self.config.lxmf_preferred_propagation_node_destination_hash.get(),
"lxmf_preferred_propagation_node_auto_sync_interval_seconds": self.config.lxmf_preferred_propagation_node_auto_sync_interval_seconds.get(), "lxmf_preferred_propagation_node_auto_sync_interval_seconds": self.config.lxmf_preferred_propagation_node_auto_sync_interval_seconds.get(),
"lxmf_preferred_propagation_node_last_synced_at": self.config.lxmf_preferred_propagation_node_last_synced_at.get(), "lxmf_preferred_propagation_node_last_synced_at": self.config.lxmf_preferred_propagation_node_last_synced_at.get(),
"lxmf_user_icon_name": self.config.lxmf_user_icon_name.get(),
"lxmf_user_icon_foreground_colour": self.config.lxmf_user_icon_foreground_colour.get(),
"lxmf_user_icon_background_colour": self.config.lxmf_user_icon_background_colour.get(),
} }
# convert audio call to dict # convert audio call to dict
@@ -2418,6 +2434,22 @@ class ReticulumMeshChat:
audio_field.audio_bytes, audio_field.audio_bytes,
] ]
# add icon appearance if configured
# fixme: we could save a tiny amount of bandwidth here, but this requires more effort...
# we could keep track of when the icon appearance was last sent to this destination, and when it last changed
# we could save 6 bytes for the 2x colours, and also however long the icon name is, but not today!
lxmf_user_icon_name = self.config.lxmf_user_icon_name.get()
lxmf_user_icon_foreground_colour = self.config.lxmf_user_icon_foreground_colour.get()
lxmf_user_icon_background_colour = self.config.lxmf_user_icon_background_colour.get()
if (lxmf_user_icon_name is not None
and lxmf_user_icon_foreground_colour is not None
and lxmf_user_icon_background_colour is not None):
lxmf_message.fields[LXMF.FIELD_ICON_APPEARANCE] = [
lxmf_user_icon_name,
ColourUtils.hex_colour_to_byte_array(lxmf_user_icon_foreground_colour),
ColourUtils.hex_colour_to_byte_array(lxmf_user_icon_background_colour),
]
# register delivery callbacks # register delivery callbacks
lxmf_message.register_delivery_callback(self.on_lxmf_sending_state_updated) lxmf_message.register_delivery_callback(self.on_lxmf_sending_state_updated)
lxmf_message.register_failed_callback(self.on_lxmf_sending_failed) lxmf_message.register_failed_callback(self.on_lxmf_sending_failed)
@@ -2846,6 +2878,9 @@ class Config:
lxmf_preferred_propagation_node_auto_sync_interval_seconds = IntConfig("lxmf_preferred_propagation_node_auto_sync_interval_seconds", 0) lxmf_preferred_propagation_node_auto_sync_interval_seconds = IntConfig("lxmf_preferred_propagation_node_auto_sync_interval_seconds", 0)
lxmf_preferred_propagation_node_last_synced_at = IntConfig("lxmf_preferred_propagation_node_last_synced_at", None) lxmf_preferred_propagation_node_last_synced_at = IntConfig("lxmf_preferred_propagation_node_last_synced_at", None)
lxmf_local_propagation_node_enabled = BoolConfig("lxmf_local_propagation_node_enabled", False) lxmf_local_propagation_node_enabled = BoolConfig("lxmf_local_propagation_node_enabled", False)
lxmf_user_icon_name = StringConfig("lxmf_user_icon_name", None)
lxmf_user_icon_foreground_colour = StringConfig("lxmf_user_icon_foreground_colour", None)
lxmf_user_icon_background_colour = StringConfig("lxmf_user_icon_background_colour", None)
# FIXME: we should probably set this as an instance variable of ReticulumMeshChat so it has a proper home, and pass it in to the constructor? # FIXME: we should probably set this as an instance variable of ReticulumMeshChat so it has a proper home, and pass it in to the constructor?
nomadnet_cached_links = {} nomadnet_cached_links = {}

View File

@@ -0,0 +1,10 @@
class ColourUtils:
@staticmethod
def hex_colour_to_byte_array(hex_colour):
# remove leading "#"
hex_colour = hex_colour.lstrip('#')
# convert the remaining hex string to bytes
return bytes.fromhex(hex_colour)

View File

@@ -146,13 +146,16 @@
<!-- my identity --> <!-- my identity -->
<div v-if="config" class="bg-white border-t dark:border-zinc-900 dark:bg-zinc-950"> <div v-if="config" class="bg-white border-t dark:border-zinc-900 dark:bg-zinc-950">
<div @click="isShowingMyIdentitySection = !isShowingMyIdentitySection" class="flex text-gray-700 p-2 cursor-pointer"> <div @click="isShowingMyIdentitySection = !isShowingMyIdentitySection" class="flex text-gray-700 p-2 cursor-pointer">
<div class="my-auto mr-2 dark:text-white"> <div class="my-auto mr-2">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6"> <RouterLink @click.stop :to="{ name: 'profile' }">
<path stroke-linecap="round" stroke-linejoin="round" d="M17.982 18.725A7.488 7.488 0 0 0 12 15.75a7.488 7.488 0 0 0-5.982 2.975m11.963 0a9 9 0 1 0-11.963 0m11.963 0A8.966 8.966 0 0 1 12 21a8.966 8.966 0 0 1-5.982-2.275M15 9.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /> <LxmfUserIcon
</svg> :icon-name="config?.lxmf_user_icon_name"
:icon-foreground-colour="config?.lxmf_user_icon_foreground_colour"
:icon-background-colour="config?.lxmf_user_icon_background_colour"/>
</RouterLink>
</div> </div>
<div class="my-auto dark:text-white">My Identity</div> <div class="my-auto dark:text-white">My Identity</div>
<div class="ml-auto"> <div class="my-auto ml-auto">
<button @click.stop="saveIdentitySettings" type="button" class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500 <button @click.stop="saveIdentitySettings" type="button" class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500
dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700 dark:focus-visible:outline-zinc-500"> dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700 dark:focus-visible:outline-zinc-500">
Save Save
@@ -311,10 +314,12 @@ import GlobalState from "../js/GlobalState";
import Utils from "../js/Utils"; import Utils from "../js/Utils";
import GlobalEmitter from "../js/GlobalEmitter"; import GlobalEmitter from "../js/GlobalEmitter";
import NotificationUtils from "../js/NotificationUtils"; import NotificationUtils from "../js/NotificationUtils";
import LxmfUserIcon from "./LxmfUserIcon.vue";
export default { export default {
name: 'App', name: 'App',
components: { components: {
LxmfUserIcon,
SidebarLink, SidebarLink,
}, },
data() { data() {

View File

@@ -0,0 +1,23 @@
<template>
<div v-if="iconName" class="p-2 rounded" :style="{ 'color': iconForegroundColour, 'background-color': iconBackgroundColour }">
<MaterialDesignIcon :icon-name="iconName" class="size-6"/>
</div>
<div v-else class="bg-gray-200 dark:bg-zinc-700 text-gray-500 dark:text-gray-400 p-2 rounded">
<MaterialDesignIcon icon-name="account-outline" class="size-6"/>
</div>
</template>
<script>
import MaterialDesignIcon from "./MaterialDesignIcon.vue";
export default {
name: "LxmfUserIcon",
components: {
MaterialDesignIcon,
},
props: {
iconName: String,
iconForegroundColour: String,
iconBackgroundColour: String,
},
}
</script>

View File

@@ -0,0 +1,237 @@
<template>
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] dark:bg-zinc-950">
<div class="overflow-y-auto space-y-2 p-2">
<!-- info -->
<div class="bg-white dark:bg-zinc-800 rounded shadow">
<div class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-gray-200 p-2 font-semibold">Customise your Profile Icon</div>
<div class="text-gray-900 dark:text-gray-100">
<div class="text-sm p-2">
<ul class="list-disc list-inside">
<li>Personalise your profile with a custom coloured icon.</li>
<li>This icon will be sent in all of your outgoing messages.</li>
<li>When you send someone a message, they will see your new icon.</li>
<li>You can <span @click="removeProfileIcon" class="cursor-pointer underline text-blue-500">remove your icon</span>, however it will still show for anyone that already received it.</li>
</ul>
</div>
</div>
</div>
<!-- colours -->
<div class="bg-white dark:bg-zinc-800 rounded shadow">
<div class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-gray-200 p-2 font-semibold">Select your Colours</div>
<div class="divide-y divide-gray-300 dark:divide-zinc-700 text-gray-900 dark:text-gray-100">
<!-- icon colour -->
<div class="p-2">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Icon Colour</div>
<div class="flex">
<select v-model="iconForegroundColour" class="bg-gray-50 dark:bg-zinc-700 border border-gray-300 dark:border-zinc-600 text-gray-900 dark:text-gray-100 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 dark:focus:ring-blue-600 dark:focus:border-blue-600 block w-full p-2.5">
<option value="#000000">Black</option>
<option value="#FFFFFF">White</option>
<option disabled></option>
<option value="#64748b">Slate</option>
<option value="#6b7280">Gray</option>
<option value="#71717a">Zinc</option>
<option value="#737373">Neutral</option>
<option value="#78716c">Stone</option>
<option disabled></option>
<option value="#ef4444">Red</option>
<option value="#f97316">Orange</option>
<option value="#f59e0b">Amber</option>
<option value="#eab308">Yellow</option>
<option value="#84cc16">Lime</option>
<option value="#22c55e">Green</option>
<option value="#10b981">Emerald</option>
<option value="#14b8a6">Teal</option>
<option value="#06b6d4">Cyan</option>
<option value="#0ea5e9">Sky</option>
<option value="#3b82f6">Blue</option>
<option value="#6366f1">Indigo</option>
<option value="#8b5cf6">Violet</option>
<option value="#a855f7">Purple</option>
<option value="#d946ef">Fuschia</option>
<option value="#ec4899">Pink</option>
<option value="#f43f5e">Rose</option>
</select>
</div>
</div>
<!-- background colour -->
<div class="p-2">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Background Colour</div>
<div class="flex">
<select v-model="iconBackgroundColour" class="bg-gray-50 dark:bg-zinc-700 border border-gray-300 dark:border-zinc-600 text-gray-900 dark:text-gray-100 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 dark:focus:ring-blue-600 dark:focus:border-blue-600 block w-full p-2.5">
<option value="#000000">Black</option>
<option value="#FFFFFF">White</option>
<option disabled></option>
<option value="#64748b">Slate</option>
<option value="#6b7280">Gray</option>
<option value="#71717a">Zinc</option>
<option value="#737373">Neutral</option>
<option value="#78716c">Stone</option>
<option disabled></option>
<option value="#ef4444">Red</option>
<option value="#f97316">Orange</option>
<option value="#f59e0b">Amber</option>
<option value="#eab308">Yellow</option>
<option value="#84cc16">Lime</option>
<option value="#22c55e">Green</option>
<option value="#10b981">Emerald</option>
<option value="#14b8a6">Teal</option>
<option value="#06b6d4">Cyan</option>
<option value="#0ea5e9">Sky</option>
<option value="#3b82f6">Blue</option>
<option value="#6366f1">Indigo</option>
<option value="#8b5cf6">Violet</option>
<option value="#a855f7">Purple</option>
<option value="#d946ef">Fuschia</option>
<option value="#ec4899">Pink</option>
<option value="#f43f5e">Rose</option>
</select>
</div>
</div>
</div>
</div>
<!-- search icons -->
<div class="bg-white dark:bg-zinc-800 rounded shadow">
<div class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-gray-200 p-2 font-semibold">Select your Icon</div>
<div class="divide-y divide-gray-300 dark:divide-zinc-700 text-gray-900 dark:text-gray-100">
<div class="flex p-1">
<input v-model="search" type="text" :placeholder="`Search ${iconNames.length} icons...`" class="bg-gray-50 dark:bg-zinc-700 border border-gray-300 dark:border-zinc-600 text-gray-900 dark:text-gray-100 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 dark:focus:ring-blue-600 dark:focus:border-blue-600 block w-full p-2.5">
</div>
<div class="divide-y">
<div @click="onIconClick(mdiIconName)" v-for="mdiIconName of searchedIconNames" class="flex space-x-1 p-2 cursor-pointer hover:bg-gray-100">
<div class="my-auto">
<LxmfUserIcon :icon-name="mdiIconName" :icon-foreground-colour="iconForegroundColour" :icon-background-colour="iconBackgroundColour"/>
</div>
<div class="my-auto">{{ mdiIconName }}</div>
</div>
<div v-if="searchedIconNames.length === 0" class="p-1 text-sm text-gray-500">No icons match your search.</div>
<div v-if="searchedIconNames.length === maxSearchResults" class="p-1 text-sm text-gray-500">A maximum of {{ maxSearchResults }} icons are shown.</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import * as mdi from "@mdi/js";
import MaterialDesignIcon from "../../../../build/exe/lib/src/frontend/components/MaterialDesignIcon.vue";
import LxmfUserIcon from "../LxmfUserIcon.vue";
import DialogUtils from "../../js/DialogUtils";
export default {
name: 'ProfilePage',
components: {
LxmfUserIcon,
MaterialDesignIcon,
},
data() {
return {
config: null,
iconForegroundColour: null,
iconBackgroundColour: null,
search: "",
maxSearchResults: 100,
iconNames: [],
};
},
mounted() {
this.getConfig();
// load icon names
this.iconNames = Object.keys(mdi).map((mdiIcon) => {
return mdiIcon
.replace(/^mdi/, '') // Remove the "mdi" prefix
.replace(/([a-z])([A-Z])/g, '$1-$2') // Add a hyphen between lowercase and uppercase letters
.toLowerCase(); // Convert the entire string to lowercase
});
},
methods: {
async getConfig() {
try {
const response = await window.axios.get("/api/v1/config");
this.config = response.data.config;
} catch(e) {
// do nothing if failed to load config
console.log(e);
}
},
async updateConfig(config) {
try {
const response = await window.axios.patch("/api/v1/config", config);
this.config = response.data.config;
} catch(e) {
alert("Failed to save config!");
console.log(e);
}
},
async onIconClick(iconName) {
// ensure foreground colour set
if(this.iconForegroundColour == null){
DialogUtils.alert("Please select an icon colour first");
return;
}
// ensure background colour set
if(this.iconBackgroundColour == null){
DialogUtils.alert("Please select a background colour first");
return;
}
// confirm user wants to update their icon
if(!confirm("Are you sure you want to set this as your profile icon?")){
return;
}
// save icon appearance
await this.updateConfig({
"lxmf_user_icon_name": iconName,
"lxmf_user_icon_foreground_colour": this.iconForegroundColour,
"lxmf_user_icon_background_colour": this.iconBackgroundColour,
});
},
async removeProfileIcon() {
// confirm user wants to remove their icon
if(!confirm("Are you sure you want to remove your profile icon? Anyone that has already received it will continue to see it until you send them a new icon.")){
return;
}
// remove profile icon
await this.updateConfig({
"lxmf_user_icon_name": null,
"lxmf_user_icon_foreground_colour": null,
"lxmf_user_icon_background_colour": null,
});
}
},
computed: {
searchedIconNames() {
return this.iconNames.filter((iconName) => {
return iconName.includes(this.search);
}).slice(0, this.maxSearchResults);
},
},
watch: {
config() {
// update ui when config is updated
this.iconName = this.config.lxmf_user_icon_name;
this.iconForegroundColour = this.config.lxmf_user_icon_foreground_colour || "#000000";
this.iconBackgroundColour = this.config.lxmf_user_icon_background_colour || "#FFFFFF";
},
},
}
</script>

View File

@@ -65,6 +65,11 @@ const router = createRouter({
path: '/ping', path: '/ping',
component: defineAsyncComponent(() => import("./components/ping/PingPage.vue")), component: defineAsyncComponent(() => import("./components/ping/PingPage.vue")),
}, },
{
name: "profile",
path: '/profile',
component: defineAsyncComponent(() => import("./components/profile/ProfilePage.vue")),
},
{ {
name: "settings", name: "settings",
path: '/settings', path: '/settings',