Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

web/composer: send inline URL previews #563

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions pkg/gomuks/gomuks.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ import (
"go.mau.fi/util/exzerolog"
"go.mau.fi/util/ptr"
"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 @@ -67,11 +69,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 @@ -450,22 +450,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 != "" {
sumnerevans marked this conversation as resolved.
Show resolved Hide resolved
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 @@ -476,39 +548,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 @@ -518,13 +580,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 @@ -240,12 +240,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 @@ -42,6 +42,7 @@ import type {
RoomStateGUID,
RoomSummary,
TimelineRowID,
URLPreview,
UserID,
UserProfile,
} from "./types"
Expand All @@ -67,6 +68,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