Compare commits

...

7 commits

Author SHA1 Message Date
10214f3369 Fix clippy warning 2025-06-27 11:03:06 +02:00
2ca6190d97 Use older ubuntu runner for older glibc version 2025-05-31 15:08:24 +02:00
67e77c8880 Show emoji hash in nick list 2025-05-31 15:06:55 +02:00
b70d7548da Bump version to 0.9.3 2025-05-31 13:54:07 +02:00
732d462775 Add user id based emoji hash
Helpful in scenarios where you want to disambiguate people based on
their user id at a glance.
2025-05-31 13:39:34 +02:00
40de073799 Silence clippy warning 2025-05-31 13:11:50 +02:00
8b928184e8 Fix autojoin key connecting to non-autojoin rooms 2025-04-08 23:52:27 +02:00
19 changed files with 176 additions and 35 deletions

View file

@ -16,7 +16,7 @@ jobs:
strategy: strategy:
matrix: matrix:
os: os:
- ubuntu-latest - ubuntu-22.04
- windows-latest - windows-latest
- macos-latest - macos-latest
- macos-13 - macos-13
@ -59,11 +59,11 @@ jobs:
- name: Zip artifacts - name: Zip artifacts
run: | run: |
chmod +x cove-ubuntu-latest/cove chmod +x cove-ubuntu-22.04/cove
chmod +x cove-windows-latest/cove.exe chmod +x cove-windows-latest/cove.exe
chmod +x cove-macos-latest/cove chmod +x cove-macos-latest/cove
chmod +x cove-macos-13/cove chmod +x cove-macos-13/cove
zip -jr "cove-$(cat cove-ubuntu-latest/host).zip" cove-ubuntu-latest/cove zip -jr "cove-$(cat cove-ubuntu-22.04/host).zip" cove-ubuntu-22.04/cove
zip -jr "cove-$(cat cove-windows-latest/host).zip" cove-windows-latest/cove.exe zip -jr "cove-$(cat cove-windows-latest/host).zip" cove-windows-latest/cove.exe
zip -jr "cove-$(cat cove-macos-latest/host).zip" cove-macos-latest/cove zip -jr "cove-$(cat cove-macos-latest/host).zip" cove-macos-latest/cove
zip -jr "cove-$(cat cove-macos-13/host).zip" cove-macos-13/cove zip -jr "cove-$(cat cove-macos-13/host).zip" cove-macos-13/cove

View file

@ -15,6 +15,21 @@ Procedure when bumping the version number:
## Unreleased ## Unreleased
### Changed
- Display emoji user id hashes in the nick list
- Compile linux binary with older glibc version
## v0.9.3 - 2025-05-31
### Added
- Key bindings for emoji-based user id hashing
### Fixed
- `keys.rooms.action.connect_autojoin` connecting to non-autojoin rooms
## v0.9.2 - 2025-03-14 ## v0.9.2 - 2025-03-14
### Added ### Added

View file

@ -537,6 +537,14 @@ Reply to message, inline if possible.
Reply opposite to normal reply. Reply opposite to normal reply.
### `keys.tree.action.toggle_nick_emoji`
**Required:** yes
**Type:** key binding
**Default:** `"e"`
Toggle agent id based nick emoji.
### `keys.tree.action.toggle_seen` ### `keys.tree.action.toggle_seen`
**Required:** yes **Required:** yes

8
Cargo.lock generated
View file

