Skip to content

Commit

Permalink
web/composer: send inline URL previews
Browse files Browse the repository at this point in the history
Signed-off-by: Sumner Evans <[email protected]>
  • Loading branch information
sumnerevans committed Jan 3, 2025
1 parent cb08f43 commit b4656bf
Show file tree
Hide file tree
Showing 14 changed files with 387 additions and 106 deletions.
10 changes: 10 additions & 0 deletions pkg/gomuks/gomuks.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ import (
"go.mau.fi/util/exerrors"
"go.mau.fi/util/exzerolog"
"golang.org/x/net/http2"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"

"go.mau.fi/gomuks/pkg/hicli"
)
Expand Down Expand Up @@ -66,11 +68,19 @@ type Gomuks struct {
stopChan chan struct{}

EventBuffer *EventBuffer

// Maps from temporary MXC URIs from by the media repository for URL
// previews to permanent MXC URIs suitable for sending in an inline preview
temporaryMXCToPermanent map[id.ContentURIString]id.ContentURIString
temporaryMXCToEncryptedFileInfo map[id.ContentURIString]*event.EncryptedFileInfo
}

func NewGomuks() *Gomuks {
return &Gomuks{
stopChan: make(chan struct{}),

temporaryMXCToPermanent: map[id.ContentURIString]id.ContentURIString{},
temporaryMXCToEncryptedFileInfo: map[id.ContentURIString]*event.EncryptedFileInfo{},
}
}

