Add support for group DMs; also make UI highlight which chat is currently active

This commit is contained in:
Alessio 2023-11-19 20:37:22 -08:00
parent 6e9a370073
commit b73ee1f5d2
11 changed files with 145 additions and 41 deletions

View File

@ -33,15 +33,11 @@ func (app *Application) Messages(w http.ResponseWriter, r *http.Request) {
chat_view := app.Profile.GetChatRoomsPreview(app.ActiveUser.ID) chat_view := app.Profile.GetChatRoomsPreview(app.ActiveUser.ID)
if strings.Trim(r.URL.Path, "/") != "" { if strings.Trim(r.URL.Path, "/") != "" {
message_id := scraper.DMChatRoomID(strings.Trim(r.URL.Path, "/")) chat_view.ActiveRoomID = scraper.DMChatRoomID(strings.Trim(r.URL.Path, "/"))
chat_contents := app.Profile.GetChatRoomContents(message_id) chat_contents := app.Profile.GetChatRoomContents(chat_view.ActiveRoomID)
chat_view.MergeWith(chat_contents.DMTrove) chat_view.MergeWith(chat_contents.DMTrove)
chat_view.MessageIDs = chat_contents.MessageIDs chat_view.MessageIDs = chat_contents.MessageIDs
if r.Header.Get("HX-Request") == "true" {
app.buffered_render_tweet_htmx(w, "chat-view", MessageData(chat_view))
return
}
} }
app.buffered_render_tweet_page(w, "tpl/messages.tpl", MessageData(chat_view)) app.buffered_render_tweet_page(w, "tpl/messages.tpl", MessageData(chat_view))

View File

@ -666,6 +666,14 @@ ul.dropdown-items {
box-sizing: border-box; box-sizing: border-box;
cursor: pointer; cursor: pointer;
} }
.chats-container .chat-list .chat.active-chat {
color: var(--color-twitter-blue);
border-left: 0.2em solid var(--color-twitter-blue);
background-color: var(--color-twitter-off-white);
}
.chats-container .chat-list .chat.active-chat .profile-image{
box-shadow: 0 0 1em 0em var(--color-twitter-blue);
}
.chats-container .chat-list .chat .chat-preview-header { .chats-container .chat-list .chat .chat-preview-header {
display: flex; display: flex;
align-items: center; align-items: center;
@ -674,13 +682,19 @@ ul.dropdown-items {
.chats-container .chat-list .chat .chat-preview-header .posted-at { .chats-container .chat-list .chat .chat-preview-header .posted-at {
margin: 0; margin: 0;
} }
.chats-container .chat-list .chat .groupchat-profile-image-container {
display: flex;
}
.chats-container .chat-list .chat .groupchat-profile-image-container .display-name {
padding: 0.6em;
font-style: italic;
}
.chats-container .chat-list .chat .chat-preview { .chats-container .chat-list .chat .chat-preview {
font-size: 0.9em; font-size: 0.9em;
color: var(--color-twitter-text-gray); color: var(--color-twitter-text-gray);
padding: 0 1em; padding: 0 1em;
border-left: 1px solid var(--color-outline-gray); border-left: 1px solid var(--color-outline-gray);
} }
.chats-container #chat-view { .chats-container #chat-view {
flex-basis: 0; flex-basis: 0;
flex-grow: 7; flex-grow: 7;

View File

@ -1,27 +1,7 @@
{{define "chat-list"}} {{define "chat-list"}}
<div class="chat-list"> <div class="chat-list">
{{range .RoomIDs}} {{range .RoomIDs}}
{{$room := (index $.Rooms .)}} {{template "chat-list-entry" (dict "room" (index $.Rooms .) "messages" $.DMTrove.Messages "is_active" (eq $.ActiveRoomID .))}}
<div class="chat" hx-get="/messages/{{$room.ID}}" hx-target="#chat-view" hx-swap="outerHTML" hx-push-url="true">
<div class="chat-preview-header">
{{range $room.Participants}}
{{if (ne .UserID (active_user).ID)}}
<!-- This is some fuckery; I have no idea why "hx-target" is needed, but otherwise it targets the #chat-view. -->
<div class="click-eater" hx-trigger="click consume" hx-target="body">
{{template "author-info" (user .UserID)}}
</div>
{{end}}
{{end}}
<div class="chat-preview-timestamp .posted-at-container">
<p class="posted-at">
{{$room.LastMessagedAt.Time.Format "Jan 2, 2006"}}
<br/>
{{$room.LastMessagedAt.Time.Format "3:04 pm"}}
</p>
</div>
</div>
<p class="chat-preview">{{(index $.DMTrove.Messages $room.LastMessageID).Text}}</p>
</div>
{{end}} {{end}}
</div> </div>
{{end}} {{end}}

View File

@ -0,0 +1,30 @@
{{define "chat-list-entry"}}
{{$room := $.room}}
<div class="chat {{if .is_active}}active-chat{{end}}" hx-get="/messages/{{$room.ID}}" hx-push-url="true" hx-swap="outerHTML" hx-target="body">
<div class="chat-preview-header">
{{if (eq $room.Type "ONE_TO_ONE")}}
{{range $room.Participants}}
{{if (ne .UserID (active_user).ID)}}
<!-- This is some fuckery; I have no idea why "hx-target" is needed, but otherwise it targets the #chat-view. -->
<div class="click-eater" hx-trigger="click consume" hx-target="body">
{{template "author-info" (user .UserID)}}
</div>
{{end}}
{{end}}
{{else}}
<div class="groupchat-profile-image-container">
<img class="profile-image" src="{{$room.AvatarImageRemoteURL}}" />
<div class="display-name row">{{$room.Name}}</div>
</div>
{{end}}
<div class="chat-preview-timestamp .posted-at-container">
<p class="posted-at">
{{$room.LastMessagedAt.Time.Format "Jan 2, 2006"}}
<br/>
{{$room.LastMessagedAt.Time.Format "3:04 pm"}}
</p>
</div>
</div>
<p class="chat-preview">{{(index $.messages $room.LastMessageID).Text}}</p>
</div>
{{end}}

View File

@ -13,10 +13,15 @@ import (
func (p Profile) SaveChatRoom(r DMChatRoom) error { func (p Profile) SaveChatRoom(r DMChatRoom) error {
_, err := p.DB.NamedExec(` _, err := p.DB.NamedExec(`
insert into chat_rooms (id, type, last_messaged_at, is_nsfw) insert into chat_rooms (id, type, last_messaged_at, is_nsfw, created_at, created_by_user_id, name,
values (:id, :type, :last_messaged_at, :is_nsfw) avatar_image_remote_url, avatar_image_local_path)
on conflict do update values (:id, :type, :last_messaged_at, :is_nsfw, :created_at, :created_by_user_id, :name,
set last_messaged_at=:last_messaged_at :avatar_image_remote_url, :avatar_image_local_path)
on conflict do update
set last_messaged_at=:last_messaged_at,
name=:name,
avatar_image_remote_url=:avatar_image_remote_url,
avatar_image_local_path=:avatar_image_local_path
`, r, `, r,
) )
if err != nil { if err != nil {
@ -68,7 +73,7 @@ func (p Profile) SaveChatRoom(r DMChatRoom) error {
func (p Profile) GetChatRoom(id DMChatRoomID) (ret DMChatRoom, err error) { func (p Profile) GetChatRoom(id DMChatRoomID) (ret DMChatRoom, err error) {
err = p.DB.Get(&ret, ` err = p.DB.Get(&ret, `
select id, type, last_messaged_at, is_nsfw select id, type, last_messaged_at, is_nsfw, created_at, created_by_user_id, name, avatar_image_remote_url, avatar_image_local_path
from chat_rooms from chat_rooms
where id = ? where id = ?
`, id) `, id)
@ -150,8 +155,9 @@ func (p Profile) GetChatMessage(id DMMessageID) (ret DMMessage, err error) {
type DMChatView struct { type DMChatView struct {
DMTrove DMTrove
RoomIDs []DMChatRoomID RoomIDs []DMChatRoomID
MessageIDs []DMMessageID MessageIDs []DMMessageID
ActiveRoomID DMChatRoomID
} }
func NewDMChatView() DMChatView { func NewDMChatView() DMChatView {
@ -167,7 +173,8 @@ func (p Profile) GetChatRoomsPreview(id UserID) DMChatView {
var rooms []DMChatRoom var rooms []DMChatRoom
err := p.DB.Select(&rooms, ` err := p.DB.Select(&rooms, `
select id, type, last_messaged_at, is_nsfw select id, type, last_messaged_at, is_nsfw, created_at, created_by_user_id, name,
avatar_image_remote_url, avatar_image_local_path
from chat_rooms from chat_rooms
where exists (select 1 from chat_room_participants where chat_room_id = chat_rooms.id and user_id = ?) where exists (select 1 from chat_room_participants where chat_room_id = chat_rooms.id and user_id = ?)
order by last_messaged_at desc order by last_messaged_at desc
@ -231,7 +238,8 @@ func (p Profile) GetChatRoomContents(id DMChatRoomID) DMChatView {
ret := NewDMChatView() ret := NewDMChatView()
var room DMChatRoom var room DMChatRoom
err := p.DB.Get(&room, ` err := p.DB.Get(&room, `
select id, type, last_messaged_at, is_nsfw select id, type, last_messaged_at, is_nsfw, created_at, created_by_user_id, name,
avatar_image_remote_url, avatar_image_local_path
from chat_rooms from chat_rooms
where id = ? where id = ?
`, id) `, id)

View File

@ -210,7 +210,14 @@ create table chat_rooms (rowid integer primary key,
id text unique not null, id text unique not null,
type text not null, type text not null,
last_messaged_at integer not null, last_messaged_at integer not null,
is_nsfw boolean not null is_nsfw boolean not null,
-- Group DM info
created_at integer not null,
created_by_user_id integer not null,
name text not null default '',
avatar_image_remote_url text not null default '',
avatar_image_local_path text not null default ''
); );
create table chat_room_participants(rowid integer primary key, create table chat_room_participants(rowid integer primary key,

View File

@ -47,6 +47,12 @@ type APIDMConversation struct {
Trusted bool `json:"trusted"` Trusted bool `json:"trusted"`
Muted bool `json:"muted"` Muted bool `json:"muted"`
Status string `json:"status"` Status string `json:"status"`
// For type == "GROUP_DM"
CreateTime int `json:"create_time,string"`
CreatedByUserID int `json:"created_by_user_id,string"`
Name string `json:"name"`
AvatarImage string `json:"avatar_image_https"`
} }
type APIInbox struct { type APIInbox struct {

View File

@ -90,6 +90,35 @@ func TestParseAPIDMConversation(t *testing.T) {
assert.False(p2.IsChatSettingsValid) assert.False(p2.IsChatSettingsValid)
} }
func TestParseAPIDMGroupChat(t *testing.T) {
assert := assert.New(t)
data, err := os.ReadFile("test_responses/dms/chat_room_group_chat.json")
require.NoError(t, err)
var api_room APIDMConversation
err = json.Unmarshal(data, &api_room)
require.NoError(t, err)
// Simulate one of the participants being logged in
InitApi(API{UserID: 1458284524761075714})
chat_room := ParseAPIDMChatRoom(api_room)
assert.Equal(DMChatRoomID("1710215025518948715"), chat_room.ID)
assert.Equal("GROUP_DM", chat_room.Type)
assert.Equal(TimestampFromUnix(1700112789457), chat_room.LastMessagedAt)
assert.False(chat_room.IsNSFW)
// Group DM settings
assert.Equal(chat_room.CreatedAt, TimestampFromUnix(1696582011))
assert.Equal(chat_room.CreatedByUserID, UserID(2694459866))
assert.Equal(chat_room.Name, "Schön ist die Welt")
assert.Equal(chat_room.AvatarImageRemoteURL,
"https://pbs.twimg.com/dm_group_img/1722785857403240448/3Wt_yJEq6i_G-kAT2rXheTojjhqkYE3okoW5JGUUHY7J9D8O9o?format=jpg&name=orig")
assert.Equal(chat_room.AvatarImageLocalPath, "1710215025518948715_avatar_3Wt_yJEq6i_G-kAT2rXheTojjhqkYE3okoW5JGUUHY7J9D8O9o.jpg")
assert.Len(chat_room.Participants, 5)
}
func TestParseInbox(t *testing.T) { func TestParseInbox(t *testing.T) {
assert := assert.New(t) assert := assert.New(t)
data, err := os.ReadFile("test_responses/dms/inbox.json") data, err := os.ReadFile("test_responses/dms/inbox.json")

View File

@ -1,5 +1,11 @@
package scraper package scraper
import (
"fmt"
"net/url"
"path"
)
type DMChatRoomID string type DMChatRoomID string
// A participant in a chat room. // A participant in a chat room.
@ -28,6 +34,13 @@ type DMChatRoom struct {
LastMessagedAt Timestamp `db:"last_messaged_at"` // Used for ordering the chats in the UI LastMessagedAt Timestamp `db:"last_messaged_at"` // Used for ordering the chats in the UI
IsNSFW bool `db:"is_nsfw"` IsNSFW bool `db:"is_nsfw"`
// GROUP_DM rooms
CreatedAt Timestamp `db:"created_at"`
CreatedByUserID UserID `db:"created_by_user_id"`
Name string `db:"name"`
AvatarImageRemoteURL string `db:"avatar_image_remote_url"`
AvatarImageLocalPath string `db:"avatar_image_local_path"`
LastMessageID DMMessageID `db:"last_message_id"` // Not stored, but used to generate preview LastMessageID DMMessageID `db:"last_message_id"` // Not stored, but used to generate preview
Participants map[UserID]DMChatParticipant Participants map[UserID]DMChatParticipant
} }
@ -39,6 +52,18 @@ func ParseAPIDMChatRoom(api_room APIDMConversation) DMChatRoom {
ret.LastMessagedAt = TimestampFromUnix(int64(api_room.SortTimestamp)) ret.LastMessagedAt = TimestampFromUnix(int64(api_room.SortTimestamp))
ret.IsNSFW = api_room.NSFW ret.IsNSFW = api_room.NSFW
if ret.Type == "GROUP_DM" {
ret.CreatedAt = TimestampFromUnix(int64(api_room.CreateTime / 1000))
ret.CreatedByUserID = UserID(api_room.CreatedByUserID)
ret.Name = api_room.Name
ret.AvatarImageRemoteURL = api_room.AvatarImage
tmp_url, err := url.Parse(ret.AvatarImageRemoteURL)
if err != nil {
panic(err)
}
ret.AvatarImageLocalPath = fmt.Sprintf("%s_avatar_%s.%s", ret.ID, path.Base(tmp_url.Path), tmp_url.Query().Get("format"))
}
ret.Participants = make(map[UserID]DMChatParticipant) ret.Participants = make(map[UserID]DMChatParticipant)
for _, api_participant := range api_room.Participants { for _, api_participant := range api_room.Participants {
participant := DMChatParticipant{} participant := DMChatParticipant{}

View File

@ -0,0 +1 @@
{"conversation_id":"1710215025518948715","type":"GROUP_DM","sort_event_id":"1725024183728394258","sort_timestamp":"1700112789457","participants":[{"user_id":"1040322921145589760","join_time":"1699480601054","join_conversation_event_id":"1722372593372606464","is_admin":false},{"user_id":"2694459866","join_time":"1696582011037","last_read_event_id":"1725024183728394258","is_admin":true},{"user_id":"1532476989721952256","join_time":"1696582011037","last_read_event_id":"1725024183728394258","join_conversation_event_id":"1710215025518948717","is_admin":false},{"user_id":"1590450335315021824","join_time":"1698335060149","last_read_event_id":"1725024183728394258","join_conversation_event_id":"1717567846618669056","is_admin":false},{"user_id":"1458284524761075714","join_time":"1696582011037","last_read_event_id":"1725024183728394258","join_conversation_event_id":"1710215025518948717","is_admin":false}],"create_time":"1696582011037","created_by_user_id":"2694459866","name":"Schön ist die Welt","avatar_image_https":"https://pbs.twimg.com/dm_group_img/1722785857403240448/3Wt_yJEq6i_G-kAT2rXheTojjhqkYE3okoW5JGUUHY7J9D8O9o?format=jpg&name=orig","avatar":{"image":{"original_info":{"url":"https://pbs.twimg.com/dm_group_img/1722785857403240448/3Wt_yJEq6i_G-kAT2rXheTojjhqkYE3okoW5JGUUHY7J9D8O9o?format=jpg&name=orig","width":400,"height":400}}},"nsfw":false,"notifications_disabled":false,"mention_notifications_disabled":false,"last_read_event_id":"1725024183728394258","trusted":true,"low_quality":false,"muted":false,"status":"HAS_MORE","min_entry_id":"1724956348776120338","max_entry_id":"1725024183728394258"}

View File

@ -336,11 +336,19 @@ create table chat_rooms (rowid integer primary key,
id text unique not null, id text unique not null,
type text not null, type text not null,
last_messaged_at integer not null, last_messaged_at integer not null,
is_nsfw boolean not null is_nsfw boolean not null,
-- Group DM info
created_at integer not null default 0,
created_by_user_id integer not null default 0,
name text not null default '',
avatar_image_remote_url text not null default '',
avatar_image_local_path text not null default ''
); );
INSERT INTO chat_rooms VALUES INSERT INTO chat_rooms VALUES
(1,'1458284524761075714-1488963321701171204','ONE_TO_ONE',1686025129132,0), (1,'1458284524761075714-1488963321701171204','ONE_TO_ONE',1686025129132,0,0,0,'','',''),
(2,'1488963321701171204-1178839081222115328','ONE_TO_ONE',1686025129144,0); (2,'1488963321701171204-1178839081222115328','ONE_TO_ONE',1686025129144,0,0,0,'','','');
create table chat_room_participants(rowid integer primary key, create table chat_room_participants(rowid integer primary key,
chat_room_id text not null, chat_room_id text not null,