@ -322,7 +322,7 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]] [[package]]
name = "cove" name = "cove"
version = "0.9.2" version = "0.9.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@ -350,7 +350,7 @@ dependencies = [
[[package]] [[package]]
name = "cove-config" name = "cove-config"
version = "0.9.2" version = "0.9.3"
dependencies = [ dependencies = [
"cove-input", "cove-input",
"cove-macro", "cove-macro",
@ -361,7 +361,7 @@ dependencies = [
[[package]] [[package]]
name = "cove-input" name = "cove-input"
version = "0.9.2" version = "0.9.3"
dependencies = [ dependencies = [
"cove-macro", "cove-macro",
"crossterm", "crossterm",
@ -375,7 +375,7 @@ dependencies = [
[[package]] [[package]]
name = "cove-macro" name = "cove-macro"
version = "0.9.2" version = "0.9.3"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",

View file

@ -3,7 +3,7 @@ resolver = "3"
members = ["cove", "cove-*"] members = ["cove", "cove-*"]
[workspace.package] [workspace.package]
version = "0.9.2" version = "0.9.3"
edition = "2024" edition = "2024"
[workspace.dependencies] [workspace.dependencies]

View file

@ -104,6 +104,7 @@ default_bindings! {
pub fn mark_older_seen => ["ctrl+s"]; pub fn mark_older_seen => ["ctrl+s"];
pub fn info => ["i"]; pub fn info => ["i"];
pub fn links => ["I"]; pub fn links => ["I"];
pub fn toggle_nick_emoji => ["e"];
pub fn increase_caesar => ["c"]; pub fn increase_caesar => ["c"];
pub fn decrease_caesar => ["C"]; pub fn decrease_caesar => ["C"];
} }
@ -356,6 +357,9 @@ pub struct TreeAction {
/// List links found in message. /// List links found in message.
#[serde(default = "default::tree_action::links")] #[serde(default = "default::tree_action::links")]
pub links: KeyBinding, pub links: KeyBinding,
/// Toggle agent id based nick emoji.
#[serde(default = "default::tree_action::toggle_nick_emoji")]
pub toggle_nick_emoji: KeyBinding,
/// Increase caesar cipher rotation. /// Increase caesar cipher rotation.
#[serde(default = "default::tree_action::increase_caesar")] #[serde(default = "default::tree_action::increase_caesar")]
pub increase_caesar: KeyBinding, pub increase_caesar: KeyBinding,

View file

@ -1,15 +1,18 @@
use crossterm::style::Stylize; use crossterm::style::Stylize;
use euphoxide::api::{MessageId, Snowflake, Time}; use euphoxide::api::{MessageId, Snowflake, Time, UserId};
use jiff::Timestamp; use jiff::Timestamp;
use toss::{Style, Styled}; use toss::{Style, Styled};
use crate::{store::Msg, ui::ChatMsg}; use crate::{store::Msg, ui::ChatMsg};
use super::util;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct SmallMessage { pub struct SmallMessage {
pub id: MessageId, pub id: MessageId,
pub parent: Option<MessageId>, pub parent: Option<MessageId>,
pub time: Time, pub time: Time,
pub user_id: UserId,
pub nick: String, pub nick: String,
pub content: String, pub content: String,
pub seen: bool, pub seen: bool,
@ -70,6 +73,10 @@ impl Msg for SmallMessage {
fn last_possible_id() -> Self::Id { fn last_possible_id() -> Self::Id {
MessageId(Snowflake::MAX) MessageId(Snowflake::MAX)
} }
fn nick_emoji(&self) -> Option<String> {
Some(util::user_id_emoji(&self.user_id))
}
} }
impl ChatMsg for SmallMessage { impl ChatMsg for SmallMessage {

View file

@ -1,11 +1,28 @@
use std::sync::LazyLock; use std::{
collections::HashSet,
hash::{DefaultHasher, Hash, Hasher},
sync::LazyLock,
};
use crossterm::style::{Color, Stylize}; use crossterm::style::{Color, Stylize};
use euphoxide::Emoji; use euphoxide::{Emoji, api::UserId};
use toss::{Style, Styled}; use toss::{Style, Styled};
pub static EMOJI: LazyLock<Emoji> = LazyLock::new(Emoji::load); pub static EMOJI: LazyLock<Emoji> = LazyLock::new(Emoji::load);
pub static EMOJI_LIST: LazyLock<Vec<String>> = LazyLock::new(|| {
let mut list = EMOJI
.0
.values()
.flatten()
.cloned()
.collect::<HashSet<_>>()
.into_iter()
.collect::<Vec<_>>();
list.sort_unstable();
list
});
/// Convert HSL to RGB following [this approach from wikipedia][1]. /// Convert HSL to RGB following [this approach from wikipedia][1].
/// ///
/// `h` must be in the range `[0, 360]`, `s` and `l` in the range `[0, 1]`. /// `h` must be in the range `[0, 360]`, `s` and `l` in the range `[0, 1]`.
@ -69,3 +86,11 @@ pub fn style_mention_exact(mention: &str, base: Style) -> Styled {
.expect("mention must start with @"); .expect("mention must start with @");
Styled::new(mention, nick_style(nick, base)) Styled::new(mention, nick_style(nick, base))
} }
pub fn user_id_emoji(user_id: &UserId) -> String {
let mut hasher = DefaultHasher::new();
user_id.0.hash(&mut hasher);
let hash = hasher.finish();
let emoji = &EMOJI_LIST[hash as usize % EMOJI_LIST.len()];
emoji.clone()
}

View file

@ -8,6 +8,10 @@ pub trait Msg {
fn parent(&self) -> Option<Self::Id>; fn parent(&self) -> Option<Self::Id>;
fn seen(&self) -> bool; fn seen(&self) -> bool;
fn nick_emoji(&self) -> Option<String> {
None
}
fn last_possible_id() -> Self::Id; fn last_possible_id() -> Self::Id;
} }

View file

@ -50,6 +50,7 @@ impl From<Infallible> for UiError {
} }
} }
#[expect(clippy::large_enum_variant)]
pub enum UiEvent { pub enum UiEvent {
GraphemeWidthsChanged, GraphemeWidthsChanged,
LogChanged, LogChanged,

View file

@ -37,6 +37,7 @@ pub struct ChatState<M: Msg, S: MsgStore<M>> {
cursor: Cursor<M::Id>, cursor: Cursor<M::Id>,
editor: EditorState, editor: EditorState,
nick_emoji: bool,
caesar: i8, caesar: i8,
mode: Mode, mode: Mode,
@ -48,6 +49,7 @@ impl<M: Msg, S: MsgStore<M> + Clone> ChatState<M, S> {
Self { Self {
cursor: Cursor::Bottom, cursor: Cursor::Bottom,
editor: EditorState::new(), editor: EditorState::new(),
nick_emoji: false,
caesar: 0, caesar: 0,
mode: Mode::Tree, mode: Mode::Tree,
@ -56,6 +58,10 @@ impl<M: Msg, S: MsgStore<M> + Clone> ChatState<M, S> {
store, store,
} }
} }
pub fn nick_emoji(&self) -> bool {
self.nick_emoji
}
} }
impl<M: Msg, S: MsgStore<M>> ChatState<M, S> { impl<M: Msg, S: MsgStore<M>> ChatState<M, S> {
@ -79,6 +85,7 @@ impl<M: Msg, S: MsgStore<M>> ChatState<M, S> {
&mut self.editor, &mut self.editor,
nick, nick,
focused, focused,
self.nick_emoji,
self.caesar, self.caesar,
) )
.boxed_async(), .boxed_async(),
@ -117,6 +124,11 @@ impl<M: Msg, S: MsgStore<M>> ChatState<M, S> {
Reaction::Composed { parent, content } Reaction::Composed { parent, content }
} }
Reaction::NotHandled if event.matches(&keys.tree.action.toggle_nick_emoji) => {
self.nick_emoji = !self.nick_emoji;
Reaction::Handled
}
Reaction::NotHandled if event.matches(&keys.tree.action.increase_caesar) => { Reaction::NotHandled if event.matches(&keys.tree.action.increase_caesar) => {
self.caesar = (self.caesar + 1).rem_euclid(26); self.caesar = (self.caesar + 1).rem_euclid(26);
Reaction::Handled Reaction::Handled

View file

@ -389,6 +389,7 @@ impl<M: Msg, S: MsgStore<M>> TreeViewState<M, S> {
editor: &'a mut EditorState, editor: &'a mut EditorState,
nick: String, nick: String,
focused: bool, focused: bool,
nick_emoji: bool,
caesar: i8, caesar: i8,
) -> TreeView<'a, M, S> { ) -> TreeView<'a, M, S> {
TreeView { TreeView {
@ -397,6 +398,7 @@ impl<M: Msg, S: MsgStore<M>> TreeViewState<M, S> {
editor, editor,
nick, nick,
focused, focused,
nick_emoji,
caesar, caesar,
} }
} }
@ -410,6 +412,8 @@ pub struct TreeView<'a, M: Msg, S: MsgStore<M>> {
nick: String, nick: String,
focused: bool, focused: bool,
nick_emoji: bool,
caesar: i8, caesar: i8,
} }
@ -438,6 +442,7 @@ where
size, size,
nick: self.nick.clone(), nick: self.nick.clone(),
focused: self.focused, focused: self.focused,
nick_emoji: self.nick_emoji,
caesar: self.caesar, caesar: self.caesar,
last_cursor: self.state.last_cursor.clone(), last_cursor: self.state.last_cursor.clone(),
last_cursor_top: self.state.last_cursor_top, last_cursor_top: self.state.last_cursor_top,

View file

@ -80,6 +80,7 @@ pub struct TreeContext<Id> {
pub size: Size, pub size: Size,
pub nick: String, pub nick: String,
pub focused: bool, pub focused: bool,
pub nick_emoji: bool,
pub caesar: i8, pub caesar: i8,
pub last_cursor: Cursor<Id>, pub last_cursor: Cursor<Id>,
pub last_cursor_top: i32, pub last_cursor_top: i32,
@ -207,6 +208,7 @@ where
self.tz.clone(), self.tz.clone(),
indent, indent,
msg, msg,
self.context.nick_emoji,
self.context.caesar, self.context.caesar,
folded_info, folded_info,
); );

View file

@ -22,6 +22,7 @@ where
size: self.last_size, size: self.last_size,
nick: self.last_nick.clone(), nick: self.last_nick.clone(),
focused: true, focused: true,
nick_emoji: false,
caesar: 0, caesar: 0,
last_cursor: self.last_cursor.clone(), last_cursor: self.last_cursor.clone(),
last_cursor_top: self.last_cursor_top, last_cursor_top: self.last_cursor_top,

View file

@ -59,10 +59,17 @@ pub fn msg<M: Msg + ChatMsg>(
tz: TimeZone, tz: TimeZone,
indent: usize, indent: usize,
msg: &M, msg: &M,
nick_emoji: bool,
caesar: i8, caesar: i8,
folded_info: Option<usize>, folded_info: Option<usize>,
) -> Boxed<'static, Infallible> { ) -> Boxed<'static, Infallible> {
let (nick, mut content) = msg.styled(); let (mut nick, mut content) = msg.styled();
if nick_emoji {
if let Some(emoji) = msg.nick_emoji() {
nick = nick.then_plain("(").then_plain(emoji).then_plain(")");
}
}
if caesar != 0 { if caesar != 0 {
// Apply caesar in inverse because we're decoding // Apply caesar in inverse because we're decoding

View file

@ -22,9 +22,10 @@ pub fn widget<'a>(
list: &'a mut ListState<SessionId>, list: &'a mut ListState<SessionId>,
joined: &Joined, joined: &Joined,
focused: bool, focused: bool,
nick_emoji: bool,
) -> impl Widget<UiError> + use<'a> { ) -> impl Widget<UiError> + use<'a> {
let mut list_builder = ListBuilder::new(); let mut list_builder = ListBuilder::new();
render_rows(&mut list_builder, joined, focused); render_rows(&mut list_builder, joined, focused, nick_emoji);
list_builder.build(list) list_builder.build(list)
} }
@ -70,6 +71,7 @@ fn render_rows(
list_builder: &mut ListBuilder<'_, SessionId, Background<Text>>, list_builder: &mut ListBuilder<'_, SessionId, Background<Text>>,
joined: &Joined, joined: &Joined,
focused: bool, focused: bool,
nick_emoji: bool,
) { ) {
let mut people = vec![]; let mut people = vec![];
let mut bots = vec![]; let mut bots = vec![];
@ -95,10 +97,38 @@ fn render_rows(
lurkers.sort_unstable(); lurkers.sort_unstable();
nurkers.sort_unstable(); nurkers.sort_unstable();
render_section(list_builder, "People", &people, &joined.session, focused); render_section(
render_section(list_builder, "Bots", &bots, &joined.session, focused); list_builder,
render_section(list_builder, "Lurkers", &lurkers, &joined.session, focused); "People",
render_section(list_builder, "Nurkers", &nurkers, &joined.session, focused); &people,
&joined.session,
focused,
nick_emoji,
);
render_section(
list_builder,
"Bots",
&bots,
&joined.session,
focused,
nick_emoji,
);
render_section(
list_builder,
"Lurkers",
&lurkers,
&joined.session,
focused,
nick_emoji,
);
render_section(
list_builder,
"Nurkers",
&nurkers,
&joined.session,
focused,
nick_emoji,
);
} }
fn render_section( fn render_section(
@ -107,6 +137,7 @@ fn render_section(
sessions: &[HalfSession], sessions: &[HalfSession],
own_session: &SessionView, own_session: &SessionView,
focused: bool, focused: bool,
nick_emoji: bool,
) { ) {
if sessions.is_empty() { if sessions.is_empty() {
return; return;
@ -124,7 +155,7 @@ fn render_section(
list_builder.add_unsel(Text::new(row).background()); list_builder.add_unsel(Text::new(row).background());
for session in sessions { for session in sessions {
render_row(list_builder, session, own_session, focused); render_row(list_builder, session, own_session, focused, nick_emoji);
} }
} }
@ -133,6 +164,7 @@ fn render_row(
session: &HalfSession, session: &HalfSession,
own_session: &SessionView, own_session: &SessionView,
focused: bool, focused: bool,
nick_emoji: bool,
) { ) {
let (name, style, style_inv, perms_style_inv) = if session.name.is_empty() { let (name, style, style_inv, perms_style_inv) = if session.name.is_empty() {
let name = "lurk".to_string(); let name = "lurk".to_string();
@ -166,16 +198,24 @@ fn render_row(
" " " "
}; };
let emoji = if nick_emoji {
format!(" ({})", euph::user_id_emoji(&session.id))
} else {
"".to_string()
};
list_builder.add_sel(session.session_id.clone(), move |selected| { list_builder.add_sel(session.session_id.clone(), move |selected| {
if focused && selected { if focused && selected {
let text = Styled::new_plain(owner) let text = Styled::new_plain(owner)
.then(name, style_inv) .then(name, style_inv)
.then(perms, perms_style_inv); .then(perms, perms_style_inv)
.then(emoji, perms_style_inv);
Text::new(text).background().with_style(style_inv) Text::new(text).background().with_style(style_inv)
} else { } else {
let text = Styled::new_plain(owner) let text = Styled::new_plain(owner)
.then(&name, style) .then(&name, style)
.then_plain(perms); .then_plain(perms)
.then_plain(emoji);
Text::new(text).background() Text::new(text).background()
} }
}); });

View file

@ -121,7 +121,7 @@ impl EuphRoom {
.server_config .server_config
.clone() .clone()
.room(self.vault().room().name.clone()) .room(self.vault().room().name.clone())
.name(format!("{room:?}-{}", next_instance_id)) .name(format!("{room:?}-{next_instance_id}"))
.human(true) .human(true)
.username(self.room_config.username.clone()) .username(self.room_config.username.clone())
.force_username(self.room_config.force_username) .force_username(self.room_config.force_username)
@ -291,7 +291,12 @@ impl EuphRoom {
joined: &Joined, joined: &Joined,
focus: Focus, focus: Focus,
) -> BoxedAsync<'a, UiError> { ) -> BoxedAsync<'a, UiError> {
let nick_list_widget = nick_list::widget(nick_list, joined, focus == Focus::NickList) let nick_list_widget = nick_list::widget(
nick_list,
joined,
focus == Focus::NickList,
chat.nick_emoji(),
)
.padding() .padding()
.with_right(1) .with_right(1)
.border() .border()

View file

@ -536,7 +536,10 @@ impl Rooms {
} }
if event.matches(&keys.rooms.action.connect_autojoin) { if event.matches(&keys.rooms.action.connect_autojoin) {
for (domain, server) in &self.config.euph.servers { for (domain, server) in &self.config.euph.servers {
for name in server.rooms.keys() { for (name, room) in &server.rooms {
if !room.autojoin {
continue;
}
let id = RoomIdentifier::new(domain.clone(), name.clone()); let id = RoomIdentifier::new(domain.clone(), name.clone());
self.connect_to_room(id).await; self.connect_to_room(id).await;
} }

View file

@ -611,7 +611,7 @@ impl Action for GetMsg {
let msg = conn let msg = conn
.query_row( .query_row(
" "
SELECT id, parent, time, name, content, seen SELECT id, parent, time, user_id, name, content, seen
FROM euph_msgs FROM euph_msgs
WHERE domain = ? WHERE domain = ?
AND room = ? AND room = ?
@ -623,9 +623,10 @@ impl Action for GetMsg {
id: MessageId(row.get::<_, WSnowflake>(0)?.0), id: MessageId(row.get::<_, WSnowflake>(0)?.0),
parent: row.get::<_, Option<WSnowflake>>(1)?.map(|s| MessageId(s.0)), parent: row.get::<_, Option<WSnowflake>>(1)?.map(|s| MessageId(s.0)),
time: row.get::<_, WTime>(2)?.0, time: row.get::<_, WTime>(2)?.0,
nick: row.get(3)?, user_id: UserId(row.get(3)?),
content: row.get(4)?, nick: row.get(4)?,
seen: row.get(5)?, content: row.get(5)?,
seen: row.get(6)?,
}) })
}, },
) )
@ -703,7 +704,7 @@ impl Action for GetTree {
AND tree.room = euph_msgs.room AND tree.room = euph_msgs.room
AND tree.id = euph_msgs.parent AND tree.id = euph_msgs.parent
) )
SELECT id, parent, time, name, content, seen SELECT id, parent, time, user_id, name, content, seen
FROM euph_msgs FROM euph_msgs
JOIN tree USING (domain, room, id) JOIN tree USING (domain, room, id)
ORDER BY id ASC ORDER BY id ASC
@ -716,9 +717,10 @@ impl Action for GetTree {
id: MessageId(row.get::<_, WSnowflake>(0)?.0), id: MessageId(row.get::<_, WSnowflake>(0)?.0),
parent: row.get::<_, Option<WSnowflake>>(1)?.map(|s| MessageId(s.0)), parent: row.get::<_, Option<WSnowflake>>(1)?.map(|s| MessageId(s.0)),
time: row.get::<_, WTime>(2)?.0, time: row.get::<_, WTime>(2)?.0,
nick: row.get(3)?, user_id: UserId(row.get(3)?),
content: row.get(4)?, nick: row.get(4)?,
seen: row.get(5)?, content: row.get(5)?,
seen: row.get(6)?,
}) })
}, },
)? )?