Expand Down
116 changes: 88 additions & 28 deletions pkg/gomuks/media.go
Original file line number Diff line number Diff line change
Expand Up @@ -348,22 +348,94 @@ func (gmx *Gomuks) DownloadMedia(w http.ResponseWriter, r *http.Request) {

func (gmx *Gomuks) UploadMedia(w http.ResponseWriter, r *http.Request) {
log := hlog.FromRequest(r)
tempFile, err := os.CreateTemp(gmx.TempDir, "upload-*")
encrypt, _ := strconv.ParseBool(r.URL.Query().Get("encrypt"))
content, err := gmx.cacheAndUploadMedia(r.Context(), r.Body, encrypt, r.URL.Query().Get("filename"))
if err != nil {
log.Err(err).Msg("Failed to create temporary file")
mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to create temp file: %v", err)).Write(w)
log.Err(err).Msg("Failed to upload media")
writeMaybeRespError(err, w)
return
}
exhttp.WriteJSONResponse(w, http.StatusOK, content)
}

func (gmx *Gomuks) GetURLPreview(w http.ResponseWriter, r *http.Request) {
log := hlog.FromRequest(r)
url := r.URL.Query().Get("url")
if url == "" {
mautrix.MInvalidParam.WithMessage("URL must be provided to preview").Write(w)
return
}
linkPreview, err := gmx.Client.Client.GetURLPreview(r.Context(), url)
if err != nil {
log.Err(err).Msg("Failed to get URL preview")
writeMaybeRespError(err, w)
return
}

preview := event.BeeperLinkPreview{
LinkPreview: *linkPreview,
MatchedURL: url,
}

if preview.ImageURL != "" {
encrypt, _ := strconv.ParseBool(r.URL.Query().Get("encrypt"))

var content *event.MessageEventContent

if encrypt {
if fileInfo, ok := gmx.temporaryMXCToEncryptedFileInfo[preview.ImageURL]; ok {
content = &event.MessageEventContent{File: fileInfo}
}
} else {
if mxc, ok := gmx.temporaryMXCToPermanent[preview.ImageURL]; ok {
content = &event.MessageEventContent{URL: mxc}
}
}

if content == nil {
resp, err := gmx.Client.Client.Download(r.Context(), preview.ImageURL.ParseOrIgnore())
if err != nil {
log.Err(err).Msg("Failed to download URL preview image")
writeMaybeRespError(err, w)
return
}
defer resp.Body.Close()

content, err = gmx.cacheAndUploadMedia(r.Context(), resp.Body, encrypt, "")
if err != nil {
log.Err(err).Msg("Failed to upload URL preview image")
writeMaybeRespError(err, w)
return
}

if encrypt {
gmx.temporaryMXCToEncryptedFileInfo[preview.ImageURL] = content.File
} else {
gmx.temporaryMXCToPermanent[preview.ImageURL] = content.URL
}
}

preview.ImageURL = content.URL
preview.ImageEncryption = content.File
}

exhttp.WriteJSONResponse(w, http.StatusOK, preview)
}

func (gmx *Gomuks) cacheAndUploadMedia(ctx context.Context, reader io.Reader, encrypt bool, fileName string) (*event.MessageEventContent, error) {
log := zerolog.Ctx(ctx)
tempFile, err := os.CreateTemp(gmx.TempDir, "upload-*")
if err != nil {
return nil, fmt.Errorf("failed to create temp file %w", err)
}
defer func() {
_ = tempFile.Close()
_ = os.Remove(tempFile.Name())
}()
hasher := sha256.New()
_, err = io.Copy(tempFile, io.TeeReader(r.Body, hasher))
_, err = io.Copy(tempFile, io.TeeReader(reader, hasher))
if err != nil {
log.Err(err).Msg("Failed to copy upload media to temporary file")
mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to copy media to temp file: %v", err)).Write(w)
return
return nil, fmt.Errorf("failed to copy upload media to temp file: %w", err)
}
_ = tempFile.Close()

Expand All @@ -374,39 +446,29 @@ func (gmx *Gomuks) UploadMedia(w http.ResponseWriter, r *http.Request) {
} else {
err = os.MkdirAll(filepath.Dir(cachePath), 0700)
if err != nil {
log.Err(err).Msg("Failed to create cache directory")
mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to create cache directory: %v", err)).Write(w)
return
return nil, fmt.Errorf("failed to create cache directory: %w", err)
}
err = os.Rename(tempFile.Name(), cachePath)
if err != nil {
log.Err(err).Msg("Failed to rename temporary file")
mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to rename temp file: %v", err)).Write(w)
return
return nil, fmt.Errorf("failed to rename temp file: %w", err)
}
}

cacheFile, err := os.Open(cachePath)
if err != nil {
log.Err(err).Msg("Failed to open cache file")
mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to open cache file: %v", err)).Write(w)
return
return nil, fmt.Errorf("failed to open cache file: %w", err)
}

msgType, info, defaultFileName, err := gmx.generateFileInfo(r.Context(), cacheFile)
msgType, info, defaultFileName, err := gmx.generateFileInfo(ctx, cacheFile)
if err != nil {
log.Err(err).Msg("Failed to generate file info")
mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to generate file info: %v", err)).Write(w)
return
return nil, fmt.Errorf("failed to generate file info: %w", err)
}
encrypt, _ := strconv.ParseBool(r.URL.Query().Get("encrypt"))
if msgType == event.MsgVideo {
err = gmx.generateVideoThumbnail(r.Context(), cacheFile.Name(), encrypt, info)
err = gmx.generateVideoThumbnail(ctx, cacheFile.Name(), encrypt, info)
if err != nil {
log.Warn().Err(err).Msg("Failed to generate video thumbnail")
}
}
fileName := r.URL.Query().Get("filename")
if fileName == "" {
fileName = defaultFileName
}
Expand All @@ -416,13 +478,11 @@ func (gmx *Gomuks) UploadMedia(w http.ResponseWriter, r *http.Request) {
Info: info,
FileName: fileName,
}
content.File, content.URL, err = gmx.uploadFile(r.Context(), checksum, cacheFile, encrypt, int64(info.Size), info.MimeType, fileName)
content.File, content.URL, err = gmx.uploadFile(ctx, checksum, cacheFile, encrypt, int64(info.Size), info.MimeType, fileName)
if err != nil {
log.Err(err).Msg("Failed to upload media")
writeMaybeRespError(err, w)
return
return nil, fmt.Errorf("failed to upload media: %w", err)
}
exhttp.WriteJSONResponse(w, http.StatusOK, content)
return content, nil
}

func (gmx *Gomuks) uploadFile(ctx context.Context, checksum []byte, cacheFile *os.File, encrypt bool, fileSize int64, mimeType, fileName string) (*event.EncryptedFileInfo, id.ContentURIString, error) {
Expand Down
1 change: 1 addition & 0 deletions pkg/gomuks/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ func (gmx *Gomuks) CreateAPIRouter() http.Handler {
api.HandleFunc("POST /sso", gmx.PrepareSSO)
api.HandleFunc("GET /media/{server}/{media_id}", gmx.DownloadMedia)
api.HandleFunc("GET /codeblock/{style}", gmx.GetCodeblockCSS)
api.HandleFunc("GET /url_preview", gmx.GetURLPreview)
return exhttp.ApplyMiddleware(
api,
hlog.NewHandler(*gmx.Log),
Expand Down
15 changes: 8 additions & 7 deletions pkg/hicli/json-commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
})
case "send_message":
return unmarshalAndCall(req.Data, func(params *sendMessageParams) (*database.Event, error) {
return h.SendMessage(ctx, params.RoomID, params.BaseContent, params.Extra, params.Text, params.RelatesTo, params.Mentions)
return h.SendMessage(ctx, params.RoomID, params.BaseContent, params.Extra, params.Text, params.RelatesTo, params.Mentions, params.URLPreviews)
})
case "send_event":
return unmarshalAndCall(req.Data, func(params *sendEventParams) (*database.Event, error) {
Expand Down Expand Up @@ -221,12 +221,13 @@ type cancelRequestParams struct {
}

type sendMessageParams struct {
RoomID id.RoomID `json:"room_id"`
BaseContent *event.MessageEventContent `json:"base_content"`
Extra map[string]any `json:"extra"`
Text string `json:"text"`
RelatesTo *event.RelatesTo `json:"relates_to"`
Mentions *event.Mentions `json:"mentions"`
RoomID id.RoomID `json:"room_id"`
BaseContent *event.MessageEventContent `json:"base_content"`
Extra map[string]any `json:"extra"`
Text string `json:"text"`
RelatesTo *event.RelatesTo `json:"relates_to"`
Mentions *event.Mentions `json:"mentions"`
URLPreviews *[]*event.BeeperLinkPreview `json:"url_previews"`
}

type sendEventParams struct {
Expand Down
4 changes: 4 additions & 0 deletions pkg/hicli/send.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ func (h *HiClient) SendMessage(
text string,
relatesTo *event.RelatesTo,
mentions *event.Mentions,
urlPreviews *[]*event.BeeperLinkPreview,
) (*database.Event, error) {
var unencrypted bool
if strings.HasPrefix(text, "/unencrypted ") {
Expand Down Expand Up @@ -169,6 +170,9 @@ func (h *HiClient) SendMessage(
content.MsgType = ""
evtType = event.EventSticker
}
if urlPreviews != nil {
content.BeeperLinkPreviews = *urlPreviews
}
return h.send(ctx, roomID, evtType, &event.Content{Parsed: content, Raw: extra}, origText, unencrypted)
}

Expand Down
2 changes: 2 additions & 0 deletions web/src/api/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import type {
RoomStateGUID,
RoomSummary,
TimelineRowID,
URLPreview,
UserID,
UserProfile,
} from "./types"
Expand All @@ -65,6 +66,7 @@ export interface SendMessageParams {
media_path?: string
relates_to?: RelatesTo
mentions?: Mentions
url_previews?: URLPreview[]
}

export default abstract class RPCClient {
Expand Down
6 changes: 6 additions & 0 deletions web/src/api/types/preferences/preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ export const preferences = {
allowedContexts: anyContext,
defaultValue: true,
}),
send_bundled_url_previews: new Preference<boolean>({
displayName: "Send bundled URL previews",
description: "Should bundled URL previews be sent to other users?",
allowedContexts: anyContext,
defaultValue: true,
}),
display_read_receipts: new Preference<boolean>({
displayName: "Display read receipts",
description: "Should read receipts be rendered in the timeline?",
Expand Down
8 changes: 8 additions & 0 deletions web/src/ui/composer/MessageComposer.css
Original file line number Diff line number Diff line change
Expand Up @@ -80,4 +80,12 @@ div.message-composer {
}
}
}

> div.url-previews {
display: flex;
flex-direction: row;
gap: 1rem;
overflow-x: scroll;
margin: 0 0.5rem;
}
}
Loading

0 comments on commit b4656bf

Please sign in to comment.