Index: applications/editors/josm/plugins/geochat/build.xml
===================================================================
--- applications/editors/josm/plugins/geochat/build.xml	(revision 36146)
+++ applications/editors/josm/plugins/geochat/build.xml	(revision 36147)
@@ -8,5 +8,5 @@
 
     <property name="plugin.author" value="Ilya Zverev"/>
-    <property name="plugin.class" value="geochat.GeoChatPlugin"/>
+    <property name="plugin.class" value="org.openstreetmap.josm.plugins.geochat.GeoChatPlugin"/>
     <property name="plugin.description" value="Talk with users editing the map nearby, be notified when someone comes close."/>
     <property name="plugin.icon" value="images/dialogs/geochat.png"/>
Index: applications/editors/josm/plugins/geochat/src/org/openstreetmap/josm/plugins/geochat/ChatMessage.java
===================================================================
--- applications/editors/josm/plugins/geochat/src/org/openstreetmap/josm/plugins/geochat/ChatMessage.java	(revision 36147)
+++ applications/editors/josm/plugins/geochat/src/org/openstreetmap/josm/plugins/geochat/ChatMessage.java	(revision 36147)
@@ -0,0 +1,99 @@
+// License: WTFPL. For details, see LICENSE file.
+package org.openstreetmap.josm.plugins.geochat;
+
+import java.time.Instant;
+
+import org.openstreetmap.josm.data.coor.LatLon;
+
+/**
+ * One message.
+ *
+ * @author zverik
+ */
+public final class ChatMessage implements Comparable<ChatMessage> {
+    private final LatLon pos;
+    private final Instant time;
+    private final String author;
+    private String recipient;
+    private final String message;
+    private final long id;
+    private boolean priv;
+    private final boolean incoming;
+
+    public ChatMessage(long id, LatLon pos, String author, boolean incoming, String message, Instant time) {
+        this.id = id;
+        this.author = author;
+        this.message = message;
+        this.pos = pos;
+        this.time = time;
+        this.incoming = incoming;
+        this.priv = false;
+        this.recipient = null;
+    }
+
+    public void setRecipient(String recipient) {
+        this.recipient = recipient;
+    }
+
+    public void setPrivate(boolean priv) {
+        this.priv = priv;
+    }
+
+    public String getAuthor() {
+        return author;
+    }
+
+    /**
+     * Is only set when the message is not incoming, that is, author is the current user.
+     * @return recipient
+     */
+    public String getRecipient() {
+        return recipient;
+    }
+
+    public long getId() {
+        return id;
+    }
+
+    public LatLon getPosition() {
+        return pos;
+    }
+
+    public String getMessage() {
+        return message;
+    }
+
+    public boolean isPrivate() {
+        return priv;
+    }
+
+    public boolean isIncoming() {
+        return incoming;
+    }
+
+    public Instant getTime() {
+        return time;
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (obj == null || getClass() != obj.getClass()) {
+            return false;
+        }
+        final ChatMessage other = (ChatMessage) obj;
+        return this.id == other.id;
+    }
+
+    @Override
+    public int hashCode() {
+        int hash = 3;
+        hash = 83 * hash + (int) (this.id ^ (this.id >>> 32));
+        return hash;
+    }
+
+    @Override
+    public int compareTo(ChatMessage o) {
+        long otherId = o.id;
+        return otherId < id ? 1 : otherId == id ? 0 : 1;
+    }
+}
Index: applications/editors/josm/plugins/geochat/src/org/openstreetmap/josm/plugins/geochat/ChatPaneManager.java
===================================================================
--- applications/editors/josm/plugins/geochat/src/org/openstreetmap/josm/plugins/geochat/ChatPaneManager.java	(revision 36147)
+++ applications/editors/josm/plugins/geochat/src/org/openstreetmap/josm/plugins/geochat/ChatPaneManager.java	(revision 36147)
@@ -0,0 +1,258 @@
+// License: WTFPL. For details, see LICENSE file.
+package org.openstreetmap.josm.plugins.geochat;
+
+import static org.openstreetmap.josm.tools.I18n.tr;
+
+import java.awt.Color;
+import java.awt.Component;
+import java.awt.Font;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import javax.swing.JLabel;
+import javax.swing.JScrollPane;
+import javax.swing.JTabbedPane;
+import javax.swing.JTextPane;
+import javax.swing.text.BadLocationException;
+import javax.swing.text.Document;
+import javax.swing.text.SimpleAttributeSet;
+import javax.swing.text.StyleConstants;
+
+import org.openstreetmap.josm.gui.util.GuiHelper;
+import org.openstreetmap.josm.spi.preferences.Config;
+import org.openstreetmap.josm.tools.Logging;
+
+/**
+ *
+ * @author zverik
+ */
+class ChatPaneManager {
+    private static final String PUBLIC_PANE = "Public Pane";
+
+    private final GeoChatPanel panel;
+    private final JTabbedPane tabs;
+    private final Map<String, ChatPane> chatPanes;
+    private boolean collapsed;
+
+    ChatPaneManager(GeoChatPanel panel, JTabbedPane tabs) {
+        this.panel = panel;
+        this.tabs = tabs;
+        this.collapsed = panel.isDialogInCollapsedView();
+        chatPanes = new HashMap<>();
+        createChatPane(null);
+        tabs.addChangeListener(e -> updateActiveTabStatus());
+    }
+
+    public void setCollapsed(boolean collapsed) {
+        this.collapsed = collapsed;
+        updateActiveTabStatus();
+    }
+
+    public boolean hasUser(String user) {
+        return chatPanes.containsKey(user == null ? PUBLIC_PANE : user);
+    }
+
+    public Component getPublicChatComponent() {
+        return chatPanes.get(PUBLIC_PANE).component;
+    }
+
+    public int getNotifyLevel() {
+        int alarm = 0;
+        for (ChatPane entry : chatPanes.values()) {
+            if (entry.notify > alarm)
+                alarm = entry.notify;
+        }
+        return alarm;
+    }
+
+    public void updateActiveTabStatus() {
+        if (tabs.getSelectedIndex() >= 0)
+            ((ChatTabTitleComponent) tabs.getTabComponentAt(tabs.getSelectedIndex())).updateAlarm();
+    }
+
+    public void notify(String user, int alarmLevel) {
+        if (alarmLevel <= 0 || !hasUser(user))
+            return;
+        ChatPane entry = chatPanes.get(user == null ? PUBLIC_PANE : user);
+        entry.notify = alarmLevel;
+        int idx = tabs.indexOfComponent(entry.component);
+        if (idx >= 0)
+            GuiHelper.runInEDT(((ChatTabTitleComponent) tabs.getTabComponentAt(idx))::updateAlarm);
+    }
+
+    public static final int MESSAGE_TYPE_DEFAULT = 0;
+    public static final int MESSAGE_TYPE_INFORMATION = 1;
+    public static final int MESSAGE_TYPE_ATTENTION = 2;
+    private static final Color COLOR_ATTENTION = new Color(0, 0, 192);
+
+    private void addLineToChatPane(String userName, String line, final int messageType) {
+        if (line.isEmpty())
+            return;
+        if (!chatPanes.containsKey(userName))
+            createChatPane(userName);
+        final String nline = line.startsWith("\n") ? line : "\n" + line;
+        final JTextPane thepane = chatPanes.get(userName).pane;
+        GuiHelper.runInEDT(() -> {
+            Document doc = thepane.getDocument();
+            try {
+                SimpleAttributeSet attrs = null;
+                if (messageType != MESSAGE_TYPE_DEFAULT) {
+                    attrs = new SimpleAttributeSet();
+                    if (messageType == MESSAGE_TYPE_INFORMATION)
+                        StyleConstants.setItalic(attrs, true);
+                    else if (messageType == MESSAGE_TYPE_ATTENTION)
+                        StyleConstants.setForeground(attrs, COLOR_ATTENTION);
+                }
+                doc.insertString(doc.getLength(), nline, attrs);
+            } catch (BadLocationException ex) {
+                Logging.warn(ex);
+            }
+            thepane.setCaretPosition(doc.getLength());
+        });
+    }
+
+    public void addLineToChatPane(String userName, String line) {
+        addLineToChatPane(userName, line, MESSAGE_TYPE_DEFAULT);
+    }
+
+    public void addLineToPublic(String line) {
+        addLineToChatPane(PUBLIC_PANE, line);
+    }
+
+    public void addLineToPublic(String line, int messageType) {
+        addLineToChatPane(PUBLIC_PANE, line, messageType);
+    }
+
+    public void clearPublicChatPane() {
+        chatPanes.get(PUBLIC_PANE).pane.setText("");
+    }
+
+    public void clearChatPane(String userName) {
+        if (userName == null || userName.equals(PUBLIC_PANE))
+            clearPublicChatPane();
+        else
+            chatPanes.get(userName).pane.setText("");
+    }
+
+    public void clearActiveChatPane() {
+        clearChatPane(getActiveChatPane());
+    }
+
+    public ChatPane createChatPane(String userName) {
+        JTextPane chatPane = new JTextPane();
+        chatPane.setEditable(false);
+        Font font = chatPane.getFont();
+        float size = Config.getPref().getInt("geochat.fontsize", -1);
+        if (size < 6)
+            size += font.getSize2D();
+        chatPane.setFont(font.deriveFont(size));
+        //        DefaultCaret caret = (DefaultCaret)chatPane.getCaret(); // does not work
+        //        caret.setUpdatePolicy(DefaultCaret.ALWAYS_UPDATE);
+        JScrollPane scrollPane = new JScrollPane(chatPane, JScrollPane.VERTICAL_SCROLLBAR_ALWAYS, JScrollPane.HORIZONTAL_SCROLLBAR_NEVER);
+        chatPane.addMouseListener(new GeoChatPopupAdapter(panel));
+
+        ChatPane entry = new ChatPane();
+        entry.pane = chatPane;
+        entry.component = scrollPane;
+        entry.notify = 0;
+        entry.userName = userName;
+        entry.isPublic = userName == null;
+        chatPanes.put(userName == null ? PUBLIC_PANE : userName, entry);
+
+        tabs.addTab(null, scrollPane);
+        tabs.setTabComponentAt(tabs.getTabCount() - 1, new ChatTabTitleComponent(entry));
+        tabs.setSelectedComponent(scrollPane);
+        return entry;
+    }
+
+    /**
+     * Returns key in chatPanes hash map for the currently active
+     * chat pane, or null in case of an error.
+     * @return key in chatPanes hash map for the currently active chat pane
+     */
+    public String getActiveChatPane() {
+        Component c = tabs.getSelectedComponent();
+        if (c == null)
+            return null;
+        for (Map.Entry<String, ChatPaneManager.ChatPane> entry : chatPanes.entrySet()) {
+            if (c.equals(entry.getValue().component))
+                return entry.getKey();
+        }
+        return null;
+    }
+
+    public String getRecipient() {
+        String user = getActiveChatPane();
+        return user == null || user.equals(PUBLIC_PANE) ? null : user;
+    }
+
+    public void closeChatPane(String user) {
+        if (user == null || user.equals(PUBLIC_PANE) || !chatPanes.containsKey(user))
+            return;
+        tabs.remove(chatPanes.get(user).component);
+        chatPanes.remove(user);
+    }
+
+    public void closeSelectedPrivatePane() {
+        String pane = getRecipient();
+        if (pane != null)
+            closeChatPane(pane);
+    }
+
+    public void closePrivateChatPanes() {
+        List<String> entries = new ArrayList<>(chatPanes.keySet());
+        for (String user : entries) {
+            if (!user.equals(PUBLIC_PANE))
+                closeChatPane(user);
+        }
+    }
+
+    public boolean hasSelectedText() {
+        String user = getActiveChatPane();
+        if (user != null) {
+            JTextPane pane = chatPanes.get(user).pane;
+            return pane.getSelectedText() != null;
+        }
+        return false;
+    }
+
+    public void copySelectedText() {
+        String user = getActiveChatPane();
+        if (user != null)
+            chatPanes.get(user).pane.copy();
+    }
+
+    private class ChatTabTitleComponent extends JLabel {
+        private final ChatPane entry;
+
+        ChatTabTitleComponent(ChatPane entry) {
+            super(entry.isPublic ? tr("Public") : entry.userName);
+            this.entry = entry;
+        }
+
+        private Font normalFont;
+        private Font boldFont;
+
+        public void updateAlarm() {
+            if (normalFont == null) {
+                // prepare cached fonts
+                normalFont = getFont().deriveFont(Font.PLAIN);
+                boldFont = getFont().deriveFont(Font.BOLD);
+            }
+            if (entry.notify > 0 && !collapsed && tabs.getSelectedIndex() == tabs.indexOfComponent(entry.component))
+                entry.notify = 0;
+            setFont(entry.notify > 0 ? boldFont : normalFont);
+            panel.updateTitleAlarm();
+        }
+    }
+
+    static class ChatPane {
+        public String userName;
+        public boolean isPublic;
+        public JTextPane pane;
+        public JScrollPane component;
+        public int notify;
+    }
+}
Index: applications/editors/josm/plugins/geochat/src/org/openstreetmap/josm/plugins/geochat/ChatServerConnection.java
===================================================================
--- applications/editors/josm/plugins/geochat/src/org/openstreetmap/josm/plugins/geochat/ChatServerConnection.java	(revision 36147)
+++ applications/editors/josm/plugins/geochat/src/org/openstreetmap/josm/plugins/geochat/ChatServerConnection.java	(revision 36147)
@@ -0,0 +1,467 @@
+// License: WTFPL. For details, see LICENSE file.
+package org.openstreetmap.josm.plugins.geochat;
+
+import static org.openstreetmap.josm.tools.I18n.tr;
+
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.net.URLEncoder;
+import java.net.UnknownHostException;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+
+import jakarta.json.JsonArray;
+import jakarta.json.JsonException;
+import jakarta.json.JsonObject;
+
+import org.openstreetmap.josm.data.coor.ILatLon;
+import org.openstreetmap.josm.data.coor.LatLon;
+import org.openstreetmap.josm.data.coor.conversion.DecimalDegreesCoordinateFormat;
+import org.openstreetmap.josm.data.preferences.JosmUrls;
+import org.openstreetmap.josm.data.projection.Projection;
+import org.openstreetmap.josm.data.projection.ProjectionRegistry;
+import org.openstreetmap.josm.gui.MainApplication;
+import org.openstreetmap.josm.gui.MapView;
+import org.openstreetmap.josm.io.NetworkManager;
+import org.openstreetmap.josm.io.OnlineResource;
+import org.openstreetmap.josm.spi.preferences.Config;
+import org.openstreetmap.josm.tools.Logging;
+import org.openstreetmap.josm.tools.Utils;
+
+/**
+ * This class holds all the chat data and periodically polls the server.
+ *
+ * @author zverik
+ */
+final class ChatServerConnection {
+    public static final String TOKEN_PREFIX = "=";
+    private static final String TOKEN_PATTERN = "^[a-zA-Z0-9]{10}$";
+    private static final ScheduledThreadPoolExecutor EXECUTOR = new ScheduledThreadPoolExecutor(1);
+
+    private int userId;
+    private String userName;
+    private static ChatServerConnection instance;
+    private final Set<ChatServerConnectionListener> listeners;
+
+    private ChatServerConnection() {
+        userId = 0;
+        userName = null;
+        listeners = new HashSet<>();
+        LogRequest requestThread = new LogRequest();
+        final int interval = Config.getPref().getInt("geochat.interval", 2);
+        EXECUTOR.scheduleAtFixedRate(requestThread, interval, interval, TimeUnit.SECONDS);
+    }
+
+    public static ChatServerConnection getInstance() {
+        if (instance == null)
+            instance = new ChatServerConnection();
+        return instance;
+    }
+
+    public void addListener(ChatServerConnectionListener listener) {
+        listeners.add(listener);
+    }
+
+    public void removeListener(ChatServerConnectionListener listener) {
+        listeners.remove(listener);
+    }
+
+    public boolean isActive() {
+        return isLoggedIn() && getPosition() != null;
+    }
+
+    public boolean isLoggedIn() {
+        return userId > 0;
+    }
+
+    public String getUserName() {
+        return userName;
+    }
+
+    /**
+     * Test that userId is still active, log out otherwise.
+     */
+    public void checkLogin() {
+        autoLogin(null);
+    }
+
+    /**
+     * Test that userId is still active, if not, tries to login with given user name.
+     * Does not autologin, if userName is null, obviously.
+     * @param userName user name
+     */
+    public void autoLogin(final String userName) {
+        final int uid = Config.getPref().getInt("geochat.lastuid", 0);
+        if (uid <= 0) {
+            if (userName != null && userName.length() > 1)
+                login(userName);
+        } else {
+            String query = "whoami&uid=" + uid;
+            JsonQueryUtil.queryAsync(query, json -> {
+                if (json != null && json.get("name") != null)
+                    login(uid, json.getString("name"));
+                else if (userName != null && userName.length() > 1)
+                    login(userName);
+            });
+        }
+    }
+
+    /**
+     * Waits until {@link #getPosition()} is not null, then calls {@link #autoLogin(java.lang.String)}.
+     * If two seconds have passed, stops the waiting. Doesn't wait if userName is empty.
+     * @param userName user name
+     */
+    public void autoLoginWithDelay(final String userName) {
+        if (userName == null || userName.isEmpty()) {
+            checkLogin();
+            return;
+        }
+        // Blocking the geochat executor here isn't a big deal, since we need to be logged in for chat anyway.
+        EXECUTOR.schedule(() -> {
+            try {
+                int cnt = 10;
+                while (getPosition() == null && cnt-- > 0) {
+                    Thread.sleep(200);
+                }
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                Logging.warn(e);
+            }
+            autoLogin(userName);
+        }, 200, TimeUnit.MILLISECONDS);
+    }
+
+    public void login(final String userName) {
+        if (userName == null)
+            throw new IllegalArgumentException("userName is null");
+        LatLon pos = getPosition();
+        if (pos == null) {
+            fireLoginFailed("Zoom level is too low");
+            return;
+        }
+        String token = userName.startsWith(TOKEN_PREFIX) ? userName.substring(TOKEN_PREFIX.length()) : null;
+        if (token != null && !token.matches(TOKEN_PATTERN)) {
+            fireLoginFailed("Incorrect token format");
+            return;
+        }
+
+        try {
+            String nameAttr = token != null ? "&token=" + token : "&name=" + URLEncoder.encode(userName, "UTF-8");
+            String query = "register&lat=" + DecimalDegreesCoordinateFormat.INSTANCE.latToString(pos)
+            + "&lon=" + DecimalDegreesCoordinateFormat.INSTANCE.lonToString(pos)
+            + nameAttr;
+            JsonQueryUtil.queryAsync(query, json -> {
+                if (json == null)
+                    fireLoginFailed(tr("Could not get server response, check logs"));
+                else if (json.get("error") != null)
+                    fireLoginFailed(tr("Failed to login as {0}:", userName) + "\n" + json.getString("error"));
+                else if (json.get("uid") == null)
+                    fireLoginFailed(tr("The server did not return user ID"));
+                else {
+                    String name = json.get("name") != null ? json.getString("name") : userName;
+                    login(json.getInt("uid"), name);
+                }
+            });
+        } catch (UnsupportedEncodingException e) {
+            Logging.error(e);
+        }
+    }
+
+    private void login(int userId, String userName) {
+        this.userId = userId;
+        this.userName = userName;
+        Config.getPref().putInt("geochat.lastuid", userId);
+        for (ChatServerConnectionListener listener : listeners) {
+            listener.loggedIn(userName);
+        }
+    }
+
+    private void logoutIntl() {
+        ChatServerConnection.this.userId = 0;
+        ChatServerConnection.this.userName = null;
+        Config.getPref().put("geochat.lastuid", null);
+        for (ChatServerConnectionListener listener : listeners) {
+            listener.notLoggedIn(null);
+        }
+    }
+
+    private void fireLoginFailed(String reason) {
+        for (ChatServerConnectionListener listener : listeners) {
+            listener.notLoggedIn(reason);
+        }
+    }
+
+    /**
+     * Unregister the current user.
+     */
+    public void logout() {
+        if (!isLoggedIn())
+            return;
+        String query = "logout&uid=" + userId;
+        JsonQueryUtil.queryAsync(query, json -> {
+            if (json != null && json.get("message") != null) {
+                logoutIntl();
+            }
+        });
+    }
+
+    /**
+     * Unregister the current user and do not call listeners.
+     * Makes synchronous request to the server.
+     * @throws IOException There was a problem connecting to the server or parsing JSON.
+     */
+    public void bruteLogout() throws IOException {
+        if (isLoggedIn())
+            JsonQueryUtil.query("logout&uid=" + userId);
+    }
+
+    private void fireMessageFailed(String reason) {
+        for (ChatServerConnectionListener listener : listeners) {
+            listener.messageSendFailed(reason);
+        }
+    }
+
+    /**
+     * Posts message to the main channel.
+     * @param message Message string.
+     * @see #postMessage(java.lang.String, java.lang.String)
+     */
+    public void postMessage(String message) {
+        postMessage(message, null);
+    }
+
+    /**
+     * Posts message to the main channel or to a specific user.
+     * Calls listener on fail.
+     * @param message Message string.
+     * @param targetUser null if sending to everyone, name of user otherwise.
+     */
+    public void postMessage(String message, String targetUser) {
+        if (!isLoggedIn()) {
+            fireMessageFailed("Not logged in");
+            return;
+        }
+        LatLon pos = getPosition();
+        if (pos == null) {
+            fireMessageFailed("Zoom level is too low");
+            return;
+        }
+        try {
+            String query = "post&lat=" + DecimalDegreesCoordinateFormat.INSTANCE.latToString(pos)
+            + "&lon=" + DecimalDegreesCoordinateFormat.INSTANCE.lonToString(pos)
+            + "&uid=" + userId
+            + "&message=" + URLEncoder.encode(message, "UTF8");
+            if (targetUser != null && targetUser.length() > 0)
+                query += "&to=" + URLEncoder.encode(targetUser, "UTF8");
+            JsonQueryUtil.queryAsync(query, json -> {
+                if (json == null)
+                    fireMessageFailed(tr("Could not get server response, check logs"));
+                else if (json.get("error") != null)
+                    fireMessageFailed(json.getString("error"));
+            });
+        } catch (UnsupportedEncodingException e) {
+            Logging.error(e);
+        }
+    }
+
+    /**
+     * Returns current coordinates or null if there is no map, or zoom is too low.
+     * @return current coordinates or null
+     */
+    private static LatLon getPosition() {
+        if (!MainApplication.isDisplayingMapView())
+            return null;
+        if (getCurrentZoom() < 10)
+            return null;
+        Projection proj = ProjectionRegistry.getProjection();
+        return proj.eastNorth2latlon(MainApplication.getMap().mapView.getCenter());
+    }
+
+    // Following three methods were snatched from TMSLayer
+    private static double latToTileY(double lat, int zoom) {
+        double l = lat / 180 * Math.PI;
+        double pf = Math.log(Math.tan(l) + (1 / Math.cos(l)));
+        return Math.pow(2.0, zoom - 1d) * (Math.PI - pf) / Math.PI;
+    }
+
+    private static double lonToTileX(double lon, int zoom) {
+        return Math.pow(2.0, zoom - 3d) * (lon + 180.0) / 45.0;
+    }
+
+    public static int getCurrentZoom() {
+        if (!MainApplication.isDisplayingMapView()) {
+            return 1;
+        }
+        MapView mv = MainApplication.getMap().mapView;
+        LatLon topLeft = mv.getLatLon(0, 0);
+        LatLon botRight = mv.getLatLon(mv.getWidth(), mv.getHeight());
+        double x1 = lonToTileX(topLeft.lon(), 1);
+        double y1 = latToTileY(topLeft.lat(), 1);
+        double x2 = lonToTileX(botRight.lon(), 1);
+        double y2 = latToTileY(botRight.lat(), 1);
+
+        int screenPixels = mv.getWidth() * mv.getHeight();
+        double tilePixels = Math.abs((y2 - y1) * (x2 - x1) * 256 * 256);
+        if (screenPixels == 0 || tilePixels == 0) {
+            return 1;
+        }
+        double factor = screenPixels / tilePixels;
+        double result = Math.log(factor) / Math.log(2) / 2 + 1;
+        return (int) Math.floor(result);
+    }
+
+    private class LogRequest implements Runnable {
+        private static final int MAX_JUMP = 20000; // in meters
+        private LatLon lastPosition;
+        private long lastUserId;
+        private long lastId;
+        private boolean lastStatus;
+
+        @Override
+        public void run() {
+            //            lastId = Config.getPref().getLong("geochat.lastid", 0);
+            if (!NetworkManager.isOffline(OnlineResource.JOSM_WEBSITE) || !Utils.isRunningWebStart()) {
+                process();
+            }
+        }
+
+        private void process() {
+            if (!isLoggedIn()) {
+                fireStatusChanged(false);
+                return;
+            }
+
+            LatLon pos = getPosition();
+            if (pos == null) {
+                fireStatusChanged(false);
+                return;
+            }
+            fireStatusChanged(true);
+
+            final boolean needReset;
+            final boolean needFullReset = lastUserId != userId;
+            if (needFullReset || (lastPosition != null && pos.greatCircleDistance((ILatLon) lastPosition) > MAX_JUMP)) {
+                // reset messages
+                lastId = 0;
+                //                Config.getPref().put("geochat.lastid", null);
+                needReset = true;
+            } else
+                needReset = false;
+            lastUserId = userId;
+            lastPosition = pos;
+
+            String query = "get&lat=" + DecimalDegreesCoordinateFormat.INSTANCE.latToString(pos)
+            + "&lon=" + DecimalDegreesCoordinateFormat.INSTANCE.lonToString(pos)
+            + "&uid=" + userId + "&last=" + lastId;
+            JsonObject json;
+            try {
+                json = JsonQueryUtil.query(query, true);
+            } catch (IOException ex) {
+                Logging.trace(ex);
+                json = null; // ?
+                final Throwable root = Utils.getRootCause(ex);
+                if (root instanceof UnknownHostException) {
+                    UnknownHostException uhe = (UnknownHostException) root;
+                    NetworkManager.addNetworkError(uhe.getMessage(), uhe);
+                    if (JosmUrls.getInstance().getJOSMWebsite().endsWith(uhe.getMessage())) {
+                        NetworkManager.setOffline(OnlineResource.JOSM_WEBSITE);
+                    }
+                }
+            }
+            if (json == null) {
+                // do nothing?
+                //              fireLoginFailed(tr("Could not get server response, check logs"));
+                //              logoutIntl(); // todo: uncomment?
+            } else if (json.get("error") != null) {
+                fireLoginFailed(tr("Failed to get messages as {0}:", userName) + "\n" + json.getString("error"));
+                logoutIntl();
+            } else {
+                if (json.get("users") != null) {
+                    Map<String, LatLon> users = parseUsers(json.getJsonArray("users"));
+                    for (ChatServerConnectionListener listener : listeners) {
+                        listener.updateUsers(users);
+                    }
+                }
+                if (json.get("messages") != null) {
+                    List<ChatMessage> messages = parseMessages(json.getJsonArray("messages"), false);
+                    for (ChatMessage m : messages) {
+                        if (m.getId() > lastId)
+                            lastId = m.getId();
+                    }
+                    for (ChatServerConnectionListener listener : listeners) {
+                        listener.receivedMessages(needReset, messages);
+                    }
+                }
+                if (json.get("private") != null) {
+                    List<ChatMessage> messages = parseMessages(json.getJsonArray("private"), true);
+                    for (ChatMessage m : messages) {
+                        if (m.getId() > lastId)
+                            lastId = m.getId();
+                    }
+                    for (ChatServerConnectionListener listener : listeners) {
+                        listener.receivedPrivateMessages(needFullReset, messages);
+                    }
+                }
+            }
+            //                    if (lastId > 0 && Config.getPref().getBoolean("geochat.store.lastid", true) )
+            //                        Config.getPref().putLong("geochat.lastid", lastId);
+        }
+
+        private List<ChatMessage> parseMessages(JsonArray messages, boolean priv) {
+            List<ChatMessage> result = new ArrayList<>();
+            for (int i = 0; i < messages.size(); i++) {
+                try {
+                    JsonObject msg = messages.getJsonObject(i);
+                    long id = Long.parseLong(msg.getString("id"));
+                    double lat = Double.parseDouble(msg.getString("lat"));
+                    double lon = Double.parseDouble(msg.getString("lon"));
+                    long timeStamp = Long.parseLong(msg.getString("timestamp"));
+                    String author = msg.getString("author");
+                    String message = msg.getString("message");
+                    boolean incoming = msg.getBoolean("incoming");
+                    ChatMessage cm = new ChatMessage(id, new LatLon(lat, lon), author,
+                            incoming, message, Instant.ofEpochSecond(timeStamp));
+                    cm.setPrivate(priv);
+                    if (msg.get("recipient") != null && !incoming)
+                        cm.setRecipient(msg.getString("recipient"));
+                    result.add(cm);
+                } catch (JsonException e) {
+                    Logging.trace(e);
+                }
+            }
+            return result;
+        }
+
+        private Map<String, LatLon> parseUsers(JsonArray users) {
+            Map<String, LatLon> result = new HashMap<>();
+            for (int i = 0; i < users.size(); i++) {
+                try {
+                    JsonObject user = users.getJsonObject(i);
+                    String name = user.getString("user");
+                    double lat = Double.parseDouble(user.getString("lat"));
+                    double lon = Double.parseDouble(user.getString("lon"));
+                    result.put(name, new LatLon(lat, lon));
+                } catch (JsonException e) {
+                    Logging.trace(e);
+                }
+            }
+            return result;
+        }
+
+        private void fireStatusChanged(boolean newStatus) {
+            if (newStatus == lastStatus)
+                return;
+            lastStatus = newStatus;
+            for (ChatServerConnectionListener listener : listeners) {
+                listener.statusChanged(newStatus);
+            }
+        }
+    }
+}
Index: applications/editors/josm/plugins/geochat/src/org/openstreetmap/josm/plugins/geochat/ChatServerConnectionListener.java
===================================================================
--- applications/editors/josm/plugins/geochat/src/org/openstreetmap/josm/plugins/geochat/ChatServerConnectionListener.java	(revision 36147)
+++ applications/editors/josm/plugins/geochat/src/org/openstreetmap/josm/plugins/geochat/ChatServerConnectionListener.java	(revision 36147)
@@ -0,0 +1,60 @@
+// License: WTFPL. For details, see LICENSE file.
+package org.openstreetmap.josm.plugins.geochat;
+
+import java.util.List;
+import java.util.Map;
+
+import org.openstreetmap.josm.data.coor.LatLon;
+
+/**
+ * An interface for listening for chat events.
+ *
+ * @author zverik
+ */
+public interface ChatServerConnectionListener {
+    /**
+     * User has been logged in.
+     * @param userName Name of the logged in user.
+     */
+    void loggedIn(String userName);
+
+    /**
+     * User tried to log in, but failed.
+     * @param reason Why. <tt>null</tt> if it is intended logout.
+     */
+    void notLoggedIn(String reason);
+
+    /**
+     * Sending message failed.
+     * @param reason Why.
+     */
+    void messageSendFailed(String reason);
+
+    /**
+     * Chat has become active or not.
+     * @param active Is the chat active.
+     */
+    void statusChanged(boolean active);
+
+    /**
+     * Received an update to users list.
+     * @param users a hash of user names and coordinates.
+     */
+    void updateUsers(Map<String, LatLon> users);
+
+    /**
+     * New messages were received. This would only be called in active state.
+     * @param replace if set, remove all old messages.
+     * @param messages new messages array.
+     */
+    void receivedMessages(boolean replace, List<ChatMessage> messages);
+
+    /**
+     * New private messages were received. See {@link #receivedMessages(boolean, java.util.List)}.
+     * Note that the array of messages can be reset, for example, when user has changed.
+     * Also, private messages go both way: check the recipient field.
+     * @param replace if set, remove all old messages.
+     * @param messages list of new private messages.
+     */
+    void receivedPrivateMessages(boolean replace, List<ChatMessage> messages);
+}
Index: applications/editors/josm/plugins/geochat/src/org/openstreetmap/josm/plugins/geochat/GeoChatPanel.java
===================================================================
--- applications/editors/josm/plugins/geochat/src/org/openstreetmap/josm/plugins/geochat/GeoChatPanel.java	(revision 36147)
+++ applications/editors/josm/plugins/geochat/src/org/openstreetmap/josm/plugins/geochat/GeoChatPanel.java	(revision 36147)
@@ -0,0 +1,376 @@
+// License: WTFPL. For details, see LICENSE file.
+package org.openstreetmap.josm.plugins.geochat;
+
+import static org.openstreetmap.josm.tools.I18n.tr;
+import static org.openstreetmap.josm.tools.I18n.trn;
+
+import java.awt.AlphaComposite;
+import java.awt.BorderLayout;
+import java.awt.Color;
+import java.awt.Composite;
+import java.awt.Dimension;
+import java.awt.Font;
+import java.awt.FontMetrics;
+import java.awt.Graphics2D;
+import java.awt.GridBagConstraints;
+import java.awt.GridBagLayout;
+import java.awt.Point;
+import java.awt.RenderingHints;
+import java.io.IOException;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.time.format.FormatStyle;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+
+import javax.swing.JButton;
+import javax.swing.JCheckBox;
+import javax.swing.JComponent;
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+import javax.swing.JTabbedPane;
+import javax.swing.JTextField;
+import javax.swing.SwingConstants;
+
+import org.openstreetmap.josm.data.Bounds;
+import org.openstreetmap.josm.data.UserIdentityManager;
+import org.openstreetmap.josm.data.coor.LatLon;
+import org.openstreetmap.josm.gui.MainApplication;
+import org.openstreetmap.josm.gui.MapView;
+import org.openstreetmap.josm.gui.Notification;
+import org.openstreetmap.josm.gui.dialogs.ToggleDialog;
+import org.openstreetmap.josm.gui.layer.MapViewPaintable;
+import org.openstreetmap.josm.gui.util.GuiHelper;
+import org.openstreetmap.josm.spi.preferences.Config;
+import org.openstreetmap.josm.tools.GBC;
+import org.openstreetmap.josm.tools.Logging;
+
+/**
+ * Chat Panel. Contains of one public chat pane and multiple private ones.
+ *
+ * @author zverik
+ */
+public class GeoChatPanel extends ToggleDialog implements ChatServerConnectionListener, MapViewPaintable {
+    private final JTextField input;
+    private final JTabbedPane tabs;
+    private final JComponent noData;
+    private final JPanel loginPanel;
+    private final JPanel gcPanel;
+    private final ChatServerConnection connection;
+    // those fields should be visible to popup menu actions
+    Map<String, LatLon> users;
+    ChatPaneManager chatPanes;
+    boolean userLayerActive;
+
+    /**
+     * Create a new {@link GeoChatPanel}
+     */
+    public GeoChatPanel() {
+        super(tr("GeoChat"), "org/openstreetmap/josm/plugins/geochat", tr("Open GeoChat panel"), null, 200, true);
+
+        noData = new JLabel(tr("Zoom in to see messages"), SwingConstants.CENTER);
+
+        tabs = new JTabbedPane();
+        tabs.addMouseListener(new GeoChatPopupAdapter(this));
+        chatPanes = new ChatPaneManager(this, tabs);
+
+        input = new JPanelTextField() {
+            @Override
+            protected void processEnter(String text) {
+                connection.postMessage(text, chatPanes.getRecipient());
+            }
+
+            @Override
+            protected String autoComplete(String word, boolean atStart) {
+                return autoCompleteUser(word, atStart);
+            }
+        };
+
+        String defaultUserName = constructUserName();
+        loginPanel = createLoginPanel(defaultUserName);
+
+        gcPanel = new JPanel(new BorderLayout());
+        gcPanel.add(loginPanel, BorderLayout.CENTER);
+        createLayout(gcPanel, false, null);
+
+        users = new TreeMap<>();
+        // Start threads
+        connection = ChatServerConnection.getInstance();
+        connection.addListener(this);
+        boolean autoLogin = Config.getPref().get("geochat.username", null) != null && Config.getPref().getBoolean("geochat.autologin", true);
+        connection.autoLoginWithDelay(autoLogin ? defaultUserName : null);
+        updateTitleAlarm();
+    }
+
+    private static String constructUserName() {
+        String userName = Config.getPref().get("geochat.username", null); // so the default is null
+        if (userName == null)
+            userName = UserIdentityManager.getInstance().getUserName();
+        if (userName == null)
+            userName = "";
+        if (userName.contains("@"))
+            userName = userName.substring(0, userName.indexOf('@'));
+        userName = userName.replace(' ', '_');
+        return userName;
+    }
+
+    private JPanel createLoginPanel(String defaultUserName) {
+        final JTextField nameField = new JPanelTextField() {
+            @Override
+            protected void processEnter(String text) {
+                connection.login(text);
+            }
+        };
+        nameField.setText(defaultUserName);
+
+        JButton loginButton = new JButton(tr("Login"));
+        loginButton.addActionListener(e -> connection.login(nameField.getText()));
+        nameField.setPreferredSize(new Dimension(nameField.getPreferredSize().width, loginButton.getPreferredSize().height));
+
+        final JCheckBox autoLoginBox = new JCheckBox(tr("Enable autologin"), Config.getPref().getBoolean("geochat.autologin", true));
+        autoLoginBox.addActionListener(e -> Config.getPref().putBoolean("geochat.autologin", autoLoginBox.isSelected()));
+
+        JPanel panel = new JPanel(new GridBagLayout());
+        panel.add(nameField, GBC.std().fill(GridBagConstraints.HORIZONTAL).insets(15, 0, 5, 0));
+        panel.add(loginButton, GBC.eol().fill(GridBagConstraints.NONE).insets(0, 0, 15, 0));
+        panel.add(autoLoginBox, GBC.std().insets(15, 0, 15, 0));
+        return panel;
+    }
+
+    protected void logout() {
+        connection.logout();
+    }
+
+    @Override
+    public void destroy() {
+        try {
+            if (Config.getPref().getBoolean("geochat.logout.on.close", true)) {
+                connection.removeListener(this);
+                connection.bruteLogout();
+            }
+        } catch (IOException e) {
+            Logging.warn("Failed to logout from geochat server: " + e.getMessage());
+        }
+        super.destroy();
+    }
+
+    private String autoCompleteUser(String word, boolean atStart) {
+        String result = null;
+        boolean singleUser = true;
+        for (String user : users.keySet()) {
+            if (user.startsWith(word)) {
+                if (result == null)
+                    result = user;
+                else {
+                    singleUser = false;
+                    int i = word.length();
+                    while (i < result.length() && i < user.length() && result.charAt(i) == user.charAt(i)) {
+                        i++;
+                    }
+                    if (i < result.length())
+                        result = result.substring(0, i);
+                }
+            }
+        }
+        return result == null ? null : !singleUser ? result : atStart ? result + ": " : result + " ";
+    }
+
+    /**
+     * This is implementation of a "temporary layer". It paints circles
+     * for all users nearby.
+     */
+    @Override
+    public void paint(Graphics2D g, MapView mv, Bounds bbox) {
+        Graphics2D g2d = (Graphics2D) g.create();
+        g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+        Composite ac04 = AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.4f);
+        Composite ac10 = g2d.getComposite();
+
+        Font font = g2d.getFont().deriveFont(Font.BOLD, g2d.getFont().getSize2D() + 2.0f);
+        g2d.setFont(font);
+        FontMetrics fm = g2d.getFontMetrics();
+
+        for (Map.Entry<String, LatLon> entry : users.entrySet()) {
+            int stringWidth = fm.stringWidth(entry.getKey());
+            int radius = stringWidth / 2 + 10;
+            Point p = mv.getPoint(entry.getValue());
+
+            g2d.setComposite(ac04);
+            g2d.setColor(Color.white);
+            g2d.fillOval(p.x - radius, p.y - radius, radius * 2 + 1, radius * 2 + 1);
+
+            g2d.setComposite(ac10);
+            g2d.setColor(Color.black);
+            g2d.drawString(entry.getKey(), p.x - stringWidth / 2, p.y + fm.getDescent());
+        }
+    }
+
+    /* ================== Notifications in the title ======================= */
+
+    /**
+     * Display number of users and notifications in the panel title.
+     */
+    protected void updateTitleAlarm() {
+        int alarmLevel = connection.isLoggedIn() ? chatPanes.getNotifyLevel() : 0;
+        if (!isDialogInCollapsedView() && alarmLevel > 1)
+            alarmLevel = 1;
+
+        String comment;
+        if (connection.isLoggedIn()) {
+            comment = trn("{0} user", "{0} users", users.size() + 1L, users.size() + 1);
+        } else {
+            comment = tr("not logged in");
+        }
+
+        String title = tr("GeoChat");
+        if (comment != null)
+            title = title + " (" + comment + ")";
+        final String alarm = (alarmLevel <= 0 ? "" : alarmLevel == 1 ? "* " : "!!! ") + title;
+        GuiHelper.runInEDT(() -> setTitle(alarm));
+    }
+
+    /**
+     * Track panel collapse events.
+     */
+    @Override
+    protected void setIsCollapsed(boolean val) {
+        super.setIsCollapsed(val);
+        chatPanes.setCollapsed(val);
+        updateTitleAlarm();
+    }
+
+    /* ============ ChatServerConnectionListener methods ============= */
+
+    @Override
+    public void loggedIn(String userName) {
+        Config.getPref().put("geochat.username", userName);
+        if (gcPanel.getComponentCount() == 1) {
+            GuiHelper.runInEDTAndWait(() -> {
+                gcPanel.remove(0);
+                gcPanel.add(tabs, BorderLayout.CENTER);
+                gcPanel.add(input, BorderLayout.SOUTH);
+            });
+        }
+        updateTitleAlarm();
+    }
+
+    @Override
+    public void notLoggedIn(final String reason) {
+        if (reason != null) {
+            GuiHelper.runInEDT(() -> new Notification(tr("Failed to log in to GeoChat:") + '\n' + reason).show());
+        } else {
+            // regular logout
+            if (gcPanel.getComponentCount() > 1) {
+                gcPanel.removeAll();
+                gcPanel.add(loginPanel, BorderLayout.CENTER);
+            }
+        }
+        updateTitleAlarm();
+    }
+
+    @Override
+    public void messageSendFailed(final String reason) {
+        GuiHelper.runInEDT(() -> new Notification(tr("Failed to send message:") + '\n' + reason).show());
+    }
+
+    @Override
+    public void statusChanged(boolean active) {
+        // only the public tab, because private chats don't rely on coordinates
+        tabs.setComponentAt(0, active ? chatPanes.getPublicChatComponent() : noData);
+        repaint();
+    }
+
+    @Override
+    public void updateUsers(Map<String, LatLon> newUsers) {
+        for (String uname : this.users.keySet()) {
+            if (!newUsers.containsKey(uname))
+                chatPanes.addLineToPublic(tr("User {0} has left", uname), ChatPaneManager.MESSAGE_TYPE_INFORMATION);
+        }
+        for (String uname : newUsers.keySet()) {
+            if (!this.users.containsKey(uname))
+                chatPanes.addLineToPublic(tr("User {0} is mapping nearby", uname), ChatPaneManager.MESSAGE_TYPE_INFORMATION);
+        }
+        this.users = newUsers;
+        updateTitleAlarm();
+        if (userLayerActive && MainApplication.isDisplayingMapView())
+            MainApplication.getMap().mapView.repaint();
+    }
+
+    private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withZone(ZoneId.systemDefault());
+
+    private static void formatMessage(StringBuilder sb, ChatMessage msg) {
+        sb.append("\n");
+        sb.append('[').append(TIME_FORMAT.format(msg.getTime())).append("] ");
+        sb.append(msg.getAuthor()).append(": ").append(msg.getMessage());
+    }
+
+    @Override
+    public void receivedMessages(boolean replace, List<ChatMessage> messages) {
+        if (replace)
+            chatPanes.clearPublicChatPane();
+        if (!messages.isEmpty()) {
+            int alarm = 0;
+            StringBuilder sb = new StringBuilder();
+            for (ChatMessage msg : messages) {
+                boolean important = msg.isIncoming() && containsName(msg.getMessage());
+                if (msg.isIncoming() && alarm < 2) {
+                    alarm = important ? 2 : 1;
+                }
+                if (important) {
+                    // add buffer, then add current line with italic, then clear buffer
+                    chatPanes.addLineToPublic(sb.toString());
+                    sb.setLength(0);
+                    formatMessage(sb, msg);
+                    chatPanes.addLineToPublic(sb.toString(), ChatPaneManager.MESSAGE_TYPE_ATTENTION);
+                    sb.setLength(0);
+                } else
+                    formatMessage(sb, msg);
+            }
+            chatPanes.addLineToPublic(sb.toString());
+            if (alarm > 0)
+                chatPanes.notify(null, alarm);
+        }
+        if (replace)
+            showNearbyUsers();
+    }
+
+    private void showNearbyUsers() {
+        if (!users.isEmpty()) {
+            StringBuilder sb = new StringBuilder(tr("Users mapping nearby:"));
+            boolean first = true;
+            for (String user : users.keySet()) {
+                sb.append(first ? " " : ", ");
+                sb.append(user);
+                first = false;
+            }
+            chatPanes.addLineToPublic(sb.toString(), ChatPaneManager.MESSAGE_TYPE_INFORMATION);
+        }
+    }
+
+    private boolean containsName(String message) {
+        String userName = connection.getUserName();
+        int length = userName.length();
+        int i = message.indexOf(userName);
+        while (i >= 0) {
+            if ((i == 0 || !Character.isJavaIdentifierPart(message.charAt(i - 1)))
+                    && (i + length >= message.length() || !Character.isJavaIdentifierPart(message.charAt(i + length))))
+                return true;
+            i = message.indexOf(userName, i + 1);
+        }
+        return false;
+    }
+
+    @Override
+    public void receivedPrivateMessages(boolean replace, List<ChatMessage> messages) {
+        if (replace)
+            chatPanes.closePrivateChatPanes();
+        for (ChatMessage msg : messages) {
+            StringBuilder sb = new StringBuilder();
+            formatMessage(sb, msg);
+            chatPanes.addLineToChatPane(msg.isIncoming() ? msg.getAuthor() : msg.getRecipient(), sb.toString());
+            if (msg.isIncoming())
+                chatPanes.notify(msg.getAuthor(), 2);
+        }
+    }
+}
Index: applications/editors/josm/plugins/geochat/src/org/openstreetmap/josm/plugins/geochat/GeoChatPlugin.java
===================================================================
--- applications/editors/josm/plugins/geochat/src/org/openstreetmap/josm/plugins/geochat/GeoChatPlugin.java	(revision 36147)
+++ applications/editors/josm/plugins/geochat/src/org/openstreetmap/josm/plugins/geochat/GeoChatPlugin.java	(revision 36147)
@@ -0,0 +1,24 @@
+// License: WTFPL. For details, see LICENSE file.
+package org.openstreetmap.josm.plugins.geochat;
+
+import org.openstreetmap.josm.gui.MapFrame;
+import org.openstreetmap.josm.plugins.Plugin;
+import org.openstreetmap.josm.plugins.PluginInformation;
+
+/**
+ * Create chat panel.
+ *
+ * @author zverik
+ */
+public class GeoChatPlugin extends Plugin {
+    public GeoChatPlugin(PluginInformation info) {
+        super(info);
+    }
+
+    @Override
+    public void mapFrameInitialized(MapFrame oldFrame, MapFrame newFrame) {
+        if (oldFrame == null && newFrame != null) {
+            newFrame.addToggleDialog(new GeoChatPanel());
+        }
+    }
+}
Index: applications/editors/josm/plugins/geochat/src/org/openstreetmap/josm/plugins/geochat/GeoChatPopupAdapter.java
===================================================================
--- applications/editors/josm/plugins/geochat/src/org/openstreetmap/josm/plugins/geochat/GeoChatPopupAdapter.java	(revision 36147)
+++ applications/editors/josm/plugins/geochat/src/org/openstreetmap/josm/plugins/geochat/GeoChatPopupAdapter.java	(revision 36147)
@@ -0,0 +1,142 @@
+// License: WTFPL. For details, see LICENSE file.
+package org.openstreetmap.josm.plugins.geochat;
+
+import static org.openstreetmap.josm.tools.I18n.tr;
+
+import java.awt.event.ActionEvent;
+import java.awt.event.MouseAdapter;
+import java.awt.event.MouseEvent;
+
+import javax.swing.AbstractAction;
+import javax.swing.JCheckBoxMenuItem;
+import javax.swing.JMenu;
+import javax.swing.JPopupMenu;
+
+import org.openstreetmap.josm.gui.MainApplication;
+
+/**
+ *
+ * @author zverik
+ */
+class GeoChatPopupAdapter extends MouseAdapter {
+    private final GeoChatPanel panel;
+
+    GeoChatPopupAdapter(GeoChatPanel panel) {
+        this.panel = panel;
+    }
+
+    @Override
+    public void mousePressed(MouseEvent e) {
+        check(e);
+    }
+
+    @Override
+    public void mouseReleased(MouseEvent e) {
+        check(e);
+    }
+
+    private void check(MouseEvent e) {
+        if (e.isPopupTrigger()) {
+            createPopupMenu().show(e.getComponent(), e.getX(), e.getY());
+        }
+    }
+
+    private JPopupMenu createPopupMenu() {
+        JMenu userMenu = new JMenu(tr("Private chat"));
+        for (String user : panel.users.keySet()) {
+            if (!panel.chatPanes.hasUser(user))
+                userMenu.add(new PrivateChatAction(user));
+        }
+
+        JPopupMenu menu = new JPopupMenu();
+        if (panel.chatPanes.hasSelectedText())
+            menu.add(new CopyTextAction());
+        menu.add(new JCheckBoxMenuItem(new ToggleUserLayerAction()));
+        if (userMenu.getItemCount() > 0)
+            menu.add(userMenu);
+        if (panel.chatPanes.getRecipient() != null)
+            menu.add(new CloseTabAction());
+        menu.add(new LogoutAction());
+        return menu;
+    }
+
+    private class PrivateChatAction extends AbstractAction {
+        private final String userName;
+
+        PrivateChatAction(String userName) {
+            super(userName);
+            this.userName = userName;
+        }
+
+        @Override
+        public void actionPerformed(ActionEvent e) {
+            if (!panel.chatPanes.hasUser(userName)) {
+                panel.chatPanes.createChatPane(userName);
+            }
+        }
+    }
+
+    private class CloseTabAction extends AbstractAction {
+        CloseTabAction() {
+            super(tr("Close tab"));
+        }
+
+        @Override
+        public void actionPerformed(ActionEvent e) {
+            panel.chatPanes.closeSelectedPrivatePane();
+        }
+    }
+
+    private class LogoutAction extends AbstractAction {
+        LogoutAction() {
+            super(tr("Logout"));
+            //            putValue(SMALL_ICON, ImageProvider.get("help"));
+        }
+
+        @Override
+        public void actionPerformed(ActionEvent e) {
+            panel.logout();
+        }
+    }
+
+    private class ClearPaneAction extends AbstractAction {
+        ClearPaneAction() {
+            super(tr("Clear log"));
+        }
+
+        @Override
+        public void actionPerformed(ActionEvent e) {
+            panel.chatPanes.clearActiveChatPane();
+        }
+    }
+
+    private class ToggleUserLayerAction extends AbstractAction {
+        ToggleUserLayerAction() {
+            super(tr("Show users on map"));
+            putValue(SELECTED_KEY, panel.userLayerActive);
+        }
+
+        @Override
+        public void actionPerformed(ActionEvent e) {
+            if (!MainApplication.isDisplayingMapView())
+                return;
+            boolean wasAdded = MainApplication.getMap().mapView.addTemporaryLayer(panel);
+            if (!wasAdded)
+                MainApplication.getMap().mapView.removeTemporaryLayer(panel);
+            panel.userLayerActive = wasAdded;
+            putValue(SELECTED_KEY, panel.userLayerActive);
+            MainApplication.getMap().mapView.repaint();
+        }
+    }
+
+    private class CopyTextAction extends AbstractAction {
+        CopyTextAction() {
+            super(tr("Copy"));
+        }
+
+        @Override
+        public void actionPerformed(ActionEvent e) {
+            panel.chatPanes.copySelectedText();
+        }
+    }
+}
Index: applications/editors/josm/plugins/geochat/src/org/openstreetmap/josm/plugins/geochat/JPanelTextField.java
===================================================================
--- applications/editors/josm/plugins/geochat/src/org/openstreetmap/josm/plugins/geochat/JPanelTextField.java	(revision 36147)
+++ applications/editors/josm/plugins/geochat/src/org/openstreetmap/josm/plugins/geochat/JPanelTextField.java	(revision 36147)
@@ -0,0 +1,105 @@
+// License: WTFPL. For details, see LICENSE file.
+package org.openstreetmap.josm.plugins.geochat;
+
+import java.awt.KeyboardFocusManager;
+import java.awt.event.InputEvent;
+import java.awt.event.KeyEvent;
+import java.util.HashSet;
+
+import javax.swing.JComponent;
+import javax.swing.KeyStroke;
+
+import org.openstreetmap.josm.gui.MainApplication;
+import org.openstreetmap.josm.gui.widgets.DisableShortcutsOnFocusGainedTextField;
+
+/**
+ * JTextField tweaked to work in a JOSM panel. It prevents unwanted keystrokes
+ * to be caught by the editor.
+ *
+ * @author zverik
+ */
+public class JPanelTextField extends DisableShortcutsOnFocusGainedTextField {
+
+    /**
+     * Create a new {@link JPanelTextField}
+     */
+    public JPanelTextField() {
+        setFocusTraversalKeys(KeyboardFocusManager.FORWARD_TRAVERSAL_KEYS, new HashSet<>());
+        standardKeys = getInputMap(JComponent.WHEN_FOCUSED).allKeys();
+    }
+
+    // list of "standard" OS keys for JTextFiels = cursor moving, selection, copy/paste
+    private final KeyStroke[] standardKeys;
+    private static final int MODIFIERS_MASK =
+            InputEvent.META_DOWN_MASK | InputEvent.CTRL_DOWN_MASK | InputEvent.ALT_DOWN_MASK | InputEvent.SHIFT_DOWN_MASK;
+
+    @Override
+    protected void processKeyEvent(KeyEvent e) {
+        if (e.getID() == KeyEvent.KEY_PRESSED) {
+            int code = e.getKeyCode();
+            if (code == KeyEvent.VK_ENTER) {
+                String text = getText();
+                if (text.length() > 0) {
+                    processEnter(text);
+                    setText("");
+                }
+            } else if (code == KeyEvent.VK_TAB) {
+                String text = getText();
+                int caret = getCaretPosition();
+                int start = caret - 1;
+                while (start >= 0 && Character.isJavaIdentifierPart(text.charAt(start))) {
+                    start--;
+                }
+                start++;
+                if (start < caret) {
+                    String word = text.substring(start, caret);
+                    String complete = autoComplete(word, start == 0);
+                    if (complete != null && !complete.equals(word)) {
+                        StringBuilder sb = new StringBuilder();
+                        if (start > 0)
+                            sb.append(text, 0, start);
+                        sb.append(complete);
+                        if (caret < text.length())
+                            sb.append(text.substring(caret));
+                        setText(sb.toString());
+                        setCaretPosition(start + complete.length());
+                    }
+                }
+            } else if (code == KeyEvent.VK_ESCAPE && MainApplication.isDisplayingMapView()) {
+                MainApplication.getMap().mapView.requestFocus();
+            }
+
+            boolean keyIsStandard = false;
+            for (KeyStroke ks: standardKeys) {
+                if (code == ks.getKeyCode() &&
+                        (e.getModifiersEx() & MODIFIERS_MASK) == (ks.getModifiers() & MODIFIERS_MASK)) {
+                    keyIsStandard = true;
+                    break;
+                }
+            }
+            // Do not pass other events to JOSM
+            if (!keyIsStandard) {
+                e.consume();
+            }
+        }
+        super.processKeyEvent(e);
+    }
+
+    /**
+     * Process VK_ENTER. Override this to submit the text.
+     *
+     * @param text Contents of the text field.
+     */
+    protected void processEnter(String text) {
+        // Overridden where needed
+    }
+
+    /**
+     * Autocomplete the word.
+     * @param word Partly typed word.
+     * @return The whole word.
+     */
+    protected String autoComplete(String word, boolean atStart) {
+        return word;
+    }
+}
Index: applications/editors/josm/plugins/geochat/src/org/openstreetmap/josm/plugins/geochat/JsonQueryCallback.java
===================================================================
--- applications/editors/josm/plugins/geochat/src/org/openstreetmap/josm/plugins/geochat/JsonQueryCallback.java	(revision 36147)
+++ applications/editors/josm/plugins/geochat/src/org/openstreetmap/josm/plugins/geochat/JsonQueryCallback.java	(revision 36147)
@@ -0,0 +1,21 @@
+// License: WTFPL. For details, see LICENSE file.
+package org.openstreetmap.josm.plugins.geochat;
+
+import jakarta.json.JsonObject;
+
+/**
+ * A callback for {@link JsonQueryUtil}.
+ *
+ * @author zverik
+ */
+@FunctionalInterface
+public interface JsonQueryCallback {
+
+    /**
+     * Process JSON response from a query. This method is called every time,
+     * even on unsuccessful query.
+     *
+     * @param json JSON parsed response or null if the query was unsuccessful.
+     */
+    void processJson(JsonObject json);
+}
Index: applications/editors/josm/plugins/geochat/src/org/openstreetmap/josm/plugins/geochat/JsonQueryUtil.java
===================================================================
--- applications/editors/josm/plugins/geochat/src/org/openstreetmap/josm/plugins/geochat/JsonQueryUtil.java	(revision 36147)
+++ applications/editors/josm/plugins/geochat/src/org/openstreetmap/josm/plugins/geochat/JsonQueryUtil.java	(revision 36147)
@@ -0,0 +1,103 @@
+// License: WTFPL. For details, see LICENSE file.
+package org.openstreetmap.josm.plugins.geochat;
+
+import java.awt.EventQueue;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.util.ServiceConfigurationError;
+
+import jakarta.json.Json;
+import jakarta.json.JsonException;
+import jakarta.json.JsonObject;
+import jakarta.json.JsonReader;
+
+import org.openstreetmap.josm.gui.MainApplication;
+import org.openstreetmap.josm.spi.preferences.Config;
+import org.openstreetmap.josm.tools.HttpClient;
+import org.openstreetmap.josm.tools.HttpClient.Response;
+import org.openstreetmap.josm.tools.Logging;
+
+/**
+ * A static class to query the server and return parsed JSON hash.
+ *
+ * @author zverik
+ */
+public final class JsonQueryUtil implements Runnable {
+
+    /**
+     * Query the server synchronously.
+     * @param query Query string, starting with action. Example: <tt>get&amp;lat=1.0&amp;lon=-2.0&amp;uid=12345</tt>
+     * @return Parsed JsonObject if the query was successful, <tt>null</tt> otherwise.
+     * @throws IOException There was a problem connecting to the server or parsing JSON.
+     */
+    public static JsonObject query(String query) throws IOException {
+        return query(query, false);
+    }
+
+    /**
+     * Query the server synchronously.
+     * @param query Query string, starting with action. Example: <tt>get&amp;lat=1.0&amp;lon=-2.0&amp;uid=12345</tt>
+     * @param logAtDebug {@code true} to set http client connection log at DEBUG level instead of default INFO level
+     * @return Parsed JsonObject if the query was successful, <tt>null</tt> otherwise.
+     * @throws IOException There was a problem connecting to the server or parsing JSON.
+     */
+    public static JsonObject query(String query, boolean logAtDebug) throws IOException {
+        String serverURL = Config.getPref().get("geochat.server", "https://zverik.dev.openstreetmap.org/osmochat.php?action=");
+        URI url = URI.create(serverURL + query);
+        Response connection = HttpClient.create(url.toURL()).setLogAtDebug(logAtDebug).connect();
+        if (connection.getResponseCode() != 200) {
+            throw new IOException("HTTP Response code " + connection.getResponseCode() + " (" + connection.getResponseMessage() + ")");
+        }
+        InputStream inp = connection.getContent();
+        if (inp == null)
+            throw new IOException("Empty response");
+        try (JsonReader reader = Json.createReader(inp)) {
+            return reader.readObject();
+        } catch (ServiceConfigurationError | JsonException e) {
+            throw new IOException("Failed to parse JSON: " + e.getMessage(), e);
+        } finally {
+            connection.disconnect();
+        }
+    }
+
+    // Asynchronous operation
+
+    private final String query;
+    private final JsonQueryCallback callback;
+
+    private JsonQueryUtil(String query, JsonQueryCallback callback) {
+        this.query = query;
+        this.callback = callback;
+    }
+
+    /**
+     * Query the server asynchronously.
+     * @param query Query string (see {@link #query}).
+     * @param callback Callback listener to process the JSON response.
+     */
+    public static void queryAsync(String query, JsonQueryCallback callback) {
+        MainApplication.worker.submit(new JsonQueryUtil(query, callback));
+    }
+
+    private void doRealRun() {
+        JsonObject obj;
+        try {
+            obj = query(query);
+        } catch (IOException e) {
+            Logging.warn(e.getClass().getName() + " while connecting to a chat server: " + e.getMessage());
+            obj = null;
+        }
+        if (callback != null)
+            callback.processJson(obj);
+    }
+
+    @Override
+    public void run() {
+        if (EventQueue.isDispatchThread()) {
+            new Thread(this::doRealRun).start();
+        } else {
+            doRealRun();
+        }
+    }
+}
