feat: add openinapp plugin to open chat links in shared neko instance (#682)

* feat: add openinapp plugin to open chat links in shared neko instance

* lint.

* feat(openinapp): implement open link handler and route for opening links.

* feat(openinapp): switched to http backend and removed example in docker-compose.yaml

* lint.

---------

Co-authored-by: Miroslav Šedivý <sedivy.miro@gmail.com>
This commit is contained in:
Paul Troeger
2026-08-09 22:53:39 +02:00
committed by GitHub
parent 365944d1ea
commit cc014571cc
16 changed files with 338 additions and 4 deletions
+31 -1
View File
@@ -18,7 +18,11 @@
<span>{{ member(message.id).displayname }}</span>
<span class="timestamp">{{ timestamp(message.created) }}</span>
</div>
<neko-markdown class="content-body" :source="message.content" />
<neko-markdown
class="content-body"
:source="message.content"
:open-in-app="$accessor.remote.hosting && $accessor.openinapp.enabled"
/>
</div>
</li>
<li :key="index" class="event" v-if="message.type === 'event'">
@@ -225,6 +229,11 @@
display: block;
}
}
.open-in-app {
margin-left: 0.3em;
cursor: pointer;
}
}
}
@@ -460,6 +469,27 @@
target.parentElement.classList.add('active')
event.preventDefault()
}
if (target.classList.contains('open-in-app')) {
const href = target.dataset.href
if (href) {
this.$accessor.openinapp.sendOpenLink(href)
}
event.preventDefault()
}
if (
this.$accessor.settings.links_in_app &&
this.$accessor.openinapp.enabled &&
this.$accessor.remote.hosting &&
target.tagName.toLowerCase() === 'a'
) {
const href = target.getAttribute('href')
if (href) {
this.$accessor.openinapp.sendOpenLink(href)
event.preventDefault()
}
}
}
onKeyDown(event: KeyboardEvent) {
+24 -2
View File
@@ -136,12 +136,21 @@ const rules: MarkdownRules = {
}
},
html(node, output, state) {
return htmlTag(
const href = md.sanitizeUrl(node.target) as string
const link = htmlTag(
'a',
output(node.content, state),
{ href: md.sanitizeUrl(node.target) as string, target: '_blank' },
state,
)
if (!state.openInApp) return link
const inAppIcon = htmlTag(
'i',
'',
{ class: 'open-in-app fas fa-arrow-up-right-from-square', 'data-href': href },
state,
)
return link + inAppIcon
},
},
url: {
@@ -158,12 +167,21 @@ const rules: MarkdownRules = {
}
},
html(node, output, state) {
return htmlTag(
const href = md.sanitizeUrl(node.target) as string
const link = htmlTag(
'a',
output(node.content, state),
{ href: md.sanitizeUrl(node.target) as string, target: '_blank' },
state,
)
if (!state.openInApp) return link
const inAppIcon = htmlTag(
'i',
'',
{ class: 'open-in-app fas fa-arrow-right-to-bracket', 'data-href': href },
state,
)
return link + inAppIcon
},
},
strike: {
@@ -257,12 +275,16 @@ export default class extends Vue {
@Prop({ required: true })
source!: string
@Prop({ default: false })
openInApp!: boolean
render(h: any) {
const state: MarkdownState = {
inline: true,
inQuote: false,
escapeHTML: true,
cssModuleNames: null,
openInApp: this.openInApp,
}
return h({ template: `<div>${htmlOutput(parser(this.source, state), state)}</div>` })
}
+15
View File
@@ -35,6 +35,13 @@
<span />
</label>
</li>
<li v-if="$accessor.openinapp.enabled">
<span>Always open Links in App</span>
<label class="switch">
<input type="checkbox" v-model="links_in_app" />
<span />
</label>
</li>
<li>
<span>{{ $t('setting.keyboard_layout') }}</span>
<label class="select">
@@ -358,6 +365,14 @@
this.$accessor.settings.setSound(value)
}
get links_in_app() {
return this.$accessor.settings.links_in_app
}
set links_in_app(value: boolean) {
this.$accessor.settings.setLinksInApp(value)
}
get keyboard_layouts_list() {
return this.$accessor.settings.keyboard_layouts_list
}
+7
View File
@@ -45,6 +45,10 @@ export const EVENT = {
LIST: 'filetransfer/list',
REFRESH: 'filetransfer/refresh',
},
OPENINAPP: {
INIT: 'openinapp/init',
OPENLINK: 'openinapp/openlink',
},
SCREEN: {
CONFIGURATIONS: 'screen/configurations',
RESOLUTION: 'screen/resolution',
@@ -78,6 +82,7 @@ export type WebSocketEvents =
| SignalEvents
| ChatEvents
| FileTransferEvents
| OpenInAppEvents
| ScreenEvents
| BroadcastEvents
| AdminEvents
@@ -104,6 +109,8 @@ export type ChatEvents = typeof EVENT.CHAT.MESSAGE | typeof EVENT.CHAT.EMOTE
export type FileTransferEvents = typeof EVENT.FILETRANSFER.LIST | typeof EVENT.FILETRANSFER.REFRESH
export type OpenInAppEvents = typeof EVENT.OPENINAPP.INIT | typeof EVENT.OPENINAPP.OPENLINK
export type ScreenEvents = typeof EVENT.SCREEN.CONFIGURATIONS | typeof EVENT.SCREEN.RESOLUTION | typeof EVENT.SCREEN.SET
export type BroadcastEvents =
+7
View File
@@ -375,6 +375,13 @@ export class NekoClient extends BaseClient implements EventEmitter<NekoEvents> {
this.$accessor.files.setUserDelete(user_delete)
}
/////////////////////////////
// Open in App Events
/////////////////////////////
protected [EVENT.OPENINAPP.INIT]({ enabled }: { enabled: boolean }) {
this.$accessor.openinapp.setEnabled(enabled)
}
/////////////////////////////
// Screen Events
/////////////////////////////
+2 -1
View File
@@ -8,6 +8,7 @@ import { get, set } from '~/utils/localstorage'
import * as video from './video'
import * as chat from './chat'
import * as files from './files'
import * as openinapp from './openinapp'
import * as remote from './remote'
import * as user from './user'
import * as settings from './settings'
@@ -111,7 +112,7 @@ export const storePattern = {
mutations,
actions,
getters,
modules: { video, chat, files, user, remote, settings, client, emoji },
modules: { video, chat, files, openinapp, user, remote, settings, client, emoji },
}
Vue.use(Vuex)
+29
View File
@@ -0,0 +1,29 @@
import { getterTree, mutationTree, actionTree } from 'typed-vuex'
import { EVENT } from '~/neko/events'
import { accessor } from '~/store'
export const namespaced = true
export const state = () => ({
enabled: false,
})
export const getters = getterTree(state, {
//
})
export const mutations = mutationTree(state, {
setEnabled(state, enabled: boolean) {
state.enabled = enabled
},
})
export const actions = actionTree(
{ state, getters, mutations },
{
sendOpenLink(store, url: string) {
if (!accessor.connected) return
$client.sendMessage(EVENT.OPENINAPP.OPENLINK, { text: url })
},
},
)
+6
View File
@@ -16,6 +16,7 @@ export const state = () => {
autoplay: get<boolean>('autoplay', true),
ignore_emotes: get<boolean>('ignore_emotes', false),
chat_sound: get<boolean>('chat_sound', true),
links_in_app: get<boolean>('links_in_app', false),
keyboard_layout: get<string>('keyboard_layout', 'us'),
keyboard_layouts_list: {} as KeyboardLayouts,
@@ -53,6 +54,11 @@ export const mutations = mutationTree(state, {
set('chat_sound', value)
},
setLinksInApp(state, value: boolean) {
state.links_in_app = value
set('links_in_app', value)
},
setKeyboardLayout(state, value: string) {
state.keyboard_layout = value
set('keyboard_layout', value)
@@ -13,6 +13,7 @@ import (
"github.com/m1k1o/neko/server/internal/api/room"
"github.com/m1k1o/neko/server/internal/plugins/chat"
"github.com/m1k1o/neko/server/internal/plugins/filetransfer"
"github.com/m1k1o/neko/server/internal/plugins/openinapp"
"github.com/m1k1o/neko/server/pkg/types"
"github.com/m1k1o/neko/server/pkg/types/event"
"github.com/m1k1o/neko/server/pkg/types/message"
@@ -199,6 +200,14 @@ func (s *session) wsToBackend(msg []byte) error {
case oldEvent.FILETRANSFER_REFRESH:
return s.toBackend(filetransfer.FILETRANSFER_UPDATE, nil)
// Open In App Events
case openinapp.OPENINAPP_OPENLINK:
request := &openinapp.Url{}
if err := json.Unmarshal(msg, request); err != nil {
return err
}
return s.apiReq(http.MethodPost, "/api/openinapp/openlink", request, nil)
// Screen Events
case oldEvent.SCREEN_RESOLUTION:
response := &types.ScreenSize{}
+16
View File
@@ -14,6 +14,7 @@ import (
"github.com/m1k1o/neko/server/internal/plugins/chat"
"github.com/m1k1o/neko/server/internal/plugins/filetransfer"
"github.com/m1k1o/neko/server/internal/plugins/openinapp"
"github.com/m1k1o/neko/server/pkg/types"
"github.com/m1k1o/neko/server/pkg/types/event"
"github.com/m1k1o/neko/server/pkg/types/message"
@@ -615,6 +616,21 @@ func (s *session) wsToClient(msg []byte) error {
Files: files,
})
// Open In App Events
case openinapp.OPENINAPP_INIT:
request := &openinapp.Init{}
if err := json.Unmarshal(data.Payload, request); err != nil {
return err
}
return s.toClient(&struct {
Event string `json:"event"`
Enabled bool `json:"enabled"`
}{
Event: openinapp.OPENINAPP_INIT,
Enabled: request.Enabled,
})
// Screen Events
case event.SCREEN_UPDATED:
request := &message.ScreenSizeUpdate{}
+2
View File
@@ -13,6 +13,7 @@ import (
"github.com/m1k1o/neko/server/internal/config"
"github.com/m1k1o/neko/server/internal/plugins/chat"
"github.com/m1k1o/neko/server/internal/plugins/filetransfer"
"github.com/m1k1o/neko/server/internal/plugins/openinapp"
"github.com/m1k1o/neko/server/pkg/types"
)
@@ -47,6 +48,7 @@ func New(config *config.Plugins) *ManagerCtx {
// add built-in plugins
manager.plugins.addPlugin(filetransfer.NewPlugin())
manager.plugins.addPlugin(chat.NewPlugin())
manager.plugins.addPlugin(openinapp.NewPlugin())
return manager
}
@@ -0,0 +1,30 @@
package openinapp
import (
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
type Config struct {
Enabled bool
OpenCommand string
}
func (Config) Init(cmd *cobra.Command) error {
cmd.PersistentFlags().Bool("openinapp.enabled", false, "whether to enable openinapp plugin")
if err := viper.BindPFlag("openinapp.enabled", cmd.PersistentFlags().Lookup("openinapp.enabled")); err != nil {
return err
}
cmd.PersistentFlags().String("openinapp.open_command", "xdg-open", "command used to open URLs inside the app")
if err := viper.BindPFlag("openinapp.open_command", cmd.PersistentFlags().Lookup("openinapp.open_command")); err != nil {
return err
}
return nil
}
func (s *Config) Set() {
s.Enabled = viper.GetBool("openinapp.enabled")
s.OpenCommand = viper.GetString("openinapp.open_command")
}
@@ -0,0 +1,94 @@
package openinapp
import (
"encoding/json"
"net/http"
"net/url"
"os/exec"
"strings"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/m1k1o/neko/server/pkg/auth"
"github.com/m1k1o/neko/server/pkg/types"
"github.com/m1k1o/neko/server/pkg/utils"
)
func NewManager(
sessions types.SessionManager,
config *Config,
) *Manager {
logger := log.With().Str("module", "openinapp").Logger()
return &Manager{
logger: logger,
config: config,
sessions: sessions,
}
}
type Manager struct {
logger zerolog.Logger
config *Config
sessions types.SessionManager
}
func (m *Manager) Route(r types.Router) {
r.Post("/openlink", m.openLinkHandler)
}
func (m *Manager) openLinkHandler(w http.ResponseWriter, r *http.Request) error {
session, ok := auth.GetSession(r)
if !ok {
return utils.HttpUnauthorized("session not found")
}
if !m.config.Enabled {
return utils.HttpForbidden("openinapp is disabled")
}
if !session.IsHost() && (!session.Profile().CanHost || !m.sessions.Settings().ImplicitHosting) {
return utils.HttpForbidden("only the host can open links")
}
var req Url
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return utils.HttpBadRequest().WithInternalErr(err).Msg("failed to parse request body")
}
parsedUrl, err := url.Parse(req.Text)
if err != nil {
return utils.HttpBadRequest().WithInternalErr(err).Msg("failed to parse URL")
}
if parsedUrl.Scheme != "http" && parsedUrl.Scheme != "https" {
return utils.HttpBadRequest().Msg("URL must use http or https scheme")
}
parts := strings.Fields(m.config.OpenCommand)
if len(parts) == 0 {
return utils.HttpInternalServerError().Msg("open command is not configured")
}
if err := exec.Command(parts[0], append(parts[1:], parsedUrl.String())...).Start(); err != nil {
return utils.HttpInternalServerError().WithInternalErr(err).Msg("failed to start open command")
}
return utils.HttpSuccess(w, nil)
}
func (m *Manager) Start() error {
// send init message once a user connects
m.sessions.OnConnected(func(session types.Session) {
session.Send(OPENINAPP_INIT, Init{
Enabled: m.config.Enabled,
})
})
return nil
}
func (m *Manager) Shutdown() error {
return nil
}
@@ -0,0 +1,34 @@
package openinapp
import (
"github.com/m1k1o/neko/server/pkg/types"
)
type Plugin struct {
config *Config
manager *Manager
}
func NewPlugin() *Plugin {
return &Plugin{
config: &Config{},
}
}
func (p *Plugin) Name() string {
return PluginName
}
func (p *Plugin) Config() types.PluginConfig {
return p.config
}
func (p *Plugin) Start(m types.PluginManagers) error {
p.manager = NewManager(m.SessionManager, p.config)
m.ApiManager.AddRouter("/openinapp", p.manager.Route)
return p.manager.Start()
}
func (p *Plugin) Shutdown() error {
return p.manager.Shutdown()
}
@@ -0,0 +1,16 @@
package openinapp
const PluginName = "openinapp"
const (
OPENINAPP_INIT = "openinapp/init"
OPENINAPP_OPENLINK = "openinapp/openlink"
)
type Init struct {
Enabled bool `json:"enabled"`
}
type Url struct {
Text string `json:"text"`
}
+16
View File
@@ -79,3 +79,19 @@ plugins:
```
- `filetransfer.enabled` in the room settings context controls whether the file transfer is enabled for any user in the room, and in the user's profile context controls whether the user can transfer files.
## Open In App Plugin {#openinapp}
The open in app plugin allows users to open links directly from the chat inside the shared neko instance.
<ConfigurationTab options={{
'openinapp.enabled': true,
'openinapp.open_command': 'xdg-open',
}} />
- <Def id="openinapp.enabled" /> enables the open in app support.
If set to `false`, the feature is hidden from all clients.
- <Def id="openinapp.open_command" /> is the shell command used to open
URLs inside the neko instance. Defaults to `xdg-open`. Can be set to a
browser-specific command such as `firefox -P default` depending on
the app image in use.