diff --git a/assets.go b/assets.go index a3f23ce..dca5958 100644 --- a/assets.go +++ b/assets.go @@ -72,6 +72,9 @@ var disconnectBytes []byte //go:embed assets/chart_bar.png var barBytes []byte +//go:embed assets/chart_bar_laggy.png +var barLaggyBytes []byte + //go:embed assets/ok.png var okBytes []byte @@ -90,6 +93,47 @@ var informationBytes []byte //go:embed assets/car.png var carBytes []byte +//go:embed assets/car_high.png +var carHighBytes []byte + +// muc icons + +//go:embed assets/muc_open.png +var mucOpenBytes []byte + +//go:embed assets/muc_membersonly.png +var mucMembersOnlyBytes []byte + +//go:embed assets/muc_passwordprotected.png +var mucPasswordProtectedBytes []byte + +//go:embed assets/muc_unsecured.png +var mucUnsecuredBytes []byte + +//go:embed assets/muc_hidden.png +var mucHiddenBytes []byte + +//go:embed assets/muc_public.png +var mucPublicBytes []byte + +//go:embed assets/muc_unmoderated.png +var mucUnmoderatedBytes []byte + +//go:embed assets/muc_moderated.png +var mucModeratedBytes []byte + +//go:embed assets/muc_nonanonymous.png +var mucNonAnonymousBytes []byte + +//go:embed assets/muc_semianonymous.png +var mucSemiAnonymousBytes []byte + +//go:embed assets/muc_persistent.png +var mucPersistentBytes []byte + +//go:embed assets/muc_temporary.png +var mucTemporaryBytes []byte + func init() { loader := gdkpixbuf.NewPixbufLoader() @@ -212,6 +256,13 @@ func init() { loader = gdkpixbuf.NewPixbufLoader() + loader.Write(barLaggyBytes) + loader.Close() + + clientAssets["chart_bar_laggy"] = gdk.NewTextureForPixbuf(loader.Pixbuf()) + + loader = gdkpixbuf.NewPixbufLoader() + loader.Write(okBytes) loader.Close() @@ -286,4 +337,84 @@ func init() { loader.Close() clientAssets["car"] = gdk.NewTextureForPixbuf(loader.Pixbuf()) + + loader = gdkpixbuf.NewPixbufLoader() + + loader.Write(carHighBytes) + loader.Close() + + clientAssets["car_high"] = gdk.NewTextureForPixbuf(loader.Pixbuf()) + + loader = gdkpixbuf.NewPixbufLoader() + + loader.Write(mucOpenBytes) + loader.Close() + + clientAssets["muc_open"] = gdk.NewTextureForPixbuf(loader.Pixbuf()) + + loader = gdkpixbuf.NewPixbufLoader() + loader.Write(mucMembersOnlyBytes) + loader.Close() + + clientAssets["muc_membersonly"] = gdk.NewTextureForPixbuf(loader.Pixbuf()) + + loader = gdkpixbuf.NewPixbufLoader() + loader.Write(mucPasswordProtectedBytes) + loader.Close() + + clientAssets["muc_passwordprotected"] = gdk.NewTextureForPixbuf(loader.Pixbuf()) + + loader = gdkpixbuf.NewPixbufLoader() + loader.Write(mucUnsecuredBytes) + loader.Close() + + clientAssets["muc_unsecured"] = gdk.NewTextureForPixbuf(loader.Pixbuf()) + + loader = gdkpixbuf.NewPixbufLoader() + loader.Write(mucHiddenBytes) + loader.Close() + + clientAssets["muc_hidden"] = gdk.NewTextureForPixbuf(loader.Pixbuf()) + + loader = gdkpixbuf.NewPixbufLoader() + loader.Write(mucPublicBytes) + loader.Close() + + clientAssets["muc_public"] = gdk.NewTextureForPixbuf(loader.Pixbuf()) + + loader = gdkpixbuf.NewPixbufLoader() + loader.Write(mucUnmoderatedBytes) + loader.Close() + + clientAssets["muc_unmoderated"] = gdk.NewTextureForPixbuf(loader.Pixbuf()) + + loader = gdkpixbuf.NewPixbufLoader() + loader.Write(mucModeratedBytes) + loader.Close() + + clientAssets["muc_moderated"] = gdk.NewTextureForPixbuf(loader.Pixbuf()) + + loader = gdkpixbuf.NewPixbufLoader() + loader.Write(mucNonAnonymousBytes) + loader.Close() + + clientAssets["muc_nonanonymous"] = gdk.NewTextureForPixbuf(loader.Pixbuf()) + + loader = gdkpixbuf.NewPixbufLoader() + loader.Write(mucSemiAnonymousBytes) + loader.Close() + + clientAssets["muc_semianonymous"] = gdk.NewTextureForPixbuf(loader.Pixbuf()) + + loader = gdkpixbuf.NewPixbufLoader() + loader.Write(mucPersistentBytes) + loader.Close() + + clientAssets["muc_persistent"] = gdk.NewTextureForPixbuf(loader.Pixbuf()) + + loader = gdkpixbuf.NewPixbufLoader() + loader.Write(mucTemporaryBytes) + loader.Close() + + clientAssets["muc_temporary"] = gdk.NewTextureForPixbuf(loader.Pixbuf()) } diff --git a/assets/car_down.png b/assets/car_down.png new file mode 100644 index 0000000..24ceb57 Binary files /dev/null and b/assets/car_down.png differ diff --git a/assets/car_high.png b/assets/car_high.png new file mode 100644 index 0000000..132a097 Binary files /dev/null and b/assets/car_high.png differ diff --git a/assets/car_up.png b/assets/car_up.png new file mode 100644 index 0000000..28a8547 Binary files /dev/null and b/assets/car_up.png differ diff --git a/assets/chart_bar_laggy.png b/assets/chart_bar_laggy.png new file mode 100644 index 0000000..51a9da1 Binary files /dev/null and b/assets/chart_bar_laggy.png differ diff --git a/gtk-helpers.go b/gtk-helpers.go index c64295c..04d07be 100644 --- a/gtk-helpers.go +++ b/gtk-helpers.go @@ -27,7 +27,7 @@ func scrollToBottomAfterUpdate(scrolledWindow *gtk.ScrolledWindow) { }) } -func createTab(jid string, isMuc bool) bool { +func createTab(jid string, isMuc bool, name string) bool { fmt.Println("Creating tab", jid, "isMuc:", isMuc) _, ok := tabs.Load(jid) _, uok := userdevices.Load(jid) @@ -38,6 +38,7 @@ func createTab(jid string, isMuc bool) bool { newTab.msgs = gtk.NewListBox() newTab.msgs.SetVExpand(true) newTab.msgs.SetShowSeparators(true) + newTab.name = name newTab.msgs.Append(gtk.NewButtonWithLabel("Get past messages...")) tabs.Store(jid, newTab) @@ -58,7 +59,6 @@ func switchToTab(jid string, w *gtk.Window) { scroller.SetChild(typed_tab.msgs) typingStatus.SetText("") if typed_tab.isMuc { - m, ok := mucmembers.Load(jid) if !ok { return @@ -476,6 +476,9 @@ func switchToTab(jid string, w *gtk.Window) { muci := getAvatar(jid, jid) muci.SetPixelSize(80) gen.Prepend(muci) + muc_name := gtk.NewLabel(typed_tab.name) + muc_name.AddCSSClass("author") + gen.Prepend(muc_name) memberList.SetChild(gen) } else { memberList.SetChild(gtk.NewLabel(jid)) @@ -484,7 +487,23 @@ func switchToTab(jid string, w *gtk.Window) { } func showErrorDialog(err error) { - fmt.Println(err.Error()) + err_win := gtk.NewWindow() + err_win.SetTitle("Error") + err_win.SetDefaultSize(400, 200) + err_win.SetResizable(false) + + box := gtk.NewBox(gtk.OrientationVertical, 0) + err_label := gtk.NewLabel(err.Error()) + err_label.SetSelectable(true) + box.Append(err_label) + + close_btn := gtk.NewButtonWithLabel("Close") + close_btn.ConnectClicked(func() { + err_win.SetVisible(false) + }) + box.Append(close_btn) + err_win.SetChild(box) + err_win.Present() } func createIdenticon(word string) *gtk.Image { // This function generates an identicon diff --git a/gtk-message.go b/gtk-message.go index 8eb344c..94bf231 100644 --- a/gtk-message.go +++ b/gtk-message.go @@ -248,6 +248,30 @@ func generateMessageWidget(p stanza.Packet) gtk.Widgetter { mainBox.Append(subjectlabel) } + link_preview := LinkPreview{} + ok = m.Get(&link_preview) + if ok { + lp_box := gtk.NewBox(gtk.OrientationVertical, 10) + lp_box.AddCSSClass("link_preview") + lp_title := gtk.NewLabel(link_preview.Title) + lp_title.SetSelectable(true) + lp_title.SetWrap(true) + lp_title.SetHAlign(gtk.AlignFill) + lp_desc := gtk.NewLabel(link_preview.URL + "\n" + link_preview.Description) + lp_desc.SetSelectable(true) + lp_desc.SetWrap(true) + lp_desc.SetHAlign(gtk.AlignFill) + + lp_box.Append(lp_title) + lp_box.Append(lp_desc) + + warning := gtk.NewLabel("⚠️") + warning.SetTooltipText("This link preview was generated by the client sending it and may not be accurate of the actual website content") + lp_box.Append(warning) + + mainBox.Append(lp_box) + } + return mainBox } diff --git a/main.go b/main.go index 1c5a87f..bf39159 100644 --- a/main.go +++ b/main.go @@ -78,6 +78,8 @@ var pingTimes = [][]float64{} var clientAssets map[string]gdk.Paintabler = make(map[string]gdk.Paintabler) +var xmlLog *os.File + func init() { beeep.AppName = "Lambda" @@ -94,13 +96,6 @@ func init() { } func main() { - // Setup log - xmlLog, err := os.CreateTemp("", "xmpp-log") - if err != nil { - panic(err) - } - - defer os.Remove(xmlLog.Name()) pingTimes = append(pingTimes, []float64{}) p, err := ensureConfig() @@ -112,7 +107,6 @@ func main() { if err != nil { dropToSignInPage(err) return - // panic(err) } _, err = toml.Decode(string(b), &loadedConfig) @@ -125,6 +119,17 @@ func main() { loadedConfig.Resource = randomClientResource() } + if !loadedConfig.Debug { + xmlLog, err = os.CreateTemp("", "xmpp-log") + if err != nil { + panic(err) + } + + defer os.Remove(xmlLog.Name()) + } else { + xmlLog = os.Stdout + } + config := xmpp.Config{ TransportConfiguration: xmpp.TransportConfiguration{ Address: loadedConfig.Server, @@ -393,7 +398,7 @@ func main() { _, ok := userdevices.Load(user) _, mok := mucmembers.Load(user) if !ok && !mok { // FIXME: The initial muc presence gets picked up from this check - ok := createTab(user, false) + ok := createTab(user, false, user) if ok { userdevices.Store(user, userUnit{}) @@ -484,10 +489,18 @@ func main() { newsize = stat.Size() diff := float64(newsize-oldsize) / 1000 + + if diff > 100 { + sIcon.SetFromPaintable(clientAssets["car_high"]) + } else { + sIcon.SetFromPaintable(clientAssets["car"]) + } + sStatus.SetText(fmt.Sprintf("%.2fKB/s", diff)) oldsize = stat.Size() } }() + connectionStatus.SetText(fmt.Sprintf("Connected as %s", JidMustParse(clientroot.Session.BindJid).Bare())) connectionStatus.SetTooltipText(fmt.Sprintf("Binded JID: %s\nUsing TLS: %t", clientroot.Session.BindJid, clientroot.Session.TlsEnabled)) connectionIcon.SetFromPaintable(clientAssets["connect"]) @@ -514,15 +527,24 @@ func main() { jid := item.Id node := item.Any autojoin := false + name := "" for _, attr := range node.Attrs { if attr.Name.Local == "autojoin" { autojoin = attr.Value == "true" + break + } + } + + for _, attr := range node.Attrs { + if attr.Name.Local == "name" { + name = attr.Value + break } } _, ok := tabs.Load(jid) if !ok && autojoin { - createTab(jid, true) + createTab(jid, true, name) b := gtk.NewLabel(jid) gesture1 := gtk.NewGestureClick() gesture1.SetButton(1) @@ -544,17 +566,19 @@ func main() { for _, attr := range node.Attrs { if attr.Name.Local == "autojoin" { autojoin = attr.Value == "true" + break } } for _, node := range node.Nodes { if node.XMLName.Local == "nick" { nick = node.Content + break } } if autojoin { - err := joinMuc(client, clientroot.Session.BindJid, jid, nick) + err := joinMuc(client, clientroot.Session.BindJid, jid, nick, "") if err != nil { panic(err) } @@ -707,9 +731,11 @@ func activate(app *gtk.Application) { box := gtk.NewBox(gtk.OrientationVertical, 0) jid_box := gtk.NewBox(gtk.OrientationHorizontal, 0) nick_box := gtk.NewBox(gtk.OrientationHorizontal, 0) + disco_box := gtk.NewBox(gtk.OrientationHorizontal, 0) jid_entry := gtk.NewEntry() nick_entry := gtk.NewEntry() + disco_check := gtk.NewCheckButton() jid_entry.SetHAlign(gtk.AlignEnd) jid_entry.SetHExpand(true) @@ -725,8 +751,14 @@ func activate(app *gtk.Application) { nick_box.Append(gtk.NewLabel("Nick:")) nick_box.Append(nick_entry) + disco_check.SetActive(true) + disco_box.Append(gtk.NewLabel("Check MUC features before joining")) + disco_box.Append(disco_check) + disco_box.SetTooltipText("If you are creating a MUC through this window then turn this off") + box.Append(jid_box) box.Append(nick_box) + box.Append(disco_box) btn := gtk.NewButtonWithLabel("Submit") btn.SetVAlign(gtk.AlignBaseline) @@ -742,14 +774,14 @@ func activate(app *gtk.Application) { btn.ConnectClicked(func() { t := jid_entry.Text() _, ok := tabs.Load(t) - jm := func() { - - err := joinMuc(client, clientroot.Session.BindJid, t, nick_entry.Text()) + jm := func(n string, pw string) { + err := joinMuc(client, clientroot.Session.BindJid, t, nick_entry.Text(), pw) if err != nil { - panic(err) + showErrorDialog(err) + return } - createTab(t, true) + createTab(t, true, n) b := gtk.NewLabel(t) gesture1 := gtk.NewGestureClick() gesture1.SetButton(1) @@ -761,7 +793,14 @@ func activate(app *gtk.Application) { menu.Append(b) } if !ok { - // First check the MUC's disco and see if it's semianon + + if !disco_check.Active() { + jm(t, "") + win.SetVisible(false) + return + } + + var res *stanza.DiscoInfo allowed := true fmt.Println("Attempting to get Disco info") @@ -783,55 +822,137 @@ func activate(app *gtk.Application) { mychan, err := client.SendIQ(ctx, myIQ) if err == nil { result := <-mychan - res, ok := result.Payload.(*stanza.DiscoInfo) + res, ok = result.Payload.(*stanza.DiscoInfo) if ok { - semianon := false features := res.Features + allowed = false + password_protected := false + password := "" + warning_win := gtk.NewWindow() + warning_win.SetTitle(fmt.Sprintf("Joining %s", res.Identity[0].Name)) + warning_win.SetDefaultSize(400, 400) + warning_win.SetResizable(false) + + buttons := gtk.NewBox(gtk.OrientationHorizontal, 0) + join_button := gtk.NewButtonWithLabel("Join") + join_button.ConnectClicked(func() { + warning_win.SetVisible(false) + if password_protected { + allowed = false + + password_win := gtk.NewWindow() + password_win.SetTitle("Password required") + password_win.SetDefaultSize(400, 1) + password_win.SetResizable(false) + box := gtk.NewBox(gtk.OrientationVertical, 0) + en := gtk.NewEntry() + en.SetPlaceholderText("Password") + submit := gtk.NewButtonWithLabel("Submit") + submit.ConnectClicked(func() { + password = en.Text() + jm(res.Identity[0].Name, password) + password_win.SetVisible(false) + }) + box.Append(en) + box.Append(submit) + password_win.SetChild(box) + password_win.SetVisible(true) + } + + jm(res.Identity[0].Name, password) + }) + + cancel_button := gtk.NewButtonWithLabel("Cancel") + cancel_button.ConnectClicked(func() { + warning_win.SetVisible(false) + }) + + buttons.Append(join_button) + buttons.Append(cancel_button) + warning_box := gtk.NewBox(gtk.OrientationVertical, 0) + header := gtk.NewLabel(res.Identity[0].Name) + warning_box.Append(header) for _, feature := range features { - if feature.Var == "muc_nonanonymous" { - semianon = false - break - } else if feature.Var == "muc_semianonymous" { - semianon = true - break + switch feature.Var { + case "muc_passwordprotected": + password_protected = true + box := gtk.NewBox(gtk.OrientationHorizontal, 0) + box.Append(gtk.NewImageFromPaintable(clientAssets["muc_passwordprotected"])) + box.Append(gtk.NewLabel("This MUC is password-protected")) + warning_box.Append(box) + case "muc_unsecured": + box := gtk.NewBox(gtk.OrientationHorizontal, 0) + box.Append(gtk.NewImageFromPaintable(clientAssets["muc_unsecured"])) + box.Append(gtk.NewLabel("This MUC does not require a password")) + warning_box.Append(box) + case "muc_membersonly": + box := gtk.NewBox(gtk.OrientationHorizontal, 0) + box.Append(gtk.NewImageFromPaintable(clientAssets["muc_membersonly"])) + box.Append(gtk.NewLabel("Only members can join this MUC")) + warning_box.Append(box) + case "muc_open": + box := gtk.NewBox(gtk.OrientationHorizontal, 0) + box.Append(gtk.NewImageFromPaintable(clientAssets["muc_open"])) + box.Append(gtk.NewLabel("Anyone can join this MUC")) + warning_box.Append(box) + case "muc_moderated": + box := gtk.NewBox(gtk.OrientationHorizontal, 0) + box.Append(gtk.NewImageFromPaintable(clientAssets["muc_moderated"])) + box.Append(gtk.NewLabel("Only members can speak in this MUC")) + warning_box.Append(box) + case "muc_unmoderated": + box := gtk.NewBox(gtk.OrientationHorizontal, 0) + box.Append(gtk.NewImageFromPaintable(clientAssets["muc_unmoderated"])) + box.Append(gtk.NewLabel("Anyone can speak in this MUC")) + warning_box.Append(box) + case "muc_nonanonymous": + box := gtk.NewBox(gtk.OrientationHorizontal, 0) + box.Append(gtk.NewImageFromPaintable(clientAssets["muc_nonanonymous"])) + box.Append(gtk.NewLabel("This MUC is non-anonymous, your JID will be visible to other users")) + warning_box.Append(box) + case "muc_semianonymous": + box := gtk.NewBox(gtk.OrientationHorizontal, 0) + box.Append(gtk.NewImageFromPaintable(clientAssets["muc_semianonymous"])) + box.Append(gtk.NewLabel("This MUC is semi-anonymous, only moderators will see your full JID")) + warning_box.Append(box) + case "muc_persistent": + box := gtk.NewBox(gtk.OrientationHorizontal, 0) + box.Append(gtk.NewImageFromPaintable(clientAssets["muc_persistent"])) + box.Append(gtk.NewLabel("This MUC is persistent, it will not be deleted when the last user leaves")) + warning_box.Append(box) + case "muc_temporary": + box := gtk.NewBox(gtk.OrientationHorizontal, 0) + box.Append(gtk.NewImageFromPaintable(clientAssets["muc_temporary"])) + box.Append(gtk.NewLabel("This MUC is temporary, it will be deleted when the last user leaves")) + warning_box.Append(box) + case "muc_public": + box := gtk.NewBox(gtk.OrientationHorizontal, 0) + box.Append(gtk.NewImageFromPaintable(clientAssets["muc_public"])) + box.Append(gtk.NewLabel("This MUC can be found in directories and search engines")) + warning_box.Append(box) + case "muc_hidden": + box := gtk.NewBox(gtk.OrientationHorizontal, 0) + box.Append(gtk.NewImageFromPaintable(clientAssets["muc_hidden"])) + box.Append(gtk.NewLabel("This MUC is hidden and cannot be found in directories or search engines")) + warning_box.Append(box) } } - if !semianon { - allowed = false - warning_win := gtk.NewWindow() - warning_win.SetTitle("Warning") - warning_win.SetDefaultSize(400, 400) - warning_win.SetResizable(false) - - warning_box := gtk.NewBox(gtk.OrientationVertical, 0) - warning_label := gtk.NewLabel("This muc is not semi-anonymous. Your JID will be revealed to non-moderators if you join. Continue?") - - buttons := gtk.NewBox(gtk.OrientationHorizontal, 0) - join_button := gtk.NewButtonWithLabel("Join") - join_button.ConnectClicked(func() { - warning_win.SetVisible(false) - jm() - }) - - cancel_button := gtk.NewButtonWithLabel("Cancel") - cancel_button.ConnectClicked(func() { - warning_win.SetVisible(false) - }) - - buttons.Append(join_button) - buttons.Append(cancel_button) - - warning_box.Append(warning_label) - warning_box.Append(buttons) - warning_win.SetChild(warning_box) - warning_win.Present() + warning_box.Append(buttons) + warning_win.SetChild(warning_box) + warning_win.Present() + } else { + allowed = false + if result.Error != nil { + showErrorDialog(fmt.Errorf("Failed to get disco info: %s - %s", result.Error.Reason, result.Error.Text)) + } else { + showErrorDialog(fmt.Errorf("Failed to get disco info")) } } } if allowed { - jm() + jm(res.Identity[0].Name, "") } } win.SetVisible(false) diff --git a/style.css b/style.css index 5d59713..9e7dbe8 100644 --- a/style.css +++ b/style.css @@ -73,3 +73,10 @@ .None_CVD { } + +.link_preview { + color: white; + background-color: grey; + border-radius: 5px; + padding: 5px; +} diff --git a/types.go b/types.go index ad2bbcf..f38ba64 100644 --- a/types.go +++ b/types.go @@ -9,6 +9,7 @@ import ( type chatTab struct { isMuc bool msgs *gtk.ListBox + name string } type lambdaConfig struct { @@ -21,6 +22,7 @@ type lambdaConfig struct { JoinBookmarks bool CVD color.CVD Identicons bool + Debug bool } type mucUnit struct { diff --git a/version.go b/version.go index 29bd199..d1f4ba5 100644 --- a/version.go +++ b/version.go @@ -1,3 +1,3 @@ package main -var lambda_version string = "26w15a" +var lambda_version string = "26w17a" diff --git a/xmpp-helpers.go b/xmpp-helpers.go index 24b946d..211f58c 100644 --- a/xmpp-helpers.go +++ b/xmpp-helpers.go @@ -28,17 +28,32 @@ func sendMessage(c xmpp.Sender, sendTo string, msgType stanza.StanzaType, body s } // Joins a MUC -func joinMuc(c xmpp.Sender, jid string, muc string, nick string) error { +func joinMuc(c xmpp.Sender, jid string, muc string, nick string, password string) error { + var joinPresence stanza.Presence addr := muc + "/" + nick fmt.Println(addr) - joinPresence := stanza.Presence{ - Attrs: stanza.Attrs{ - From: jid, - To: addr, - }, - Extensions: []stanza.PresExtension{ - &stanza.MucPresence{}, - }, + if password == "" { + joinPresence = stanza.Presence{ + Attrs: stanza.Attrs{ + From: jid, + To: addr, + }, + Extensions: []stanza.PresExtension{ + &stanza.MucPresence{}, + }, + } + } else { + joinPresence = stanza.Presence{ + Attrs: stanza.Attrs{ + From: jid, + To: addr, + }, + Extensions: []stanza.PresExtension{ + &stanza.MucPresence{ + Password: password, + }, + }, + } } err := client.Send(joinPresence) diff --git a/xmpp-link-previews.go b/xmpp-link-previews.go new file mode 100644 index 0000000..8e50b7a --- /dev/null +++ b/xmpp-link-previews.go @@ -0,0 +1,20 @@ +package main + +import ( + "encoding/xml" + "gosrc.io/xmpp/stanza" +) + +type LinkPreview struct { + stanza.MsgExtension + XMLName xml.Name `xml:"http://www.w3.org/1999/02/22-rdf-syntax-ns# Description"` + About string `xml:"https://ogp.me/ns#,attr"` + Title string `xml:"https://ogp.me/ns# title"` + Description string `xml:"https://ogp.me/ns# description"` + Image string `xml:"https://ogp.me/ns# image"` + URL string `xml:"https://ogp.me/ns# url"` +} + +func init() { + stanza.TypeRegistry.MapExtension(stanza.PKTMessage, xml.Name{Space: "http://www.w3.org/1999/02/22-rdf-syntax-ns#", Local: "Description"}, LinkPreview{}) +}